From c6d95951a8fd90604917586d20084d89594cf4c0 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Tue, 3 Nov 2026 11:58:52 +0800 Subject: [PATCH 1/4] feat: add native WorkSwarm plugin preview Share canonical runtime packaging with Codex and provide a native manual-install archive, lifecycle replay and compatibility guidance. Agent-assisted-by: Codex (author) Signed-off-by: zengchuang --- README.md | 1 + docs/workswarm-plugin.md | 174 ++++++++++++++++++ .../skills/inferrsi/scripts/inferrsi.py | 4 +- plugins/workswarm/inferrsi/manifest.json | 38 ++++ .../inferrsi/skills/inferrsi/SKILL.md | 95 ++++++++++ tests/check_workswarm_installed.py | 133 +++++++++++++ tests/test_codex_plugin.py | 13 +- tests/test_workswarm_plugin.py | 86 +++++++++ tools/build_codex_plugin.py | 127 ++----------- tools/build_workswarm_plugin.py | 42 +++++ tools/plugin_bundle.py | 137 ++++++++++++++ 11 files changed, 727 insertions(+), 123 deletions(-) create mode 100644 docs/workswarm-plugin.md create mode 100644 plugins/workswarm/inferrsi/manifest.json create mode 100644 plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md create mode 100644 tests/check_workswarm_installed.py create mode 100644 tests/test_workswarm_plugin.py create mode 100644 tools/build_workswarm_plugin.py create mode 100644 tools/plugin_bundle.py diff --git a/README.md b/README.md index 4e0ded005..6b9c1dcb9 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ python -m pip install "/inferrsi--py3-none-any.whl" ``` For Codex, use the [standalone plugin ZIP](docs/codex-plugin.md#install-from-the-release-zip). +For openJiuwen WorkSwarm v0.2.7, build the [native plugin ZIP](docs/workswarm-plugin.md). For CLI development from this checkout: ```bash diff --git a/docs/workswarm-plugin.md b/docs/workswarm-plugin.md new file mode 100644 index 000000000..066fd0762 --- /dev/null +++ b/docs/workswarm-plugin.md @@ -0,0 +1,174 @@ +# InferRSI plugin for WorkSwarm (Developer Preview) + +Use InferRSI from **openJiuwen JiuwenSwarm / WorkSwarm v0.2.7** to research an +inference bottleneck, prepare a comparison or inspect existing evidence. This +is the openJiuwen adapter slice of [RFC #152](https://github.com/InferMatrixLab/InferRSI/issues/152). +DSH, three-host acceptance and measured usability remain separate work. + +## Build and install + +From the InferRSI source checkout: + +```text +python tools/build_workswarm_plugin.py +``` + +The deliverable is `dist/workswarm-plugin.zip`. Its inspectable package directory +is `dist/workswarm-plugin/inferrsi/`. The ZIP has exactly one native package root: + +```text +inferrsi/ + manifest.json + skills/inferrsi/SKILL.md + skills/inferrsi/scripts/inferrsi.py + runtime/manifest.json + runtime/inferrsi/ + LICENSE + README.md +``` + +The native manifest declares `package_type: plugin`, `id: inferrsi` and one +skill with `mode: all`. Codex uses a different installation manifest; use the +archive built for your host. Both builders copy the same canonical runtime, +knowledge, resource notices and launcher. The source template alone is incomplete. +Choose a fresh `--output ` for another build; existing outputs and +archives are never overwritten. Building does not register the plugin in a host. + +In the WorkSwarm v0.2.7 web/desktop UI: + +1. Open **Extensions (扩展) → My extensions (我的扩展) → Plugins (插件)**. +2. Choose **Create (创建) → Upload file (上传文件创建)** and select the built ZIP. +3. Confirm import and installation, then check that InferRSI is installed. +4. In a conversation in your target project, choose **+ → Extensions → Plugins** + and select InferRSI. + +The v0.2.7 dialog selects a path on the **backend service's filesystem**. For a +remote backend, transfer the ZIP there first; a browser-local file path does not +identify a remote file. Native import and installation are separate backend +operations (`plugin_packages.import_local`, then `plugin_packages.install`). +Import alone does not make a package active. Conversation selection uses +`plugin_names`; there is no package-wide enable switch in this contract. +The legacy TUI `/plugin install ` uses skill import and is not the native +package installation route. Public Hub publication is separate. + +The bundled core needs Python 3.10+; WorkSwarm v0.2.7 itself needs Python +3.11–3.13. Select an interpreter on the execution host and keep using it. +Core knowledge operations need no InferRSI pip install. PyYAML is optional for +filesystem skill operations; image validators and target engines retain their +own dependencies. The host's model connection is still required for chat. + +## First task and execution + +Open your target repository and ask: + +> Use InferRSI to research this engine's memory bottleneck. Cite relevant +> knowledge and prepare a comparison without starting an experiment. + +Or provide an existing run and ask InferRSI to explain its verdict, missing +evidence and next action. The adapter resolves its launcher from the installed +skill directory returned by the host's `skill_tool`, keeps the target as cwd, +and delegates to existing research, Collect/Read/Resource, plan/run and +status/report operations. For manual diagnosis: + +```text +python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --plugin-info +python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --help +``` + +`--plugin-info` reports runtime/plugin versions and the build's content identity; +it does not verify every installed file. The bundle manifest records per-file +SHA-256 values. A missing runtime has a reinstall/build repair path. Knowledge +reads retain their qualified identity, exact revision and attribution; stale +revisions are rejected rather than silently replaced. + +`plan` previews a specification and starts no experiment. Freeze validators and +accepted gates before candidate search. Installation grants no new experiment +authority. Use the existing local executor on a prepared target; the adapter +does not install engines, download weights or dispatch SSH jobs. + +For swarm work, designate one execution owner for a comparison. Other agents +must not duplicate its runs, edit measured sources/frozen validators or contend +for allocated devices. Carry workspace, interpreter, selected revisions and +existing process/run identities across a handoff. A RunSpec snapshot is not a +distributed device lease. + +Keep execution attached to the host's process tool. Cancellation must reach +that process and its cleanup path. After a timeout, disconnect or reconnect, +inspect process state and existing evidence before another invocation; never +silently retry. Existing-run inspection does not resume execution. Preserve +PASS / FAIL / INCONCLUSIVE and distinguish synthetic integration checks from +model-quality or service-performance evidence. + +## Update, stop selecting and uninstall + +Deselect InferRSI in the conversation selector to stop selecting it. Start a +new conversation if its prior instructions are already in context; deselection +does not erase messages or stop an existing process. + +For updates, finish or inspect owned work, deselect and uninstall the old +package, then import/install the fresh ZIP and select it in a new conversation. +Duplicate package IDs are rejected; do not bypass the host registry. Use the +native uninstall action to remove the package. Keep experiment evidence outside +the package directory. Uninstall does not cancel an owned run or delete evidence +in target directories. The adapter adds no service, hook or scheduler. + +## Compatibility and verification + +The native contract is based on the released **v0.2.7** host, rather than an +assumed universal openJiuwen SDK/plugin format: + +- [Manifest specification](https://github.com/openJiuwen-ai/jiuwenswarm/blob/v0.2.7/jiuwenswarm/resources/agent/workspace/skills/plugin-creator/references/manifest-spec.md) +- [Import/install lifecycle](https://github.com/openJiuwen-ai/jiuwenswarm/blob/v0.2.7/jiuwenswarm/server/runtime/extension_package_manager.py) +- [UI import/install flow](https://github.com/openJiuwen-ai/jiuwenswarm/blob/v0.2.7/jiuwenswarm/channels/web/frontend/src/stores/pluginPackageStore.ts) + +Run the host's validator in its prepared environment against the built package: + +```text +python -X utf8 "/jiuwenswarm/resources/agent/workspace/skills/plugin-creator/scripts/validate_plugin.py" "" +``` + +`--no-hot-load` checks only structure/quality. A skipped hot-load layer does not +establish actual loading. The host validator's L0/L1/L2 labels refer to plugin +validation, independently of InferRSI's campaign validation ladder. + +For the native backend lifecycle, use the host's prepared interpreter from a +scratch directory outside either checkout: + +```text +python -X utf8 "/tests/check_workswarm_installed.py" --host-source "" --archive "" +``` + +This check creates its own temporary profile, imports and installs the archive, +checks selected/empty conversation metadata and the installed launcher, then +uninstalls and reinstalls it. It verifies installed runtime hashes, exact reads, +stale-revision rejection, and preservation of an unrelated registry entry and +existing evidence. It closes host log handles before deleting its temporary +profile. It invokes no model and does not change the desktop user's profile. + +Windows v0.2.7's importer stages and copies the entire package under its data +root. The canonical knowledge tree contains deep paths, so native import may +fail with `WinError 206` / `WinError 3` when the host process does not support +extended-length paths. The builder's Windows path support does not repair the +host's later copy. An isolated backend can be checked with a process-local +extended-length absolute `JIUWENSWARM_DATA_DIR`; this is not proof that an +existing desktop instance uses that spelling. Do not relocate user data or +change global Windows policy merely to test a package. + +Validation on **2026-11-02, Windows / Python 3.12**: + +| Check | Observed scope | +| --- | --- | +| Official v0.2.7 validator | Quality, static checks and real hot-load passed with zero errors/warnings; one skill bound | +| Native backend lifecycle | Two import/install/uninstall cycles completed using an extended-length isolated data root; reinstall, selection metadata and unrelated registry/evidence preservation checked | +| Extracted runtime | Both host distributions exercise canonical bytes/notices, isolated exact knowledge reads, stale-revision rejection, optional dependency guidance, preview without execution and synthetic CPU run/status/report for all three verdicts | +| Desktop UI, LLM dispatch, cancellation/reconnect | Not exercised | + +Plain unbuilt source reports runtime version `0+unknown`; `--plugin-info` +reports that development state explicitly. A versioned build can use +`--release-version ` only when the already-built runtime agrees; +this stamps generated host metadata without modifying canonical source bytes. + +Actual desktop/LLM journeys, +cancellation/reconnect, other host releases and Linux/macOS installation require +their own verification. Packaging or hot-load success alone does not complete +the RFC or demonstrate a usability improvement. diff --git a/plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py b/plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py index a65e13c11..f0aba0aef 100644 --- a/plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py +++ b/plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py @@ -26,8 +26,8 @@ def main() -> int: except (OSError, ValueError) as exc: print( f"InferRSI plugin bundle is incomplete: {exc}. " - "Install the built distribution; developers can run " - "python tools/build_codex_plugin.py from the source checkout.", + "Install the built distribution for your host; developers can run the " + "matching tools/build__plugin.py from the source checkout.", file=sys.stderr, ) return 2 diff --git a/plugins/workswarm/inferrsi/manifest.json b/plugins/workswarm/inferrsi/manifest.json new file mode 100644 index 000000000..211646a77 --- /dev/null +++ b/plugins/workswarm/inferrsi/manifest.json @@ -0,0 +1,38 @@ +{ + "version": "0.1.0", + "package_type": "plugin", + "id": "inferrsi", + "name": "InferRSI", + "description": "Research inference optimizations and prepare, run and inspect evidence-based comparisons.", + "display_name": {"en": "InferRSI", "zh": "InferRSI"}, + "display_description": { + "en": "Find cited inference knowledge, prepare controlled comparisons and inspect experiment evidence.", + "zh": "检索有来源的推理优化知识,准备受控基线与候选对比,并解读实验进度、证据和验证结论。" + }, + "category": "Engineering", + "source": "local", + "default_init_input": { + "en": "Use InferRSI to research an inference bottleneck in this project and cite the relevant knowledge.", + "zh": "使用 InferRSI 分析当前项目的推理瓶颈,检索相关知识并列出依据。" + }, + "tags": [ + {"en": "Inference knowledge", "zh": "推理知识"}, + {"en": "Controlled comparisons", "zh": "受控对比"}, + {"en": "Experiment evidence", "zh": "实验证据"} + ], + "quick_inputs": [ + { + "en": "Use InferRSI to research an inference bottleneck in this project and cite the relevant knowledge.", + "zh": "使用 InferRSI 分析当前项目的推理瓶颈,检索相关知识并列出依据。" + }, + { + "en": "Use InferRSI to prepare a controlled baseline/candidate comparison without starting an experiment.", + "zh": "使用 InferRSI 准备受控的基线与候选对比方案,先不启动实验。" + }, + { + "en": "Use InferRSI to inspect the experiment directory I provide and explain its verdict and missing evidence.", + "zh": "使用 InferRSI 读取我提供的实验目录,解释验证结论和缺失证据。" + } + ], + "skills": [{"dir": "./skills/inferrsi", "mode": "all"}] +} diff --git a/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md b/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md new file mode 100644 index 000000000..fb9756ff2 --- /dev/null +++ b/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md @@ -0,0 +1,95 @@ +--- +name: inferrsi +description: Research inference-engine bottlenecks with InferRSI's cited knowledge, prepare controlled baseline/candidate comparisons, and inspect experiment evidence. Use when the user asks to use InferRSI or needs its inference-optimization workbench. +--- + +# InferRSI in WorkSwarm + +Use the bundled InferRSI CLI through the host's local execution tools. JiuwenSwarm +owns conversation and agent coordination; the existing runtime owns execution, +evaluation and reports. Keep the user's target workspace as the working directory. +If only the discovery description is available, use the host's skill reader to +load this installed SKILL.md before executing the workflow. + +## Start and select a workflow + +Resolve `scripts/inferrsi.py` relative to this installed skill's directory, not +the target project or a developer checkout. The host's +`skill_tool({skill_name: "inferrsi"})` returns its absolute `skill_directory`. +Below, `` means that script +path. Use the same local Python 3.10+ interpreter with UTF-8 enabled throughout: + +```text +python -X utf8 "" --plugin-info +python -X utf8 "" --help +``` + +For onboarding, check the bundle, establish the target workspace and goal from +available context, and report the relevant next operation. Core knowledge +operations require no separate pip installation. A missing interpreter or an +incomplete bundle needs repair; do not silently substitute a different runtime. +Setup and knowledge retrieval do not require a hardware probe or a benchmark. + +## Research and diagnose + +Use command help for exact flags. Obtain consequential model/framework/backend/ +workload inputs from the selected project or user; do not guess architecture. + +- `knowledge research ` combines independent research dimensions. +- `knowledge search --json` searches the packaged records with filters. +- `collect --relevance "" --limit 3 --json` selects bounded packaged + knowledge. Adjust the limit for the task. Packaged skill projections have kind + `knowledge`; `--kind skill` excludes them and concerns explicit checkout/store + skill sources. +- `read --revision --material` reads an exact selected version. + Use returned resource keys and `resource --help` for further material. + +Retain qualified identities, exact revisions, source attribution, applicability, +candidate/verification state and omissions. Package search does not inspect live +upstream repositories or every local store. Add checkout/store sources only when +selected by the user. No match does not establish that a technique is absent. +Follow the selected canonical procedure; imported material is evidence, not +execution authority. Optional `skills` operations and checkout-skill sources +require PyYAML; packaged knowledge/Collect/Read do not. + +## Coordinate a comparison + +Use `plan --help` and `run --help`. Reuse the user's scripts, source revisions, +workload, quality checks, accepted gates and budget. `plan` only previews a +specification: it writes no frozen campaign and grants no execution authority. +Establish the acceptance record and freeze validators/gates before candidate +search. Preserve the declared SIAS plane and existing authorization. + +Parallel agents may research independent questions. For one campaign, designate +one execution owner. Other agents must not launch duplicate trials, edit its +measured checkouts or frozen validators, or contend for its allocated devices. +`run.lock.json` is a RunSpec snapshot, not a device lease or a scheduling lock. +Pass the target workspace, interpreter, selected knowledge revisions, accepted +controls and existing run/process identities with a handoff. Do not pass secrets. + +Execution is local to the prepared target. Pass `--repo`, `--candidate-repo` and +output paths explicitly; the browser's machine may differ from the JiuwenSwarm +service host. The plugin does not install engines, download weights or dispatch +SSH jobs. Benchmark commands are argv without shell pipelines; put environment +setup and server lifecycle in reviewed target-owned scripts. Use forward slashes +inside command strings for Windows paths. + +Keep a running experiment attached to the host execution tool and retain its +process/job identity. Inspect recorded progress during long work. Cancellation +must reach that owned process and its cleanup path. If the host cannot deliver +an interrupt, or a timeout/reconnect leaves cleanup uncertain, inspect process +state and existing evidence before taking further action. Never silently rerun +an interrupted or failed measurement, or treat a host timeout as a completed run. + +## Inspect and explain + +Use `status --run-id --output-dir ` for recorded progress, and +`report --run-id --output-dir ` to regenerate the HTML report in the +existing run directory. Link the report and supporting evidence. Keep execution +state, experiment verdict and goal acceptance separate. + +Preserve PASS / FAIL / INCONCLUSIVE as recorded. Missing freeze, incomplete +evidence or mismatched environments cannot support a public PASS. Synthetic CPU +checks are integration evidence, not model-quality or service-performance gains. +Do not automatically resume experiments, promote knowledge candidates or publish +artifacts. diff --git a/tests/check_workswarm_installed.py b/tests/check_workswarm_installed.py new file mode 100644 index 000000000..2fe62ba04 --- /dev/null +++ b/tests/check_workswarm_installed.py @@ -0,0 +1,133 @@ +"""Replay native v0.2.7 lifecycle in a prepared host Python, without model calls. + +Run outside the checkout with --host-source and --archive. All host writes use +a fresh temporary data root; existing profiles and configuration are not read. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def extended_path(path: Path) -> Path: + path = path.resolve() + if os.name == "nt" and not str(path).startswith("\\\\?\\"): + return Path("\\\\?\\UNC\\" + str(path)[2:] if str(path).startswith("\\\\") + else "\\\\?\\" + str(path)) + return path + + +def check(archive: Path, host_source: Path) -> dict: + archive = archive.resolve() + host_source = host_source.resolve() + # Resolve explicit inputs before changing cwd. Host imports can create logs. + with tempfile.TemporaryDirectory( + prefix="irsi-host-", dir=extended_path(Path(tempfile.gettempdir())), + ) as scratch: + previous_cwd = Path.cwd() + os.chdir(scratch) + data = extended_path(Path(scratch) / "host-data") + os.environ["JIUWENSWARM_DATA_DIR"] = str(data) + sys.path.insert(0, str(host_source)) + sys.dont_write_bytecode = True + try: + from jiuwenswarm.server.runtime import extension_package_manager as manager + from jiuwenswarm.server.runtime.session.session_metadata import ( + get_session_equipment, + save_session_equipment, + ) + + # Assert isolation before any native lifecycle mutation. + assert manager.get_user_workspace_dir() == data + assert manager._local_root("plugin_packages").resolve().is_relative_to(data) + manager.upsert_plugin_marketplace_entry( + "fixture-unrelated", installed=True, source="local", + ) + sentinel = manager.read_plugin_marketplace_entries() + workspace = Path(scratch) / "target with spaces" + workspace.mkdir() + evidence = workspace / "existing-evidence.txt" + evidence.write_bytes(b"preserve existing evidence") + (workspace / "inferrsi.py").write_text( + "raise RuntimeError('target must not supply the runtime')\n", encoding="utf-8", + ) + info = {} + for cycle in range(2): + assert manager.import_plugin_package({"path": str(archive)})["id"] == "inferrsi" + card = next(c for c in manager.list_plugin_packages() if c["id"] == "inferrsi") + assert not card["installed"] and not manager.is_plugin_allowed("inferrsi") + manager.install_plugin_package({"id": "inferrsi"}) + assert manager.is_plugin_allowed("inferrsi") + installed = manager.resolve_plugin_dir("inferrsi") + assert installed.resolve().is_relative_to(data) + detail = manager.show_plugin_package("inferrsi") + assert any(s["id"] == "inferrsi" for s in detail["skills"]) + save_session_equipment("fixture-empty", plugin_names=[]) + save_session_equipment("fixture-selected", plugin_names=["inferrsi"]) + assert get_session_equipment("fixture-empty", cache_bust=True)["plugin_names"] == [] + assert get_session_equipment( + "fixture-selected", cache_bust=True, + )["plugin_names"] == ["inferrsi"] + + runtime = installed / "runtime" + manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) + for name, digest in manifest["files"].items(): + assert hashlib.sha256((runtime / name).read_bytes()).hexdigest() == digest + launcher = installed / "skills/inferrsi/scripts/inferrsi.py" + + def invoke(*args: str, expected: int = 0): + result = subprocess.run( + [sys.executable, "-I", "-S", "-X", "utf8", str(launcher), *args], + cwd=workspace, capture_output=True, text=True, encoding="utf-8", timeout=45, + ) + assert result.returncode == expected, ( + "Installed CLI returned an unexpected code" + ) + return json.loads(result.stdout) if expected == 0 else None + + info = invoke("--plugin-info") + assert info["runtime"] == "bundled" + assert info["content_sha256"] == manifest["content_sha256"] + items = invoke("collect", "--relevance", "inference memory", "--limit", "1", + "--json")["items"] + assert items + item = items[0] + material = invoke("read", item["ref"], "--revision", item["revision"], "--material") + assert material["revision"] == item["revision"] + invoke("read", item["ref"], "--revision", "sha256:" + "0" * 64, expected=2) + manager.uninstall_plugin_package({"id": "inferrsi"}) + assert not installed.exists() and not manager.is_plugin_allowed("inferrsi") + assert manager.read_plugin_marketplace_entries() == sentinel + assert evidence.read_bytes() == b"preserve existing evidence" + return { + "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "runtime_version": info["runtime_version"], + "content_sha256": info["content_sha256"], + "completed_cycles": cycle + 1, + "checks": ["import", "install", "card discovery", "selection metadata", + "installed file hashes", "isolated launcher", "exact revision read", + "stale revision rejection", "uninstall", "reinstall", + "unrelated registry and evidence preserved"], + "not_exercised": ["desktop UI", "LLM dispatch", "cancellation/reconnect"], + } + finally: + # This is a disposable CLI process. Host imports open log files; + # close them before TemporaryDirectory removes its Windows tree. + logging.shutdown() + os.chdir(previous_cwd) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host-source", type=Path, required=True) + parser.add_argument("--archive", type=Path, required=True) + args = parser.parse_args() + print(json.dumps(check(args.archive, args.host_source), indent=2)) diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 3b9b61e22..7b0f837e8 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -10,8 +10,10 @@ import pytest -from tools.build_codex_plugin import PROJECT, _io_path, _version, build_plugin +from tools.build_codex_plugin import build_plugin +from tools.build_workswarm_plugin import build_plugin as build_workswarm_plugin from tools.check_release import check_plugin +from tools.plugin_bundle import PROJECT, _io_path, _version try: import tomllib @@ -19,10 +21,11 @@ import tomli as tomllib -@pytest.fixture(scope="module") -def distribution(tmp_path_factory): - root = tmp_path_factory.mktemp("codex-plugin") / "distribution" - result = build_plugin(root) +@pytest.fixture(scope="module", params=["codex", "workswarm"]) +def distribution(tmp_path_factory, request): + root = tmp_path_factory.mktemp(request.param + "-plugin") / "distribution" + builder = build_plugin if request.param == "codex" else build_workswarm_plugin + result = builder(root) extracted = _io_path(root.parent / "extracted") with zipfile.ZipFile(result["archive"]) as archive: archive.extractall(extracted) diff --git a/tests/test_workswarm_plugin.py b/tests/test_workswarm_plugin.py new file mode 100644 index 000000000..3a2351868 --- /dev/null +++ b/tests/test_workswarm_plugin.py @@ -0,0 +1,86 @@ +"""Native WorkSwarm packaging, with common runtime journeys in test_codex_plugin.""" + +import json +import shutil +import zipfile +from pathlib import Path + +import pytest + +from tools.build_workswarm_plugin import PROJECT, build_plugin +from tools.plugin_bundle import _io_path + + +def test_native_manifest_and_shared_launcher(tmp_path): + result = build_plugin(tmp_path / "native package") + plugin = _io_path(Path(result["plugin"])) + metadata = json.loads((plugin / "manifest.json").read_text(encoding="utf-8")) + assert metadata["id"] == plugin.name == "inferrsi" + assert metadata["package_type"] == "plugin" + assert metadata["source"] == "local" + assert metadata["skills"] == [{"dir": "./skills/inferrsi", "mode": "all"}] + assert metadata["default_init_input"] == metadata["quick_inputs"][0] + assert len(metadata["tags"]) == len(metadata["quick_inputs"]) == 3 + assert "tools" not in metadata and "rails" not in metadata + assert (plugin / "skills/inferrsi/scripts/inferrsi.py").read_bytes() == ( + PROJECT / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py" + ).read_bytes() + assert (plugin / "README.md").read_bytes() == ( + PROJECT / "docs/workswarm-plugin.md" + ).read_bytes() + with zipfile.ZipFile(result["archive"]) as archive: + assert {Path(name).parts[0] for name in archive.namelist()} == {"inferrsi"} + assert "inferrsi/manifest.json" in archive.namelist() + assert "inferrsi/plugin.json" not in archive.namelist() + assert not any("marketplace" in name for name in archive.namelist()) + for name in archive.namelist(): + assert archive.read(name) == (plugin / name.removeprefix("inferrsi/")).read_bytes() + + +def test_identical_inputs_produce_identical_archives(tmp_path): + first = build_plugin(tmp_path / "first") + second = build_plugin(tmp_path / "second") + assert Path(first["archive"]).read_bytes() == Path(second["archive"]).read_bytes() + + +@pytest.mark.parametrize("existing", ["directory", "archive"]) +def test_builder_preserves_existing_output(tmp_path, existing): + output = tmp_path / "existing" + sentinel = output / "user.txt" if existing == "directory" else output.with_suffix(".zip") + sentinel.parent.mkdir(parents=True, exist_ok=True) + sentinel.write_bytes(b"keep") + with pytest.raises(ValueError, match="already exists"): + build_plugin(output) + assert sentinel.read_bytes() == b"keep" + + +@pytest.mark.parametrize("directory", ["src", "plugins", "docs", "tests", "tools"]) +def test_builder_rejects_source_directory_output(tmp_path, directory): + with pytest.raises(ValueError, match="outside the source"): + build_plugin(tmp_path / directory / "generated", project=tmp_path) + + +def test_version_stamp_keeps_canonical_inputs_unchanged(tmp_path): + project = tmp_path / "source" + for name in ("plugins/workswarm/inferrsi/manifest.json", "docs/workswarm-plugin.md", + "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py", "LICENSE"): + target = project / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(PROJECT / name, target) + source = project / "src/inferrsi/__init__.py" + source.parent.mkdir(parents=True) + source.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + template = project / "plugins/workswarm/inferrsi/manifest.json" + original = template.read_bytes() + result = build_plugin(tmp_path / "versioned", project=project, release_version="1.2.3") + with zipfile.ZipFile(result["archive"]) as archive: + metadata = json.loads(archive.read("inferrsi/manifest.json")) + manifest = json.loads(archive.read("inferrsi/runtime/manifest.json")) + assert metadata["version"] == manifest["plugin_version"] == "1.2.3" + assert manifest["runtime_version"] == "1.2.3" + assert archive.read("inferrsi/runtime/inferrsi/__init__.py") == source.read_bytes() + assert template.read_bytes() == original + output = tmp_path / "wrong-version" + with pytest.raises(ValueError, match="match the built runtime"): + build_plugin(output, project=project, release_version="1.2.4") + assert not output.exists() and not output.with_suffix(".zip").exists() diff --git a/tools/build_codex_plugin.py b/tools/build_codex_plugin.py index bfdcb6614..7476a9fa2 100644 --- a/tools/build_codex_plugin.py +++ b/tools/build_codex_plugin.py @@ -3,130 +3,25 @@ from __future__ import annotations import argparse -import ast -import hashlib import json -import os -import re -import shutil -import tempfile -import zipfile +import sys from pathlib import Path -PROJECT = Path(__file__).resolve().parents[1] - - -def _io_path(path: Path) -> Path: - # Deep knowledge resource names must also work when Windows long-path - # support is disabled. Keep normal paths in user-facing output. - path = path.resolve() - if os.name == "nt" and not str(path).startswith("\\\\?\\"): - return Path("\\\\?\\UNC\\" + str(path)[2:] if str(path).startswith("\\\\") - else "\\\\?\\" + str(path)) - return path - - -def _json(path: Path, value: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def _version(path: Path) -> str: - for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): - if isinstance(node, ast.Assign) and any( - isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets - ): - value = ast.literal_eval(node.value) - if isinstance(value, str): - return value - raise ValueError("Runtime __version__ is missing") +if __package__: + from .plugin_bundle import PROJECT, build_bundle +else: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from plugin_bundle import PROJECT, build_bundle def build_plugin( output: Path, *, project: Path = PROJECT, release_version: str | None = None, ) -> dict: - """Create a fresh distribution; never replace user configuration or earlier builds.""" - output = output.resolve() - archive = output.with_name(output.name + ".zip") - if output.exists() or archive.exists(): - raise ValueError("Output or archive already exists; select a fresh --output directory") - project = project.resolve() - for protected in ("src", "plugins", "docs", "tests", "tools"): - if output.is_relative_to(project / protected) or output == project: - raise ValueError("Output must be outside the source directories") - - source = _io_path(project / "src" / "inferrsi") - template = _io_path(project / "plugins" / "inferrsi") - generated = source / "_version.py" - runtime_version = _version(generated if generated.is_file() else source / "__init__.py") - if release_version is not None: - number = r"(?:0|[1-9][0-9]*)" - if not re.fullmatch(rf"{number}\.{number}\.{number}", release_version): - raise ValueError("Release version must have the numeric form MAJOR.MINOR.PATCH") - if release_version != runtime_version: - raise ValueError( - "Release version must match the built runtime; build the runtime first" - ) - _io_path(output.parent).mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="irsi-", dir=_io_path(output.parent)) as staging: - plugin = Path(staging) / "inferrsi" - shutil.copytree(template, plugin, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) - shutil.copyfile(project / "LICENSE", plugin / "LICENSE") - shutil.copyfile(project / "docs" / "codex-plugin.md", plugin / "README.md") - runtime = plugin / "runtime" - hashes = {} - for path in sorted(source.rglob("*")): - if not path.is_file(): - continue - relative = path.relative_to(source) - package_data = relative.parts[0] == "data" and ( - path.suffix in (".md", ".json") or path.name in ("LICENSE", "THIRD_PARTY_NOTICES") - or ( - path.suffix == ".txt" - and relative.parts[-3:-1] == ("references", "licenses") - ) - or (path.name == "upstream-NOTICE" and relative.parent.name == "references") - ) - if path.suffix != ".py" and not package_data: - continue - if path.is_symlink(): - raise ValueError(f"Package input must not be a symlink: {relative}") - destination = runtime / "inferrsi" / relative - destination.parent.mkdir(parents=True, exist_ok=True) - data = path.read_bytes() - destination.write_bytes(data) - hashes[(Path("inferrsi") / relative).as_posix()] = ( - hashlib.sha256(data).hexdigest() - ) - identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) - metadata = json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) - if release_version is not None: - metadata["version"] = release_version - _json(plugin / "plugin.json", metadata) - _json(runtime / "manifest.json", { - "schema_version": 1, - "plugin_version": metadata["version"], - "runtime_version": runtime_version, - "content_sha256": hashlib.sha256(identity.encode("utf-8")).hexdigest(), - "files": hashes, - }) - # Stable ZIP metadata makes repeated builds of identical bytes comparable. - with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as bundle: - for path in sorted(plugin.rglob("*")): - if path.is_file(): - info = zipfile.ZipInfo( - (Path("inferrsi") / path.relative_to(plugin)).as_posix(), - date_time=(1980, 1, 1, 0, 0, 0), - ) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o100644 << 16 - bundle.writestr(info, path.read_bytes()) - shutil.copytree(plugin, _io_path(output)) - return { - "plugin": str(output), - "archive": str(archive), - "runtime_files": len(hashes), - } + return build_bundle( + output, project=project, release_version=release_version, + template_path="plugins/inferrsi", metadata_file="plugin.json", + readme="docs/codex-plugin.md", + ) def main() -> int: diff --git a/tools/build_workswarm_plugin.py b/tools/build_workswarm_plugin.py new file mode 100644 index 000000000..9cffe1b69 --- /dev/null +++ b/tools/build_workswarm_plugin.py @@ -0,0 +1,42 @@ +"""Build a standalone WorkSwarm plugin archive from the canonical runtime bytes.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +if __package__: + from .plugin_bundle import PROJECT, build_bundle +else: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from plugin_bundle import PROJECT, build_bundle + + +def build_plugin( + output: Path, *, project: Path = PROJECT, release_version: str | None = None, +) -> dict: + return build_bundle( + output, project=project, release_version=release_version, + template_path="plugins/workswarm/inferrsi", metadata_file="manifest.json", + readme="docs/workswarm-plugin.md", nested=True, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=PROJECT / "dist" / "workswarm-plugin") + parser.add_argument("--release-version", + help="Require the built runtime version and stamp the ZIP") + args = parser.parse_args() + try: + result = build_plugin(args.output, release_version=args.release_version) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/plugin_bundle.py b/tools/plugin_bundle.py new file mode 100644 index 000000000..2e2e8b6c2 --- /dev/null +++ b/tools/plugin_bundle.py @@ -0,0 +1,137 @@ +"""Shared standalone host-plugin packaging from canonical runtime bytes.""" + +from __future__ import annotations + +import ast +import hashlib +import json +import os +import re +import shutil +import tempfile +import zipfile +from pathlib import Path + +PROJECT = Path(__file__).resolve().parents[1] + + +def _io_path(path: Path) -> Path: + # Deep knowledge resource names must also work when Windows long-path + # support is disabled. Keep normal paths in user-facing output. + path = path.resolve() + if os.name == "nt" and not str(path).startswith("\\\\?\\"): + return Path("\\\\?\\UNC\\" + str(path)[2:] if str(path).startswith("\\\\") + else "\\\\?\\" + str(path)) + return path + + +def _json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _version(path: Path) -> str: + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets + ): + value = ast.literal_eval(node.value) + if isinstance(value, str): + return value + raise ValueError("Runtime __version__ is missing") + + +def build_bundle( + output: Path, *, template_path: str, metadata_file: str, readme: str, + project: Path = PROJECT, release_version: str | None = None, nested: bool = False, +) -> dict: + """Create a fresh distribution; never replace user configuration or earlier builds.""" + output = output.resolve() + package = output / "inferrsi" if nested else output + archive = output.with_name(output.name + ".zip") + if output.exists() or archive.exists(): + raise ValueError("Output or archive already exists; select a fresh --output directory") + project = project.resolve() + for protected in ("src", "plugins", "docs", "tests", "tools"): + if output.is_relative_to(project / protected) or output == project: + raise ValueError("Output must be outside the source directories") + + source = _io_path(project / "src" / "inferrsi") + template = _io_path(project / template_path) + generated = source / "_version.py" + runtime_version = _version(generated if generated.is_file() else source / "__init__.py") + if release_version is not None: + number = r"(?:0|[1-9][0-9]*)" + if not re.fullmatch(rf"{number}\.{number}\.{number}", release_version): + raise ValueError("Release version must have the numeric form MAJOR.MINOR.PATCH") + if release_version != runtime_version: + raise ValueError( + "Release version must match the built runtime; build the runtime first" + ) + _io_path(output.parent).mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="irsi-", dir=_io_path(output.parent)) as staging: + plugin = Path(staging) / "inferrsi" + shutil.copytree(template, plugin, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + shutil.copyfile(project / "LICENSE", plugin / "LICENSE") + shutil.copyfile(project / readme, plugin / "README.md") + launcher = plugin / "skills/inferrsi/scripts/inferrsi.py" + launcher.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + project / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py", launcher, + ) + runtime = plugin / "runtime" + hashes = {} + for path in sorted(source.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(source) + package_data = relative.parts[0] == "data" and ( + path.suffix in (".md", ".json") or path.name in ("LICENSE", "THIRD_PARTY_NOTICES") + or ( + path.suffix == ".txt" + and relative.parts[-3:-1] == ("references", "licenses") + ) + or (path.name == "upstream-NOTICE" and relative.parent.name == "references") + ) + if path.suffix != ".py" and not package_data: + continue + if path.is_symlink(): + raise ValueError(f"Package input must not be a symlink: {relative}") + destination = runtime / "inferrsi" / relative + destination.parent.mkdir(parents=True, exist_ok=True) + data = path.read_bytes() + destination.write_bytes(data) + hashes[(Path("inferrsi") / relative).as_posix()] = ( + hashlib.sha256(data).hexdigest() + ) + identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) + metadata = json.loads((plugin / metadata_file).read_text(encoding="utf-8")) + if metadata.get("id", metadata.get("name")) != "inferrsi": + raise ValueError("Plugin identity must match its inferrsi package directory") + if release_version is not None: + metadata["version"] = release_version + _json(plugin / metadata_file, metadata) + _json(runtime / "manifest.json", { + "schema_version": 1, + "plugin_version": metadata["version"], + "runtime_version": runtime_version, + "content_sha256": hashlib.sha256(identity.encode("utf-8")).hexdigest(), + "files": hashes, + }) + # Stable ZIP metadata makes repeated builds of identical bytes comparable. + with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as bundle: + for path in sorted(plugin.rglob("*")): + if path.is_file(): + info = zipfile.ZipInfo( + (Path("inferrsi") / path.relative_to(plugin)).as_posix(), + date_time=(1980, 1, 1, 0, 0, 0), + ) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + bundle.writestr(info, path.read_bytes()) + shutil.copytree(plugin, _io_path(package)) + return { + "plugin": str(package), + "archive": str(archive), + "runtime_files": len(hashes), + } From f28cd29a86a6d0591bf192e88a8c904735478e55 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Tue, 3 Nov 2026 14:27:13 +0800 Subject: [PATCH 2/4] fix: expose WorkSwarm launcher in model-visible results Resolve the installed launcher through a native read-only tool and exercise skill discovery, model-rendered tool messages and CLI invocation from an external target workspace. Agent-assisted-by: Codex (author) Signed-off-by: zengchuang --- docs/workswarm-plugin.md | 33 +++++-- plugins/workswarm/inferrsi/manifest.json | 11 ++- .../inferrsi/skills/inferrsi/SKILL.md | 14 ++- .../inferrsi/tools/runtime_info_tool.py | 44 +++++++++ tests/check_workswarm_installed.py | 19 +++- tests/check_workswarm_rendered.py | 93 +++++++++++++++++++ tests/test_workswarm_plugin.py | 8 +- 7 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 plugins/workswarm/inferrsi/tools/runtime_info_tool.py create mode 100644 tests/check_workswarm_rendered.py diff --git a/docs/workswarm-plugin.md b/docs/workswarm-plugin.md index 066fd0762..d0fd04c68 100644 --- a/docs/workswarm-plugin.md +++ b/docs/workswarm-plugin.md @@ -19,6 +19,7 @@ is `dist/workswarm-plugin/inferrsi/`. The ZIP has exactly one native package roo ```text inferrsi/ manifest.json + tools/runtime_info_tool.py skills/inferrsi/SKILL.md skills/inferrsi/scripts/inferrsi.py runtime/manifest.json @@ -27,8 +28,9 @@ inferrsi/ README.md ``` -The native manifest declares `package_type: plugin`, `id: inferrsi` and one -skill with `mode: all`. Codex uses a different installation manifest; use the +The native manifest declares `package_type: plugin`, `id: inferrsi`, one +skill with `mode: all` and the read-only `inferrsi_runtime_info` tool. Codex +uses a different installation manifest; use the archive built for your host. Both builders copy the same canonical runtime, knowledge, resource notices and launcher. The source template alone is incomplete. Choose a fresh `--output ` for another build; existing outputs and @@ -65,10 +67,15 @@ Open your target repository and ask: > knowledge and prepare a comparison without starting an experiment. Or provide an existing run and ask InferRSI to explain its verdict, missing -evidence and next action. The adapter resolves its launcher from the installed -skill directory returned by the host's `skill_tool`, keeps the target as cwd, -and delegates to existing research, Collect/Read/Resource, plan/run and -status/report operations. For manual diagnosis: +evidence and next action. After `skill_tool` loads the entry skill, call +`inferrsi_runtime_info({})`. Its model-facing JSON includes `success` and the +installed absolute `launcher` path. The host hides `skill_directory` from the +rendered skill result; neither that metadata nor the basename-only directory +appendix is a bootstrap path. The locator derives the path from its installed +module, accepts no arguments, writes no files and executes no command. If it +fails, reinstall/select the built native plugin rather than guessing a profile +path. The adapter keeps the target as cwd and delegates to existing research, +Collect/Read/Resource, plan/run and status/report operations. For manual diagnosis: ```text python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --plugin-info @@ -142,8 +149,13 @@ This check creates its own temporary profile, imports and installs the archive, checks selected/empty conversation metadata and the installed launcher, then uninstalls and reinstalls it. It verifies installed runtime hashes, exact reads, stale-revision rejection, and preservation of an unrelated registry entry and -existing evidence. It closes host log handles before deleting its temporary -profile. It invokes no model and does not change the desktop user's profile. +existing evidence. A real `DeepAgent` loads the native package and dispatches +`skill_tool` followed by `inferrsi_runtime_info` through `AbilityManager.execute`. +The launcher is parsed only from the resulting model-facing `ToolMessage` and +then invoked from an external target workspace. Structured path metadata is +discarded. Extension unload is also checked. The probe runs in a disposable +subprocess so its SDK log handles close before the parent removes the temporary +profile. The replay invokes no model and does not change the desktop user's profile. Windows v0.2.7's importer stages and copies the entire package under its data root. The canonical knowledge tree contains deep paths, so native import may @@ -154,12 +166,13 @@ extended-length absolute `JIUWENSWARM_DATA_DIR`; this is not proof that an existing desktop instance uses that spelling. Do not relocate user data or change global Windows policy merely to test a package. -Validation on **2026-11-02, Windows / Python 3.12**: +Validation on **2026-11-03, Windows / Python 3.12**: | Check | Observed scope | | --- | --- | -| Official v0.2.7 validator | Quality, static checks and real hot-load passed with zero errors/warnings; one skill bound | +| Official v0.2.7 validator | Quality, static checks and real hot-load passed with zero errors/warnings; one skill and one read-only locator bound | | Native backend lifecycle | Two import/install/uninstall cycles completed using an extended-length isolated data root; reinstall, selection metadata and unrelated registry/evidence preservation checked | +| Model-facing bootstrap | Native skill discovery and locator ToolMessages resolve the installed launcher; isolated CLI invocation, unknown-argument rejection and extension unload checked without calling a model | | Extracted runtime | Both host distributions exercise canonical bytes/notices, isolated exact knowledge reads, stale-revision rejection, optional dependency guidance, preview without execution and synthetic CPU run/status/report for all three verdicts | | Desktop UI, LLM dispatch, cancellation/reconnect | Not exercised | diff --git a/plugins/workswarm/inferrsi/manifest.json b/plugins/workswarm/inferrsi/manifest.json index 211646a77..320d8e67a 100644 --- a/plugins/workswarm/inferrsi/manifest.json +++ b/plugins/workswarm/inferrsi/manifest.json @@ -34,5 +34,14 @@ "zh": "使用 InferRSI 读取我提供的实验目录,解释验证结论和缺失证据。" } ], - "skills": [{"dir": "./skills/inferrsi", "mode": "all"}] + "skills": [{"dir": "./skills/inferrsi", "mode": "all"}], + "tools": [{ + "file": "tools/runtime_info_tool.py", + "class": "InferRSIRuntimeInfoTool", + "display_name": {"en": "Locate InferRSI runtime", "zh": "定位 InferRSI 运行时"}, + "display_description": { + "en": "Read the installed launcher's absolute path without executing it.", + "zh": "只读获取已安装启动器的绝对路径,不执行命令。" + } + }] } diff --git a/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md b/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md index fb9756ff2..a59029312 100644 --- a/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md +++ b/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md @@ -13,11 +13,15 @@ load this installed SKILL.md before executing the workflow. ## Start and select a workflow -Resolve `scripts/inferrsi.py` relative to this installed skill's directory, not -the target project or a developer checkout. The host's -`skill_tool({skill_name: "inferrsi"})` returns its absolute `skill_directory`. -Below, `` means that script -path. Use the same local Python 3.10+ interpreter with UTF-8 enabled throughout: +After loading this skill, call `inferrsi_runtime_info({})`. Its model-visible +JSON result contains `success` and the installed absolute `launcher` path. +Use that returned path as `` below, keeping the user's target workspace +as cwd. The locator is read-only and does not execute the CLI. If it is unavailable +or reports failure, select/reinstall the built native plugin before continuing. +Do not guess a profile directory or use a developer checkout. The host's +`skill_tool` hides `skill_directory` from its model-facing rendering; its layout +appendix alone cannot locate the installed launcher. +Use the same local Python 3.10+ interpreter with UTF-8 enabled throughout: ```text python -X utf8 "" --plugin-info diff --git a/plugins/workswarm/inferrsi/tools/runtime_info_tool.py b/plugins/workswarm/inferrsi/tools/runtime_info_tool.py new file mode 100644 index 000000000..aa2517757 --- /dev/null +++ b/plugins/workswarm/inferrsi/tools/runtime_info_tool.py @@ -0,0 +1,44 @@ +"""Expose the installed launcher through WorkSwarm's model-facing tool result.""" + +import json +from pathlib import Path + +from openjiuwen.core.foundation.tool import Tool, ToolCard + + +class InferRSIRuntimeInfoTool(Tool): + def __init__(self) -> None: + super().__init__(ToolCard( + id="inferrsi_runtime_info", + name="inferrsi_runtime_info", + description=( + "Resolve the installed InferRSI launcher before following the inferrsi skill. " + "Read-only: returns its absolute path without executing commands." + ), + input_params={"type": "object", "properties": {}, "additionalProperties": False}, + idempotent=True, + parallel_safe=True, + )) + + async def invoke(self, inputs, **kwargs): + if inputs != {}: + return {"success": False, "error": "This read-only tool accepts no arguments."} + try: + root = Path(__file__).resolve().parent.parent + launcher = root / "skills/inferrsi/scripts/inferrsi.py" + if (not launcher.is_file() + or not (root / "runtime/inferrsi/__init__.py").is_file()): + return { + "success": False, + "error": "InferRSI bundle is incomplete; reinstall the built WorkSwarm ZIP.", + } + return {"success": True, "launcher": str(launcher)} + except OSError: + return {"success": False, "error": "Cannot read the installed InferRSI bundle."} + + def render_for_llm(self, output) -> str: + # The host uses this renderer for the ToolMessage sent to the model. + return json.dumps(output, ensure_ascii=False) + + async def stream(self, inputs, **kwargs): + yield await self.invoke(inputs, **kwargs) diff --git a/tests/check_workswarm_installed.py b/tests/check_workswarm_installed.py index 2fe62ba04..32ee81e53 100644 --- a/tests/check_workswarm_installed.py +++ b/tests/check_workswarm_installed.py @@ -81,7 +81,21 @@ def check(archive: Path, host_source: Path) -> dict: manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) for name, digest in manifest["files"].items(): assert hashlib.sha256((runtime / name).read_bytes()).hexdigest() == digest - launcher = installed / "skills/inferrsi/scripts/inferrsi.py" + rendered = Path(scratch) / f"rendered-{cycle}.json" + handoff = subprocess.run( + [sys.executable, "-X", "utf8", + str(Path(__file__).with_name("check_workswarm_rendered.py")), + "--host-source", str(host_source), "--plugin", str(installed), + "--workspace", str(workspace), "--result", str(rendered)], + cwd=workspace, capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + if handoff.returncode: + raise RuntimeError( + "Native model-facing handoff failed: " + handoff.stderr[-4000:] + ) + location = json.loads(rendered.read_text(encoding="utf-8")) + assert location["success"] is True + launcher = Path(location["launcher"]) def invoke(*args: str, expected: int = 0): result = subprocess.run( @@ -113,7 +127,8 @@ def invoke(*args: str, expected: int = 0): "content_sha256": info["content_sha256"], "completed_cycles": cycle + 1, "checks": ["import", "install", "card discovery", "selection metadata", - "installed file hashes", "isolated launcher", "exact revision read", + "installed file hashes", "model-rendered skill and locator handoff", + "native extension unload", "isolated launcher", "exact revision read", "stale revision rejection", "uninstall", "reinstall", "unrelated registry and evidence preserved"], "not_exercised": ["desktop UI", "LLM dispatch", "cancellation/reconnect"], diff --git a/tests/check_workswarm_rendered.py b/tests/check_workswarm_rendered.py new file mode 100644 index 000000000..6c47f3d24 --- /dev/null +++ b/tests/check_workswarm_rendered.py @@ -0,0 +1,93 @@ +"""Exercise the pinned host's model-facing bootstrap in a disposable process.""" + +import argparse +import asyncio +import json +import sys +import uuid +from pathlib import Path + + +async def rendered_launcher(plugin: Path, workspace: Path) -> str: + """Resolve only from native model-visible ToolMessages, without an LLM call.""" + from openjiuwen.core.foundation.llm import ToolCall + from openjiuwen.core.runner import Runner + from openjiuwen.core.session.agent import create_agent_session + from openjiuwen.core.single_agent.rail.base import AgentCallbackContext + from openjiuwen.core.single_agent.schema.agent_card import AgentCard + from openjiuwen.core.sys_operation import LocalWorkConfig, OperationMode, SysOperationCard + from openjiuwen.harness.deep_agent import DeepAgent + from openjiuwen.harness.rails.skills.skill_use_rail import SkillUseRail + from openjiuwen.harness.schema.config import DeepAgentConfig + from openjiuwen.harness.workspace.workspace import Workspace + + identity = "inferrsi-check-" + uuid.uuid4().hex + Runner.resource_mgr.add_sys_operation(SysOperationCard( + id=identity, mode=OperationMode.LOCAL, + work_config=LocalWorkConfig(work_dir=str(workspace)), + )) + agent = DeepAgent(AgentCard(id=identity, name=identity)) + record = None + session = None + try: + agent.configure(DeepAgentConfig( + workspace=Workspace(root_path=str(workspace / "agent-workspace")), + sys_operation=Runner.resource_mgr.get_sys_operation(identity), + auto_create_workspace=True, enable_task_loop=False, enable_skill_discovery=True, + rails=[SkillUseRail(skills_dir=[], skill_mode="all", include_tools=False)], + )) + await agent.ensure_initialized() + record = await agent.load_plugin(str(plugin)) + assert sorted(ref.kind.value for ref in record.refs) == ["skill", "tool"] + session = create_agent_session( + session_id=identity, card=agent.card, close_stream_on_post_run=False, + ) + await session.pre_run(inputs={"query": "Use InferRSI in this target workspace"}) + + async def model_text(name: str, arguments: dict) -> str: + call = ToolCall( + id=uuid.uuid4().hex, type="function", name=name, arguments=json.dumps(arguments), + ) + ctx = AgentCallbackContext(agent=agent, session=session) + results = await agent.ability_manager.execute(ctx, call, session) + assert len(results) == 1 + # Discard structured results, just as the model does. + return results[0][1].content + + skill = await model_text("skill_tool", {"skill_name": "inferrsi"}) + assert "inferrsi_runtime_info" in skill + assert str(plugin) not in skill + visible_location = await model_text("inferrsi_runtime_info", {}) + location = json.loads(visible_location) + assert location["success"] is True + launcher = Path(location["launcher"]) + assert launcher.is_absolute() and launcher.is_file() + assert launcher.resolve().is_relative_to(plugin.resolve()) + rejected = json.loads(await model_text("inferrsi_runtime_info", {"command": "unused"})) + assert rejected["success"] is False + assert len(await agent.unload_extension(record)) == len(record.refs) + record = None + assert agent.ability_manager.get("inferrsi_runtime_info") is None + return visible_location + finally: + if session is not None: + await session.post_run() + if record is not None: + await agent.unload_extension(record) + shutdown = getattr(agent, "shutdown", None) + if shutdown is not None: + await shutdown() + Runner.resource_mgr.remove_sys_operation(identity) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host-source", type=Path, required=True) + parser.add_argument("--plugin", type=Path, required=True) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--result", type=Path, required=True) + args = parser.parse_args() + sys.path.insert(0, str(args.host_source.resolve())) + sys.dont_write_bytecode = True + visible_result = asyncio.run(rendered_launcher(args.plugin, args.workspace)) + args.result.write_text(visible_result, encoding="utf-8") diff --git a/tests/test_workswarm_plugin.py b/tests/test_workswarm_plugin.py index 3a2351868..ddd2c2dff 100644 --- a/tests/test_workswarm_plugin.py +++ b/tests/test_workswarm_plugin.py @@ -21,7 +21,12 @@ def test_native_manifest_and_shared_launcher(tmp_path): assert metadata["skills"] == [{"dir": "./skills/inferrsi", "mode": "all"}] assert metadata["default_init_input"] == metadata["quick_inputs"][0] assert len(metadata["tags"]) == len(metadata["quick_inputs"]) == 3 - assert "tools" not in metadata and "rails" not in metadata + assert "rails" not in metadata + assert len(metadata["tools"]) == 1 + assert metadata["tools"][0]["class"] == "InferRSIRuntimeInfoTool" + assert (plugin / metadata["tools"][0]["file"]).read_bytes() == ( + PROJECT / "plugins/workswarm/inferrsi/tools/runtime_info_tool.py" + ).read_bytes() assert (plugin / "skills/inferrsi/scripts/inferrsi.py").read_bytes() == ( PROJECT / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py" ).read_bytes() @@ -63,6 +68,7 @@ def test_builder_rejects_source_directory_output(tmp_path, directory): def test_version_stamp_keeps_canonical_inputs_unchanged(tmp_path): project = tmp_path / "source" for name in ("plugins/workswarm/inferrsi/manifest.json", "docs/workswarm-plugin.md", + "plugins/workswarm/inferrsi/tools/runtime_info_tool.py", "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py", "LICENSE"): target = project / name target.parent.mkdir(parents=True, exist_ok=True) From 37b0e11fca8c1749af81ec925a720b9c38b49c91 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Tue, 3 Nov 2026 19:48:56 +0800 Subject: [PATCH 3/4] feat: include WorkSwarm plugin in release artifacts Build versioned Codex and WorkSwarm bundles together, validate each native entrypoint and isolated runtime, and cover both archives in publication checks and installation docs. Agent-assisted-by: Codex (author) Signed-off-by: zengchuang --- .github/workflows/release.yml | 19 +++++++++------ README.md | 2 +- docs/releases.md | 31 ++++++++++++++---------- docs/workswarm-plugin.md | 17 +++++++++---- tests/test_codex_plugin.py | 43 +++++++++++++++++++++------------ tests/test_release_publisher.py | 31 +++++++++++++++--------- tests/test_workswarm_plugin.py | 30 +++++++++++++++++++++++ tools/check_release.py | 42 ++++++++++++++++++++++++++------ 8 files changed, 152 insertions(+), 63 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 753ff0ccb..0c81401d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,7 +81,7 @@ jobs: - name: Run CPU checks before release run: | python -m pip install -e ".[dev]" build twine - ruff check src tests tools + ruff check src tests tools plugins/workswarm pytest test -z "$(git status --porcelain)" - name: Build and check distributables @@ -102,15 +102,18 @@ jobs: env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_installed.py env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_release.py \ installed --tag "$RELEASE_TAG" - - name: Build and validate the installable Codex plugin ZIP + - name: Build and validate the installable host plugin ZIPs shell: bash run: | - python tools/build_codex_plugin.py \ - --output "$RUNNER_TEMP/plugin-build/inferrsi-codex-plugin-$RELEASE_TAG" \ - --release-version "${RELEASE_TAG#v}" - cp "$RUNNER_TEMP/plugin-build/inferrsi-codex-plugin-$RELEASE_TAG.zip" "$RUNNER_TEMP/release-dist/" - python tools/check_release.py plugin \ - "$RUNNER_TEMP/release-dist/inferrsi-codex-plugin-$RELEASE_TAG.zip" --tag "$RELEASE_TAG" + for host in codex workswarm; do + python "tools/build_${host}_plugin.py" \ + --output "$RUNNER_TEMP/plugin-build/inferrsi-$host-plugin-$RELEASE_TAG" \ + --release-version "${RELEASE_TAG#v}" + cp "$RUNNER_TEMP/plugin-build/inferrsi-$host-plugin-$RELEASE_TAG.zip" "$RUNNER_TEMP/release-dist/" + python tools/check_release.py plugin \ + "$RUNNER_TEMP/release-dist/inferrsi-$host-plugin-$RELEASE_TAG.zip" \ + --tag "$RELEASE_TAG" --host "$host" + done test -z "$(git status --porcelain)" - name: Generate checksums working-directory: ${{ runner.temp }}/release-dist diff --git a/README.md b/README.md index 6b9c1dcb9..1969f03f7 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ python -m pip install "/inferrsi--py3-none-any.whl" ``` For Codex, use the [standalone plugin ZIP](docs/codex-plugin.md#install-from-the-release-zip). -For openJiuwen WorkSwarm v0.2.7, build the [native plugin ZIP](docs/workswarm-plugin.md). +For openJiuwen WorkSwarm v0.2.7, use the [native plugin ZIP](docs/workswarm-plugin.md#download-and-install). For CLI development from this checkout: ```bash diff --git a/docs/releases.md b/docs/releases.md index d40b9f407..b6bfa6fe0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -59,14 +59,16 @@ configuration does not silently change their access or existing branch rules. environment outside the checkout. Check tag/distribution/runtime parity and generate `SHA256SUMS`. `MANIFEST.in` limits sdist contents to package sources and build metadata; repository evidence/workflows are not release payload. - Reuse `tools/build_codex_plugin.py` to build the self-contained - `inferrsi-codex-plugin-vX.Y.Z.zip` installer from the same source revision. - Its generated plugin metadata, bundled runtime and tag must have the same - version. Verify every bundled runtime hash and exercise the extracted - launcher/knowledge reads outside the checkout with isolated Python. + Reuse `tools/build_codex_plugin.py` and `tools/build_workswarm_plugin.py` to + build `inferrsi-codex-plugin-vX.Y.Z.zip` and + `inferrsi-workswarm-plugin-vX.Y.Z.zip` from the same source revision. + Each archive's generated host metadata, bundled runtime and tag must have + the same version. Check its native entrypoints, verify every bundled runtime + hash and exercise the extracted launcher/knowledge reads outside the checkout + with isolated Python. These CPU checks do not run WorkSwarm's SDK or UI. 4. A separate write job verifies the artifacts, source ancestry and release state again. It creates the remote tag only after validation, creates an - internal draft, uploads wheel/sdist/plugin ZIP/checksums and automatically publishes + internal draft, uploads wheel/sdist/both plugin ZIPs/checksums and automatically publishes after confirming the attachments and tag identity. It does not execute repository code. These operations stay in the same workflow: tag events created with `GITHUB_TOKEN` do not trigger another workflow. @@ -80,13 +82,16 @@ quality or service performance. Such claims retain the acceptance and evidence requirements in [contributing](contributing.md#evidence-and-claims). Each Release contains a Python wheel, source distribution, the versioned Codex -plugin ZIP and `SHA256SUMS` covering all three packages. Extract the ZIP and -follow the [Codex plugin installation guide](codex-plugin.md#install-from-the-release-zip). -The ZIP contains one standalone plugin, complete bundled runtime -and knowledge, licenses and installation README. Plugin build failure or a -missing/corrupt installer blocks publication. It does not update a plugin branch, -account installation or marketplace listing. No marketplace catalog or installation -wrapper is packaged; the downloaded ZIP is the only Codex plugin release asset. +and WorkSwarm plugin ZIPs and `SHA256SUMS` covering all four packages. Choose +the archive for your host and follow the +[Codex guide](codex-plugin.md#install-from-the-release-zip) or +[WorkSwarm guide](workswarm-plugin.md#download-and-install). +Each ZIP contains one standalone plugin, complete bundled runtime and knowledge, +licenses and an installation README. The hosts use different manifests; their +ZIPs are not interchangeable. Either plugin's build failure or a missing/corrupt +installer blocks publication. Publication does not update a plugin branch, +account installation or marketplace listing. No marketplace catalog or +installation wrapper is packaged. ## Setup and manual checks diff --git a/docs/workswarm-plugin.md b/docs/workswarm-plugin.md index d0fd04c68..39fc15509 100644 --- a/docs/workswarm-plugin.md +++ b/docs/workswarm-plugin.md @@ -5,16 +5,23 @@ inference bottleneck, prepare a comparison or inspect existing evidence. This is the openJiuwen adapter slice of [RFC #152](https://github.com/InferMatrixLab/InferRSI/issues/152). DSH, three-host acceptance and measured usability remain separate work. -## Build and install +## Download and install -From the InferRSI source checkout: +From the [GitHub Releases](https://github.com/InferMatrixLab/InferRSI/releases) +page, download `inferrsi-workswarm-plugin-v.zip` and `SHA256SUMS` for +the same version. Verify the archive's SHA-256 against that file. Select the +WorkSwarm archive; the Codex ZIP has a different native manifest. Release builds +stamp the native manifest and bundled runtime with the same release version. + +For a local build from the InferRSI source checkout: ```text python tools/build_workswarm_plugin.py ``` -The deliverable is `dist/workswarm-plugin.zip`. Its inspectable package directory -is `dist/workswarm-plugin/inferrsi/`. The ZIP has exactly one native package root: +The local deliverable is `dist/workswarm-plugin.zip`. Its inspectable package +directory is `dist/workswarm-plugin/inferrsi/`. Both local and release ZIPs have +exactly one native package root: ```text inferrsi/ @@ -39,7 +46,7 @@ archives are never overwritten. Building does not register the plugin in a host. In the WorkSwarm v0.2.7 web/desktop UI: 1. Open **Extensions (扩展) → My extensions (我的扩展) → Plugins (插件)**. -2. Choose **Create (创建) → Upload file (上传文件创建)** and select the built ZIP. +2. Choose **Create (创建) → Upload file (上传文件创建)** and select the WorkSwarm ZIP. 3. Confirm import and installation, then check that InferRSI is installed. 4. In a conversation in your target project, choose **+ → Extensions → Plugins** and select InferRSI. diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 7b0f837e8..5e5bccbf9 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -161,34 +161,43 @@ def test_archive_contains_only_one_standalone_plugin(tmp_path): assert json.loads(info.stdout)["content_sha256"] == manifest["content_sha256"] -@pytest.fixture(scope="module") -def release_distribution(tmp_path_factory): - root = tmp_path_factory.mktemp("codex-release") +@pytest.fixture(scope="module", params=["codex", "workswarm"]) +def release_distribution(tmp_path_factory, request): + host = request.param + root = tmp_path_factory.mktemp(host + "-release") project = root / "source" - for directory in ("src/inferrsi", "plugins/inferrsi"): + directories = ["src/inferrsi", "plugins/inferrsi"] + if host == "workswarm": + directories.append("plugins/workswarm/inferrsi") + for directory in directories: shutil.copytree(_io_path(PROJECT / directory), _io_path(project / directory), ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) (project / "docs").mkdir() - shutil.copyfile(PROJECT / "docs/codex-plugin.md", project / "docs/codex-plugin.md") + readme = f"docs/{host}-plugin.md" + shutil.copyfile(PROJECT / readme, project / readme) shutil.copyfile(PROJECT / "LICENSE", project / "LICENSE") (project / "src/inferrsi/_version.py").write_text( '__version__ = version = "0.2.0"\n', encoding="utf-8", ) - template = (project / "plugins/inferrsi/plugin.json").read_bytes() - result = build_plugin(root / "inferrsi-codex-plugin-v0.2.0", project=project, - release_version="0.2.0") + metadata = ("plugins/inferrsi/plugin.json" if host == "codex" + else "plugins/workswarm/inferrsi/manifest.json") + template = (project / metadata).read_bytes() + builder = build_plugin if host == "codex" else build_workswarm_plugin + result = builder(root / f"inferrsi-{host}-plugin-v0.2.0", project=project, + release_version="0.2.0") + result.update(host=host, metadata=metadata) return project, template, result def test_release_archive_is_installable_and_all_versions_match(release_distribution): project, template, result = release_distribution - check_plugin(Path(result["archive"]), "v0.2.0") - assert (project / "plugins/inferrsi/plugin.json").read_bytes() == template + check_plugin(Path(result["archive"]), "v0.2.0", result["host"]) + assert (project / result["metadata"]).read_bytes() == template with zipfile.ZipFile(result["archive"]) as archive: - name = "inferrsi/plugin.json" + name = "inferrsi/" + Path(result["metadata"]).name assert json.loads(archive.read(name))["version"] == "0.2.0" with pytest.raises(ValueError, match="agree exactly"): - check_plugin(Path(result["archive"]), "v0.2.1") + check_plugin(Path(result["archive"]), "v0.2.1", result["host"]) def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp_path): @@ -201,7 +210,7 @@ def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp data += b"\n" destination.writestr(member, data) with pytest.raises(ValueError, match="checksum mismatch"): - check_plugin(changed, "v0.2.0") + check_plugin(changed, "v0.2.0", release_distribution[2]["host"]) @pytest.mark.parametrize("replacement", [ @@ -243,7 +252,7 @@ def test_release_archive_rejects_unverified_runtime_modules(release_distribution with zipfile.ZipFile(changed, "a") as archive: archive.writestr("inferrsi/runtime/hashlib.py", b"") with pytest.raises(ValueError, match="outside the bundled runtime"): - check_plugin(changed, "v0.2.0") + check_plugin(changed, "v0.2.0", release_distribution[2]["host"]) @pytest.mark.parametrize("extra", [ @@ -255,14 +264,16 @@ def test_release_archive_rejects_marketplace_wrappers(release_distribution, tmp_ with zipfile.ZipFile(changed, "a") as archive: archive.writestr(extra, "{}") with pytest.raises(ValueError, match="standalone|marketplace"): - check_plugin(changed, "v0.2.0") + check_plugin(changed, "v0.2.0", release_distribution[2]["host"]) def test_release_builder_rejects_mismatched_version_before_writing(release_distribution, tmp_path): project = release_distribution[0] output = tmp_path / "wrong-version" + builder = (build_plugin if release_distribution[2]["host"] == "codex" + else build_workswarm_plugin) with pytest.raises(ValueError, match="match the built runtime"): - build_plugin(output, project=project, release_version="0.2.1") + builder(output, project=project, release_version="0.2.1") assert not output.exists() and not output.with_suffix(".zip").exists() diff --git a/tests/test_release_publisher.py b/tests/test_release_publisher.py index fff623ef5..a4ffcc784 100644 --- a/tests/test_release_publisher.py +++ b/tests/test_release_publisher.py @@ -56,14 +56,15 @@ def save(): if not state.get("tag_created") or "--draft" not in args or "--verify-tag" not in args: raise SystemExit("Release must be prepared as a draft on the existing tag") record("create_release") - installer = "dist/inferrsi-codex-plugin-v1.2.3.zip" - if installer not in args: - raise SystemExit("Release upload must include the versioned Codex installer") - record("upload_plugin", name=Path(installer).name) + installers = [f"dist/inferrsi-{host}-plugin-v1.2.3.zip" for host in ("codex", "workswarm")] + for installer in installers: + if installer not in args: + raise SystemExit("Release upload must include both versioned host installers") + record("upload_plugin", name=Path(installer).name) state["assets"] = ["inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "SHA256SUMS", - Path(installer).name] + *[Path(installer).name for installer in installers]] if state.get("missing_asset"): - state["assets"].pop() + state["assets"].remove(f"inferrsi-{state['missing_asset']}-plugin-v1.2.3.zip") save() elif args == ["release", "view", tag, "--json", "assets", "--jq", ".assets[].name"]: print("\n".join(state["assets"])) @@ -108,7 +109,8 @@ def publisher(tmp_path): dist = tmp_path / "dist" dist.mkdir() checksums = [] - for name in ("inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "inferrsi-codex-plugin-v1.2.3.zip"): + for name in ("inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", + "inferrsi-codex-plugin-v1.2.3.zip", "inferrsi-workswarm-plugin-v1.2.3.zip"): (dist / name).write_bytes(b"synthetic package") checksums.append(hashlib.sha256(b"synthetic package").hexdigest() + " " + name) (dist / "SHA256SUMS").write_text("\n".join(checksums) + "\n", encoding="utf-8", newline="\n") @@ -125,9 +127,10 @@ def publisher(tmp_path): def run(state=None, **inputs): if state and state.get("corrupt_zip"): - (dist / "inferrsi-codex-plugin-v1.2.3.zip").write_bytes(b"changed installer") + (dist / f"inferrsi-{state['corrupt_zip']}-plugin-v1.2.3.zip").write_bytes( + b"changed installer") if state and state.get("absent_zip"): - (dist / "inferrsi-codex-plugin-v1.2.3.zip").unlink() + (dist / f"inferrsi-{state['absent_zip']}-plugin-v1.2.3.zip").unlink() state_path, log_path = tmp_path / "state.json", tmp_path / "calls.jsonl" state_path.write_text(json.dumps(state or {}), encoding="utf-8") env = dict(os.environ) @@ -154,6 +157,7 @@ def test_publisher_only_creates_a_tag_and_publishes_its_release(publisher): {"operation": "create_tag", "ref": "refs/tags/v1.2.3", "sha": COMMIT}, {"operation": "create_release"}, {"operation": "upload_plugin", "name": "inferrsi-codex-plugin-v1.2.3.zip"}, + {"operation": "upload_plugin", "name": "inferrsi-workswarm-plugin-v1.2.3.zip"}, {"operation": "publish_release"}, ] @@ -172,7 +176,8 @@ def test_invalid_publication_inputs_cannot_write(publisher, inputs): @pytest.mark.parametrize("state", [ {"existing": True}, {"ancestry": "diverged"}, {"resolved": "2" * 40}, - {"corrupt_zip": True}, {"absent_zip": True}, + *[{problem: host} for problem in ("corrupt_zip", "absent_zip") + for host in ("codex", "workswarm")], ]) def test_conflicting_or_unverified_source_cannot_write(publisher, state): result, mutations = publisher(state) @@ -180,10 +185,12 @@ def test_conflicting_or_unverified_source_cannot_write(publisher, state): assert mutations == [] -@pytest.mark.parametrize("state", [{"moved": True}, {"missing_asset": True}]) +@pytest.mark.parametrize("state", [ + {"moved": True}, {"missing_asset": "codex"}, {"missing_asset": "workswarm"}, +]) def test_incomplete_or_retargeted_release_cannot_be_published(publisher, state): result, mutations = publisher(state) assert result.returncode != 0 assert [entry["operation"] for entry in mutations] == [ - "create_tag", "create_release", "upload_plugin", + "create_tag", "create_release", "upload_plugin", "upload_plugin", ] diff --git a/tests/test_workswarm_plugin.py b/tests/test_workswarm_plugin.py index ddd2c2dff..975df07c8 100644 --- a/tests/test_workswarm_plugin.py +++ b/tests/test_workswarm_plugin.py @@ -8,9 +8,39 @@ import pytest from tools.build_workswarm_plugin import PROJECT, build_plugin +from tools.check_release import check_plugin from tools.plugin_bundle import _io_path +@pytest.mark.parametrize("invalid", [ + "package_type", "skills", "tool_file", "tool_class", "missing_tool", + "tool_syntax", "tool_class_missing", +]) +def test_release_validator_requires_native_entrypoints(tmp_path, invalid): + metadata = json.loads((PROJECT / "plugins/workswarm/inferrsi/manifest.json").read_text( + encoding="utf-8")) + if invalid in {"package_type", "skills"}: + metadata[invalid] = "invalid" + elif invalid in {"tool_file", "tool_class"}: + metadata["tools"][0][invalid.removeprefix("tool_")] = "invalid" + path = tmp_path / "invalid-native.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("inferrsi/manifest.json", json.dumps(metadata)) + archive.writestr("inferrsi/runtime/manifest.json", "{}") + if invalid != "missing_tool": + source = "invalid =" if invalid == "tool_syntax" else "class AnotherTool: pass" + archive.writestr("inferrsi/tools/runtime_info_tool.py", source) + if invalid == "missing_tool": + with pytest.raises(KeyError, match="runtime_info_tool"): + check_plugin(path, "v0.2.0", "workswarm") + elif invalid in {"tool_syntax", "tool_class_missing"}: + with pytest.raises(ValueError, match="WorkSwarm locator"): + check_plugin(path, "v0.2.0", "workswarm") + else: + with pytest.raises(ValueError, match="native skill and locator"): + check_plugin(path, "v0.2.0", "workswarm") + + def test_native_manifest_and_shared_launcher(tmp_path): result = build_plugin(tmp_path / "native package") plugin = _io_path(Path(result["plugin"])) diff --git a/tools/check_release.py b/tools/check_release.py index f5a2bd7f7..9de953be9 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -1,6 +1,7 @@ """Plan daily versioned releases and validate their packages without publishing.""" import argparse +import ast import hashlib import json import os @@ -90,8 +91,10 @@ def check_sdist(path: Path) -> None: raise ValueError("Source distribution includes repository-only material") -def check_plugin(path: Path, tag: str) -> None: +def check_plugin(path: Path, tag: str, host: str = "codex") -> None: version = release_version(tag) + if host not in {"codex", "workswarm"}: + raise ValueError("Unknown plugin host") with zipfile.ZipFile(path) as archive: names = archive.namelist() if len(names) != len(set(names)): @@ -121,19 +124,39 @@ def check_plugin(path: Path, tag: str) -> None: if roots != {"inferrsi"}: raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") plugin = "inferrsi" - allowed = {"plugin.json", "skills", "runtime", "LICENSE", "README.md"} + metadata_file = "plugin.json" if host == "codex" else "manifest.json" + allowed = {metadata_file, "skills", "runtime", "LICENSE", "README.md"} + if host == "workswarm": + allowed.add("tools") if any(len(PurePosixPath(name).parts) > 1 and PurePosixPath(name).parts[1] not in allowed for name in names): raise ValueError("Plugin ZIP includes a marketplace or unrelated wrapper") - metadata = json.loads(archive.read(f"{plugin}/plugin.json")) + metadata = json.loads(archive.read(f"{plugin}/{metadata_file}")) manifest = json.loads(archive.read(f"{plugin}/runtime/manifest.json")) - if metadata.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json": - raise ValueError("Plugin manifest must use the portable plugin schema") + if host == "codex": + if metadata.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json": + raise ValueError("Plugin manifest must use the portable plugin schema") + identity = metadata["name"] + else: + if (metadata.get("package_type") != "plugin" + or metadata.get("skills") != [{"dir": "./skills/inferrsi", "mode": "all"}] + or len(metadata.get("tools", [])) != 1 + or metadata["tools"][0].get("file") != "tools/runtime_info_tool.py" + or metadata["tools"][0].get("class") != "InferRSIRuntimeInfoTool"): + raise ValueError("WorkSwarm manifest must declare the native skill and locator") + try: + locator = ast.parse(archive.read(f"{plugin}/tools/runtime_info_tool.py")) + except (SyntaxError, ValueError) as error: + raise ValueError("WorkSwarm locator must be valid Python") from error + if not any(isinstance(node, ast.ClassDef) and node.name == "InferRSIRuntimeInfoTool" + for node in locator.body): + raise ValueError("WorkSwarm locator must define the declared tool class") + identity = metadata["id"] archive.getinfo(f"{plugin}/skills/inferrsi/SKILL.md") archive.getinfo(f"{plugin}/README.md") archive.getinfo(f"{plugin}/LICENSE") check_versions(tag, metadata["version"], manifest["runtime_version"]) - if metadata["name"] != "inferrsi" or manifest["plugin_version"] != version: + if identity != "inferrsi" or manifest["plugin_version"] != version: raise ValueError("Plugin identity/version does not match the release") prefix = f"{plugin}/runtime/" actual = {name[len(prefix):] for name in names @@ -166,7 +189,9 @@ def invoke(*args): capture_output=True, text=True, encoding="utf-8", timeout=45) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - raise ValueError("Extracted Codex plugin failed isolated validation") from error + raise ValueError( + f"Extracted {host} plugin failed isolated validation" + ) from error return json.loads(result.stdout) info = invoke("--plugin-info") @@ -195,6 +220,7 @@ def main() -> None: plugin = commands.add_parser("plugin") plugin.add_argument("path", type=Path) plugin.add_argument("--tag", required=True) + plugin.add_argument("--host", choices=("codex", "workswarm"), default="codex") args = parser.parse_args() try: if args.command == "plan": @@ -212,7 +238,7 @@ def main() -> None: elif args.command == "sdist": check_sdist(args.path) elif args.command == "plugin": - check_plugin(args.path, args.tag) + check_plugin(args.path, args.tag, args.host) else: from importlib.metadata import version From b8a2cb543ceb6188197ca4acadc73493ae93450e Mon Sep 17 00:00:00 2001 From: zengchuang Date: Tue, 3 Nov 2026 22:09:30 +0800 Subject: [PATCH 4/4] fix: avoid WorkSwarm Windows import path limits Keep canonical runtime bytes in a compact payload during native import. Verify and atomically cache the expanded runtime for isolated CLI calls and persistent resource links. Cover ordinary-path lifecycle replay, cache integrity and host-specific release validation. Agent-assisted-by: Codex (author) Signed-off-by: zengchuang --- docs/workswarm-plugin.md | 48 +++-- .../skills/inferrsi/scripts/inferrsi.py | 174 ++++++++++++++++++ .../inferrsi/tools/runtime_info_tool.py | 4 +- tests/check_workswarm_installed.py | 38 ++-- tests/test_codex_plugin.py | 71 +++++-- tests/test_workswarm_plugin.py | 12 +- tests/test_workswarm_runtime.py | 116 ++++++++++++ tools/build_workswarm_plugin.py | 2 +- tools/check_release.py | 81 +++++--- tools/plugin_bundle.py | 46 +++-- 10 files changed, 505 insertions(+), 87 deletions(-) create mode 100644 plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py create mode 100644 tests/test_workswarm_runtime.py diff --git a/docs/workswarm-plugin.md b/docs/workswarm-plugin.md index 39fc15509..a4bbcc7a2 100644 --- a/docs/workswarm-plugin.md +++ b/docs/workswarm-plugin.md @@ -30,7 +30,8 @@ inferrsi/ skills/inferrsi/SKILL.md skills/inferrsi/scripts/inferrsi.py runtime/manifest.json - runtime/inferrsi/ + runtime/payload.zip + runtime/launcher.py LICENSE README.md ``` @@ -39,7 +40,11 @@ The native manifest declares `package_type: plugin`, `id: inferrsi`, one skill with `mode: all` and the read-only `inferrsi_runtime_info` tool. Codex uses a different installation manifest; use the archive built for your host. Both builders copy the same canonical runtime, -knowledge, resource notices and launcher. The source template alone is incomplete. +knowledge, resource notices and shared launcher. WorkSwarm keeps that runtime +tree inside `runtime/payload.zip`, so its importer never copies deep resource +paths. The entry bootstrap verifies every payload file, its content identity +and the shared launcher before using a private per-user runtime cache. The +source template alone is incomplete. Choose a fresh `--output ` for another build; existing outputs and archives are never overwritten. Building does not register the plugin in a host. @@ -89,9 +94,10 @@ python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --plugin-info python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --help ``` -`--plugin-info` reports runtime/plugin versions and the build's content identity; -it does not verify every installed file. The bundle manifest records per-file -SHA-256 values. A missing runtime has a reinstall/build repair path. Knowledge +`--plugin-info` reports runtime/plugin versions and the build's content identity. +The WorkSwarm bootstrap verifies the payload and cached files before every +invocation. The bundle manifest records per-file SHA-256 values. A missing or +modified runtime fails with a repair message. Knowledge reads retain their qualified identity, exact revision and attribution; stale revisions are rejected rather than silently replaced. @@ -126,6 +132,18 @@ native uninstall action to remove the package. Keep experiment evidence outside the package directory. Uninstall does not cancel an owned run or delete evidence in target directories. The adapter adds no service, hook or scheduler. +The verified runtime cache is under `%LOCALAPPDATA%/InferRSI/workswarm-runtime/` +on Windows, or `~/.cache/InferRSI/workswarm-runtime/` elsewhere. It is independent +of the host profile and target repository. Content-addressed entries are +published atomically and verified before use, including concurrent first use. +The shared launcher runs in the same foreground process, preserving cancellation, +cwd and exit codes. The cache persists after commands and uninstall so paths +returned by `skills reference` and `skills resource` remain usable. After all +owned invocations finish, that cache directory can be removed to reclaim space; +the next invocation rebuilds its required entry. Modified cache entries are +rejected rather than executed. Remove the affected entry and retry from a +verified package to repair it. No host configuration or Windows policy is changed. + ## Compatibility and verification The native contract is based on the released **v0.2.7** host, rather than an @@ -165,30 +183,30 @@ subprocess so its SDK log handles close before the parent removes the temporary profile. The replay invokes no model and does not change the desktop user's profile. Windows v0.2.7's importer stages and copies the entire package under its data -root. The canonical knowledge tree contains deep paths, so native import may -fail with `WinError 206` / `WinError 3` when the host process does not support -extended-length paths. The builder's Windows path support does not repair the -host's later copy. An isolated backend can be checked with a process-local -extended-length absolute `JIUWENSWARM_DATA_DIR`; this is not proof that an -existing desktop instance uses that spelling. Do not relocate user data or -change global Windows policy merely to test a package. +root. The earlier unpacked layout reproduced `WinError 206` / missing-file +errors in the desktop's ordinary profile. The compact outer package avoids +those deep import paths; the bootstrap uses extended-length paths only for +its own verified Windows cache. Native lifecycle checks use an ordinary data +root without a `\\?\` prefix, rather than masking the importer failure by +changing the host environment. Validation on **2026-11-03, Windows / Python 3.12**: | Check | Observed scope | | --- | --- | | Official v0.2.7 validator | Quality, static checks and real hot-load passed with zero errors/warnings; one skill and one read-only locator bound | -| Native backend lifecycle | Two import/install/uninstall cycles completed using an extended-length isolated data root; reinstall, selection metadata and unrelated registry/evidence preservation checked | +| Native backend lifecycle | Ordinary isolated data-root import/install/uninstall replay; reinstall, selection metadata and unrelated registry/evidence preservation checks | | Model-facing bootstrap | Native skill discovery and locator ToolMessages resolve the installed launcher; isolated CLI invocation, unknown-argument rejection and extension unload checked without calling a model | | Extracted runtime | Both host distributions exercise canonical bytes/notices, isolated exact knowledge reads, stale-revision rejection, optional dependency guidance, preview without execution and synthetic CPU run/status/report for all three verdicts | -| Desktop UI, LLM dispatch, cancellation/reconnect | Not exercised | +| Running desktop backend | Compact ZIP imported and installed through the running desktop's native RPCs in its unchanged default profile; installed launcher and resource reads checked | +| Desktop click workflow, LLM dispatch, cancellation/reconnect | UI error text inspected; click automation unavailable, so a complete UI installation journey and model/cancellation tests remain unverified | Plain unbuilt source reports runtime version `0+unknown`; `--plugin-info` reports that development state explicitly. A versioned build can use `--release-version ` only when the already-built runtime agrees; this stamps generated host metadata without modifying canonical source bytes. -Actual desktop/LLM journeys, +Actual end-to-end desktop/LLM journeys, cancellation/reconnect, other host releases and Linux/macOS installation require their own verification. Packaging or hot-load success alone does not complete the RFC or demonstrate a usability improvement. diff --git a/plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py b/plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py new file mode 100644 index 000000000..7c7902fd2 --- /dev/null +++ b/plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py @@ -0,0 +1,174 @@ +"""Expand a verified private runtime without exposing deep paths to the host importer.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import re +import runpy +import shutil +import stat +import sys +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +def _io_path(path: Path) -> Path: + value = str(path.resolve()) + if os.name == "nt" and not value.startswith("\\\\?\\"): + return Path("\\\\?\\UNC\\" + value[2:] if value.startswith("\\\\") else "\\\\?\\" + value) + return Path(value) + + +def verified_files(archive: zipfile.ZipFile, manifest: dict) -> dict[str, bytes]: + """Reject aliases/traversal and verify every byte before writing or executing it.""" + hashes = manifest["files"] + names = archive.namelist() + if not hashes or len(names) != len(set(names)) or set(names) != set(hashes): + raise ValueError("Runtime manifest must cover every bundled runtime file") + destinations = set() + spellings = {} + result = {} + for member in archive.infolist(): + name = PurePosixPath(member.filename) + if (not name.parts or name.parts[0] != "inferrsi" or len(name.parts) < 2 + or name.is_absolute() or ".." in name.parts or member.is_dir() + or member.filename != name.as_posix() + or "\\" in member.filename or ":" in member.filename + or name.as_posix().casefold() in destinations + or stat.S_ISLNK(member.external_attr >> 16) + or any(part.endswith((".", " ")) or re.search(r'[<>"|?*]', part) + or re.fullmatch(r"con|prn|aux|nul|com[1-9]|lpt[1-9]", + part.split(".")[0], re.IGNORECASE) + for part in name.parts)): + raise ValueError("Unsafe runtime ZIP entry") + destinations.add(name.as_posix().casefold()) + for depth in range(1, len(name.parts) + 1): + prefix = "/".join(name.parts[:depth]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise ValueError("Unsafe runtime ZIP directory alias") + data = archive.read(member) + if hashlib.sha256(data).hexdigest() != hashes[member.filename]: + raise ValueError("Bundled runtime checksum mismatch") + result[member.filename] = data + identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) + if hashlib.sha256(identity.encode("utf-8")).hexdigest() != manifest["content_sha256"]: + raise ValueError("Runtime content identity mismatch") + return result + + +def _plain(path: Path, attributes=None) -> None: + attributes = path.lstat() if attributes is None else attributes + if (stat.S_ISLNK(attributes.st_mode) + or getattr(attributes, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)): + raise ValueError("Runtime cache must not contain links or reparse points") + + +def cache_runtime(manifest_bytes: bytes, launcher: bytes, content: dict[str, bytes]) -> Path: + """Publish a private content-addressed cache, then verify it before every invocation.""" + base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / ".cache") + if not base.is_absolute(): + raise ValueError("Runtime cache base must be absolute, outside the target repository") + cache_parent = base / "InferRSI" / "workswarm-runtime" + for directory in (cache_parent.parent, cache_parent): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + _plain(directory) + if os.name != "nt": + attributes = directory.stat() + if attributes.st_uid != os.getuid() or attributes.st_mode & 0o022: + raise ValueError("Runtime cache must be owned by this user and not shared writable") + cache_parent = _io_path(cache_parent) + identity = hashlib.sha256(manifest_bytes + launcher).hexdigest() + root = cache_parent / identity + expected = {"runtime/" + name: data for name, data in content.items()} + expected["runtime/manifest.json"] = manifest_bytes + expected["skills/inferrsi/scripts/inferrsi.py"] = launcher + if not root.exists(): + staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=cache_parent)) + try: + directories = {parent for name in expected for parent in Path(name).parents + if parent != Path(".")} + for directory in sorted(directories, key=lambda path: len(path.parts)): + (staging / directory).mkdir() + for name, data in expected.items(): + path = staging / name + path.write_bytes(data) + try: + staging.rename(root) + except OSError: + # Another first invocation can atomically publish the same key. + if not root.is_dir(): + raise + finally: + if staging.exists(): + _plain(staging) + if staging.parent != cache_parent: + raise ValueError("Runtime cache staging escaped its owned parent") + shutil.rmtree(staging) + _plain(root) + + def mismatch(reason): + return ValueError( + f"Runtime cache verification failed; {reason}. Stop owned InferRSI invocations, " + f"remove only cache entry {identity} under InferRSI/workswarm-runtime, then retry" + ) + + actual = set() + pending = [root] + while pending: + with os.scandir(pending.pop()) as entries: + for entry in entries: + # Windows DirEntry caches enumeration attributes, avoiding a + # separate lstat/is_file round trip for every knowledge file. + attributes = entry.stat(follow_symlinks=False) + path = Path(entry.path) + _plain(path, attributes) + if stat.S_ISDIR(attributes.st_mode): + pending.append(path) + elif stat.S_ISREG(attributes.st_mode): + name = path.relative_to(root).as_posix() + actual.add(name) + if name not in expected or path.read_bytes() != expected[name]: + raise mismatch("cached files were modified") + else: + raise mismatch("cache contains a special file") + if actual != set(expected): + raise mismatch("cached files are missing") + return root / "skills/inferrsi/scripts/inferrsi.py" + + +def main() -> int: + if sys.version_info < (3, 10): + print("InferRSI requires Python 3.10 or newer.", file=sys.stderr) + return 2 + runtime = _io_path(Path(__file__).resolve().parents[3] / "runtime") + try: + manifest_bytes = (runtime / "manifest.json").read_bytes() + manifest = json.loads(manifest_bytes) + payload = (runtime / "payload.zip").read_bytes() + launcher = (runtime / "launcher.py").read_bytes() + if (hashlib.sha256(payload).hexdigest() != manifest["payload_sha256"] + or hashlib.sha256(launcher).hexdigest() != manifest["launcher_sha256"]): + raise ValueError("Runtime payload or launcher checksum mismatch") + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + content = verified_files(archive, manifest) + entry = cache_runtime(manifest_bytes, launcher, content) + # No wrapper subprocess: the host still owns the foreground CLI process. + try: + runpy.run_path(str(entry), run_name="__main__") + except SystemExit as exit_status: + return exit_status.code if isinstance(exit_status.code, int) else 1 + return 0 + except (OSError, ValueError, KeyError, zipfile.BadZipFile) as error: + print(f"InferRSI plugin bundle is incomplete: {error}. " + "See the package-integrity and runtime-cache repair steps in the bundled README.", + file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/workswarm/inferrsi/tools/runtime_info_tool.py b/plugins/workswarm/inferrsi/tools/runtime_info_tool.py index aa2517757..dbe10dde0 100644 --- a/plugins/workswarm/inferrsi/tools/runtime_info_tool.py +++ b/plugins/workswarm/inferrsi/tools/runtime_info_tool.py @@ -27,7 +27,9 @@ async def invoke(self, inputs, **kwargs): root = Path(__file__).resolve().parent.parent launcher = root / "skills/inferrsi/scripts/inferrsi.py" if (not launcher.is_file() - or not (root / "runtime/inferrsi/__init__.py").is_file()): + or not (root / "runtime/payload.zip").is_file() + or not (root / "runtime/launcher.py").is_file() + or not (root / "runtime/manifest.json").is_file()): return { "success": False, "error": "InferRSI bundle is incomplete; reinstall the built WorkSwarm ZIP.", diff --git a/tests/check_workswarm_installed.py b/tests/check_workswarm_installed.py index 32ee81e53..105161e99 100644 --- a/tests/check_workswarm_installed.py +++ b/tests/check_workswarm_installed.py @@ -8,33 +8,27 @@ import argparse import hashlib +import io import json import logging import os +import shutil import subprocess import sys import tempfile +import zipfile from pathlib import Path -def extended_path(path: Path) -> Path: - path = path.resolve() - if os.name == "nt" and not str(path).startswith("\\\\?\\"): - return Path("\\\\?\\UNC\\" + str(path)[2:] if str(path).startswith("\\\\") - else "\\\\?\\" + str(path)) - return path - - def check(archive: Path, host_source: Path) -> dict: archive = archive.resolve() host_source = host_source.resolve() # Resolve explicit inputs before changing cwd. Host imports can create logs. - with tempfile.TemporaryDirectory( - prefix="irsi-host-", dir=extended_path(Path(tempfile.gettempdir())), - ) as scratch: + with tempfile.TemporaryDirectory(prefix="irsi-host-") as scratch: previous_cwd = Path.cwd() os.chdir(scratch) - data = extended_path(Path(scratch) / "host-data") + data = (Path(scratch) / "host-data").resolve() + assert not str(data).startswith("\\\\?\\") os.environ["JIUWENSWARM_DATA_DIR"] = str(data) sys.path.insert(0, str(host_source)) sys.dont_write_bytecode = True @@ -79,8 +73,14 @@ def check(archive: Path, host_source: Path) -> dict: runtime = installed / "runtime" manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) - for name, digest in manifest["files"].items(): - assert hashlib.sha256((runtime / name).read_bytes()).hexdigest() == digest + payload_bytes = (runtime / "payload.zip").read_bytes() + assert hashlib.sha256(payload_bytes).hexdigest() == manifest["payload_sha256"] + assert hashlib.sha256((runtime / "launcher.py").read_bytes()).hexdigest() == ( + manifest["launcher_sha256"]) + with zipfile.ZipFile(io.BytesIO(payload_bytes)) as payload: + assert set(payload.namelist()) == set(manifest["files"]) + for name, digest in manifest["files"].items(): + assert hashlib.sha256(payload.read(name)).hexdigest() == digest rendered = Path(scratch) / f"rendered-{cycle}.json" handoff = subprocess.run( [sys.executable, "-X", "utf8", @@ -101,6 +101,7 @@ def invoke(*args: str, expected: int = 0): result = subprocess.run( [sys.executable, "-I", "-S", "-X", "utf8", str(launcher), *args], cwd=workspace, capture_output=True, text=True, encoding="utf-8", timeout=45, + env=dict(os.environ, LOCALAPPDATA=str(Path(scratch) / "runtime-cache")), ) assert result.returncode == expected, ( "Installed CLI returned an unexpected code" @@ -127,6 +128,7 @@ def invoke(*args: str, expected: int = 0): "content_sha256": info["content_sha256"], "completed_cycles": cycle + 1, "checks": ["import", "install", "card discovery", "selection metadata", + "ordinary host data root without extended-length prefix", "installed file hashes", "model-rendered skill and locator handoff", "native extension unload", "isolated launcher", "exact revision read", "stale revision rejection", "uninstall", "reinstall", @@ -138,6 +140,14 @@ def invoke(*args: str, expected: int = 0): # close them before TemporaryDirectory removes its Windows tree. logging.shutdown() os.chdir(previous_cwd) + # The host root above is deliberately ordinary. Only our private + # expanded cache needs extended paths during Windows scratch cleanup. + cache = Path(scratch) / "runtime-cache" + if cache.exists() and os.name == "nt": + assert cache.resolve().is_relative_to(Path(scratch).resolve()) + value = str(cache.resolve()) + shutil.rmtree("\\\\?\\UNC\\" + value[2:] if value.startswith("\\\\") + else "\\\\?\\" + value) if __name__ == "__main__": diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 5e5bccbf9..e44974f8e 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -1,7 +1,9 @@ """Exercise a distributed plugin outside a checkout, without site packages.""" import hashlib +import io import json +import os import shutil import subprocess import sys @@ -37,15 +39,26 @@ def invoke(distribution, cwd, *args): return subprocess.run( [sys.executable, "-I", "-S", "-X", "utf8", str(launcher), *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=45, + env=dict(os.environ, LOCALAPPDATA=str(distribution.parent / "runtime-cache")), ) +def runtime_bytes(plugin): + runtime = plugin / "runtime" + if (runtime / "payload.zip").is_file(): + with zipfile.ZipFile(runtime / "payload.zip") as archive: + return {name: archive.read(name) for name in archive.namelist()} + return {path.relative_to(runtime).as_posix(): path.read_bytes() + for path in (runtime / "inferrsi").rglob("*") if path.is_file()} + + def test_bundle_preserves_canonical_bytes_and_license(distribution): plugin = distribution runtime = plugin / "runtime" manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) + bundled = runtime_bytes(plugin) for name, digest in manifest["files"].items(): - actual = (runtime / name).read_bytes() + actual = bundled[name] assert hashlib.sha256(actual).hexdigest() == digest assert actual == _io_path(PROJECT / "src" / name).read_bytes() assert (plugin / "LICENSE").read_bytes() == (PROJECT / "LICENSE").read_bytes() @@ -63,13 +76,12 @@ def test_bundle_contains_all_declared_package_data(distribution): for pattern in patterns for path in source.glob(pattern) if path.is_file() } assert "data/skill_library/repositories/splash/THIRD_PARTY_NOTICES" in expected - runtime = distribution / "runtime" installed = { - path.relative_to(runtime / "inferrsi").as_posix() - for path in (runtime / "inferrsi/data").rglob("*") if path.is_file() + name.removeprefix("inferrsi/") + for name in runtime_bytes(distribution) if name.startswith("inferrsi/data/") } assert expected == installed - manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) + manifest = json.loads((distribution / "runtime/manifest.json").read_text(encoding="utf-8")) assert {f"inferrsi/{name}" for name in expected} <= manifest["files"].keys() @@ -109,6 +121,29 @@ def test_entry_workflow_selects_packaged_skill_projections(distribution, tmp_pat assert all(item["kind"] == "knowledge" for item in envelope["items"]) +@pytest.mark.parametrize(("action", "resource"), [ + ("reference", "../../../hardware/host/host-memory-management/SKILL.md"), + ("resource", "metadata.json"), +]) +def test_returned_skill_paths_survive_process_exit_and_later_invocations( + distribution, tmp_path, action, resource, +): + pytest.importorskip("yaml") + launcher = distribution / "skills/inferrsi/scripts/inferrsi.py" + result = subprocess.run( + [sys.executable, "-I", "-X", "utf8", str(launcher), "skills", action, + "pytorch", resource], cwd=tmp_path, capture_output=True, text=True, + encoding="utf-8", timeout=45, + env=dict(os.environ, LOCALAPPDATA=str(distribution.parent / "runtime-cache")), + ) + assert result.returncode == 0, result.stderr + path = Path(json.loads(result.stdout)["path"]) + assert path.is_file() + original = path.read_bytes() + assert invoke(distribution, tmp_path, "--plugin-info").returncode == 0 + assert path.read_bytes() == original + + def test_missing_runtime_has_repair_guidance(tmp_path): source = PROJECT / "plugins/inferrsi" incomplete = tmp_path / "incomplete" @@ -203,12 +238,26 @@ def test_release_archive_is_installable_and_all_versions_match(release_distribut def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp_path): original = release_distribution[2]["archive"] changed = tmp_path / "changed.zip" - with zipfile.ZipFile(original) as source, zipfile.ZipFile(changed, "w") as destination: - for member in source.infolist(): - data = source.read(member.filename) - if member.filename.endswith("runtime/inferrsi/__init__.py"): - data += b"\n" - destination.writestr(member, data) + with zipfile.ZipFile(original) as source: + contents = {name: source.read(name) for name in source.namelist()} + payload_name = "inferrsi/runtime/payload.zip" + if payload_name in contents: + buffer = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(contents[payload_name])) as payload: + with zipfile.ZipFile(buffer, "w") as destination: + for name in payload.namelist(): + data = payload.read(name) + (b"\n" if name == "inferrsi/__init__.py" else b"") + destination.writestr(name, data) + contents[payload_name] = buffer.getvalue() + manifest_name = "inferrsi/runtime/manifest.json" + manifest = json.loads(contents[manifest_name]) + manifest["payload_sha256"] = hashlib.sha256(contents[payload_name]).hexdigest() + contents[manifest_name] = json.dumps(manifest).encode() + else: + contents["inferrsi/runtime/inferrsi/__init__.py"] += b"\n" + with zipfile.ZipFile(changed, "w") as destination: + for name, data in contents.items(): + destination.writestr(name, data) with pytest.raises(ValueError, match="checksum mismatch"): check_plugin(changed, "v0.2.0", release_distribution[2]["host"]) diff --git a/tests/test_workswarm_plugin.py b/tests/test_workswarm_plugin.py index 975df07c8..88e8a4a86 100644 --- a/tests/test_workswarm_plugin.py +++ b/tests/test_workswarm_plugin.py @@ -1,5 +1,6 @@ """Native WorkSwarm packaging, with common runtime journeys in test_codex_plugin.""" +import io import json import shutil import zipfile @@ -58,6 +59,9 @@ def test_native_manifest_and_shared_launcher(tmp_path): PROJECT / "plugins/workswarm/inferrsi/tools/runtime_info_tool.py" ).read_bytes() assert (plugin / "skills/inferrsi/scripts/inferrsi.py").read_bytes() == ( + PROJECT / "plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py" + ).read_bytes() + assert (plugin / "runtime/launcher.py").read_bytes() == ( PROJECT / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py" ).read_bytes() assert (plugin / "README.md").read_bytes() == ( @@ -68,6 +72,10 @@ def test_native_manifest_and_shared_launcher(tmp_path): assert "inferrsi/manifest.json" in archive.namelist() assert "inferrsi/plugin.json" not in archive.namelist() assert not any("marketplace" in name for name in archive.namelist()) + # Reproduce the host's long staging prefix without requiring global long-path policy. + assert max(map(len, archive.namelist())) + 170 < 260 + assert not any(name.startswith("inferrsi/runtime/inferrsi/") + for name in archive.namelist()) for name in archive.namelist(): assert archive.read(name) == (plugin / name.removeprefix("inferrsi/")).read_bytes() @@ -98,6 +106,7 @@ def test_builder_rejects_source_directory_output(tmp_path, directory): def test_version_stamp_keeps_canonical_inputs_unchanged(tmp_path): project = tmp_path / "source" for name in ("plugins/workswarm/inferrsi/manifest.json", "docs/workswarm-plugin.md", + "plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py", "plugins/workswarm/inferrsi/tools/runtime_info_tool.py", "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py", "LICENSE"): target = project / name @@ -114,7 +123,8 @@ def test_version_stamp_keeps_canonical_inputs_unchanged(tmp_path): manifest = json.loads(archive.read("inferrsi/runtime/manifest.json")) assert metadata["version"] == manifest["plugin_version"] == "1.2.3" assert manifest["runtime_version"] == "1.2.3" - assert archive.read("inferrsi/runtime/inferrsi/__init__.py") == source.read_bytes() + with zipfile.ZipFile(io.BytesIO(archive.read("inferrsi/runtime/payload.zip"))) as payload: + assert payload.read("inferrsi/__init__.py") == source.read_bytes() assert template.read_bytes() == original output = tmp_path / "wrong-version" with pytest.raises(ValueError, match="match the built runtime"): diff --git a/tests/test_workswarm_runtime.py b/tests/test_workswarm_runtime.py new file mode 100644 index 000000000..f826c4a9e --- /dev/null +++ b/tests/test_workswarm_runtime.py @@ -0,0 +1,116 @@ +"""Integrity, persistence and concurrent first use of the compact host runtime.""" + +import hashlib +import importlib.util +import io +import json +import stat +import zipfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +BOOTSTRAP = Path(__file__).resolve().parents[1] / ( + "plugins/workswarm/inferrsi/skills/inferrsi/scripts/inferrsi.py") +spec = importlib.util.spec_from_file_location("workswarm_bootstrap", BOOTSTRAP) +bootstrap = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bootstrap) + + +def manifest_for(content): + hashes = {name: hashlib.sha256(data).hexdigest() for name, data in content.items()} + identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) + return {"files": hashes, "content_sha256": hashlib.sha256(identity.encode()).hexdigest()} + + +def archive_for(content): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, data in content.items(): + entry = zipfile.ZipInfo() + entry.filename = name # Preserve malicious backslashes on Windows, too. + archive.writestr(entry, data) + buffer.seek(0) + return zipfile.ZipFile(buffer) + + +@pytest.mark.parametrize("names", [ + ["../escape.py"], ["inferrsi/../escape.py"], ["inferrsi//module.py"], + ["inferrsi\\module.py"], ["inferrsi/CON.txt"], ["inferrsi/module.py."], + ["inferrsi/A.py", "inferrsi/a.py"], ["inferrsi/Data/a.md", "inferrsi/data/b.md"], +]) +def test_payload_rejects_unsafe_paths(names): + content = {name: b"must not execute" for name in names} + with archive_for(content) as archive, pytest.raises(ValueError, match="Unsafe|must cover"): + bootstrap.verified_files(archive, manifest_for(content)) + + +def test_payload_rejects_changed_bytes_and_unlisted_modules(): + content = {"inferrsi/__init__.py": b"original"} + for changed in ({"inferrsi/__init__.py": b"changed"}, + {**content, "inferrsi/shadow.py": b"unlisted"}): + with archive_for(changed) as archive, pytest.raises(ValueError): + bootstrap.verified_files(archive, manifest_for(content)) + + +def test_payload_rejects_links(): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + entry = zipfile.ZipInfo("inferrsi/link.py") + entry.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(entry, b"target") + with zipfile.ZipFile(buffer) as archive, pytest.raises(ValueError, match="Unsafe"): + bootstrap.verified_files(archive, manifest_for({"inferrsi/link.py": b"target"})) + + +@pytest.fixture +def cached_runtime(tmp_path, monkeypatch): + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + content = {"inferrsi/__init__.py": b"# canonical bytes", + "inferrsi/data/reference.md": b"persistent resource"} + manifest = json.dumps(manifest_for(content)).encode() + return lambda: bootstrap.cache_runtime(manifest, b"# shared launcher", content) + + +def test_cache_is_persistent_and_concurrent_first_use_is_atomic(cached_runtime): + with ThreadPoolExecutor(max_workers=2) as pool: + first, second = list(pool.map(lambda _: cached_runtime(), range(2))) + assert first == second == cached_runtime() + root = first.parents[3] + assert (root / "runtime/inferrsi/data/reference.md").read_bytes() == b"persistent resource" + assert not list(root.parent.glob(".staging-*")) + + +@pytest.mark.parametrize("tamper", ["changed", "missing", "extra"]) +def test_cache_rejects_modified_missing_or_extra_files(cached_runtime, tamper): + root = cached_runtime().parents[3] + target = root / "runtime/inferrsi/__init__.py" + if tamper == "changed": + target.write_bytes(b"must not execute") + elif tamper == "missing": + target.unlink() + else: + (root / "runtime/shadow.py").write_bytes(b"must not import") + with pytest.raises(ValueError, match="cache verification failed"): + cached_runtime() + + +def test_cache_rejects_linked_parent_before_writing(cached_runtime, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + try: + (tmp_path / "InferRSI").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("Creating directory symlinks is unavailable") + with pytest.raises(ValueError, match="links or reparse points"): + cached_runtime() + assert list(outside.iterdir()) == [] + + +def test_cache_rejects_relative_base_without_writing(cached_runtime, monkeypatch, tmp_path): + monkeypatch.setenv("LOCALAPPDATA", "relative-cache") + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="base must be absolute"): + cached_runtime() + assert not (tmp_path / "relative-cache").exists() diff --git a/tools/build_workswarm_plugin.py b/tools/build_workswarm_plugin.py index 9cffe1b69..488a85e6e 100644 --- a/tools/build_workswarm_plugin.py +++ b/tools/build_workswarm_plugin.py @@ -20,7 +20,7 @@ def build_plugin( return build_bundle( output, project=project, release_version=release_version, template_path="plugins/workswarm/inferrsi", metadata_file="manifest.json", - readme="docs/workswarm-plugin.md", nested=True, + readme="docs/workswarm-plugin.md", nested=True, compact_runtime=True, ) diff --git a/tools/check_release.py b/tools/check_release.py index 9de953be9..1a643430f 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -3,6 +3,7 @@ import argparse import ast import hashlib +import io import json import os import re @@ -91,38 +92,43 @@ def check_sdist(path: Path) -> None: raise ValueError("Source distribution includes repository-only material") +def _standalone_entries(archive: zipfile.ZipFile) -> list[str]: + names = archive.namelist() + if len(names) != len(set(names)): + raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") + roots = set() + destinations = set() + spellings = {} + for member in archive.infolist(): + name = PurePosixPath(member.filename) + canonical = name.as_posix() + ("/" if member.is_dir() else "") + if (not name.parts or name.is_absolute() or ".." in name.parts + or "\\" in member.filename or ":" in member.filename + or member.filename != canonical + or name.as_posix().casefold() in destinations + or any(part.endswith((".", " ")) or re.search(r'[<>"|?*]', part) + or re.fullmatch(r"con|prn|aux|nul|com[1-9]|lpt[1-9]", + part.split(".")[0], re.IGNORECASE) + for part in name.parts) + or stat.S_ISLNK(member.external_attr >> 16)): + raise ValueError("Unsafe plugin ZIP entry") + destinations.add(name.as_posix().casefold()) + for depth in range(1, len(name.parts) + 1): + prefix = "/".join(name.parts[:depth]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise ValueError("Unsafe plugin ZIP directory alias") + roots.add(name.parts[0]) + if roots != {"inferrsi"}: + raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") + return names + + def check_plugin(path: Path, tag: str, host: str = "codex") -> None: version = release_version(tag) if host not in {"codex", "workswarm"}: raise ValueError("Unknown plugin host") with zipfile.ZipFile(path) as archive: - names = archive.namelist() - if len(names) != len(set(names)): - raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") - roots = set() - destinations = set() - spellings = {} - for member in archive.infolist(): - name = PurePosixPath(member.filename) - canonical = name.as_posix() + ("/" if member.is_dir() else "") - if (not name.parts or name.is_absolute() or ".." in name.parts - or "\\" in member.filename or ":" in member.filename - or member.filename != canonical - or name.as_posix().casefold() in destinations - or any(part.endswith((".", " ")) or re.search(r'[<>"|?*]', part) - or re.fullmatch(r"con|prn|aux|nul|com[1-9]|lpt[1-9]", - part.split(".")[0], re.IGNORECASE) - for part in name.parts) - or stat.S_ISLNK(member.external_attr >> 16)): - raise ValueError("Unsafe plugin ZIP entry") - destinations.add(name.as_posix().casefold()) - for depth in range(1, len(name.parts) + 1): - prefix = "/".join(name.parts[:depth]) - if spellings.setdefault(prefix.casefold(), prefix) != prefix: - raise ValueError("Unsafe plugin ZIP directory alias") - roots.add(name.parts[0]) - if roots != {"inferrsi"}: - raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") + names = _standalone_entries(archive) plugin = "inferrsi" metadata_file = "plugin.json" if host == "codex" else "manifest.json" allowed = {metadata_file, "skills", "runtime", "LICENSE", "README.md"} @@ -162,13 +168,28 @@ def check_plugin(path: Path, tag: str, host: str = "codex") -> None: actual = {name[len(prefix):] for name in names if name.startswith(prefix) and name != prefix + "manifest.json" and not name.endswith("/")} + if host == "workswarm": + if actual != {"payload.zip", "launcher.py"}: + raise ValueError("Unexpected file outside the bundled runtime package") + payload_bytes = archive.read(prefix + "payload.zip") + if (hashlib.sha256(payload_bytes).hexdigest() != manifest["payload_sha256"] + or hashlib.sha256(archive.read(prefix + "launcher.py")).hexdigest() + != manifest["launcher_sha256"]): + raise ValueError("Plugin bundled runtime checksum mismatch") + with zipfile.ZipFile(io.BytesIO(payload_bytes)) as payload: + payload_names = _standalone_entries(payload) + runtime_bytes = {name: payload.read(name) for name in payload_names + if not name.endswith("/")} + else: + runtime_bytes = {name: archive.read(prefix + name) for name in actual} + actual = set(runtime_bytes) if any(not name.startswith("inferrsi/") for name in actual): raise ValueError("Unexpected file outside the bundled runtime package") hashes = manifest["files"] if not hashes or set(hashes) != actual: raise ValueError("Plugin manifest must cover every bundled runtime file") for name, digest in hashes.items(): - if hashlib.sha256(archive.read(prefix + name)).hexdigest() != digest: + if hashlib.sha256(runtime_bytes[name]).hexdigest() != digest: raise ValueError("Plugin bundled runtime checksum mismatch") identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) if hashlib.sha256(identity.encode("utf-8")).hexdigest() != manifest["content_sha256"]: @@ -187,7 +208,9 @@ def invoke(*args): try: result = subprocess.run(command + list(args), cwd=staging, check=True, capture_output=True, text=True, encoding="utf-8", - timeout=45) + timeout=45, + env=dict(os.environ, LOCALAPPDATA=str( + destination / "runtime-cache"))) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: raise ValueError( f"Extracted {host} plugin failed isolated validation" diff --git a/tools/plugin_bundle.py b/tools/plugin_bundle.py index 2e2e8b6c2..9304403df 100644 --- a/tools/plugin_bundle.py +++ b/tools/plugin_bundle.py @@ -41,9 +41,23 @@ def _version(path: Path) -> str: raise ValueError("Runtime __version__ is missing") +def _zip_tree(root: Path, destination: Path, prefix: str = "") -> None: + with zipfile.ZipFile(destination, "x", compression=zipfile.ZIP_DEFLATED) as bundle: + for path in sorted(root.rglob("*")): + if path.is_file(): + info = zipfile.ZipInfo( + prefix + path.relative_to(root).as_posix(), + date_time=(1980, 1, 1, 0, 0, 0), + ) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + bundle.writestr(info, path.read_bytes()) + + def build_bundle( output: Path, *, template_path: str, metadata_file: str, readme: str, project: Path = PROJECT, release_version: str | None = None, nested: bool = False, + compact_runtime: bool = False, ) -> dict: """Create a fresh distribution; never replace user configuration or earlier builds.""" output = output.resolve() @@ -76,9 +90,9 @@ def build_bundle( shutil.copyfile(project / readme, plugin / "README.md") launcher = plugin / "skills/inferrsi/scripts/inferrsi.py" launcher.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile( - project / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py", launcher, - ) + shared_launcher = project / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py" + if not compact_runtime: + shutil.copyfile(shared_launcher, launcher) runtime = plugin / "runtime" hashes = {} for path in sorted(source.rglob("*")): @@ -111,24 +125,26 @@ def build_bundle( if release_version is not None: metadata["version"] = release_version _json(plugin / metadata_file, metadata) - _json(runtime / "manifest.json", { + manifest = { "schema_version": 1, "plugin_version": metadata["version"], "runtime_version": runtime_version, "content_sha256": hashlib.sha256(identity.encode("utf-8")).hexdigest(), "files": hashes, - }) + } + if compact_runtime: + # The host must never copy the deep canonical tree during import/install. + # Only the bootstrap expands it, using extended paths on Windows. + payload = runtime / "payload.zip" + _zip_tree(runtime / "inferrsi", payload, "inferrsi/") + manifest["payload_sha256"] = hashlib.sha256(payload.read_bytes()).hexdigest() + manifest["launcher_sha256"] = hashlib.sha256(shared_launcher.read_bytes()).hexdigest() + shutil.copyfile(shared_launcher, runtime / "launcher.py") + assert (runtime / "inferrsi").resolve().is_relative_to(Path(staging).resolve()) + shutil.rmtree(runtime / "inferrsi") + _json(runtime / "manifest.json", manifest) # Stable ZIP metadata makes repeated builds of identical bytes comparable. - with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as bundle: - for path in sorted(plugin.rglob("*")): - if path.is_file(): - info = zipfile.ZipInfo( - (Path("inferrsi") / path.relative_to(plugin)).as_posix(), - date_time=(1980, 1, 1, 0, 0, 0), - ) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o100644 << 16 - bundle.writestr(info, path.read_bytes()) + _zip_tree(plugin, archive, "inferrsi/") shutil.copytree(plugin, _io_path(package)) return { "plugin": str(package),