diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 936352333..d39e13977 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 4e0ded005..1969f03f7 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, 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 c4cec8438..61a6cbf00 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -59,11 +59,13 @@ 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. After a successful build, an optional read-only notes job calls the AI API with bounded change metadata for exactly the planned revision range. Its Markdown summary is transferred as a separate artifact. Missing credentials, @@ -71,7 +73,7 @@ configuration does not silently change their access or existing branch rules. existing GitHub-generated notes. 5. 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. @@ -148,13 +150,16 @@ write permission, and the publisher receives no API key. There is no AI code commit, merge, account installation or update to an existing public Release. 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 new file mode 100644 index 000000000..a4bbcc7a2 --- /dev/null +++ b/docs/workswarm-plugin.md @@ -0,0 +1,212 @@ +# 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. + +## Download and install + +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 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/ + manifest.json + tools/runtime_info_tool.py + skills/inferrsi/SKILL.md + skills/inferrsi/scripts/inferrsi.py + runtime/manifest.json + runtime/payload.zip + runtime/launcher.py + LICENSE + README.md +``` + +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 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. + +In the WorkSwarm v0.2.7 web/desktop UI: + +1. Open **Extensions (扩展) → My extensions (我的扩展) → Plugins (插件)**. +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. + +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. 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 +python -X utf8 "/skills/inferrsi/scripts/inferrsi.py" --help +``` + +`--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. + +`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. + +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 +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. 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 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 | 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 | +| 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 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/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..320d8e67a --- /dev/null +++ b/plugins/workswarm/inferrsi/manifest.json @@ -0,0 +1,47 @@ +{ + "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"}], + "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 new file mode 100644 index 000000000..a59029312 --- /dev/null +++ b/plugins/workswarm/inferrsi/skills/inferrsi/SKILL.md @@ -0,0 +1,99 @@ +--- +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 + +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 +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/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 new file mode 100644 index 000000000..dbe10dde0 --- /dev/null +++ b/plugins/workswarm/inferrsi/tools/runtime_info_tool.py @@ -0,0 +1,46 @@ +"""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/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.", + } + 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 new file mode 100644 index 000000000..105161e99 --- /dev/null +++ b/tests/check_workswarm_installed.py @@ -0,0 +1,158 @@ +"""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 io +import json +import logging +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import 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-") as scratch: + previous_cwd = Path.cwd() + os.chdir(scratch) + 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 + 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")) + 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", + 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( + [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" + ) + 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", + "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", + "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) + # 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__": + 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/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_codex_plugin.py b/tests/test_codex_plugin.py index 57ab19a65..eb10b505f 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 @@ -10,8 +12,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 +23,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) @@ -34,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() @@ -60,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() @@ -119,6 +134,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" @@ -171,47 +209,70 @@ 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): 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") + check_plugin(changed, "v0.2.0", release_distribution[2]["host"]) @pytest.mark.parametrize("replacement", [ @@ -253,7 +314,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", [ @@ -265,14 +326,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 e6be38588..fbcf06cf0 100644 --- a/tests/test_release_publisher.py +++ b/tests/test_release_publisher.py @@ -56,9 +56,10 @@ 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") + 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") if "--generate-notes" not in args or "--notes-file" not in args: raise SystemExit("Keep the GitHub changelog and read summary as a file") notes = Path(args[args.index("--notes-file") + 1]).read_text(encoding="utf-8") @@ -72,11 +73,12 @@ def save(): raise SystemExit("Missing AI summary") elif "## AI-generated summary" in notes: raise SystemExit("Missing AI output must fall back to ordinary release notes") - record("upload_plugin", name=Path(installer).name) + for installer in 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"])) @@ -138,7 +140,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") @@ -158,9 +161,10 @@ def run(state=None, **inputs): (tmp_path / "notes").mkdir() (tmp_path / "notes/ai-notes.md").write_text(state["ai_summary"], encoding="utf-8") 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) @@ -192,6 +196,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"}, ] @@ -202,7 +207,7 @@ def test_ai_notes_are_text_and_keep_the_publication_mutation_boundary(publisher, result, mutations = publisher({"ai_summary": summary}) assert result.returncode == 0, result.stderr assert [entry["operation"] for entry in mutations] == [ - "create_tag", "create_release", "upload_plugin", "publish_release", + "create_tag", "create_release", "upload_plugin", "upload_plugin", "publish_release", ] assert not (tmp_path / "should-not-exist").exists() @@ -231,7 +236,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) @@ -239,10 +245,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 new file mode 100644 index 000000000..88e8a4a86 --- /dev/null +++ b/tests/test_workswarm_plugin.py @@ -0,0 +1,132 @@ +"""Native WorkSwarm packaging, with common runtime journeys in test_codex_plugin.""" + +import io +import json +import shutil +import zipfile +from pathlib import Path + +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"])) + 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 "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/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() == ( + 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()) + # 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() + + +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/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 + 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" + 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"): + build_plugin(output, project=project, release_version="1.2.4") + assert not output.exists() and not output.with_suffix(".zip").exists() 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_codex_plugin.py b/tools/build_codex_plugin.py index 75c5769a6..7476a9fa2 100644 --- a/tools/build_codex_plugin.py +++ b/tools/build_codex_plugin.py @@ -3,131 +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.suffix == ".html" and relative.parent.parts == ("data", "dashboard")) - 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..488a85e6e --- /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, compact_runtime=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/check_release.py b/tools/check_release.py index f5a2bd7f7..89b06f13c 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -1,7 +1,9 @@ """Plan daily versioned releases and validate their packages without publishing.""" import argparse +import ast import hashlib +import io import json import os import re @@ -90,72 +92,116 @@ def check_sdist(path: Path) -> None: raise ValueError("Source distribution includes repository-only material") -def check_plugin(path: Path, tag: str) -> None: +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" - 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 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"]: raise ValueError("Plugin content identity mismatch") - with tempfile.TemporaryDirectory(prefix="irsi-") as staging: + # Cleanup must use the same extended path as the deep runtime cache. + temp_root = Path(tempfile.gettempdir()).resolve() + value = str(temp_root) + if os.name == "nt" and not value.startswith("\\\\?\\"): + temp_root = Path("\\\\?\\UNC\\" + value[2:] if value.startswith("\\\\") + else "\\\\?\\" + value) + with tempfile.TemporaryDirectory(prefix="irsi-", dir=temp_root) as staging: destination = Path(staging).resolve() - if os.name == "nt": - value = str(destination) - destination = Path("\\\\?\\UNC\\" + value[2:] if value.startswith("\\\\") - else "\\\\?\\" + value) archive.extractall(destination) launcher = destination / plugin / "skills/inferrsi/scripts/inferrsi.py" command = [sys.executable, "-I", "-S", "-X", "utf8", str(launcher)] @@ -164,9 +210,13 @@ 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("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 +245,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 +263,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 diff --git a/tools/plugin_bundle.py b/tools/plugin_bundle.py new file mode 100644 index 000000000..4dbde16ef --- /dev/null +++ b/tools/plugin_bundle.py @@ -0,0 +1,154 @@ +"""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 _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() + 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) + 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("*")): + 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.suffix == ".html" and relative.parent.parts == ("data", "dashboard")) + 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) + 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. + _zip_tree(plugin, archive, "inferrsi/") + shutil.copytree(plugin, _io_path(package)) + return { + "plugin": str(package), + "archive": str(archive), + "runtime_files": len(hashes), + }