diff --git a/.github/workflows/staged-bump-acp-tools.yml b/.github/workflows/staged-bump-acp-tools.yml deleted file mode 100644 index 642008dcf..000000000 --- a/.github/workflows/staged-bump-acp-tools.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Staged Bump ACP Tools - -on: - schedule: - # Daily at 06:30 UTC. - - cron: "30 6 * * *" - workflow_dispatch: - -# One run at a time so a late-finishing run cannot race a newer one. -concurrency: - group: staged-bump-acp-tools - -jobs: - bump: - name: Bump ACP tool pins - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - # Install hermit (manages node, just) - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1.1.5 - - # The branch is bot-owned and single-commit: every run recreates it from - # main, so it never diverges or accumulates history. - - name: Reset automation branch from main - run: git switch --force-create automation/update-acp-pins - - # The recipe pins the newest releases that have aged past the script's - # 50-hour cooling-off window (its default), not bare `latest`, so a - # release that is about to be yanked or superseded never lands here. - - name: Bump ACP tool pins - run: just -f apps/staged/justfile bump-acp-tools - - # The recipe touches only the lockfile; a clean diff means no upstream - # releases outside the cooling-off window and the run is done. - - name: Detect lockfile changes - id: bump - run: | - if git diff --quiet -- apps/staged/acp-tools.lock.json; then - echo "Lockfile unchanged; no upstream releases." - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - # PR CI does not consume the lockfile, so a broken pin would otherwise - # surface only at release time (aarch64-apple-darwin) or on a dev - # machine's next `just dev`. Install the new pins (with integrity - # validation) for every target in the lockfile before pushing them - # anywhere; npm's --os/--cpu flags let this Linux runner install the - # foreign-platform entries too. Targets are passed explicitly because - # the script's auto-detection needs rustc, which this job otherwise - # does not use. - - name: Smoke-check new pins for all lockfile targets - if: steps.bump.outputs.changed == 'true' - run: | - targets="$(node -e ' - const { tools } = require("./apps/staged/acp-tools.lock.json"); - process.stdout.write([...new Set(tools.map((tool) => tool.target))].sort().join("\n")); - ')" - while IFS= read -r target; do - apps/staged/scripts/ensure-acp-tools.sh --target "$target" - done <<< "$targets" - - - name: Commit and force-push - if: steps.bump.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(staged): bump bundled ACP tools" \ - -- apps/staged/acp-tools.lock.json - git push --force origin automation/update-acp-pins - - # Refresh-in-place: while a PR for this branch is open, the force-push - # above already updated it to show the fresh pins rebased on current - # main. Only create a PR when none is open. - - name: Create PR if none open - if: steps.bump.outputs.changed == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - open_prs="$(gh pr list --head automation/update-acp-pins --state open --json number --jq length)" - if [ "$open_prs" = "0" ]; then - gh pr create \ - --base main \ - --head automation/update-acp-pins \ - --title "chore(staged): bump bundled ACP tools" \ - --body "Automated daily bump of the ACP bridge tool pins in \`apps/staged/acp-tools.lock.json\` via \`just bump-acp-tools\`, which pins the newest releases that have aged past a 50-hour cooling-off window rather than bare \`latest\`. The new pins were smoke-checked in the workflow run by installing them for every lockfile target with \`scripts/ensure-acp-tools.sh\`. While this PR is open, each daily run force-pushes the freshest eligible pins to this branch." - else - echo "Open PR already exists for automation/update-acp-pins; it now shows the fresh pins." - fi diff --git a/.github/workflows/staged-release.yml b/.github/workflows/staged-release.yml index de6e821e2..cef2016bd 100644 --- a/.github/workflows/staged-release.yml +++ b/.github/workflows/staged-release.yml @@ -79,10 +79,6 @@ jobs: STAGED_UPDATER_ENDPOINT: https://github.com/${{ github.repository }}/releases/download/staged-latest/latest.json run: pnpm run tauri:release:config - - name: Stage bundled ACP tools - working-directory: apps/staged - run: ./scripts/prepare-acp-tools-resource.sh "$TAURI_TARGET" - - name: Build unsigned Tauri app working-directory: apps/staged env: diff --git a/.gitignore b/.gitignore index 89142c17b..af9ed7944 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,3 @@ target/ .DS_Store .hermit node_modules/ - -# Staged bundled ACP bridge tools (regenerated by apps/staged/scripts/prepare-acp-tools-resource.sh) -apps/staged/src-tauri/resources/acp/bin/* -!apps/staged/src-tauri/resources/acp/bin/.gitkeep -apps/staged/src-tauri/resources/acp/node/ -apps/staged/src-tauri/resources/acp/node-runtime.json diff --git a/apps/staged/README.md b/apps/staged/README.md index 87473c221..3ad41af5f 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -64,15 +64,19 @@ If you installed manually (not via the install script), copy `scripts/staged` to ### AI Agent Setup (ACP) -Staged discovers ACP providers by CLI command name. For Claude Code ACP: - -```bash -npm install -g @zed-industries/claude-agent-acp -``` - -This package installs the `claude-agent-acp` executable. - -After installing, open Staged and run **Settings -> Doctor** to verify the Claude check is detected as installed. +Staged manages the Claude Code and Codex ACP bridges itself: on every launch +it installs (or upgrades) them into `~/.staged/packages` on a Staged-managed +Node.js runtime, so no host Node or manual `npm install` is needed. The first +launch downloads them, which requires network access; after that they work +offline. Other agents (Goose, Pi, copilot, amp) are discovered by CLI command +name on your PATH, and **Settings -> Doctor** can install the npm-based ones +into the same Staged-private prefix. + +Open **Settings -> Doctor** to verify agent checks are detected as installed. + +To develop against a local bridge checkout, export `STAGED_ACP_TOOLS_DIR` +pointing at a directory with the bridge binaries — this disables the managed +installs and resolves bridges from that directory instead. ## Development diff --git a/apps/staged/acp-tools.lock.json b/apps/staged/acp-tools.lock.json deleted file mode 100644 index c96344337..000000000 --- a/apps/staged/acp-tools.lock.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "tools": [ - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "aarch64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "arm64", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "aarch64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "arm64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "x86_64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "x64", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "x86_64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "x64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "codex-acp", - "binary": "codex-acp", - "source": "npm", - "package": "@agentclientprotocol/codex-acp", - "version": "1.1.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "aarch64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "arm64", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-darwin-arm64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-darwin-arm64", - "nativeIntegrity": "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-arm64.tgz", - "nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex" - }, - { - "id": "codex-acp", - "binary": "codex-acp", - "source": "npm", - "package": "@agentclientprotocol/codex-acp", - "version": "1.1.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "aarch64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "arm64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-linux-arm64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-linux-arm64", - "nativeIntegrity": "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-arm64.tgz", - "nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex" - }, - { - "id": "codex-acp", - "binary": "codex-acp", - "source": "npm", - "package": "@agentclientprotocol/codex-acp", - "version": "1.1.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "x86_64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "x64", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-darwin-x64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-darwin-x64", - "nativeIntegrity": "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-x64.tgz", - "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" - }, - { - "id": "codex-acp", - "binary": "codex-acp", - "source": "npm", - "package": "@agentclientprotocol/codex-acp", - "version": "1.1.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "x86_64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "x64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-linux-x64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-linux-x64", - "nativeIntegrity": "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-x64.tgz", - "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" - } - ] -} diff --git a/apps/staged/justfile b/apps/staged/justfile index 8e1f5bce3..10a662a30 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -9,7 +9,6 @@ install: pnpm install cd src-tauri && cargo fetch - ./scripts/ensure-acp-tools.sh # ============================================================================ # Development @@ -23,11 +22,10 @@ dev repo="": # Install JS deps if needed [[ -d node_modules ]] || pnpm install - # Stage the pinned ACP bridge tools and point the app at the staged dir so - # dev builds resolve the same bundled bridges as packaged builds. - ./scripts/prepare-acp-tools-resource.sh - export STAGED_ACP_TOOLS_DIR="$(pwd)/src-tauri/resources/acp/bin" - echo "Using ACP tools dir: ${STAGED_ACP_TOOLS_DIR}" + # Dev instances share the managed ACP bridges and Node runtime in + # ~/.staged/packages with every other Staged instance. Export + # STAGED_ACP_TOOLS_DIR manually to point sessions at a local bridge + # checkout instead (it disables the managed installs). # Derive a stable port from the working directory so the same worktree # always gets the same port. This avoids changing TAURI_CONFIG between @@ -62,7 +60,6 @@ dev repo="": # Build the app for production build: - ./scripts/prepare-acp-tools-resource.sh pnpm run tauri:build # Generate the release-only Tauri config with updater settings @@ -71,7 +68,6 @@ release-config: # Build a signed/notarized release bundle once release config exists release-build target="aarch64-apple-darwin" *args: - ./scripts/prepare-acp-tools-resource.sh {{target}} pnpm exec tauri build --target {{target}} --config src-tauri/tauri.release.conf.json {{args}} # Create a release branch, bump staged versions, and open a release PR @@ -137,9 +133,9 @@ release version: echo "Pushed tag staged/v{{version}} — CI will build and publish the release." -# Update acp-tools.lock.json to the newest npm releases of the bundled ACP bridge tools that have aged past the 50h cooling-off window -bump-acp-tools *ARGS: - node scripts/update-acp-tools-lock.mjs {{ ARGS }} +# Fetch official Node.js release checksums and update node-runtime.lock.json (e.g. `just bump-node-runtime v24.12.0`) +bump-node-runtime *ARGS: + node scripts/update-node-runtime-lock.mjs {{ ARGS }} # ============================================================================ # Code Quality diff --git a/apps/staged/node-runtime.lock.json b/apps/staged/node-runtime.lock.json new file mode 100644 index 000000000..b4e593d8f --- /dev/null +++ b/apps/staged/node-runtime.lock.json @@ -0,0 +1,21 @@ +{ + "version": "v24.11.0", + "artifacts": { + "aarch64-apple-darwin": { + "filename": "node-v24.11.0-darwin-arm64.tar.gz", + "sha256": "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201" + }, + "aarch64-unknown-linux-gnu": { + "filename": "node-v24.11.0-linux-arm64.tar.gz", + "sha256": "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d" + }, + "x86_64-apple-darwin": { + "filename": "node-v24.11.0-darwin-x64.tar.gz", + "sha256": "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1" + }, + "x86_64-unknown-linux-gnu": { + "filename": "node-v24.11.0-linux-x64.tar.gz", + "sha256": "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd" + } + } +} diff --git a/apps/staged/scripts/ensure-acp-tools.sh b/apps/staged/scripts/ensure-acp-tools.sh deleted file mode 100755 index 922ab567d..000000000 --- a/apps/staged/scripts/ensure-acp-tools.sh +++ /dev/null @@ -1,387 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -app_root="$(cd "$script_dir/.." && pwd)" -lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" - -# shellcheck source=scripts/lib/acp-node-wrapper.sh -source "$script_dir/lib/acp-node-wrapper.sh" - -usage() { - cat <<'USAGE' -Usage: scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] - -Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared -Staged dev cache. The lockfile is target-specific; only entries matching the -requested target are prepared. Each tool is installed as a vendored npm -package tree with a small executable wrapper, validated against the locked -versions and integrity hashes. - -Environment variables: - ACP_TOOLS_LOCK_FILE lockfile path (default: ./acp-tools.lock.json) - ACP_TOOLS_CACHE_DIR cache dir override - ACP_TOOLS_NPM_REGISTRY npm registry override (default: normal npm config - resolution, minus project .npmrc files — see below) -USAGE -} - -default_cache_root() { - if [[ -n "${XDG_CACHE_HOME:-}" ]]; then - printf '%s/staged-dev/acp-tools\n' "$XDG_CACHE_HOME" - return - fi - case "$(uname -s)" in - Darwin) printf '%s/Library/Caches/staged-dev/acp-tools\n' "$HOME" ;; - *) printf '%s/.cache/staged-dev/acp-tools\n' "$HOME" ;; - esac -} - -target="" -print_bin_dir=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --target) - target="${2:-}" - [[ -n "$target" ]] || { echo "--target requires a value" >&2; exit 1; } - shift 2 - ;; - --print-bin-dir) - print_bin_dir=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$target" ]]; then - target="$(rustc -vV | sed -n 's|host: ||p')" -fi -if [[ -z "$target" ]]; then - echo "Could not determine rust host target. Pass --target explicitly." >&2 - exit 1 -fi - -cache_root="${ACP_TOOLS_CACHE_DIR:-$(default_cache_root)}" -bin_dir="$cache_root/bin/$target" - -if [[ ! -f "$lock_file" ]]; then - echo "ACP tools lockfile not found: $lock_file" >&2 - exit 1 -fi - -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Required tool missing: $1" >&2 - exit 1 - fi -} - -require_tool node -require_tool npm - -lock_entries="$(node - "$lock_file" "$target" <<'NODE' -const fs = require("node:fs"); -const [lockFile, target] = process.argv.slice(2); -const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); -const entries = (data.tools ?? []).filter((tool) => tool.target === target); -function requireString(entry, field) { - if (typeof entry[field] !== "string" || entry[field].trim() === "") { - throw new Error(`Invalid ACP tool lock entry for ${entry.id ?? "(unknown)"}: missing ${field}`); - } -} -for (const entry of entries) { - if (entry.source !== "npm") { - throw new Error(`Invalid ACP tool lock entry for ${entry.id}: unsupported source ${entry.source}`); - } - for (const field of [ - "id", - "binary", - "target", - "package", - "version", - "integrity", - "tarball", - "npmOs", - "npmCpu", - "dependencyPackage", - "dependencyVersion", - "dependencyIntegrity", - "dependencyTarball", - "nativePackage", - "nativePackageName", - "nativeVersion", - "nativeIntegrity", - "nativeTarball", - "nativeExecutable", - ]) { - requireString(entry, field); - } -} -process.stdout.write(JSON.stringify(entries)); -NODE -)" - -entry_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).length))' "$lock_entries")" -mkdir -p "$bin_dir" -if [[ "$entry_count" == "0" ]]; then - find "$bin_dir" -type f -delete - if [[ "$print_bin_dir" == "1" ]]; then - printf '%s\n' "$bin_dir" - else - echo "No ACP tools locked for target $target." - fi - exit 0 -fi - -validate_npm_install() { - local install_dir="$1" - local package="$2" - local version="$3" - local integrity="$4" - local dependency_package="$5" - local dependency_version="$6" - local dependency_integrity="$7" - local native_package="$8" - local native_package_name="$9" - local native_version="${10}" - local native_integrity="${11}" - local native_executable="${12}" - local claude_code_version="${13}" - - node - "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const [ - installDir, - packageName, - expectedVersion, - expectedIntegrity, - dependencyPackageName, - expectedDependencyVersion, - expectedDependencyIntegrity, - nativePackageName, - expectedNativePackageName, - expectedNativeVersion, - expectedNativeIntegrity, - nativeExecutable, - expectedClaudeCodeVersion, -] = process.argv.slice(2); - -function packagePath(name, ...segments) { - return path.join(installDir, "node_modules", ...name.split("/"), ...segments); -} - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function assertEqual(actual, expected, label) { - if (actual !== expected) { - throw new Error(`${label}: expected ${expected}, got ${actual}`); - } -} - -function packageLockEntry(lock, packageName) { - const suffix = `node_modules/${packageName}`; - const match = Object.entries(lock.packages ?? {}).find(([key]) => key === suffix || key.endsWith(`/${suffix}`)); - if (!match) { - throw new Error(`package-lock entry not found for ${packageName}`); - } - return match[1]; -} - -const packageJson = readJson(packagePath(packageName, "package.json")); -assertEqual(packageJson.name, packageName, `${packageName} name`); -assertEqual(packageJson.version, expectedVersion, `${packageName} version`); - -const lock = readJson(path.join(installDir, "package-lock.json")); -assertEqual(packageLockEntry(lock, packageName).integrity, expectedIntegrity, `${packageName} integrity`); - -const dependencyPackageJson = readJson(packagePath(dependencyPackageName, "package.json")); -assertEqual( - dependencyPackageJson.name, - dependencyPackageName, - `${dependencyPackageName} name`, -); -assertEqual( - dependencyPackageJson.version, - expectedDependencyVersion, - `${dependencyPackageName} version`, -); -if (expectedClaudeCodeVersion && expectedClaudeCodeVersion !== "null") { - assertEqual( - dependencyPackageJson.claudeCodeVersion, - expectedClaudeCodeVersion, - `${dependencyPackageName} claudeCodeVersion`, - ); -} -assertEqual( - packageLockEntry(lock, dependencyPackageName).integrity, - expectedDependencyIntegrity, - `${dependencyPackageName} integrity`, -); - -const nativePackageJson = readJson(packagePath(nativePackageName, "package.json")); -assertEqual( - nativePackageJson.name, - expectedNativePackageName, - `${nativePackageName} package name`, -); -assertEqual( - nativePackageJson.version, - expectedNativeVersion, - `${nativePackageName} version`, -); -fs.accessSync(packagePath(nativePackageName, nativeExecutable), fs.constants.X_OK); -assertEqual( - packageLockEntry(lock, nativePackageName).integrity, - expectedNativeIntegrity, - `${nativePackageName} integrity`, -); -NODE -} - -node -e ' -const entries = JSON.parse(process.argv[1]); -for (const entry of entries) { - console.log([ - entry.id, - entry.binary, - entry.package, - entry.version, - entry.integrity, - entry.tarball, - entry.npmOs, - entry.npmCpu, - entry.npmLibc ?? "", - entry.nodeEngine ?? ">=22", - entry.dependencyPackage, - entry.dependencyVersion, - entry.dependencyIntegrity, - entry.dependencyTarball, - entry.nativePackage, - entry.nativePackageName, - entry.nativeVersion, - entry.nativeIntegrity, - entry.nativeTarball, - entry.nativeExecutable, - entry.claudeCodeVersion ?? "", - ].join("\x1f")); -} -' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball npm_os npm_cpu npm_libc node_engine dependency_package dependency_version dependency_integrity dependency_tarball native_package native_package_name native_version native_integrity native_tarball native_executable claude_code_version; do - [[ -n "$id" ]] || continue - - tool_dir="$cache_root/$target/$id/$version" - install_dir="$tool_dir/npm" - package_dir="$install_dir/node_modules/$package" - entrypoint="$package_dir/dist/index.js" - native_binary="$install_dir/node_modules/$native_package/$native_executable" - staged_bin="$bin_dir/$binary" - # The staged output is shared across lock versions, so its freshness stamp - # must live next to it, not in the per-version tool_dir: a per-version stamp - # stays self-consistent after a lock revert and would skip re-staging. - stamp="$staged_bin.stamp" - if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" && -x "$native_binary" ]]; then - # shellcheck disable=SC1090 - source "$stamp" - if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then - continue - fi - fi - - echo "Installing ACP tool $id $version from npm for $target..." >&2 - rm -rf "$install_dir" - mkdir -p "$install_dir" "$bin_dir" - # npm resolves the bridge's range for its vendored harness dependency at - # install time, so a plain install picks the newest in-range release — not - # the locked one whenever the lock's cooling-off window pinned older (or - # upstream published in-range after the lock landed). The override forces - # the locked resolution; without it validate_npm_install below rejects the - # install. Package names and semver versions cannot contain JSON - # metacharacters, so printf-templated JSON is safe. - printf '{ "name": "acp-tools-install", "private": true, "overrides": { "%s": "%s" } }\n' \ - "$dependency_package" "$dependency_version" > "$install_dir/package.json" - # The install runs with the (freshly wiped) install dir as cwd — npm - # silently ignores root overrides when --prefix is passed — and the - # package.json written above makes it the project root, so repo .npmrc - # files can't influence this install; only user/global config and the - # optional ACP_TOOLS_NPM_REGISTRY flag apply. There is no hard-coded - # registry.npmjs.org default on purpose: Block's Cloudflare gateway blocks - # that host outright on managed devices (dependency confusion policy), so a - # corporate-mirror ~/.npmrc must keep winning by default and that flag is - # the escape hatch for other environments. - npm_args=( - install - --omit=dev - --include=optional - --ignore-scripts - --no-audit - --no-fund - --os "$npm_os" - --cpu "$npm_cpu" - ) - if [[ -n "${ACP_TOOLS_NPM_REGISTRY:-}" ]]; then - npm_args+=(--registry "$ACP_TOOLS_NPM_REGISTRY") - fi - if [[ -n "$npm_libc" ]]; then - npm_args+=(--libc "$npm_libc") - fi - npm_args+=("$package@$version") - if ! (cd "$install_dir" && npm "${npm_args[@]}") >&2; then - echo "Failed to install $package@$version. If your configured registry is a corporate mirror that has not synced this version yet, retry once it catches up, or set ACP_TOOLS_NPM_REGISTRY to a registry that has it." >&2 - exit 1 - fi - - validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" - write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine" - { - printf 'STAMP_TARGET=%q\n' "$target" - printf 'STAMP_PACKAGE=%q\n' "$package" - printf 'STAMP_VERSION=%q\n' "$version" - printf 'STAMP_INTEGRITY=%q\n' "$integrity" - printf 'STAMP_TARBALL=%q\n' "$tarball" - printf 'STAMP_NPM_OS=%q\n' "$npm_os" - printf 'STAMP_NPM_CPU=%q\n' "$npm_cpu" - printf 'STAMP_NPM_LIBC=%q\n' "$npm_libc" - printf 'STAMP_NODE_ENGINE=%q\n' "$node_engine" - printf 'STAMP_DEPENDENCY_PACKAGE=%q\n' "$dependency_package" - printf 'STAMP_DEPENDENCY_VERSION=%q\n' "$dependency_version" - printf 'STAMP_DEPENDENCY_INTEGRITY=%q\n' "$dependency_integrity" - printf 'STAMP_DEPENDENCY_TARBALL=%q\n' "$dependency_tarball" - printf 'STAMP_CLAUDE_CODE_VERSION=%q\n' "$claude_code_version" - printf 'STAMP_NATIVE_PACKAGE=%q\n' "$native_package" - printf 'STAMP_NATIVE_PACKAGE_NAME=%q\n' "$native_package_name" - printf 'STAMP_NATIVE_VERSION=%q\n' "$native_version" - printf 'STAMP_NATIVE_INTEGRITY=%q\n' "$native_integrity" - printf 'STAMP_NATIVE_TARBALL=%q\n' "$native_tarball" - printf 'STAMP_NATIVE_EXECUTABLE=%q\n' "$native_executable" - printf 'STAMP_BINARY=%q\n' "$binary" - } > "$stamp" -done - -# bin_dir is on the Goose search path, so binaries (and stamps) for tools no -# longer in the lock must be pruned, not just left behind. -locked_binaries="$(node -e ' -const entries = JSON.parse(process.argv[1]); -for (const entry of entries) console.log(entry.binary); -' "$lock_entries" | sort -u)" -find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do - name="$(basename "$staged_file")" - if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then - rm -f -- "$staged_file" - fi -done - -if [[ "$print_bin_dir" == "1" ]]; then - printf '%s\n' "$bin_dir" -fi diff --git a/apps/staged/scripts/lib/acp-node-wrapper.sh b/apps/staged/scripts/lib/acp-node-wrapper.sh deleted file mode 100644 index 7d99839ee..000000000 --- a/apps/staged/scripts/lib/acp-node-wrapper.sh +++ /dev/null @@ -1,61 +0,0 @@ -# Shared Node wrapper generation for ACP bridge tools staged from npm. -# Sourced by ensure-acp-tools.sh and prepare-acp-tools-resource.sh so the -# wrapper staged into the dev cache and the wrapper bundled into app -# resources cannot drift (a drift would make dev and bundled installs fail -# differently on the same missing/old Node runtime). -# -# acp_required_node_major -# Prints the minimum Node.js major version implied by a ">=N..." engine -# range, defaulting to 22 when the range is not in that form. Shared by -# the wrapper shim below and the node-runtime.json manifest consumed by -# the app's Node.js runtime doctor check, so the version the wrapper -# enforces at spawn time and the version the doctor reports at setup -# time cannot disagree. -acp_required_node_major() { - local node_engine="$1" - local major - major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" - if [[ -z "$major" ]]; then - major=22 - fi - printf '%s\n' "$major" -} - -# write_node_wrapper [node-engine] -# Writes an executable bash shim at that verifies a Node.js -# runtime satisfying (default ">=22") is on PATH, then -# execs node on . An absolute is embedded -# verbatim; a relative one is resolved against the wrapper's directory -# at run time. - -write_node_wrapper() { - local wrapper="$1" - local entrypoint="$2" - local node_engine="${3:->=22}" - local required_node_major - required_node_major="$(acp_required_node_major "$node_engine")" - - mkdir -p "$(dirname "$wrapper")" - { - printf '#!/usr/bin/env bash\n' - printf 'set -euo pipefail\n' - printf 'if ! command -v node >/dev/null 2>&1; then\n' - printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" - printf ' exit 127\n' - printf 'fi\n' - printf 'required_node_major=%q\n' "$required_node_major" - printf 'node_major="$(node -p '\''process.versions.node.split(".")[0]'\'' 2>/dev/null || true)"\n' - printf 'if [[ -z "$node_major" || "$node_major" -lt "$required_node_major" ]]; then\n' - printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" - printf ' exit 1\n' - printf 'fi\n' - if [[ "$entrypoint" == /* ]]; then - printf 'entrypoint=%q\n' "$entrypoint" - else - printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' - printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint" - fi - printf 'exec node "$entrypoint" "$@"\n' - } > "$wrapper" - chmod +x "$wrapper" -} diff --git a/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh deleted file mode 100755 index 2250e4b88..000000000 --- a/apps/staged/scripts/prepare-acp-tools-resource.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -app_root="$(cd "$script_dir/.." && pwd)" -lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" - -# shellcheck source=scripts/lib/acp-node-wrapper.sh -source "$script_dir/lib/acp-node-wrapper.sh" - -usage() { - cat <<'USAGE' -Usage: scripts/prepare-acp-tools-resource.sh [target-triple] - -Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can -bundle them as application resources: vendored npm package trees under -resources/acp/node and executable wrappers under resources/acp/bin. The -optional target triple defaults to the Rust host target. - -Note: resources/acp/bin holds a single target at a time, so staging must stay -tied to the build target. -USAGE -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -target="${1:-}" -ensure_args=() -if [[ -n "$target" ]]; then - ensure_args+=(--target "$target") -else - target="$(rustc -vV | sed -n 's|host: ||p')" -fi -if [[ -z "$target" ]]; then - echo "Could not determine rust host target." >&2 - exit 1 -fi - -cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)" -cache_root="$(dirname "$(dirname "$cache_bin_dir")")" -resource_root="$app_root/src-tauri/resources/acp" -resource_bin_dir="$resource_root/bin" -resource_node_dir="$resource_root/node" -mkdir -p "$resource_bin_dir" - -# Keep .gitkeep but refresh any staged tools from the lock. -find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete -rm -rf "$resource_node_dir" -mkdir -p "$resource_node_dir" - -# Manifest for the app's Node.js runtime doctor check, staged next to the -# bin dir so the app can resolve it as the bin dir's parent. Removed up -# front so locks with no npm-sourced tools ship no manifest and the doctor -# check stays silent. -node_runtime_manifest="$resource_root/node-runtime.json" -rm -f "$node_runtime_manifest" -node_runtime_entries=() - -codesign_if_darwin() { - local file="$1" - if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then - codesign --force --sign - "$file" >/dev/null 2>&1 || true - fi -} - -while IFS=$'\t' read -r id binary package version node_engine; do - [[ -n "$id" ]] || continue - install_dir="$cache_root/$target/$id/$version/npm" - entrypoint="$install_dir/node_modules/$package/dist/index.js" - if [[ ! -f "$entrypoint" ]]; then - echo "Locked npm ACP tool missing from cache: $package@$version" >&2 - exit 1 - fi - resource_package_dir="$resource_node_dir/$id" - mkdir -p "$resource_package_dir" - cp -R "$install_dir/." "$resource_package_dir/" - resource_entrypoint="$resource_package_dir/node_modules/$package/dist/index.js" - if [[ ! -f "$resource_entrypoint" ]]; then - echo "Failed to stage npm ACP tool: $package@$version" >&2 - exit 1 - fi - write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" - node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")") - # Ad-hoc sign every Mach-O in the staged package, not just the main CLIs: - # the codex native package also vendors executables like rg and zsh, and - # unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux - # staging skips the file(1) scan. - if [[ "$(uname -s)" == "Darwin" ]]; then - while IFS= read -r -d '' candidate; do - if file -b "$candidate" | grep -q "Mach-O"; then - codesign_if_darwin "$candidate" - fi - done < <(find "$resource_package_dir" -type f -print0) - fi -done < <(node - "$lock_file" "$target" <<'NODE' -const fs = require("node:fs"); -const [lockFile, target] = process.argv.slice(2); -const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); -for (const entry of data.tools ?? []) { - if (entry.target !== target || typeof entry.binary !== "string") continue; - if (entry.source !== "npm") { - throw new Error(`Unsupported ACP tool source: ${entry.source}`); - } - console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t")); -} -NODE -) - -# One manifest entry per npm-sourced bridge, each carrying its own required -# Node major, so bridges with different engine ranges surface distinct -# requirements in the doctor check. -if ((${#node_runtime_entries[@]} > 0)); then - node -e ' -const fs = require("node:fs"); -const [manifestFile, ...entries] = process.argv.slice(1); -const tools = entries.map((line) => { - const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t"); - return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) }; -}); -fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`); -' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"} - echo "Wrote ACP Node runtime manifest: $node_runtime_manifest" -fi - -echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/apps/staged/scripts/update-acp-tools-lock.mjs b/apps/staged/scripts/update-acp-tools-lock.mjs deleted file mode 100755 index 88dca34bf..000000000 --- a/apps/staged/scripts/update-acp-tools-lock.mjs +++ /dev/null @@ -1,602 +0,0 @@ -#!/usr/bin/env node -import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { promisify } from "node:util"; - -const appRoot = path.resolve(import.meta.dirname, ".."); -const defaultLockFile = path.join(appRoot, "acp-tools.lock.json"); - -const SUPPORTED_TARGETS = [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "aarch64-unknown-linux-gnu", - "x86_64-unknown-linux-gnu", -]; - -// Releases must age past a cooling-off window before they are eligible to -// pin: broken or compromised releases are typically yanked or superseded -// within a day or two of publish, so waiting out the window keeps the daily -// bump from shipping them. When `latest` is still inside the window, the -// newest older stable release that has aged past it is pinned instead. -const DEFAULT_COOLING_OFF_HOURS = 50; - -// The Codex ACP executable stays `codex-acp`, but bundled installs must come -// from the maintained Agent Client Protocol package rather than the stale -// Zed package. -const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp"; - -// `passthroughArgs` is the bridge's CLI-passthrough invocation that prints the -// vendored harness CLI's version. Doctor probes auth (and, for bundled -// installs, freshness) through these same passthrough subcommands -// (`claude-agent-acp --cli …`, `codex-acp cli …`), so a bridge release that -// drops or renames them must fail the smoke check here instead of silently -// breaking every doctor probe in the field. -// -// codex-acp uses `-V` because its entrypoint intercepts a literal `--version` -// anywhere in argv and prints the bridge's own version; only codex's clap -// short flag reaches the vendored binary. -const TOOL_SPECS = [ - { - id: "claude-acp", - binary: "claude-agent-acp", - package: "@agentclientprotocol/claude-agent-acp", - dependencyPackage: "@anthropic-ai/claude-agent-sdk", - nativePackageKey: "claudeAgentSdk", - includeClaudeCodeVersion: true, - passthroughArgs: ["--cli", "--version"], - }, - { - id: "codex-acp", - binary: "codex-acp", - package: CODEX_ACP_PACKAGE, - dependencyPackage: "@openai/codex", - nativePackageKey: "openaiCodex", - passthroughArgs: ["cli", "-V"], - }, -]; - -const NPM_TARGET_CONFIG = { - "aarch64-apple-darwin": { - npmOs: "darwin", - npmCpu: "arm64", - nativePackages: { - claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-arm64", - openaiCodex: "@openai/codex-darwin-arm64", - }, - nativeExecutables: { - claudeAgentSdk: "claude", - openaiCodex: "vendor/aarch64-apple-darwin/bin/codex", - }, - }, - "x86_64-apple-darwin": { - npmOs: "darwin", - npmCpu: "x64", - nativePackages: { - claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-x64", - openaiCodex: "@openai/codex-darwin-x64", - }, - nativeExecutables: { - claudeAgentSdk: "claude", - openaiCodex: "vendor/x86_64-apple-darwin/bin/codex", - }, - }, - "aarch64-unknown-linux-gnu": { - npmOs: "linux", - npmCpu: "arm64", - npmLibc: "glibc", - nativePackages: { - claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-arm64", - openaiCodex: "@openai/codex-linux-arm64", - }, - nativeExecutables: { - claudeAgentSdk: "claude", - openaiCodex: "vendor/aarch64-unknown-linux-musl/bin/codex", - }, - }, - "x86_64-unknown-linux-gnu": { - npmOs: "linux", - npmCpu: "x64", - npmLibc: "glibc", - nativePackages: { - claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-x64", - openaiCodex: "@openai/codex-linux-x64", - }, - nativeExecutables: { - claudeAgentSdk: "claude", - openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex", - }, - }, -}; - -const npmViewCache = new Map(); -const execFileAsync = promisify(execFile); - -function usage() { - console.log(`Usage: scripts/update-acp-tools-lock.mjs [--target ]... [--lock-file ] [--skip-smoke] [--cooling-off-hours ] - -Queries npm for the newest release of each supported ACP bridge tool that has -aged past the cooling-off window (default ${DEFAULT_COOLING_OFF_HOURS} hours, 0 disables it) and -writes acp-tools.lock.json. Fails loudly when a package or one of its -per-target native dependencies cannot be resolved — never silently pins an -older version than the cooling-off window calls for. - -Before writing the lock, each tool's CLI passthrough (the subcommand doctor's -auth/version probes rely on) is smoke-checked against the resolved release by -installing it into a temp prefix and running it on the current platform. ---skip-smoke bypasses this (e.g. on hosts that cannot execute the vendored -binaries). - -Supported targets: - ${SUPPORTED_TARGETS.join("\n ")} - -Environment: - npm registry config used to resolve packages - ACP_TOOLS_LOCK_FILE lockfile path override -`); -} - -function parseArgs(argv) { - const targets = []; - let lockFile = process.env.ACP_TOOLS_LOCK_FILE ?? defaultLockFile; - let skipSmoke = false; - let coolingOffHours = DEFAULT_COOLING_OFF_HOURS; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "-h" || arg === "--help") { - usage(); - process.exit(0); - } - if (arg === "--target") { - const value = argv[++i]; - if (!value) throw new Error("--target requires a value"); - targets.push(value); - continue; - } - if (arg === "--lock-file") { - const value = argv[++i]; - if (!value) throw new Error("--lock-file requires a value"); - lockFile = path.resolve(value); - continue; - } - if (arg === "--skip-smoke") { - skipSmoke = true; - continue; - } - if (arg === "--cooling-off-hours") { - const value = argv[++i]; - if (!value) throw new Error("--cooling-off-hours requires a value"); - coolingOffHours = Number(value); - if (!Number.isFinite(coolingOffHours) || coolingOffHours < 0) { - throw new Error("--cooling-off-hours must be a non-negative number"); - } - continue; - } - throw new Error(`Unknown argument: ${arg}`); - } - - const selectedTargets = targets.length ? targets : SUPPORTED_TARGETS; - for (const target of selectedTargets) { - if (!SUPPORTED_TARGETS.includes(target)) { - throw new Error(`Unsupported target '${target}'`); - } - } - return { targets: selectedTargets, lockFile, skipSmoke, coolingOffHours }; -} - -async function npmView(spec, fields) { - const cacheKey = `${spec}\0${fields.join("\0")}`; - if (!npmViewCache.has(cacheKey)) { - npmViewCache.set( - cacheKey, - execFileAsync("npm", ["view", spec, ...fields, "--json"], { - maxBuffer: 10 * 1024 * 1024, - }).then(({ stdout }) => { - try { - return JSON.parse(stdout); - } catch (error) { - throw new Error( - `npm view ${spec} returned invalid JSON: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }), - ); - } - return npmViewCache.get(cacheKey); -} - -function requireString(value, label) { - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`Missing ${label}`); - } - return value; -} - -function packageDist(metadata, label) { - const dist = metadata?.dist; - return { - tarball: requireString(dist?.tarball, `${label} dist.tarball`), - integrity: requireString(dist?.integrity, `${label} dist.integrity`), - }; -} - -function compareSemver(left, right) { - const leftCore = left.split("-", 1)[0].split(".").map(Number); - const rightCore = right.split("-", 1)[0].split(".").map(Number); - for (let i = 0; i < 3; i += 1) { - if ((leftCore[i] ?? 0) !== (rightCore[i] ?? 0)) { - return (leftCore[i] ?? 0) - (rightCore[i] ?? 0); - } - } - // A release outranks any prerelease of the same core version. - return (left.includes("-") ? 0 : 1) - (right.includes("-") ? 0 : 1); -} - -// Publish timestamp of a version from a packument `time` map, or null when -// the registry does not report one (an unknown publish time never counts as -// aged — the cooling-off window cannot be silently waived). -function publishedAtMs(timeMap, version) { - const published = Date.parse(timeMap?.[version] ?? ""); - return Number.isFinite(published) ? published : null; -} - -function hasAged(timeMap, version, coolingOffHours, now) { - if (coolingOffHours <= 0) return true; - const published = publishedAtMs(timeMap, version); - return published !== null && now - published >= coolingOffHours * 3600_000; -} - -// Resolve the version to pin for a package: the `latest` dist-tag once it has -// aged past the cooling-off window, otherwise the newest older stable release -// that has. Never resolves past `latest`, so an upstream dist-tag rollback -// (e.g. after a bad release) is honored even when newer versions exist. -async function resolveLatestAgedVersion(packageName, coolingOffHours, now) { - const packument = await npmView(packageName, [ - "dist-tags", - "time", - "versions", - ]); - const latest = requireString( - packument["dist-tags"]?.latest, - `${packageName} dist-tags.latest`, - ); - const timeMap = packument.time ?? {}; - if (hasAged(timeMap, latest, coolingOffHours, now)) return latest; - // npm view returns a bare string instead of a one-element array when the - // package has a single version. - const versions = Array.isArray(packument.versions) - ? packument.versions - : [packument.versions]; - const candidates = versions.filter( - (version) => - typeof version === "string" && - !version.includes("-") && - compareSemver(version, latest) < 0 && - hasAged(timeMap, version, coolingOffHours, now), - ); - if (candidates.length === 0) { - throw new Error( - `${packageName} has no stable release that has aged past the ` + - `${coolingOffHours}h cooling-off window (latest ${latest} published ` + - `${timeMap[latest] ?? "at an unknown time"})`, - ); - } - const resolved = candidates.reduce((best, candidate) => - compareSemver(candidate, best) > 0 ? candidate : best, - ); - console.log( - `${packageName}: latest ${latest} (published ${timeMap[latest]}) is ` + - `inside the ${coolingOffHours}h cooling-off window; pinning ` + - `${resolved} instead.`, - ); - return resolved; -} - -const coolingOffLogDedup = new Set(); - -// npm view returns a single object when a spec matches one version, but an -// array of per-version objects when a range matches several. Pick the highest -// matching version that has aged past the cooling-off window, so a ranged -// dependency still pins the newest eligible release. A bridge release that -// aged past the window had at least one in-range dependency version at -// publish time — necessarily just as aged — so an empty result means the -// range never matched anything, not an over-strict window. -function pickLatestAgedMatch(metadata, label, timeMap, coolingOffHours, now) { - const matches = Array.isArray(metadata) ? metadata : [metadata]; - if (matches.length === 0) { - throw new Error(`No versions match ${label}`); - } - const pickNewest = (candidates) => - candidates.reduce((best, candidate) => - compareSemver( - requireString(candidate?.version, `${label} version`), - requireString(best?.version, `${label} version`), - ) > 0 - ? candidate - : best, - ); - const aged = matches.filter((candidate) => - hasAged( - timeMap, - requireString(candidate?.version, `${label} version`), - coolingOffHours, - now, - ), - ); - if (aged.length === 0) { - throw new Error( - `All versions matching ${label} were published within the ` + - `${coolingOffHours}h cooling-off window`, - ); - } - const best = pickNewest(aged); - const newest = pickNewest(matches); - // Deduped because this runs once per target with identical inputs. - if (newest.version !== best.version && !coolingOffLogDedup.has(label)) { - coolingOffLogDedup.add(label); - console.log( - `${label}: newest match ${newest.version} (published ` + - `${timeMap?.[newest.version] ?? "at an unknown time"}) is inside ` + - `the ${coolingOffHours}h cooling-off window; pinning ` + - `${best.version} instead.`, - ); - } - return best; -} - -function parseNpmAliasSpec(spec, fallbackPackage) { - if (!spec.startsWith("npm:")) { - return { packageName: fallbackPackage, version: spec }; - } - const aliased = spec.slice("npm:".length); - const versionSeparator = aliased.lastIndexOf("@"); - if (versionSeparator <= 0) { - throw new Error(`Unsupported npm alias spec: ${spec}`); - } - return { - packageName: aliased.slice(0, versionSeparator), - version: aliased.slice(versionSeparator + 1), - }; -} - -async function lockToolForTarget( - tool, - target, - agedVersion, - coolingOffHours, - now, -) { - const npmTarget = NPM_TARGET_CONFIG[target]; - if (!npmTarget) { - throw new Error(`No npm target mapping for ${target}`); - } - - const packageName = tool.package; - const packageMetadata = await npmView(`${packageName}@${agedVersion}`, [ - "name", - "version", - "dist", - "dependencies", - "engines", - "bin", - ]); - - if (packageMetadata.name !== packageName) { - throw new Error( - `npm package ${packageName} resolved to ${packageMetadata.name}`, - ); - } - - const version = requireString( - packageMetadata.version, - `${packageName} version`, - ); - const packageInfo = packageDist(packageMetadata, `${packageName}@${version}`); - const entry = { - id: tool.id, - binary: tool.binary, - source: "npm", - package: packageName, - version, - integrity: packageInfo.integrity, - tarball: packageInfo.tarball, - target, - npmOs: npmTarget.npmOs, - npmCpu: npmTarget.npmCpu, - ...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}), - nodeEngine: packageMetadata.engines?.node ?? ">=22", - }; - - const dependencyRange = requireString( - packageMetadata.dependencies?.[tool.dependencyPackage], - `${packageName} dependency ${tool.dependencyPackage}`, - ); - const dependencyMetadata = pickLatestAgedMatch( - await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [ - "name", - "version", - "dist", - "optionalDependencies", - "claudeCodeVersion", - ]), - `${tool.dependencyPackage}@${dependencyRange}`, - await npmView(tool.dependencyPackage, ["time"]), - coolingOffHours, - now, - ); - const dependencyVersion = requireString( - dependencyMetadata.version, - `${tool.dependencyPackage}@${dependencyRange} version`, - ); - const dependencyInfo = packageDist( - dependencyMetadata, - `${tool.dependencyPackage}@${dependencyVersion}`, - ); - entry.dependencyPackage = tool.dependencyPackage; - entry.dependencyVersion = dependencyVersion; - entry.dependencyIntegrity = dependencyInfo.integrity; - entry.dependencyTarball = dependencyInfo.tarball; - if (tool.includeClaudeCodeVersion) { - entry.claudeCodeVersion = dependencyMetadata.claudeCodeVersion ?? null; - } - - const nativePackage = requireString( - npmTarget.nativePackages?.[tool.nativePackageKey], - `${target} native package for ${tool.nativePackageKey}`, - ); - const nativeExecutable = requireString( - npmTarget.nativeExecutables?.[tool.nativePackageKey], - `${target} native executable for ${tool.nativePackageKey}`, - ); - const nativeSpec = requireString( - dependencyMetadata.optionalDependencies?.[nativePackage], - `${tool.dependencyPackage}@${dependencyVersion} optional dependency ${nativePackage}`, - ); - const nativeAlias = parseNpmAliasSpec(nativeSpec, nativePackage); - const nativeMetadata = await npmView( - `${nativeAlias.packageName}@${nativeAlias.version}`, - ["name", "version", "dist"], - ); - const nativeVersion = requireString( - nativeMetadata.version, - `${nativeAlias.packageName}@${nativeAlias.version} version`, - ); - if (nativeVersion !== nativeAlias.version) { - throw new Error( - `${nativeAlias.packageName}@${nativeAlias.version} resolved to ${nativeVersion}`, - ); - } - const nativeInfo = packageDist( - nativeMetadata, - `${nativeAlias.packageName}@${nativeVersion}`, - ); - - return { - ...entry, - nativePackage, - nativePackageName: nativeMetadata.name ?? nativePackage, - nativeVersion, - nativeIntegrity: nativeInfo.integrity, - nativeTarball: nativeInfo.tarball, - nativeExecutable, - }; -} - -// Install the resolved release into a temp npm prefix and run the bridge's -// CLI passthrough on the current platform, requiring exit 0 and the locked -// vendored-harness version in the output. Guards the interface doctor's -// probes depend on: the passthrough flags are bridge behavior, not a -// documented stable contract, so a release that breaks them — or one whose -// entrypoint starts answering the probe with the bridge's own version -// instead of the vendored CLI's — must fail here before it gets pinned. -async function smokeCheckPassthrough(tool, locked) { - const invocation = `${tool.binary} ${tool.passthroughArgs.join(" ")}`; - const version = locked.version; - // The version doctor surfaces: the vendored harness CLI's, not the bridge's. - const harnessVersion = locked.claudeCodeVersion ?? locked.dependencyVersion; - const prefix = await mkdtemp(path.join(os.tmpdir(), "acp-tools-smoke-")); - try { - // npm resolves the bridge's dependency range at install time, so a plain - // install picks the newest in-range release — not the locked one whenever - // the cooling-off window pinned older. The override forces the locked - // resolution (ensure-acp-tools.sh installs the same way), so the check - // exercises the exact bridge + vendored-harness pair the lock ships. - // npm silently ignores root overrides when --prefix is passed, so the - // install must run with the prefix as its working directory instead; - // writing package.json first makes the prefix the project root, keeping - // repo config out of the install just as --prefix did. - await writeFile( - path.join(prefix, "package.json"), - `${JSON.stringify({ - name: "acp-tools-smoke", - private: true, - overrides: { [locked.dependencyPackage]: locked.dependencyVersion }, - })}\n`, - ); - await execFileAsync( - "npm", - [ - "install", - "--no-fund", - "--no-audit", - "--loglevel=error", - `${tool.package}@${version}`, - ], - { cwd: prefix, maxBuffer: 10 * 1024 * 1024, timeout: 10 * 60 * 1000 }, - ); - const binary = path.join(prefix, "node_modules", ".bin", tool.binary); - let output; - try { - const { stdout, stderr } = await execFileAsync( - binary, - tool.passthroughArgs, - { timeout: 60 * 1000 }, - ); - output = `${stdout}\n${stderr}`.trim(); - } catch (error) { - throw new Error( - `Passthrough smoke check failed for ${tool.package}@${version}: ` + - `\`${invocation}\` did not exit 0 — doctor's auth/version probes ` + - `would break on this release. ${error instanceof Error ? error.message : String(error)}`, - ); - } - if (!output.includes(harnessVersion)) { - throw new Error( - `Passthrough smoke check failed for ${tool.package}@${version}: ` + - `\`${invocation}\` did not print the vendored ` + - `${tool.dependencyPackage} version ${harnessVersion}: ` + - `${output || "(empty output)"}. If the output shows the bridge's ` + - `own version, its entrypoint is intercepting the probe before the ` + - `passthrough dispatch and doctor's freshness readout would be wrong.`, - ); - } - console.log(`Smoke-checked \`${invocation}\`: ${output.split("\n")[0]}`); - } finally { - await rm(prefix, { recursive: true, force: true }); - } -} - -async function main() { - const { targets, lockFile, skipSmoke, coolingOffHours } = parseArgs( - process.argv.slice(2), - ); - const now = Date.now(); - const tools = []; - for (const tool of TOOL_SPECS) { - const agedVersion = await resolveLatestAgedVersion( - tool.package, - coolingOffHours, - now, - ); - for (const target of targets) { - tools.push( - await lockToolForTarget(tool, target, agedVersion, coolingOffHours, now), - ); - } - } - tools.sort((left, right) => - `${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`), - ); - if (skipSmoke) { - console.log("Skipping passthrough smoke checks (--skip-smoke)"); - } else { - for (const tool of TOOL_SPECS) { - const locked = tools.find((entry) => entry.id === tool.id); - if (locked) { - await smokeCheckPassthrough(tool, locked); - } - } - } - await mkdir(path.dirname(lockFile), { recursive: true }); - await writeFile(lockFile, `${JSON.stringify({ tools }, null, 2)}\n`); - console.log(`Updated ${path.relative(process.cwd(), lockFile)}`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/apps/staged/scripts/update-node-runtime-lock.mjs b/apps/staged/scripts/update-node-runtime-lock.mjs new file mode 100755 index 000000000..044c6a9db --- /dev/null +++ b/apps/staged/scripts/update-node-runtime-lock.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const appRoot = path.resolve(import.meta.dirname, ".."); +const defaultLockFile = path.join(appRoot, "node-runtime.lock.json"); + +// Block's Artifactory `nodejs` repo is a read-through mirror of +// https://nodejs.org/dist with an identical path shape, so SHASUMS256.txt is +// the official Node.js checksum file either way. +const DEFAULT_BASE_URL = + "https://global.block-artifacts.com/artifactory/nodejs"; + +// The rust target triples Staged builds for (macOS release targets plus +// Linux local builds), mapped to the platform component of Node's release +// tarball names. +const TARGET_PLATFORMS = { + "aarch64-apple-darwin": "darwin-arm64", + "aarch64-unknown-linux-gnu": "linux-arm64", + "x86_64-apple-darwin": "darwin-x64", + "x86_64-unknown-linux-gnu": "linux-x64", +}; + +function usage() { + console.log(`Usage: scripts/update-node-runtime-lock.mjs [version] [--lock-file ] [--base-url ] + +Fetches the official SHASUMS256.txt for a Node.js release and rewrites +node-runtime.lock.json with the tarball pins for all supported targets. +Without a version argument, the currently locked version is re-resolved +(a checksum refresh). + +Supported targets: + ${Object.keys(TARGET_PLATFORMS).join("\n ")} + +Environment: + NODE_RUNTIME_LOCK_FILE lockfile path override +`); +} + +function normalizeVersion(value) { + const version = value.startsWith("v") ? value : `v${value}`; + if (!/^v\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid Node.js version: ${value}`); + } + return version; +} + +function parseArgs(argv) { + let version = null; + let lockFile = process.env.NODE_RUNTIME_LOCK_FILE ?? defaultLockFile; + let baseUrl = DEFAULT_BASE_URL; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } + if (arg === "--lock-file") { + const value = argv[++i]; + if (!value) throw new Error("--lock-file requires a value"); + lockFile = path.resolve(value); + continue; + } + if (arg === "--base-url") { + const value = argv[++i]; + if (!value) throw new Error("--base-url requires a value"); + baseUrl = value.replace(/\/+$/, ""); + continue; + } + if (arg.startsWith("-")) { + throw new Error(`Unknown argument: ${arg}`); + } + if (version !== null) { + throw new Error(`Unexpected extra argument: ${arg}`); + } + version = normalizeVersion(arg); + } + return { version, lockFile, baseUrl }; +} + +async function currentLockedVersion(lockFile) { + let raw; + try { + raw = await readFile(lockFile, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + return normalizeVersion(JSON.parse(raw).version); +} + +async function fetchShasums(baseUrl, version) { + const url = `${baseUrl}/${version}/SHASUMS256.txt`; + const response = await fetch(url, { + headers: { "User-Agent": "staged-node-runtime-lock" }, + }); + if (!response.ok) { + throw new Error( + `Fetch failed: ${url} (${response.status} ${response.statusText})`, + ); + } + const shasums = new Map(); + for (const line of (await response.text()).split("\n")) { + const match = line.match(/^([0-9a-f]{64})\s+(\S+)$/); + if (match) shasums.set(match[2], match[1]); + } + if (shasums.size === 0) { + throw new Error(`No checksums parsed from ${url}`); + } + return shasums; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const version = args.version ?? (await currentLockedVersion(args.lockFile)); + if (!version) { + throw new Error( + "No version given and no existing lockfile to refresh; pass a version (e.g. v24.11.0)", + ); + } + + const shasums = await fetchShasums(args.baseUrl, version); + const artifacts = {}; + for (const [target, platform] of Object.entries(TARGET_PLATFORMS)) { + const filename = `node-${version}-${platform}.tar.gz`; + const sha256 = shasums.get(filename); + if (!sha256) { + throw new Error( + `SHASUMS256.txt for ${version} has no entry for ${filename}`, + ); + } + artifacts[target] = { filename, sha256 }; + } + + await writeFile( + args.lockFile, + `${JSON.stringify({ version, artifacts }, null, 2)}\n`, + ); + console.log( + `Updated ${path.relative(process.cwd(), args.lockFile)} to Node.js ${version}`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c1024ff2e..48ee386de 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "builderbot-actions", "dirs", "doctor", + "flate2", "git-diff", "hex", "include_dir", @@ -36,6 +37,7 @@ dependencies = [ "sha2 0.11.0", "strip-ansi-escapes", "subtle", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 54c1125f1..364427766 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -81,6 +81,24 @@ resvg = "0.47" usvg = "0.47" tiny-skia = "0.12" +# Managed Node.js runtime: tarball extraction for runtime installs +tar = "0.4" +flate2 = "1" + +[features] +# no-block-npm-registry: downloads the managed Node.js runtime from upstream +# nodejs.org instead of Block's Artifactory mirror, and lets npm-backed +# doctor checks and fixes use npm's default public registry instead of +# Artifactory's square-npm proxy. +no-block-npm-registry = [] + +# no-managed-acp-tools: compiles the managed ACP bridge set empty for +# restricted builds — the startup reconciler installs nothing, doctor fixes +# fall back to the doctor crate's regular commands, and the managed shim dir +# is hidden from PATH prepends so shims another build left in the shared +# ~/.staged/packages tree cannot resolve. +no-managed-acp-tools = [] + # Debug binaries archived — uncomment when needed # [[bin]] # name = "debug_diff" diff --git a/apps/staged/src-tauri/resources/acp/bin/.gitkeep b/apps/staged/src-tauri/resources/acp/bin/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index 35e05496e..98f1a161c 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -1,94 +1,75 @@ -//! Bundled ACP bridge tool resolution and spawn-environment shaping. +//! ACP bridge tool resolution and spawn-environment shaping. //! -//! Staged ships pinned ACP bridge CLIs (`claude-agent-acp`, `codex-acp`) as -//! application resources (see `acp-tools.lock.json` and -//! `scripts/prepare-acp-tools-resource.sh`). This module resolves the staged -//! bin directory at runtime and shapes captured shell-env snapshots so the -//! bundled bridges win over user-installed copies while everything else on -//! the user's PATH (including installed harness CLIs and their auth state) -//! stays discoverable. - -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; - -use tauri::path::BaseDirectory; -use tauri::Manager; - -/// Dev-mode override exported by `just dev`, pointing at the freshly staged -/// `src-tauri/resources/acp/bin` in the working tree. -pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_ACP_TOOLS_DIR"; -/// Bundled resource path, relative to the Tauri resource dir (mirrors the -/// `resources/acp` entry in `tauri.conf.json`). -const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; -/// Node runtime manifest staged by `scripts/prepare-acp-tools-resource.sh` -/// next to the bundled bin dir. -const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; +//! The claude/codex ACP bridges resolve from the `STAGED_ACP_TOOLS_DIR` dev +//! override or the Staged-managed bridge shims the startup reconciler +//! installs (`managed_acp_tools`). This module shapes captured shell-env +//! snapshots so those dirs — together with the other Staged-managed install +//! dirs (the private npm prefix's bin, the managed Node runtime's bin) — win +//! over user-installed copies while everything else on the user's PATH +//! (including installed harness CLIs and their auth state) stays +//! discoverable. Sessions and doctor both shape their snapshots here, so an +//! agent installed by a doctor fix resolves identically at check time and at +//! spawn time. + +use std::path::PathBuf; + /// Goose reads extra binary search dirs from this env var as a JSON array. const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; -/// Resolve the directory holding the bundled ACP bridge executables: the dev -/// env override wins, then the Tauri resource dir for packaged apps. -pub fn resolve_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) -> Option { - bundled_acp_tools_dir_from_parts( - std::env::var_os(ACP_TOOLS_DIR_ENV).as_deref(), - app_handle - .path() - .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) - .ok() - .as_deref(), - ) -} - -/// Path of the Node runtime manifest staged by -/// `scripts/prepare-acp-tools-resource.sh`: it lives next to the tools bin -/// dir (`acp/node-runtime.json` beside `acp/bin`), so it resolves for both -/// the bundled resource dir and a `STAGED_ACP_TOOLS_DIR` dev override. -pub fn node_runtime_manifest_path(bin_dir: &Path) -> Option { - bin_dir - .parent() - .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) -} - -fn bundled_acp_tools_dir_from_parts( - env_override: Option<&OsStr>, - resource_dir: Option<&Path>, -) -> Option { - if let Some(value) = env_override { - if !value.is_empty() { - return Some(PathBuf::from(value)); - } - } - resource_dir.map(Path::to_path_buf) +/// The highest-precedence bridge dir: the `STAGED_ACP_TOOLS_DIR` dev +/// override, else the managed shim dir (when this build manages bridges). +/// Registered with `acp_client::set_bundled_tools_dir` and labeled `Bundled` +/// by doctor — Staged owns updates for whatever resolves here, so the user +/// is never nagged to update it manually. +pub fn primary_tools_dir() -> Option { + // The shim dir is already `None` while the dev override is active, so + // the override wins whenever both could apply. + crate::managed_acp_tools::dev_tools_override_dir() + .or_else(crate::managed_acp_tools::managed_shim_bin_dir) } -/// Shape a captured shell-env snapshot so the bundled ACP bridges win: +/// Shape a captured shell-env snapshot so the Staged-managed install dirs +/// ([`crate::managed_acp_tools::managed_prepend_dirs`]) win: /// -/// - Prepend `bundled_dir` to the snapshot's PATH, keeping the rest of the -/// imported shell PATH intact so user-installed CLIs (and their auth -/// state) remain discoverable. +/// - Prepend them to the snapshot's PATH, keeping the rest of the imported +/// shell PATH intact so user-installed CLIs (and their auth state) remain +/// discoverable. /// - Pin `GOOSE_SEARCH_PATHS` *after* the shell-env import so same-named -/// values from the user's shell cannot override the bundled tools. The +/// values from the user's shell cannot override the managed tools. The /// value is a JSON array to match Goose's config env parsing, scoped to -/// the managed dir plus any explicit pre-existing Goose search dirs — +/// the same search dirs plus any explicit pre-existing Goose search dirs — /// never the whole shell PATH. -pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, bundled_dir: &Path) { - prepend_dir_to_path(vars, bundled_dir); - apply_goose_search_paths(vars, bundled_dir); +pub fn apply_managed_tools_env(vars: &mut Vec<(String, String)>) { + apply_tools_env_with_dirs(vars, &crate::managed_acp_tools::managed_prepend_dirs()); } -fn prepend_dir_to_path(vars: &mut Vec<(String, String)>, dir: &Path) { +fn apply_tools_env_with_dirs(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { + if dirs.is_empty() { + return; + } + prepend_dirs_to_path(vars, dirs); + apply_goose_search_paths(vars, dirs); +} + +fn prepend_dirs_to_path(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { match vars.iter_mut().find(|(key, _)| key == "PATH") { Some((_, value)) => { - let mut paths = vec![dir.to_path_buf()]; - paths.extend(std::env::split_paths(value).filter(|path| path != dir)); + let mut paths = dirs.to_vec(); + paths.extend(std::env::split_paths(value).filter(|path| !dirs.contains(path))); *value = crate::shell_env::join_paths_best_effort(paths); } - None => vars.push(("PATH".to_string(), dir.to_string_lossy().to_string())), + None => vars.push(( + "PATH".to_string(), + crate::shell_env::join_paths_best_effort(dirs.to_vec()), + )), } } -fn apply_goose_search_paths(vars: &mut Vec<(String, String)>, dir: &Path) { - let mut search_paths = vec![dir.to_string_lossy().to_string()]; +fn apply_goose_search_paths(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { + let mut search_paths: Vec = dirs + .iter() + .map(|dir| dir.to_string_lossy().into_owned()) + .collect(); if let Some((_, existing)) = vars.iter().find(|(key, _)| key == GOOSE_SEARCH_PATHS_ENV) { match parse_goose_search_paths(existing) { Ok(paths) => search_paths.extend(paths), @@ -116,97 +97,96 @@ fn parse_goose_search_paths(value: &str) -> Result, serde_json::Erro #[cfg(test)] mod tests { - use super::{apply_bundled_tools_env, bundled_acp_tools_dir_from_parts}; - use std::ffi::OsStr; - use std::path::{Path, PathBuf}; + use super::{apply_goose_search_paths, apply_tools_env_with_dirs, prepend_dirs_to_path}; + use std::path::PathBuf; fn var<'a>(vars: &'a [(String, String)], key: &str) -> Option<&'a str> { vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) } - #[test] - fn env_override_wins_over_resource_dir() { - assert_eq!( - bundled_acp_tools_dir_from_parts( - Some(OsStr::new("/dev/acp/bin")), - Some(Path::new("/bundle/resources/acp/bin")), - ) - .as_deref(), - Some(Path::new("/dev/acp/bin")), - ); + fn dirs(paths: &[&str]) -> Vec { + paths.iter().map(PathBuf::from).collect() } #[test] - fn empty_env_override_falls_back_to_resource_dir() { - assert_eq!( - bundled_acp_tools_dir_from_parts( - Some(OsStr::new("")), - Some(Path::new("/bundle/resources/acp/bin")), - ) - .as_deref(), - Some(Path::new("/bundle/resources/acp/bin")), - ); + fn no_managed_dirs_leaves_the_snapshot_untouched() { + // A build with nothing to manage (no override, no managed dirs) must + // not touch PATH or invent a GOOSE_SEARCH_PATHS. + let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; + apply_tools_env_with_dirs(&mut vars, &[]); + assert_eq!(vars, vec![("PATH".to_string(), "/shell/bin".to_string())]); } #[test] - fn missing_inputs_resolve_to_none() { - assert!(bundled_acp_tools_dir_from_parts(None, None).is_none()); - } + fn managed_dirs_shape_path_and_goose_search_paths_together() { + let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; - #[test] - fn node_runtime_manifest_sits_beside_bin_dir() { + apply_tools_env_with_dirs( + &mut vars, + &dirs(&["/data/packages/bin", "/data/packages/npm-prefix/bin"]), + ); + + let path = var(&vars, "PATH").expect("PATH should be set"); + let paths: Vec<_> = std::env::split_paths(path).collect(); assert_eq!( - super::node_runtime_manifest_path(Path::new("/bundle/resources/acp/bin")).as_deref(), - Some(Path::new("/bundle/resources/acp/node-runtime.json")), + paths, + dirs(&[ + "/data/packages/bin", + "/data/packages/npm-prefix/bin", + "/shell/bin", + ]) + ); + let goose = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); + let goose_paths: Vec = serde_json::from_str(goose).expect("valid JSON array"); + assert_eq!( + goose_paths, + vec!["/data/packages/bin", "/data/packages/npm-prefix/bin"] ); - assert!(super::node_runtime_manifest_path(Path::new("/")).is_none()); } #[test] - fn bundled_dir_is_prepended_before_shell_path() { + fn tool_dirs_are_prepended_before_shell_path() { let mut vars = vec![ ("PATH".to_string(), "/shell/bin:/user/bin".to_string()), ("LANG".to_string(), "en_US.UTF-8".to_string()), ]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin", "/packages/npm-prefix/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); assert_eq!( paths, - vec![ - PathBuf::from("/acp/bin"), - PathBuf::from("/shell/bin"), - PathBuf::from("/user/bin"), - ] + dirs(&[ + "/acp/bin", + "/packages/npm-prefix/bin", + "/shell/bin", + "/user/bin", + ]) ); // Non-PATH variables are untouched. assert_eq!(var(&vars, "LANG"), Some("en_US.UTF-8")); } #[test] - fn bundled_dir_is_not_duplicated_in_path() { + fn tool_dirs_are_not_duplicated_in_path() { let mut vars = vec![("PATH".to_string(), "/shell/bin:/acp/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); - assert_eq!( - paths, - vec![PathBuf::from("/acp/bin"), PathBuf::from("/shell/bin")] - ); + assert_eq!(paths, dirs(&["/acp/bin", "/shell/bin"])); } #[test] #[cfg(unix)] - fn unjoinable_bundled_dir_keeps_shell_path_instead_of_emptying_it() { + fn unjoinable_tool_dir_keeps_shell_path_instead_of_emptying_it() { // A dir embedding the separator (legal in macOS paths) can't be joined // into PATH; it must be dropped, not erase every shell search path. let mut vars = vec![("PATH".to_string(), "/shell/bin:/user/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/weird:dir/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/weird:dir/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); @@ -216,10 +196,10 @@ mod tests { } #[test] - fn missing_path_gets_bundled_dir_only() { + fn missing_path_gets_tool_dirs_only() { let mut vars = Vec::new(); - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin"])); assert_eq!(var(&vars, "PATH"), Some("/acp/bin")); } @@ -228,11 +208,11 @@ mod tests { fn goose_search_paths_is_set_as_json_array() { let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin", "/packages/npm-prefix/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); - assert_eq!(paths, vec!["/acp/bin"]); + assert_eq!(paths, vec!["/acp/bin", "/packages/npm-prefix/bin"]); } #[test] @@ -245,7 +225,7 @@ mod tests { ), ]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); @@ -261,7 +241,7 @@ mod tests { "/not/a/json/array".to_string(), )]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); diff --git a/apps/staged/src-tauri/src/acp_tools_reconciler.rs b/apps/staged/src-tauri/src/acp_tools_reconciler.rs new file mode 100644 index 000000000..62bfd8578 --- /dev/null +++ b/apps/staged/src-tauri/src/acp_tools_reconciler.rs @@ -0,0 +1,112 @@ +//! Reconciler for the Staged-managed ACP bridges. +//! +//! Spawned from app setup: installs or upgrades every managed bridge +//! ([`crate::managed_acp_tools::MANAGED_TOOLS`]) to the latest published +//! version on launch, and then re-runs once a day for the lifetime of the +//! process, so a new bridge release ships to users the next time Staged +//! starts *and* a Staged instance left running for days keeps its private +//! npm packages current without a restart. Each install runs a floating +//! `npm install @latest` onto the Staged-managed Node runtime in +//! `~/.staged/packages`. Failures are logged, recorded in `state.json`, and +//! retried on the next daily pass or launch; a previously installed version +//! keeps working in the meantime, so an offline launch never removes a +//! working bridge. Superseded managed Node runtimes +//! are pruned only in the epilogue of a fully-successful run — every bridge +//! shim execs its Node by absolute versioned path, so an old runtime must +//! outlive the last shim that references it. +//! +//! Silent when there is nothing to manage: the `STAGED_ACP_TOOLS_DIR` dev +//! override is active, the `no-managed-acp-tools` build feature is set, or +//! the target is unsupported. +//! +//! Completion is broadcast to the renderer as [`ACP_TOOLS_RECONCILED_EVENT`]: +//! on a fresh profile the frontend caches its doctor report and provider +//! discovery (bridges missing) long before the reconciler finishes +//! downloading Node and installing the bridges, and nothing re-probes on its +//! own — without a signal the agent picker keeps reporting missing bridges +//! that are already installed until the user manually refreshes or restarts. + +use std::time::Duration; + +use tauri::AppHandle; + +use crate::managed_acp_tools; + +/// Emitted after every reconcile pass finishes, successful or not — a partial +/// failure still installs the other bridge, so the renderer should re-probe +/// either way. Mirrored in `src/lib/listeners/acpToolsListener.ts`. +pub const ACP_TOOLS_RECONCILED_EVENT: &str = "acp-tools-reconciled"; + +/// Re-run cadence beyond the launch pass. The bridges float to `@latest`, so a +/// daily sweep keeps a long-running Staged instance's private npm packages +/// current without hammering the registry. +const RECONCILE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AcpToolsReconciledPayload { + ok: bool, + /// Managed tool ids (`claude-acp`, `codex-acp`). + provider_ids: Vec<&'static str>, +} + +pub fn spawn_reconcile_loop(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + reconcile_loop(app).await; + }); +} + +/// Reconcile at launch, then once a day for the lifetime of the process. +async fn reconcile_loop(app: AppHandle) { + // Nothing to manage on this build/target/override — don't spin a daily + // timer that would only no-op. `reconcile` re-checks the same predicate, + // so a build that does manage bridges is unaffected. + if managed_acp_tools::managed_tools().is_empty() { + return; + } + + let mut interval = tokio::time::interval(RECONCILE_INTERVAL); + // The first `interval.tick()` resolves immediately, so the launch pass runs + // with no delay. `Skip` collapses the catch-up burst after the machine + // wakes from a multi-day sleep into a single reconcile rather than one per + // missed day. + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + reconcile(app.clone()).await; + } +} + +async fn reconcile(app: AppHandle) { + let tools = managed_acp_tools::managed_tools(); + if tools.is_empty() { + return; + } + + let mut errors = Vec::new(); + for tool in &tools { + let log_prefix = format!("[acp-tools reconcile {}]", tool.id); + let on_line = |line: &str| log::info!("{log_prefix} {line}"); + match managed_acp_tools::install_managed_tool(tool.id, &on_line).await { + Ok(()) => log::info!("{log_prefix} {} is up to date", tool.package), + Err(error) => { + log::warn!("{log_prefix} install failed (will retry next launch): {error}"); + errors.push(format!("{}: {error}", tool.id)); + } + } + } + let ok = errors.is_empty(); + managed_acp_tools::finish_reconcile(&tools, errors).await; + + // Through `emit_to_all` rather than a bare Tauri emit so web-mode + // browser clients get the refresh signal over the WebSocket fanout too. + crate::web_server::emit_to_all( + &app, + ACP_TOOLS_RECONCILED_EVENT, + AcpToolsReconciledPayload { + ok, + provider_ids: tools.iter().map(|tool| tool.id).collect(), + }, + ); +} diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index a4620ff75..6feae12ca 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,8 +1,6 @@ //! Tauri command wrappers for the doctor health-check system. use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; pub use doctor::types::{AuthStatus, InstallSource}; pub use doctor::{ @@ -11,18 +9,22 @@ pub use doctor::{ }; /// Environment snapshot for doctor checks and fixes. Shaped through -/// `apply_bundled_tools_env` so checks resolve binaries from the same PATH -/// the agent spawn path uses — a bridge Staged bundles must never be +/// `apply_managed_tools_env` so checks resolve binaries from the same PATH +/// the agent spawn path uses — a bridge Staged manages must never be /// reported missing (or prompt an install) just because the user has no -/// global copy. -async fn doctor_env_vars(bundled_dir: Option<&Path>) -> Vec<(String, String)> { +/// global copy. The managed npm env is overlaid on top, so checks probe npm +/// state (`npm prefix -g`, version lookups) with the same private-prefix view +/// the fixes install into — a check never contradicts the fix that just ran. +async fn doctor_env_vars() -> Vec<(String, String)> { let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), ) .await; - if let Some(dir) = bundled_dir { - crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dir); - } + crate::acp_tools::apply_managed_tools_env(&mut env_vars); + crate::managed_acp_tools::apply_managed_npm_env( + &mut env_vars, + &crate::managed_acp_tools::managed_npm_env(), + ); env_vars } @@ -34,14 +36,16 @@ fn run_checks_options( RunChecksOptions { check_freshness, offline: false, - // Use the default public registries — Staged installs these agents - // from public npm/brew/crates.io, not an internal mirror. - npm_registry: None, + // npm-backed checks and fixes route through Block's Artifactory + // square-npm proxy (registry.npmjs.org is blocked on managed + // devices); `no-block-npm-registry` builds fall back to npm's + // default public registry. + npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), env: None, // Doctor labels binaries resolved from this dir as bundled (install // source + readout flag) and suppresses registry update fixes for - // them — versions are pinned by acp-tools.lock.json and ship with - // Staged updates. + // them — the startup reconciler floats the managed shims to @latest, + // so Staged owns their updates. bundled_tools_dir: bundled_dir, } .with_env_snapshot(env_vars) @@ -53,7 +57,7 @@ fn execute_fix_options( ) -> ExecuteFixOptions { ExecuteFixOptions { command_override, - npm_registry: None, + npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), env: None, } .with_env_snapshot(env_vars) @@ -66,8 +70,8 @@ fn execute_fix_options( /// The frontend calls this first for an instant paint, then follows up with /// [`run_doctor_freshness`] to fill in version/update information. #[tauri::command] -pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { - run_doctor_report(&app_handle, false).await +pub async fn run_doctor() -> DoctorReport { + run_doctor_report(false).await } /// Run all health checks with version freshness enabled. @@ -78,24 +82,23 @@ pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { /// `updateAvailable`, and the source-aware `updateCommand`/`updateFixType` on /// each readout. Hits the network, so it must never block first paint. #[tauri::command] -pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport { - run_doctor_report(&app_handle, true).await +pub async fn run_doctor_freshness() -> DoctorReport { + run_doctor_report(true).await } /// Run the doctor crate's checks plus Staged-local ones (currently the -/// bundled ACP Node.js runtime check) over one shared env snapshot. Bundled +/// managed Node.js runtime check) over one shared env snapshot. Bundled /// readouts are labeled by the doctor crate itself via /// `RunChecksOptions::bundled_tools_dir`. -async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) -> DoctorReport { - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; +async fn run_doctor_report(check_freshness: bool) -> DoctorReport { + let env_vars = doctor_env_vars().await; let (mut report, node_runtime) = tokio::join!( doctor::run_checks_with_options(run_checks_options( check_freshness, env_vars.clone(), - bundled_dir.clone(), + crate::acp_tools::primary_tools_dir(), )), - run_node_runtime_check(&env_vars, bundled_dir.as_deref()), + run_node_runtime_check(), ); if let Some(check) = node_runtime { report.checks.push(check); @@ -106,19 +109,69 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) /// Run a fix for a doctor check, identified by check ID and fix type. /// /// The actual shell command is looked up from the static check definitions — -/// the caller never sends a raw command string. +/// the caller never sends a raw command string. Two families of fixes are +/// native rather than shell commands: the node-runtime fix (re)installs the +/// pinned managed runtime, and install fixes for the managed ACP bridges run +/// the floating managed installer so the bridge lands in +/// `~/.staged/packages/tools` with an absolute-path shim instead of the +/// crate's `npm install -g`. Remaining npm-backed fixes install the managed +/// runtime first, since they run npm from it into the private prefix (the +/// existing "Running…" spinner covers the one-time download). #[tauri::command] -pub async fn run_doctor_fix( - app_handle: tauri::AppHandle, - check_id: String, - fix_type: FixType, -) -> Result<(), String> { - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; +pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> { + if check_id == NODE_RUNTIME_CHECK_ID { + return ensure_managed_node_runtime_for_fix().await; + } + if matches!(fix_type, FixType::Command | FixType::Bridge) { + if let Some(tool_id) = managed_tool_for_check(&check_id) { + return install_managed_tool_logged(tool_id, &check_id).await; + } + } + let env_vars = doctor_env_vars().await; + if doctor::agents::lookup_fix_command(&check_id, &fix_type) + .as_deref() + .is_some_and(crate::managed_acp_tools::is_npm_backed_command) + { + ensure_managed_node_runtime_for_fix().await?; + } doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) .await } +/// The managed ACP bridge behind a doctor check id, when this build manages +/// it. `None` routes the check to the doctor crate's regular fix commands — +/// which is also the correct fallback whenever bridge management is off (dev +/// override, `no-managed-acp-tools`, unsupported target). +fn managed_tool_for_check(check_id: &str) -> Option<&'static str> { + let tool_id = match check_id { + "ai-agent-claude" => "claude-acp", + "ai-agent-codex" => "codex-acp", + _ => return None, + }; + crate::managed_acp_tools::managed_tool(tool_id).map(|tool| tool.id) +} + +/// Install (or float-upgrade) a managed bridge for a doctor fix/update. +/// Progress goes to the log — doctor fixes have no streamed-output channel, +/// only a button spinner. +async fn install_managed_tool_logged(tool_id: &str, check_id: &str) -> Result<(), String> { + let log_prefix = format!("[doctor fix {check_id}]"); + crate::managed_acp_tools::install_managed_tool(tool_id, &|line| { + log::info!("{log_prefix} {line}"); + }) + .await + .map_err(|error| error.to_string()) +} + +/// Install (or repair) the managed Node.js runtime ahead of a fix that needs +/// it. Progress goes to the log — doctor fixes have no streamed-output +/// channel, only a button spinner. +async fn ensure_managed_node_runtime_for_fix() -> Result<(), String> { + crate::managed_node::ensure_managed_node_runtime() + .await + .map_err(|error| error.to_string()) +} + /// Run a source-aware update for a single readout (main CLI or ACP bridge). /// /// Unlike [`run_doctor_fix`], update commands (`UpdateMain`/`UpdateBridge`) are @@ -133,21 +186,40 @@ pub async fn run_doctor_fix( /// validated against the authoritative backend derivation. #[tauri::command] pub async fn run_doctor_update( - app_handle: tauri::AppHandle, check_id: String, fix_type: FixType, command: String, ) -> Result<(), String> { - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; - let expected = - expected_update_command(&check_id, &fix_type, env_vars.clone(), bundled_dir).await?; + // Updates for the managed ACP bridges are the floating installer itself + // (`@latest` onto the managed runtime) — no shell command runs, so + // the frontend-supplied command needs no validation here. Readouts + // resolved from the managed shim dir derive no update command at all + // (they are labeled bundled), so this arm only fires for a bridge copy + // that resolved elsewhere (e.g. a user install found on PATH before the + // first reconcile lands) — and the managed install is the correct + // upgrade for that state too. + if let Some(tool_id) = managed_tool_for_check(&check_id) { + return install_managed_tool_logged(tool_id, &check_id).await; + } + let env_vars = doctor_env_vars().await; + let expected = expected_update_command( + &check_id, + &fix_type, + env_vars.clone(), + crate::acp_tools::primary_tools_dir(), + ) + .await?; if expected != command { return Err(format!( "Update command mismatch for {check_id}: refusing to run a command \ that does not match the backend-derived update command." )); } + // npm-backed updates run the managed npm into the private prefix, so the + // managed runtime must exist before the command does. + if crate::managed_acp_tools::is_npm_backed_command(&expected) { + ensure_managed_node_runtime_for_fix().await?; + } // Run the backend-derived `expected`, not the frontend-supplied `command`. // They are equal past the guard above, but executing `expected` makes the // command that runs provably the one the backend derived — no dependence on @@ -192,242 +264,130 @@ async fn expected_update_command( } // ============================================================================= -// Bundled ACP Node.js runtime check +// Managed Node.js runtime check // ============================================================================= const NODE_RUNTIME_CHECK_ID: &str = "node-runtime"; const NODE_RUNTIME_CHECK_LABEL: &str = "Node.js Runtime"; -const NODE_RUNTIME_FIX_URL: &str = "https://nodejs.org/en/download"; -const NODE_PROBE_TIMEOUT: Duration = Duration::from_secs(10); - -/// On-disk shape of `resources/acp/node-runtime.json`, written by -/// `scripts/prepare-acp-tools-resource.sh` while staging npm-sourced ACP -/// bridges. Each tool carries its own required Node major so bridges with -/// different engine ranges are checked independently. -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NodeRuntimeManifest { - #[serde(default)] - tools: Vec, -} - -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NodeRuntimeTool { - binary: String, - #[serde(default)] - node_engine: Option, - required_node_major: u32, -} - -impl NodeRuntimeTool { - fn requirement_label(&self) -> String { - self.node_engine - .clone() - .unwrap_or_else(|| format!(">={}", self.required_node_major)) - } -} -enum NodeRuntimeManifestState { - /// No manifest next to the bundled tools dir: no npm-sourced bridges are - /// bundled, so the check stays silent. +/// Disk states of the Staged-managed Node.js runtime the check reports on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ManagedNodeRuntimeState { + /// The pinned version is installed and answers the readiness probe. + Ready, + /// The pinned install dir exists but the probe fails — a crashed install + /// or damaged tree that a reinstall repairs. + Broken, + /// The pinned version is not on disk (fresh profile, or a pin bump left + /// only a superseded version behind). Missing, - Invalid { - path: PathBuf, - error: String, - }, - Loaded { - path: PathBuf, - manifest: NodeRuntimeManifest, - }, } -fn load_node_runtime_manifest(bundled_bin_dir: Option<&Path>) -> NodeRuntimeManifestState { - let Some(path) = bundled_bin_dir.and_then(crate::acp_tools::node_runtime_manifest_path) else { - return NodeRuntimeManifestState::Missing; - }; - let contents = match std::fs::read(&path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return NodeRuntimeManifestState::Missing; - } - Err(error) => { - return NodeRuntimeManifestState::Invalid { - path, - error: format!("failed to read manifest: {error}"), - }; - } +/// Report the state of the Staged-managed Node.js runtime that npm-installed +/// agent tools run on, with a native fix that (re)installs the pinned +/// version (`run_doctor_fix` routes this check id to +/// `ensure_managed_node_runtime`). Silent when there is nothing to report: +/// an unsupported target, or a runtime that was never installed and no +/// Staged-installed npm tools that would need it. +async fn run_node_runtime_check() -> Option { + let node_root = crate::managed_node::managed_node_root()?; + let install_dir = crate::managed_node::pinned_install_dir(&node_root)?; + let state = if crate::managed_node::pinned_runtime_ready(&node_root).await { + ManagedNodeRuntimeState::Ready + } else if install_dir.exists() { + ManagedNodeRuntimeState::Broken + } else { + ManagedNodeRuntimeState::Missing }; - match serde_json::from_slice::(&contents) { - Ok(manifest) => NodeRuntimeManifestState::Loaded { path, manifest }, - Err(error) => NodeRuntimeManifestState::Invalid { - path, - error: format!("failed to parse manifest JSON: {error}"), - }, - } + // Both install families depend on the runtime: the private-prefix npm + // tools (copilot, amp-acp) and the managed bridge shims, whose embedded + // node paths break silently without it. + let mut npm_tools: Vec = [ + crate::managed_acp_tools::npm_prefix_bin_dir(), + crate::managed_acp_tools::managed_shim_bin_dir(), + ] + .into_iter() + .flatten() + .flat_map(|dir| installed_npm_tool_names(&dir)) + .collect(); + npm_tools.sort(); + npm_tools.dedup(); + build_node_runtime_check(state, &install_dir, &npm_tools) } -/// Surface the bundled ACP bridges' Node.js runtime requirement at setup -/// time instead of letting the first session spawn die with a bare exit 127 -/// (the bundled bridges are bash shims that exec node). Returns `None` when -/// no npm-sourced bridges are bundled; an unreadable manifest warns instead -/// of silently hiding a packaging break. -async fn run_node_runtime_check( - env_vars: &[(String, String)], - bundled_bin_dir: Option<&Path>, -) -> Option { - let (manifest_path, manifest) = match load_node_runtime_manifest(bundled_bin_dir) { - NodeRuntimeManifestState::Missing => return None, - NodeRuntimeManifestState::Invalid { path, error } => { - return Some(node_runtime_doctor_check( - CheckStatus::Warn, - "Bundled ACP bridge Node.js manifest is unreadable; bridge runtime requirements cannot be verified".to_string(), - Some(path.display().to_string()), - Some(format!("error: {error}")), - )); - } - NodeRuntimeManifestState::Loaded { path, manifest } => (path, manifest), +/// Names of the bin shims npm wrote into the Staged-private prefix — the +/// tools that need the managed runtime to run at all. +fn installed_npm_tool_names(bin_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return Vec::new(); }; - if manifest.tools.is_empty() { - return None; - } - - // Resolve node from the same PATH shape every other doctor check and the - // agent spawn path use, so this check cannot disagree with what the - // bundled wrapper shims will find at spawn time. - let path_value = env_vars - .iter() - .find(|(key, _)| key == "PATH") - .map(|(_, value)| value.as_str()) - .unwrap_or_default(); - let node_path = doctor::resolve::resolve_executable_from_path("node", path_value) - .map(|path| path.to_string_lossy().to_string()); - let node_version = match node_path.as_deref() { - Some(path) => query_node_version(path).await, - None => None, - }; - - Some(build_node_runtime_check( - &manifest_path, - &manifest.tools, - node_path, - node_version, - )) -} - -async fn query_node_version(node_path: &str) -> Option { - let mut command = tokio::process::Command::new(node_path); - command - .args(["-p", "process.versions.node"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let output = tokio::time::timeout(NODE_PROBE_TIMEOUT, command.output()) - .await - .ok()? - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(String::from) -} - -fn parse_node_major(version: &str) -> Option { - version - .trim() - .trim_start_matches('v') - .split('.') - .next()? - .parse() - .ok() -} - -fn node_requirement_summary<'a>(tools: impl IntoIterator) -> String { - tools - .into_iter() - .map(|tool| format!("{} needs Node.js {}", tool.binary, tool.requirement_label())) - .collect::>() - .join(", ") + entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| !name.starts_with('.')) + .collect() } fn build_node_runtime_check( - manifest_path: &Path, - tools: &[NodeRuntimeTool], - node_path: Option, - node_version: Option, -) -> DoctorCheck { - let node_major = node_version.as_deref().and_then(parse_node_major); - let unmet: Vec<&NodeRuntimeTool> = match node_major { - Some(major) => tools - .iter() - .filter(|tool| major < tool.required_node_major) - .collect(), - None => Vec::new(), - }; - - let (status, message) = if node_path.is_none() { - ( - CheckStatus::Warn, - format!( - "Node.js was not found on PATH; bundled ACP bridges require it ({})", - node_requirement_summary(tools) - ), - ) - } else if node_major.is_none() { - ( - CheckStatus::Warn, - format!( - "Could not determine the Node.js version; bundled ACP bridges require it ({})", - node_requirement_summary(tools) - ), - ) - } else if unmet.is_empty() { - ( + state: ManagedNodeRuntimeState, + install_dir: &Path, + npm_tools: &[String], +) -> Option { + let version = &crate::managed_node::node_runtime_lock().version; + let (status, message) = match state { + ManagedNodeRuntimeState::Ready => ( CheckStatus::Pass, - format!( - "Node.js {} satisfies the bundled ACP bridge requirements", - node_version.as_deref().unwrap_or("unknown") - ), - ) - } else { - ( + format!("Staged-managed Node.js {version} is installed"), + ), + ManagedNodeRuntimeState::Broken => ( + CheckStatus::Warn, + format!("Staged-managed Node.js {version} is damaged; run the fix to reinstall it"), + ), + ManagedNodeRuntimeState::Missing if npm_tools.is_empty() => return None, + ManagedNodeRuntimeState::Missing => ( CheckStatus::Warn, format!( - "Node.js {} is too old for bundled ACP bridges: {}", - node_version.as_deref().unwrap_or("unknown"), - node_requirement_summary(unmet.iter().copied()) + "Staged-managed Node.js {version} is not installed; Staged-installed agent tools require it" ), - ) + ), }; + let state_label = match state { + ManagedNodeRuntimeState::Ready => "ready", + ManagedNodeRuntimeState::Broken => "broken", + ManagedNodeRuntimeState::Missing => "missing", + }; let mut detail = vec![ - "checked: bundled ACP bridge Node.js runtime requirement".to_string(), - format!("manifest: {}", manifest_path.display()), - format!( - "node: {}", - node_path.as_deref().unwrap_or("not found on PATH") - ), - format!("version: {}", node_version.as_deref().unwrap_or("unknown")), - "requirements:".to_string(), + "checked: Staged-managed Node.js runtime".to_string(), + format!("pinned version: {version}"), + format!("install dir: {}", install_dir.display()), + format!("state: {state_label}"), ]; - for tool in tools { - let verdict = match node_major { - Some(major) if major >= tool.required_node_major => "satisfied", - Some(_) => "unmet", - None => "unknown", - }; - detail.push(format!( - "- {}: requires Node.js {} [{verdict}]", - tool.binary, - tool.requirement_label() - )); + if npm_tools.is_empty() { + detail.push("Staged-installed npm tools: none".to_string()); + } else { + detail.push("Staged-installed npm tools:".to_string()); + detail.extend(npm_tools.iter().map(|name| format!("- {name}"))); } - node_runtime_doctor_check(status, message, node_path, Some(detail.join("\n"))) + let node_path = (state == ManagedNodeRuntimeState::Ready) + .then(|| install_dir.join("bin").join("node").display().to_string()); + // Native fix: `run_doctor_fix` routes this check id to + // `ensure_managed_node_runtime`. The command string is what the fix + // confirmation dialog displays, not a shell command. + let fix = (status != CheckStatus::Pass).then(|| { + ( + FixType::Command, + format!("download and install Node.js {version} into ~/.staged/packages"), + ) + }); + Some(node_runtime_doctor_check( + status, + message, + node_path, + Some(detail.join("\n")), + fix, + )) } fn node_runtime_doctor_check( @@ -435,16 +395,17 @@ fn node_runtime_doctor_check( message: String, path: Option, raw_output: Option, + fix: Option<(FixType, String)>, ) -> DoctorCheck { + let (fix_type, fix_command) = fix.map(|(t, c)| (Some(t), Some(c))).unwrap_or((None, None)); DoctorCheck { id: NODE_RUNTIME_CHECK_ID.to_string(), label: NODE_RUNTIME_CHECK_LABEL.to_string(), status, message, - // Only rendered by the frontend for non-pass statuses. - fix_url: Some(NODE_RUNTIME_FIX_URL.to_string()), - fix_command: None, - fix_type: None, + fix_url: None, + fix_command, + fix_type, path, bridge_path: None, raw_output, @@ -463,205 +424,139 @@ fn node_runtime_doctor_check( mod tests { use super::*; - fn node_tool(binary: &str, engine: &str, major: u32) -> NodeRuntimeTool { - NodeRuntimeTool { - binary: binary.to_string(), - node_engine: Some(engine.to_string()), - required_node_major: major, - } + fn pinned_version() -> String { + crate::managed_node::node_runtime_lock().version.clone() } #[test] - fn node_runtime_check_passes_when_all_bridges_are_satisfied() { - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - + fn ready_runtime_passes_without_a_fix() { let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - Some("22.17.0".to_string()), - ); + ManagedNodeRuntimeState::Ready, + Path::new("/data/packages/node/v9.9.9/plat"), + &["copilot".to_string()], + ) + .expect("ready runtime is reported"); assert_eq!(check.status, CheckStatus::Pass); assert_eq!( check.message, - "Node.js 22.17.0 satisfies the bundled ACP bridge requirements" + format!("Staged-managed Node.js {} is installed", pinned_version()) ); - assert_eq!(check.path.as_deref(), Some("/usr/local/bin/node")); - let output = check.raw_output.as_deref().expect("raw output"); - assert!(output.contains("manifest: /resources/acp/node-runtime.json")); - assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [satisfied]")); - assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); - } - - #[test] - fn node_runtime_check_warns_only_for_bridges_with_unmet_majors() { - // Bridges may require different Node majors; a Node 21 runtime - // satisfies codex (>=20) but not claude (>=22). - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - - let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - Some("21.7.3".to_string()), - ); - - assert_eq!(check.status, CheckStatus::Warn); assert_eq!( - check.message, - "Node.js 21.7.3 is too old for bundled ACP bridges: claude-agent-acp needs Node.js >=22" + check.path.as_deref(), + Some("/data/packages/node/v9.9.9/plat/bin/node") ); - assert!(!check.message.contains("codex-acp")); + assert!(check.fix_type.is_none()); + assert!(check.fix_command.is_none()); + assert!(check.fix_url.is_none()); let output = check.raw_output.as_deref().expect("raw output"); - assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [unmet]")); - assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); + assert!(output.contains("state: ready")); + assert!(output.contains("- copilot")); } #[test] - fn node_runtime_check_warns_when_node_is_missing() { - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - + fn damaged_runtime_warns_with_a_native_reinstall_fix() { let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - None, - None, - ); + ManagedNodeRuntimeState::Broken, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + ) + .expect("damaged runtime is reported"); assert_eq!(check.status, CheckStatus::Warn); - assert_eq!( - check.message, - "Node.js was not found on PATH; bundled ACP bridges require it (claude-agent-acp needs Node.js >=22, codex-acp needs Node.js >=20)" - ); + assert!(check.message.contains("is damaged")); assert!(check.path.is_none()); - assert_eq!( - check.fix_url.as_deref(), - Some("https://nodejs.org/en/download") - ); + assert_eq!(check.fix_type, Some(FixType::Command)); + let fix_command = check.fix_command.as_deref().expect("fix command"); + assert!(fix_command.contains(&pinned_version())); + assert!(fix_command.contains("~/.staged/packages")); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("state: broken")); + assert!(output.contains("Staged-installed npm tools: none")); } #[test] - fn node_runtime_check_warns_when_version_is_unknown() { - let tools = [node_tool("claude-agent-acp", ">=22", 22)]; - + fn missing_runtime_warns_only_when_installed_tools_need_it() { + // Tools installed into the private prefix need the runtime: warn with + // the reinstall fix. let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - None, - ); - + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &["amp-acp".to_string(), "copilot".to_string()], + ) + .expect("needed-but-missing runtime is reported"); assert_eq!(check.status, CheckStatus::Warn); - assert!(check - .message - .starts_with("Could not determine the Node.js version")); - assert!(check - .message - .contains("claude-agent-acp needs Node.js >=22")); - } - - #[test] - fn node_runtime_manifest_loads_from_bundled_dir_parent() { - let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - std::fs::write( - dir.path().join("node-runtime.json"), - r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=22","requiredNodeMajor":22},{"id":"codex-acp","binary":"codex-acp","nodeEngine":">=20","requiredNodeMajor":20}]}"#, + assert!(check.message.contains("is not installed")); + assert_eq!(check.fix_type, Some(FixType::Command)); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("- amp-acp")); + assert!(output.contains("- copilot")); + + // Nothing installed that needs it: stay silent. + assert!(build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], ) - .unwrap(); - - let NodeRuntimeManifestState::Loaded { path, manifest } = - load_node_runtime_manifest(Some(&bin_dir)) - else { - panic!("expected manifest to load"); - }; - - assert_eq!(path, dir.path().join("node-runtime.json")); - assert_eq!(manifest.tools.len(), 2); - assert_eq!(manifest.tools[0].binary, "claude-agent-acp"); - assert_eq!(manifest.tools[0].required_node_major, 22); - assert_eq!(manifest.tools[1].required_node_major, 20); + .is_none()); } #[test] - fn node_runtime_manifest_missing_or_invalid() { - assert!(matches!( - load_node_runtime_manifest(None), - NodeRuntimeManifestState::Missing - )); - + fn installed_npm_tool_names_lists_visible_entries_only() { let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - assert!(matches!( - load_node_runtime_manifest(Some(&bin_dir)), - NodeRuntimeManifestState::Missing - )); + std::fs::write(dir.path().join("copilot"), "").unwrap(); + std::fs::write(dir.path().join(".copilot.tmp"), "").unwrap(); - std::fs::write(dir.path().join("node-runtime.json"), "not json").unwrap(); - assert!(matches!( - load_node_runtime_manifest(Some(&bin_dir)), - NodeRuntimeManifestState::Invalid { .. } - )); - } + let names = installed_npm_tool_names(dir.path()); + assert_eq!(names, vec!["copilot".to_string()]); - #[tokio::test] - async fn node_runtime_check_runs_end_to_end_from_manifest() { - let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - std::fs::write( - dir.path().join("node-runtime.json"), - r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=0","requiredNodeMajor":0}]}"#, - ) - .unwrap(); - let env_vars = vec![("PATH".to_string(), std::env::var("PATH").unwrap())]; - - let check = run_node_runtime_check(&env_vars, Some(&bin_dir)) - .await - .expect("check emitted for npm-bundled bridges"); - - assert_eq!(check.id, "node-runtime"); - // With a zero required major any resolvable Node passes; on a host - // without Node the check still surfaces as a warning instead of - // vanishing. - if check.path.is_some() { - assert_eq!(check.status, CheckStatus::Pass); - } else { - assert_eq!(check.status, CheckStatus::Warn); - } - - assert!(run_node_runtime_check(&env_vars, None).await.is_none()); + // An absent dir reads as no tools, not an error. + assert!(installed_npm_tool_names(&dir.path().join("absent")).is_empty()); } - #[test] - fn parse_node_major_handles_plain_and_prefixed_versions() { - assert_eq!(parse_node_major("22.17.0"), Some(22)); - assert_eq!(parse_node_major("v20.11.1\n"), Some(20)); - assert_eq!(parse_node_major("not-a-version"), None); - assert_eq!(parse_node_major(""), None); - } - - /// Bundled-readout labeling lives in the doctor crate now; Staged's job is - /// only to hand the resolved bundled dir into the run options. + /// Bundled-readout labeling lives in the doctor crate; Staged's job is + /// only to hand the managed shim dir into the run options. #[test] fn run_checks_options_carries_bundled_tools_dir() { - let dir = PathBuf::from("/bundle/resources/acp/bin"); + let dir = PathBuf::from("/data/packages/bin"); let opts = run_checks_options(false, Vec::new(), Some(dir.clone())); assert_eq!(opts.bundled_tools_dir, Some(dir)); let opts = run_checks_options(false, Vec::new(), None); assert!(opts.bundled_tools_dir.is_none()); } + + /// Fixes and updates for the two bridge checks route to the managed + /// installer exactly when this build manages bridges; every other check + /// keeps the doctor crate's regular fix commands. + #[test] + fn managed_bridge_checks_route_to_the_managed_installer() { + let managed = crate::managed_acp_tools::managed_tools_enabled(); + assert_eq!( + managed_tool_for_check("ai-agent-claude"), + managed.then_some("claude-acp") + ); + assert_eq!( + managed_tool_for_check("ai-agent-codex"), + managed.then_some("codex-acp") + ); + assert_eq!(managed_tool_for_check("ai-agent-copilot"), None); + assert_eq!(managed_tool_for_check("ai-agent-amp"), None); + assert_eq!(managed_tool_for_check(NODE_RUNTIME_CHECK_ID), None); + } + + /// Checks and fixes must agree on the registry: both option builders take + /// it from the same `managed_acp_tools::npm_registry()` gate. + #[test] + fn doctor_options_route_npm_through_the_managed_registry() { + let expected = crate::managed_acp_tools::npm_registry().map(str::to_string); + assert_eq!( + run_checks_options(false, Vec::new(), None).npm_registry, + expected + ); + assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected); + if !cfg!(feature = "no-block-npm-registry") { + assert!(expected.is_some()); + } + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 6ed34a8a9..9f9305c04 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) mod acp_config; pub mod acp_tools; +pub mod acp_tools_reconciler; pub mod actions; pub mod agent; pub mod background_sync; @@ -16,6 +17,8 @@ pub mod doctor; pub mod git; pub mod github_commands; pub mod image_commands; +pub mod managed_acp_tools; +pub mod managed_node; pub mod migrations; pub mod note_commands; pub mod paths; @@ -1871,13 +1874,22 @@ pub fn run() { ) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { - // Register the bundled ACP bridge tools dir before any command + // Register the primary ACP bridge tools dir before any command // runs so binary resolution (session spawn, provider discovery) - // prefers the pinned bridges Staged ships as resources. - if let Some(dir) = acp_tools::resolve_bundled_acp_tools_dir(app.handle()) { + // prefers the managed bridge shims (or the STAGED_ACP_TOOLS_DIR + // dev override) over user-installed copies. The shim dir path is + // stable even before the first reconcile writes shims — + // find_command probes per call and falls through to PATH until + // then. + if let Some(dir) = acp_tools::primary_tools_dir() { acp_client::set_bundled_tools_dir(dir); } + // Install/upgrade the managed ACP bridges in the background at + // launch and once a day thereafter; the renderer refreshes doctor + + // provider discovery on each completion event. + acp_tools_reconciler::spawn_reconcile_loop(app.handle()); + let updater_pubkey_present = app .config() .plugins diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs new file mode 100644 index 000000000..cbb275a34 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -0,0 +1,1359 @@ +//! Staged-managed ACP tool installs. +//! +//! Staged owns both sides of every npm-backed agent install: the managed Node +//! runtime (`managed_node`) supplies `node`/`npm`, and everything npm writes +//! lands in Staged-private directories under `~/.staged/packages` instead of +//! the host's global prefix. Two install families live here: +//! +//! - **Private npm prefix** (`npm-prefix/`): the doctor crate's runtime +//! `npm install -g` fixes (copilot, amp-acp) are steered here by the env +//! pairs in [`managed_npm_env`]. +//! - **Managed bridges** (`tools//` + `bin/`): the claude/codex ACP +//! bridges in [`MANAGED_TOOLS`]. [`install_managed_tool`] installs — or +//! upgrades — each to the latest published version with a floating +//! `npm install @latest --prefix` on the managed runtime, writes an +//! absolute-path shim into `bin/` (no host `node` on PATH required), and +//! records the installed version in `state.json`. The startup reconciler +//! (`acp_tools_reconciler`) runs this for every managed bridge on launch, +//! so a new bridge release ships to users the next time Staged starts. +//! +//! Layout under `~/.staged/packages` (shared by every running Staged +//! instance — see the cross-process locking notes in `managed_node`; every +//! mutation of the bridge trees below holds that flock on top of this +//! module's in-process tool-install mutex): +//! +//! - `npm-prefix/` — the private npm global prefix. +//! - `node///` — managed Node runtimes (`managed_node`). +//! - `tools//` — per-bridge npm `--prefix` trees for the managed ACP +//! bridges (claude-acp, codex-acp). +//! - `bin/` — Staged-written shims for managed bridges. +//! - `state.json` — installed bridge versions + last reconcile outcome. +//! +//! `STAGED_ACP_TOOLS_DIR` stays honored as a dev/bridge-developer override: +//! when set, bridge management is disabled (no managed tools, no shim dir, +//! no installs) so the override dir is the one source of bridge binaries. +//! The `no-managed-acp-tools` build feature compiles the managed bridge set +//! to empty for restricted builds — nothing installs, and the shim dir is +//! hidden from PATH prepends so shims another build left in the shared tree +//! cannot resolve. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::io::AsyncBufReadExt; + +use crate::managed_node; + +/// Dev/bridge-developer override: a directory of bridge binaries that +/// replaces managed bridge resolution. +pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_ACP_TOOLS_DIR"; + +/// Block's internal Artifactory npm registry. Direct access to +/// `registry.npmjs.org` is blocked by Cloudflare WARP on managed devices, so +/// npm-backed agent installs must route through this proxy. The doctor crate +/// exposes an optional `npm_registry` param but bakes in no registry of its +/// own, so Staged supplies this URL at every fix/run call site. +pub const BLOCK_NPM_REGISTRY_URL: &str = + "https://global.block-artifacts.com/artifactory/api/npm/square-npm/"; + +/// The npm registry every Staged-run npm command routes through, or `None` +/// (npm's default public registry) for `no-block-npm-registry` builds. +pub fn npm_registry() -> Option<&'static str> { + if cfg!(feature = "no-block-npm-registry") { + None + } else { + Some(BLOCK_NPM_REGISTRY_URL) + } +} + +/// The `STAGED_ACP_TOOLS_DIR` override dir, when set and non-empty. +pub fn dev_tools_override_dir() -> Option { + std::env::var_os(ACP_TOOLS_DIR_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn dev_tools_override_active() -> bool { + dev_tools_override_dir().is_some() +} + +/// Whether this build manages ACP bridge installs (and therefore exposes the +/// managed shim dir): the `STAGED_ACP_TOOLS_DIR` dev override supplies +/// bridges from its own dir instead, the `no-managed-acp-tools` feature +/// compiles management out for restricted builds, and an unsupported target +/// has no managed runtime to install onto. +pub fn managed_tools_enabled() -> bool { + managed_tools_enabled_from_parts( + dev_tools_override_active(), + cfg!(feature = "no-managed-acp-tools"), + managed_node::current_target_triple().is_some(), + ) +} + +fn managed_tools_enabled_from_parts( + override_active: bool, + managed_tools_disabled: bool, + supported_target: bool, +) -> bool { + !override_active && !managed_tools_disabled && supported_target +} + +/// The Staged-private npm global prefix, `~/.staged/packages/npm-prefix`. +pub fn npm_prefix_dir() -> Option { + crate::paths::packages_dir().map(|dir| dir.join("npm-prefix")) +} + +/// Where npm writes global bin shims for the private prefix. +pub fn npm_prefix_bin_dir() -> Option { + npm_prefix_dir().map(|dir| dir.join("bin")) +} + +/// `~/.staged/packages/bin` — the Staged-written shims for managed bridges. +/// `None` when this build does not manage bridges (see +/// [`managed_tools_enabled`]), so stale managed shims cannot resolve while +/// the `STAGED_ACP_TOOLS_DIR` dev override is active. +pub fn managed_shim_bin_dir() -> Option { + if !managed_tools_enabled() { + return None; + } + crate::paths::packages_dir().map(|root| shim_bin_dir(&root)) +} + +fn shim_bin_dir(packages_root: &Path) -> PathBuf { + packages_root.join("bin") +} + +/// `/tools` — every managed bridge's npm `--prefix` tree lives +/// under here. +pub fn tools_root(packages_root: &Path) -> PathBuf { + packages_root.join("tools") +} + +/// `/tools/` — the live npm `--prefix` a managed bridge resolves +/// from. Floating upgrades stage a fresh tree beside it and swap it in +/// atomically (see [`install_managed_tool`]), so this path is stable and +/// version-independent — a shim can point at it once and never be rewritten. +pub fn tool_install_dir(packages_root: &Path, id: &str) -> PathBuf { + tools_root(packages_root).join(id) +} + +/// `/tools/.staging` — the scratch `--prefix` a floating install +/// lands in before it is verified and atomically swapped into the live +/// [`tool_install_dir`]. A sibling of the live prefix so the swap is a single +/// same-filesystem rename. +fn staging_install_dir(packages_root: &Path, id: &str) -> PathBuf { + tools_root(packages_root).join(format!("{id}.staging")) +} + +/// `/state.json` — installed bridge versions + the last reconcile +/// outcome. +pub fn state_path(packages_root: &Path) -> PathBuf { + packages_root.join("state.json") +} + +/// Directories to prepend (in order) wherever agent binaries must resolve: +/// the `STAGED_ACP_TOOLS_DIR` dev override when active (it replaces the +/// managed shim dir), then the managed bridge shims, the private prefix's +/// bin shims, and the managed Node runtime's bin dir — the latter is what +/// makes npm's `#!/usr/bin/env node` shims run without host Node, and what +/// resolves `npm` itself for install fixes. +pub fn managed_prepend_dirs() -> Vec { + managed_prepend_dirs_from_parts( + dev_tools_override_dir(), + managed_shim_bin_dir(), + npm_prefix_bin_dir(), + managed_node::managed_node_bin_dir(), + ) +} + +fn managed_prepend_dirs_from_parts( + override_bin: Option, + shim_bin: Option, + npm_prefix_bin: Option, + node_bin: Option, +) -> Vec { + override_bin + .into_iter() + .chain(shim_bin) + .chain(npm_prefix_bin) + .chain(node_bin) + .collect() +} + +/// Env pairs steering every npm invocation Staged spawns into the private +/// prefix. Both spellings are set: npm canonically reads the lowercase +/// `npm_config_*` form, but tooling conventionally exports the uppercase one. +/// `sanitize_shell_env` already strips user-shell values for these keys from +/// captured snapshots, so these pairs are authoritative, not a race. +pub fn managed_npm_env() -> Vec<(String, String)> { + npm_prefix_dir() + .map(|prefix| managed_npm_env_at(&prefix)) + .unwrap_or_default() +} + +pub fn managed_npm_env_at(prefix: &Path) -> Vec<(String, String)> { + let prefix_value = prefix.to_string_lossy().into_owned(); + let cache_value = prefix.join("cache").to_string_lossy().into_owned(); + let corepack_value = prefix.join("corepack").to_string_lossy().into_owned(); + vec![ + ("NPM_CONFIG_PREFIX".to_string(), prefix_value.clone()), + ("npm_config_prefix".to_string(), prefix_value), + ("NPM_CONFIG_CACHE".to_string(), cache_value.clone()), + ("npm_config_cache".to_string(), cache_value), + ("COREPACK_HOME".to_string(), corepack_value), + ] +} + +/// Overlay the managed npm env onto an environment snapshot, replacing any +/// same-named entries so a stray inherited value can never win. +pub fn apply_managed_npm_env(vars: &mut Vec<(String, String)>, overrides: &[(String, String)]) { + for (key, value) in overrides { + match vars.iter_mut().find(|(existing, _)| existing == key) { + Some(entry) => entry.1 = value.clone(), + None => vars.push((key.clone(), value.clone())), + } + } +} + +/// Whether a doctor fix command runs through npm — and therefore needs the +/// managed Node runtime installed first. Mirrors the doctor crate's (private) +/// npm-command predicate so the two stay in agreement about which commands +/// get registry/env treatment. +pub fn is_npm_backed_command(command: &str) -> bool { + command.starts_with("npm ") || command.contains("npm install") || command.contains("npm view") +} + +// ============================================================================= +// The managed bridge set — installed and upgraded from the npm registry +// ============================================================================= + +/// A Staged-managed ACP bridge: installed and upgraded from the npm registry +/// at runtime (see [`install_managed_tool`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ManagedTool { + /// The install id (`tools/` dir name, `state.json` key). + pub id: &'static str, + /// The bin name the shim is written under — the command name bridge + /// resolution (`find_command`, provider discovery) looks up. + pub binary: &'static str, + /// The npm package installed from the registry. + pub package: &'static str, +} + +/// The ACP bridges Staged installs and upgrades on every launch. Both vendor +/// their agent's full CLI (Claude Code, `codex`) inside the npm package, so +/// no separate main-CLI install is needed. +pub const MANAGED_TOOLS: &[ManagedTool] = &[ + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + }, + ManagedTool { + id: "codex-acp", + binary: "codex-acp", + package: "@agentclientprotocol/codex-acp", + }, +]; + +/// The managed bridges this build installs at runtime, or an empty list when +/// nothing is managed (see [`managed_tools_enabled`]). +pub fn managed_tools() -> Vec { + if !managed_tools_enabled() { + return Vec::new(); + } + MANAGED_TOOLS.to_vec() +} + +/// The managed bridge with this install id, when this build manages it. +pub fn managed_tool(id: &str) -> Option { + managed_tools().into_iter().find(|tool| tool.id == id) +} + +// ============================================================================= +// state.json — installed versions + last reconcile result +// ============================================================================= + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ManagedToolsState { + pub tools: BTreeMap, + pub last_reconcile: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledToolPin { + pub binary: String, + /// The version npm resolved for `@latest`, recorded for the + /// reconcile log and future doctor readouts. Empty when the installed + /// `package.json` could not be read. + pub version: String, + /// The managed Node.js runtime version the shim execs by absolute path. + /// After a Node pin bump this trails the embedded pin until the bridge + /// reinstalls and its shim is rewritten — which is why superseded + /// runtimes are pruned only after a fully-successful reconcile. + #[serde(default)] + pub node_version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconcileRecord { + pub at_ms: u64, + pub ok: bool, + #[serde(default)] + pub errors: Vec, +} + +/// Read `/state.json`; a missing or corrupt file is an empty state. +pub(crate) fn read_state(packages_root: &Path) -> ManagedToolsState { + std::fs::read_to_string(state_path(packages_root)) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + +fn write_state(packages_root: &Path, state: &ManagedToolsState) -> std::io::Result<()> { + std::fs::create_dir_all(packages_root)?; + let path = state_path(packages_root); + let temp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; + std::fs::write(&temp, format!("{json}\n"))?; + std::fs::rename(&temp, &path) +} + +// ============================================================================= +// install_managed_tool — floating npm install + shim + state +// ============================================================================= + +/// Floating bridge installs download ~70-95 MB of packages through the +/// registry; a hung npm must not wedge the install mutex forever. +const NPM_INSTALL_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +#[derive(Debug)] +pub enum ManagedToolError { + DataDir(String), + /// This id is not managed on this build/target; callers route before + /// installing, so surfacing one means the managed set changed under a + /// running operation. + NotManaged(String), + Node(managed_node::ManagedNodeError), + NpmInstall(String), + /// The install exited cleanly but produced no runnable bridge — a floor + /// check replacing the old lock's integrity validation. + Incomplete(String), + Io(String), +} + +impl std::fmt::Display for ManagedToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DataDir(message) => { + write!( + f, + "failed to resolve the managed ACP tools directory: {message}" + ) + } + Self::NotManaged(message) => { + write!(f, "not a Staged-managed ACP bridge: {message}") + } + Self::Node(error) => error.fmt(f), + Self::NpmInstall(message) => write!(f, "npm install failed: {message}"), + Self::Incomplete(message) => { + write!(f, "installed ACP bridge is incomplete: {message}") + } + Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ManagedToolError {} + +/// Receives every progress/output line of an install. Doctor fixes and the +/// startup reconciler feed these to the log with their own prefixes; there is +/// no streamed-output UI channel for installs. +pub type InstallLineFn<'a> = dyn Fn(&str) + Send + Sync + 'a; + +fn tool_install_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +/// Install (or upgrade) one managed bridge to the latest published version: +/// ensure the managed Node runtime, run the floating `npm install +/// @latest` into a scratch prefix, swap the verified tree into the live +/// prefix, write the absolute-path shim, and record the installed version in +/// `state.json`. Safe to call concurrently — doctor fixes and the startup +/// reconciler serialize on one process-wide install mutex, and mutations of +/// the shared packages tree additionally hold the cross-process flock (other +/// Staged instances reconcile the same `~/.staged/packages`). A failed +/// install — including one npm aborts mid-reify, or an upstream `@latest` +/// that drops the entrypoint — leaves any previously installed version fully +/// in place, since the live tree is only ever replaced by an atomic swap of a +/// verified staging tree; the Node runtime its shim execs is likewise kept, +/// as superseded runtimes are pruned only after a fully-successful reconcile. +/// So an offline or partial launch never removes a working bridge. +pub async fn install_managed_tool( + id: &str, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let tool = managed_tool(id).ok_or_else(|| { + ManagedToolError::NotManaged(format!("'{id}' is not a Staged-managed ACP bridge")) + })?; + let packages_root = crate::paths::packages_dir() + .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; + let node_root = managed_node::managed_node_root() + .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; + let node_install_dir = managed_node::pinned_install_dir(&node_root).ok_or_else(|| { + ManagedToolError::NotManaged("no managed Node.js runtime pin for this target".to_string()) + })?; + + let _guard = tool_install_lock().lock().await; + managed_node::ensure_managed_node_runtime() + .await + .map_err(ManagedToolError::Node)?; + // The cross-process lock is taken only after the runtime ensure has + // released the same flock — nesting the two would deadlock (see + // `lock_packages_dir`). In the unlocked window another process can at + // most install or prune, and both leave the pinned runtime in place. + let _packages_lock = managed_node::lock_packages_dir(&packages_root) + .await + .map_err(ManagedToolError::Node)?; + install_npm_tool( + &packages_root, + &node_install_dir, + &managed_node::node_runtime_lock().version, + &tool, + npm_registry(), + on_line, + ) + .await +} + +/// The install body, path-parameterized so tests drive it with a fixture +/// `npm`. Caller holds the install mutex + packages flock and has ensured +/// the runtime. +async fn install_npm_tool( + packages_root: &Path, + node_install_dir: &Path, + node_version: &str, + tool: &ManagedTool, + registry: Option<&str>, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let install_dir = tool_install_dir(packages_root, tool.id); + // Stage the floating install into a scratch prefix and swap it into the + // live tree only after the entrypoint floor-check passes. npm reifies in + // place, so installing straight into `install_dir` would let a failure + // mid-reify — or an upstream `@latest` that drops the entrypoint — replace + // the live tree the (version-independent) shim already points at, breaking + // the previously working bridge. Staging keeps the old tree untouched + // until a verified new one is ready to swap in atomically. + let staging_dir = staging_install_dir(packages_root, tool.id); + reset_dir(&staging_dir) + .map_err(|error| ManagedToolError::Io(format!("prepare staging dir: {error}")))?; + + on_line(&format!( + "Installing {}@latest into ~/.staged/packages", + tool.package + )); + if let Err(error) = run_floating_npm_install( + packages_root, + node_install_dir, + &staging_dir, + tool, + registry, + on_line, + ) + .await + { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(error); + } + + let staged_entrypoint = npm_entrypoint(&staging_dir, tool.package); + if !staged_entrypoint.is_file() { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(ManagedToolError::Incomplete(format!( + "{}: bridge entrypoint {} is missing after install", + tool.package, + staged_entrypoint.display() + ))); + } + let version = installed_version(&staging_dir, tool.package).unwrap_or_default(); + + // Atomically replace the live tree with the verified staging tree, keeping + // the previous tree aside as `.old` to roll back to if the rename fails. + swap_into_place(&staging_dir, &install_dir) + .map_err(|error| ManagedToolError::Io(format!("install staged bridge: {error}")))?; + + let entrypoint = npm_entrypoint(&install_dir, tool.package); + write_shim( + &shim_bin_dir(packages_root), + tool.binary, + &shim_contents(&node_binary(node_install_dir), &entrypoint), + ) + .map_err(|error| ManagedToolError::Io(format!("write bridge shim: {error}")))?; + + let mut state = read_state(packages_root); + state.tools.insert( + tool.id.to_string(), + InstalledToolPin { + binary: tool.binary.to_string(), + version: version.clone(), + node_version: node_version.to_string(), + }, + ); + write_state(packages_root, &state) + .map_err(|error| ManagedToolError::Io(format!("write state.json: {error}")))?; + on_line(&format!( + "{}@{} is ready", + tool.package, + if version.is_empty() { + "latest" + } else { + version.as_str() + } + )); + Ok(()) +} + +/// Remove `dir` if it exists, then recreate it empty — a clean scratch prefix +/// for a fresh floating install (a stale staging tree from a crashed run must +/// not seed the next one). +fn reset_dir(dir: &Path) -> std::io::Result<()> { + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + std::fs::create_dir_all(dir) +} + +/// Atomically replace `final_dir` with `staging_dir`: stage any previous tree +/// aside as `.old`, rename the verified staging tree into place, +/// and roll the previous tree back if that rename fails. Mirrors +/// `managed_node`'s runtime swap; both dirs are siblings under `tools/`, so +/// each rename is a single same-filesystem operation. +fn swap_into_place(staging_dir: &Path, final_dir: &Path) -> std::io::Result<()> { + if let Some(parent) = final_dir.parent() { + std::fs::create_dir_all(parent)?; + } + let old_dir = final_dir.with_extension("old"); + if old_dir.exists() { + std::fs::remove_dir_all(&old_dir)?; + } + if final_dir.exists() { + std::fs::rename(final_dir, &old_dir)?; + } + if let Err(error) = std::fs::rename(staging_dir, final_dir) { + if old_dir.exists() { + let _ = std::fs::rename(&old_dir, final_dir); + } + let _ = std::fs::remove_dir_all(staging_dir); + return Err(error); + } + let _ = std::fs::remove_dir_all(&old_dir); + Ok(()) +} + +/// `/node_modules//dist/index.js` — the bridge +/// entrypoint convention both managed bridges follow. +fn npm_entrypoint(install_dir: &Path, package: &str) -> PathBuf { + package_dir(install_dir, package) + .join("dist") + .join("index.js") +} + +fn package_dir(install_dir: &Path, package: &str) -> PathBuf { + package + .split('/') + .fold(install_dir.join("node_modules"), |dir, part| dir.join(part)) +} + +fn node_binary(node_install_dir: &Path) -> PathBuf { + node_install_dir.join("bin").join("node") +} + +/// The version npm resolved for the just-installed package, from its +/// `package.json`. Best-effort: the state record is informational, so an +/// unreadable version does not fail the install. +fn installed_version(install_dir: &Path, package: &str) -> Option { + let json = + std::fs::read_to_string(package_dir(install_dir, package).join("package.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&json).ok()?; + value + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +async fn run_floating_npm_install( + packages_root: &Path, + node_install_dir: &Path, + install_dir: &Path, + tool: &ManagedTool, + registry: Option<&str>, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let node_bin_dir = node_install_dir.join("bin"); + let mut command = tokio::process::Command::new(node_bin_dir.join("npm")); + command + .arg("install") + .arg("--prefix") + .arg(install_dir) + .args([ + "--omit=dev", + "--include=optional", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ]); + if let Some(registry) = registry { + command.arg("--registry").arg(registry); + } + // `@latest` floats to the newest published version; npm on the managed + // runtime resolves the platform-native optional dependency for the + // running machine on its own, so no `--os`/`--cpu` pinning is needed. + command.arg(format!("{}@latest", tool.package)); + + // npm's own `#!/usr/bin/env node` shebang must resolve the managed node. + let mut paths = vec![node_bin_dir.clone()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + if let Ok(path_value) = std::env::join_paths(paths) { + command.env("PATH", path_value); + } + // Share the private prefix's download cache; `--prefix` on the command + // line outranks any inherited prefix config. + let cache = packages_root.join("npm-prefix").join("cache"); + command.env("NPM_CONFIG_CACHE", &cache); + command.env("npm_config_cache", &cache); + command + .current_dir(install_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let mut child = command + .spawn() + .map_err(|error| ManagedToolError::NpmInstall(format!("spawn managed npm: {error}")))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let forward_out = async { + if let Some(stream) = stdout { + let mut lines = tokio::io::BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(&line); + } + } + }; + let forward_err = async { + if let Some(stream) = stderr { + let mut lines = tokio::io::BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(&line); + } + } + }; + let wait = async { + match tokio::time::timeout(NPM_INSTALL_TIMEOUT, child.wait()).await { + Ok(result) => result + .map_err(|error| ManagedToolError::NpmInstall(format!("wait on npm: {error}"))), + Err(_) => { + let _ = child.kill().await; + Err(ManagedToolError::NpmInstall(format!( + "timed out after {} seconds", + NPM_INSTALL_TIMEOUT.as_secs() + ))) + } + } + }; + let (status, (), ()) = tokio::join!(wait, forward_out, forward_err); + let status = status?; + if status.success() { + Ok(()) + } else { + Err(ManagedToolError::NpmInstall(format!( + "npm install exited with {status}" + ))) + } +} + +/// Shim body for a managed bridge. Both paths are absolute, so the shim runs +/// with no `node` on PATH at all. +fn shim_contents(node: &Path, entrypoint: &Path) -> String { + format!( + "#!/bin/sh\n# Written by Staged's managed ACP tools installer; do not edit.\nexec {} {} \"$@\"\n", + sh_quote(node), + sh_quote(entrypoint) + ) +} + +fn sh_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', r"'\''")) +} + +fn write_shim(bin_dir: &Path, binary: &str, contents: &str) -> std::io::Result<()> { + std::fs::create_dir_all(bin_dir)?; + let path = bin_dir.join(binary); + let temp = bin_dir.join(format!(".{binary}.tmp")); + std::fs::write(&temp, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o755))?; + } + std::fs::rename(&temp, &path) +} + +// ============================================================================= +// Reconcile epilogue — prune stale ids, record the outcome, gate the Node prune +// ============================================================================= + +/// Reconcile epilogue: drop installs for ids no longer in the managed set +/// (their shims, tool dirs, and state entries), record the run's outcome in +/// `state.json`, and — only when every managed bridge installed cleanly — +/// prune superseded managed Node runtimes. Takes the install mutex and the +/// cross-process flock so it cannot race an in-flight install in this or any +/// other Staged process. +pub(crate) async fn finish_reconcile(managed: &[ManagedTool], errors: Vec) { + let Some(packages_root) = crate::paths::packages_dir() else { + return; + }; + finish_reconcile_at(&packages_root, managed, errors).await; +} + +async fn finish_reconcile_at(packages_root: &Path, managed: &[ManagedTool], errors: Vec) { + let all_installed = errors.is_empty(); + { + let _guard = tool_install_lock().lock().await; + let _packages_lock = match managed_node::lock_packages_dir(packages_root).await { + Ok(lock) => lock, + Err(error) => { + log::warn!("skipping ACP tools reconcile epilogue: {error}"); + return; + } + }; + prune_stale_managed_tools(packages_root, managed); + record_reconcile(packages_root, errors); + } + // Success-gated Node prune: `errors` empty means every managed bridge + // reinstalled this run, so every shim now embeds the pinned runtime's + // path and superseded runtimes are unreferenced. On partial failure the + // old runtime is kept — the failed bridge's un-rewritten shim still + // resolves a real Node, so an offline launch never breaks a working + // bridge. Runs outside the scope above: the prune takes the same flock + // itself, and nesting would deadlock (see `lock_packages_dir`). + if all_installed { + managed_node::prune_superseded_node_runtimes(packages_root).await; + } +} + +pub(crate) fn prune_stale_managed_tools(packages_root: &Path, managed: &[ManagedTool]) { + let managed_ids: Vec<&str> = managed.iter().map(|tool| tool.id).collect(); + let managed_binaries: Vec<&str> = managed.iter().map(|tool| tool.binary).collect(); + + let mut state = read_state(packages_root); + let stale: Vec = state + .tools + .keys() + .filter(|id| !managed_ids.contains(&id.as_str())) + .cloned() + .collect(); + for id in &stale { + if let Some(pin) = state.tools.remove(id) { + let _ = std::fs::remove_file(shim_bin_dir(packages_root).join(&pin.binary)); + } + } + if !stale.is_empty() { + if let Err(error) = write_state(packages_root, &state) { + log::warn!("failed to write ACP tools state after prune: {error}"); + } + } + + // Tool dirs with no state entry (crashed installs) and shims for binaries + // no longer managed. `/bin` holds only Staged-written shims, so + // pruning by name is safe. + if let Ok(entries) = std::fs::read_dir(tools_root(packages_root)) { + for entry in entries.flatten() { + if !managed_ids.contains(&entry.file_name().to_string_lossy().as_ref()) { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + if let Ok(entries) = std::fs::read_dir(shim_bin_dir(packages_root)) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with('.') && !managed_binaries.contains(&name.as_str()) { + let _ = std::fs::remove_file(entry.path()); + } + } + } +} + +pub(crate) fn record_reconcile(packages_root: &Path, errors: Vec) { + let mut state = read_state(packages_root); + state.last_reconcile = Some(ReconcileRecord { + at_ms: now_ms(), + ok: errors.is_empty(), + errors, + }); + if let Err(error) = write_state(packages_root, &state) { + log::warn!("failed to record ACP tools reconcile result: {error}"); + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_registry_follows_registry_feature() { + if cfg!(feature = "no-block-npm-registry") { + assert_eq!(npm_registry(), None); + } else { + assert_eq!(npm_registry(), Some(BLOCK_NPM_REGISTRY_URL)); + } + } + + #[test] + fn path_helpers_lay_out_the_packages_tree() { + let root = Path::new("/home/.staged/packages"); + assert_eq!(shim_bin_dir(root), root.join("bin")); + assert_eq!(tools_root(root), root.join("tools")); + assert_eq!( + tool_install_dir(root, "claude-acp"), + root.join("tools").join("claude-acp") + ); + assert_eq!(state_path(root), root.join("state.json")); + } + + #[test] + fn managed_tools_require_no_override_no_disable_and_a_supported_target() { + assert!(managed_tools_enabled_from_parts(false, false, true)); + assert!(!managed_tools_enabled_from_parts(true, false, true)); + assert!(!managed_tools_enabled_from_parts(false, true, true)); + assert!(!managed_tools_enabled_from_parts(false, false, false)); + } + + #[test] + fn managed_npm_env_points_every_pair_into_the_prefix() { + let env = managed_npm_env_at(Path::new("/data/packages/npm-prefix")); + let expect = |key: &str, value: &str| { + assert_eq!( + env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()), + Some(value), + "{key}" + ); + }; + expect("NPM_CONFIG_PREFIX", "/data/packages/npm-prefix"); + expect("npm_config_prefix", "/data/packages/npm-prefix"); + expect("NPM_CONFIG_CACHE", "/data/packages/npm-prefix/cache"); + expect("npm_config_cache", "/data/packages/npm-prefix/cache"); + expect("COREPACK_HOME", "/data/packages/npm-prefix/corepack"); + assert_eq!(env.len(), 5); + } + + #[test] + fn apply_managed_npm_env_replaces_and_inserts() { + let mut vars = vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("NPM_CONFIG_PREFIX".to_string(), "/stray/prefix".to_string()), + ]; + + apply_managed_npm_env( + &mut vars, + &managed_npm_env_at(Path::new("/data/npm-prefix")), + ); + + assert_eq!(vars.len(), 6); + assert_eq!(vars[0], ("PATH".to_string(), "/usr/bin".to_string())); + assert_eq!( + vars[1], + ( + "NPM_CONFIG_PREFIX".to_string(), + "/data/npm-prefix".to_string() + ) + ); + assert!(vars + .iter() + .any(|(k, v)| k == "COREPACK_HOME" && v == "/data/npm-prefix/corepack")); + } + + #[test] + fn managed_prepend_dirs_orders_shims_then_prefix_then_node() { + assert_eq!( + managed_prepend_dirs_from_parts( + None, + Some(PathBuf::from("/data/packages/bin")), + Some(PathBuf::from("/data/packages/npm-prefix/bin")), + Some(PathBuf::from("/data/packages/node/v1/plat/bin")), + ), + vec![ + PathBuf::from("/data/packages/bin"), + PathBuf::from("/data/packages/npm-prefix/bin"), + PathBuf::from("/data/packages/node/v1/plat/bin"), + ] + ); + // The dev override replaces the managed shim dir and resolves first; + // the prefix bin still resolves already-installed shims (host node + // may run them). + assert_eq!( + managed_prepend_dirs_from_parts( + Some(PathBuf::from("/dev/acp/bin")), + None, + Some(PathBuf::from("/data/packages/npm-prefix/bin")), + None + ), + vec![ + PathBuf::from("/dev/acp/bin"), + PathBuf::from("/data/packages/npm-prefix/bin"), + ] + ); + } + + #[test] + fn npm_backed_commands_are_detected() { + for command in [ + "npm install -g @github/copilot", + "npm install -g amp-acp@latest --registry=https://example.test/npm/", + "sh -c 'npm install -g @agentclientprotocol/claude-agent-acp'", + ] { + assert!(is_npm_backed_command(command), "{command}"); + } + for command in [ + "curl -fsSL https://cursor.com/install | bash", + "brew install --cask codex", + "claude /login", + ] { + assert!(!is_npm_backed_command(command), "{command}"); + } + } + + #[test] + fn managed_tools_table_lists_the_two_bridges() { + let ids: Vec<&str> = MANAGED_TOOLS.iter().map(|tool| tool.id).collect(); + assert_eq!(ids, vec!["claude-acp", "codex-acp"]); + for tool in MANAGED_TOOLS { + assert!( + tool.package.starts_with("@agentclientprotocol/"), + "{}", + tool.package + ); + assert!(!tool.binary.is_empty(), "{}", tool.id); + } + } + + // -- fixtures ----------------------------------------------------------- + + fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + fn test_tool() -> ManagedTool { + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + } + } + + const TEST_NODE_VERSION: &str = "v9.9.9"; + + fn write_json(path: &Path, value: &serde_json::Value) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap(); + } + + /// A fixture install tree: the package's `package.json` (with the resolved + /// version) and its `dist/index.js` entrypoint. + fn write_fixture_install(install_dir: &Path, tool: &ManagedTool, version: &str) { + write_json( + &package_dir(install_dir, tool.package).join("package.json"), + &serde_json::json!({ "name": tool.package, "version": version }), + ); + let entrypoint = npm_entrypoint(install_dir, tool.package); + std::fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + std::fs::write(&entrypoint, "// bridge\n").unwrap(); + } + + // -- shims -------------------------------------------------------------- + + #[test] + fn shim_contents_execs_absolute_paths_and_quotes_spaces() { + let contents = shim_contents( + Path::new("/data/dir with spaces/packages/node/v1/plat/bin/node"), + Path::new("/data/dir with spaces/packages/tools/claude-acp/node_modules/@scope/claude-acp/dist/index.js"), + ); + assert!(contents.starts_with("#!/bin/sh\n")); + assert!(contents.ends_with( + "exec '/data/dir with spaces/packages/node/v1/plat/bin/node' '/data/dir with spaces/packages/tools/claude-acp/node_modules/@scope/claude-acp/dist/index.js' \"$@\"\n" + )); + } + + #[test] + fn write_shim_is_executable() { + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + write_shim(&bin_dir, "claude-agent-acp", "#!/bin/sh\nexec true\n").unwrap(); + let shim = bin_dir.join("claude-agent-acp"); + assert!(is_executable(&shim)); + assert_eq!( + std::fs::read_to_string(&shim).unwrap(), + "#!/bin/sh\nexec true\n" + ); + } + + // -- state -------------------------------------------------------------- + + #[test] + fn state_round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let mut state = ManagedToolsState::default(); + state.tools.insert( + "claude-acp".to_string(), + InstalledToolPin { + binary: "claude-agent-acp".to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }, + ); + state.last_reconcile = Some(ReconcileRecord { + at_ms: 42, + ok: false, + errors: vec!["codex-acp: boom".to_string()], + }); + write_state(dir.path(), &state).unwrap(); + assert_eq!(read_state(dir.path()), state); + + // Missing and corrupt files read as the empty state. + assert_eq!( + read_state(&dir.path().join("absent")), + ManagedToolsState::default() + ); + std::fs::write(state_path(dir.path()), "not json").unwrap(); + assert_eq!(read_state(dir.path()), ManagedToolsState::default()); + } + + // -- install flow (fake npm) -------------------------------------------- + + /// A fake managed-node install dir whose `npm` copies a pre-built fixture + /// tree into the `--prefix` dir, standing in for a real floating install. + fn write_fake_node_with_npm(node_install_dir: &Path, template: &Path, exit_code: i32) { + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), "#!/bin/sh\necho v9.9.9\n").unwrap(); + let npm = format!( + "#!/bin/sh\nprefix=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n prev=\"$arg\"\ndone\ncp -R '{}/.' \"$prefix/\"\necho \"added 3 packages\"\nexit {exit_code}\n", + template.display() + ); + std::fs::write(bin.join("npm"), npm).unwrap(); + use std::os::unix::fs::PermissionsExt; + for name in ["node", "npm"] { + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + } + + /// A fake managed-node `npm` that wipes its `--prefix` node_modules before + /// exiting — stands in for a floating upgrade npm aborts mid-reify. Proves + /// the swap fix: a live install this touched would be broken, so the test + /// installs into a staging prefix and the live tree must survive. + fn write_fake_node_with_destructive_npm(node_install_dir: &Path, exit_code: i32) { + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), "#!/bin/sh\necho v9.9.9\n").unwrap(); + let npm = format!( + "#!/bin/sh\nprefix=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n prev=\"$arg\"\ndone\nrm -rf \"$prefix/node_modules\"\nexit {exit_code}\n" + ); + std::fs::write(bin.join("npm"), npm).unwrap(); + use std::os::unix::fs::PermissionsExt; + for name in ["node", "npm"] { + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + } + + #[tokio::test] + async fn install_npm_tool_installs_shims_and_records_version() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fixture_install(&template, &tool, "1.2.3"); + write_fake_node_with_npm(&node_install_dir, &template, 0); + + let lines = std::sync::Mutex::new(Vec::new()); + let on_line = |line: &str| lines.lock().unwrap().push(line.to_string()); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &on_line, + ) + .await + .unwrap(); + + let shim = shim_bin_dir(&packages_root).join(tool.binary); + let entrypoint = npm_entrypoint(&tool_install_dir(&packages_root, tool.id), tool.package); + assert!(is_executable(&shim)); + assert_eq!( + std::fs::read_to_string(&shim).unwrap(), + shim_contents(&node_binary(&node_install_dir), &entrypoint) + ); + assert_eq!( + read_state(&packages_root).tools.get(tool.id), + Some(&InstalledToolPin { + binary: tool.binary.to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }) + ); + assert!(entrypoint.is_file()); + + let recorded = lines.lock().unwrap().clone(); + assert!(recorded + .iter() + .any(|line| line.contains("added 3 packages"))); + assert!(recorded.iter().any(|line| line.contains("1.2.3 is ready"))); + } + + #[tokio::test] + async fn failed_npm_install_writes_no_shim_and_no_state() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fixture_install(&template, &tool, "1.2.3"); + write_fake_node_with_npm(&node_install_dir, &template, 7); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!(!shim_bin_dir(&packages_root).join(tool.binary).exists()); + assert!(read_state(&packages_root).tools.is_empty()); + } + + #[tokio::test] + async fn install_without_entrypoint_fails_incomplete_before_shims() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + // A clean npm exit that produced no bridge entrypoint (empty template). + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fake_node_with_npm(&node_install_dir, &template, 0); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::Incomplete(_)), "{error}"); + assert!(!shim_bin_dir(&packages_root).join(tool.binary).exists()); + assert!(read_state(&packages_root).tools.is_empty()); + } + + #[tokio::test] + async fn failed_upgrade_preserves_the_previous_install() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + // A healthy previously-installed bridge: tree + shim + state at 1.2.3. + write_installed_tool(&packages_root, &node_install_dir, &tool); + let install_dir = tool_install_dir(&packages_root, tool.id); + let entrypoint = npm_entrypoint(&install_dir, tool.package); + let shim = shim_bin_dir(&packages_root).join(tool.binary); + let shim_before = std::fs::read_to_string(&shim).unwrap(); + + // An upgrade whose npm destroys its --prefix tree and then fails. It + // only ever touches the staging prefix, so the live install survives. + write_fake_node_with_destructive_npm(&node_install_dir, 7); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!( + entrypoint.is_file(), + "live entrypoint clobbered by failed upgrade" + ); + assert_eq!(std::fs::read_to_string(&shim).unwrap(), shim_before); + assert_eq!( + read_state(&packages_root) + .tools + .get(tool.id) + .map(|pin| pin.version.clone()), + Some("1.2.3".to_string()) + ); + // No scratch dirs are left behind for the next reconcile to trip over. + assert!(!staging_install_dir(&packages_root, tool.id).exists()); + assert!(!install_dir.with_extension("old").exists()); + } + + // -- reconcile epilogue -------------------------------------------------- + + /// Lay down a complete healthy install (tree + shim + state) for `tool`. + fn write_installed_tool(packages_root: &Path, node_install_dir: &Path, tool: &ManagedTool) { + let install_dir = tool_install_dir(packages_root, tool.id); + write_fixture_install(&install_dir, tool, "1.2.3"); + let entrypoint = npm_entrypoint(&install_dir, tool.package); + write_shim( + &shim_bin_dir(packages_root), + tool.binary, + &shim_contents(&node_binary(node_install_dir), &entrypoint), + ) + .unwrap(); + let mut state = read_state(packages_root); + state.tools.insert( + tool.id.to_string(), + InstalledToolPin { + binary: tool.binary.to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }, + ); + write_state(packages_root, &state).unwrap(); + } + + #[test] + fn prune_removes_installs_dropped_from_the_managed_set() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path(); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let kept = test_tool(); + let dropped = ManagedTool { + id: "codex-acp", + binary: "codex-acp", + package: "@agentclientprotocol/codex-acp", + }; + write_installed_tool(packages_root, &node_install_dir, &kept); + write_installed_tool(packages_root, &node_install_dir, &dropped); + // A crashed install with no state entry. + std::fs::create_dir_all(tools_root(packages_root).join("ghost-acp")).unwrap(); + + prune_stale_managed_tools(packages_root, std::slice::from_ref(&kept)); + + let state = read_state(packages_root); + assert!(state.tools.contains_key(kept.id)); + assert!(!state.tools.contains_key(dropped.id)); + assert!(shim_bin_dir(packages_root).join(kept.binary).exists()); + assert!(!shim_bin_dir(packages_root).join(dropped.binary).exists()); + assert!(tools_root(packages_root).join(kept.id).exists()); + assert!(!tools_root(packages_root).join(dropped.id).exists()); + assert!(!tools_root(packages_root).join("ghost-acp").exists()); + } + + #[test] + fn record_reconcile_stamps_the_state() { + let dir = tempfile::tempdir().unwrap(); + record_reconcile(dir.path(), vec!["codex-acp: boom".to_string()]); + let record = read_state(dir.path()).last_reconcile.unwrap(); + assert!(!record.ok); + assert_eq!(record.errors, vec!["codex-acp: boom".to_string()]); + assert!(record.at_ms > 0); + + record_reconcile(dir.path(), Vec::new()); + let record = read_state(dir.path()).last_reconcile.unwrap(); + assert!(record.ok); + assert!(record.errors.is_empty()); + } + + /// A packages root with the pinned Node runtime dir plus a superseded + /// version left over from before a Node pin bump. Returns + /// `(packages_root, pinned_dir, superseded_dir)`. + fn write_node_bump_leftovers(dir: &Path) -> (PathBuf, PathBuf, PathBuf) { + let packages_root = dir.join("packages"); + let node_root = packages_root.join("node"); + let pinned_dir = managed_node::pinned_install_dir(&node_root).unwrap(); + let superseded_dir = node_root.join("v0.0.1").join("plat"); + std::fs::create_dir_all(&pinned_dir).unwrap(); + std::fs::create_dir_all(&superseded_dir).unwrap(); + (packages_root, pinned_dir, superseded_dir) + } + + #[tokio::test] + async fn clean_reconcile_prunes_the_superseded_node_runtime() { + let dir = tempfile::tempdir().unwrap(); + let (packages_root, pinned_dir, superseded_dir) = write_node_bump_leftovers(dir.path()); + let tool = test_tool(); + write_installed_tool(&packages_root, &pinned_dir, &tool); + + finish_reconcile_at(&packages_root, std::slice::from_ref(&tool), Vec::new()).await; + + assert!(pinned_dir.exists()); + assert!(!superseded_dir.exists()); + assert!(read_state(&packages_root).last_reconcile.unwrap().ok); + } + + #[tokio::test] + async fn partial_failure_keeps_the_superseded_node_runtime() { + let dir = tempfile::tempdir().unwrap(); + let (packages_root, pinned_dir, superseded_dir) = write_node_bump_leftovers(dir.path()); + let tool = test_tool(); + // The failed bridge's shim was never rewritten: it still execs the + // superseded runtime, which must therefore survive the epilogue. + write_installed_tool(&packages_root, &superseded_dir, &tool); + + finish_reconcile_at( + &packages_root, + std::slice::from_ref(&tool), + vec![format!("{}: npm install failed", tool.id)], + ) + .await; + + assert!(pinned_dir.exists()); + assert!(superseded_dir.exists()); + let shim = std::fs::read_to_string(shim_bin_dir(&packages_root).join(tool.binary)).unwrap(); + assert!(shim.contains(&superseded_dir.to_string_lossy().into_owned())); + assert!(!read_state(&packages_root).last_reconcile.unwrap().ok); + } +} diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs new file mode 100644 index 000000000..d1bde73c2 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -0,0 +1,1075 @@ +//! Staged-managed Node.js runtime. +//! +//! Downloads the Node.js version pinned in `node-runtime.lock.json` (app +//! root, embedded at compile time; refresh with `just bump-node-runtime`), +//! verifies it against the lock's SHA-256, and atomically installs it under +//! `~/.staged/packages/node///`. The tarball comes from +//! Block's Artifactory `nodejs` repo — a read-through mirror of +//! `https://nodejs.org/dist` — so the lock's hash pin is the trust root, not +//! the mirror; `no-block-npm-registry` builds fetch from nodejs.org directly. +//! +//! `~/.staged/packages` is shared by every running Staged instance (several +//! worktree `just dev` processes routinely run alongside the installed app), +//! so installs and prunes hold a cross-process advisory `flock` on +//! `/.lock` in addition to the in-process serialization mutex. + +use std::collections::BTreeMap; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +const NODE_RUNTIME_LOCK_JSON: &str = include_str!("../../node-runtime.lock.json"); + +const BLOCK_NODE_DIST_BASE_URL: &str = "https://global.block-artifacts.com/artifactory/nodejs"; +const UPSTREAM_NODE_DIST_BASE_URL: &str = "https://nodejs.org/dist"; + +const PACKAGES_LOCK_FILENAME: &str = ".lock"; + +/// Hard cap on the compressed tarball; the largest pinned artifact today is +/// ~49 MB, so anything near this is a wrong or corrupted download. +const MAX_ARCHIVE_BYTES: u64 = 90 * 1024 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// Chunk-level download progress would log far too often; report at this +/// granularity instead. +const PROGRESS_LOG_STEP_BYTES: u64 = 10 * 1024 * 1024; + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct NodeRuntimeLock { + /// Pinned Node.js version, `v`-prefixed (`v24.11.0`) — the exact string + /// `node --version` prints. + pub version: String, + /// Rust target triple → release tarball pin. + pub artifacts: BTreeMap, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct NodeRuntimeArtifact { + pub filename: String, + pub sha256: String, +} + +impl NodeRuntimeArtifact { + /// Node's platform string (`darwin-arm64`, …), derived from the tarball + /// name so the lock stays the single source of truth. + fn platform<'a>(&'a self, version: &str) -> Option<&'a str> { + self.filename + .strip_prefix(format!("node-{version}-").as_str())? + .strip_suffix(".tar.gz") + } +} + +pub fn node_runtime_lock() -> &'static NodeRuntimeLock { + static LOCK: OnceLock = OnceLock::new(); + LOCK.get_or_init(|| { + serde_json::from_str(NODE_RUNTIME_LOCK_JSON) + .expect("embedded node-runtime.lock.json must parse") + }) +} + +pub(crate) fn current_target_triple() -> Option<&'static str> { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Some("aarch64-apple-darwin") + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + Some("x86_64-apple-darwin") + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + Some("aarch64-unknown-linux-gnu") + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + Some("x86_64-unknown-linux-gnu") + } else { + None + } +} + +fn node_dist_base_url() -> &'static str { + if cfg!(feature = "no-block-npm-registry") { + UPSTREAM_NODE_DIST_BASE_URL + } else { + BLOCK_NODE_DIST_BASE_URL + } +} + +#[derive(Debug)] +pub enum ManagedNodeError { + UnsupportedTarget { + os: &'static str, + arch: &'static str, + }, + DataDir(String), + LockMissingTarget(String), + InvalidLockFilename(String), + Network(String), + HttpStatus(u16), + ArchiveTooLarge { + limit_bytes: u64, + }, + Sha256Mismatch { + expected: String, + actual: String, + }, + UnsafeArchiveEntry(String), + IncompleteRuntime(String), + Io(String), +} + +impl std::fmt::Display for ManagedNodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedTarget { os, arch } => { + write!(f, "Staged does not provide a managed Node.js runtime for {os}-{arch}") + } + Self::DataDir(message) => { + write!(f, "failed to resolve the managed Node.js runtime directory: {message}") + } + Self::LockMissingTarget(target) => { + write!(f, "node-runtime.lock.json has no artifact for target {target}") + } + Self::InvalidLockFilename(filename) => write!( + f, + "node-runtime.lock.json artifact '{filename}' is not a node--.tar.gz tarball" + ), + Self::Network(message) => write!(f, "Node.js runtime download failed: {message}"), + Self::HttpStatus(status) => write!(f, "Node.js runtime download failed: HTTP {status}"), + Self::ArchiveTooLarge { limit_bytes } => { + write!(f, "Node.js runtime archive exceeds the {limit_bytes}-byte limit") + } + Self::Sha256Mismatch { expected, actual } => write!( + f, + "Node.js runtime archive SHA-256 mismatch: expected {expected}, got {actual}" + ), + Self::UnsafeArchiveEntry(path) => { + write!(f, "Node.js runtime archive contains an unsafe entry path: {path}") + } + Self::IncompleteRuntime(message) => { + write!(f, "managed Node.js runtime install is incomplete: {message}") + } + Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ManagedNodeError {} + +/// `~/.staged/packages/node` — every managed runtime version lives under here. +pub fn managed_node_root() -> Option { + Some(node_root_under(&crate::paths::packages_dir()?)) +} + +pub fn managed_node_bin_dir() -> Option { + Some(pinned_install_dir(&managed_node_root()?)?.join("bin")) +} + +fn node_root_under(packages_root: &Path) -> PathBuf { + packages_root.join("node") +} + +/// Where the pinned runtime for the current target lives (or belongs) under +/// `node_root` — `//`. `None` when the embedded +/// lock has no artifact for this target. +pub fn pinned_install_dir(node_root: &Path) -> Option { + let lock = node_runtime_lock(); + let artifact = lock.artifacts.get(current_target_triple()?)?; + let platform = artifact.platform(&lock.version)?; + Some(install_dir(node_root, &lock.version, platform)) +} + +/// Whether the pinned runtime under `node_root` is installed and healthy for +/// the current target. `false` on unsupported targets. +pub async fn pinned_runtime_ready(node_root: &Path) -> bool { + match pinned_install_dir(node_root) { + Some(final_dir) => runtime_ready(&final_dir, &node_runtime_lock().version).await, + None => false, + } +} + +fn install_dir(node_root: &Path, version: &str, platform: &str) -> PathBuf { + node_root.join(version).join(platform) +} + +/// Make sure the pinned Node.js runtime is installed and healthy, downloading +/// and atomically swapping it into place when it is not. Safe to call +/// concurrently — including from other Staged processes sharing +/// `~/.staged/packages`: installs are serialized on an in-process mutex plus +/// a cross-process file lock, and readiness is re-checked after acquiring +/// them. +pub async fn ensure_managed_node_runtime() -> Result<(), ManagedNodeError> { + let packages_root = crate::paths::packages_dir() + .ok_or_else(|| ManagedNodeError::DataDir("home directory is unavailable".to_string()))?; + ensure_managed_node_runtime_at( + &packages_root, + node_dist_base_url(), + node_runtime_lock(), + MAX_ARCHIVE_BYTES, + ) + .await +} + +async fn ensure_managed_node_runtime_at( + packages_root: &Path, + base_url: &str, + lock: &NodeRuntimeLock, + max_archive_bytes: u64, +) -> Result<(), ManagedNodeError> { + let target = current_target_triple().ok_or(ManagedNodeError::UnsupportedTarget { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + })?; + let artifact = lock + .artifacts + .get(target) + .ok_or_else(|| ManagedNodeError::LockMissingTarget(target.to_string()))?; + let platform = artifact + .platform(&lock.version) + .ok_or_else(|| ManagedNodeError::InvalidLockFilename(artifact.filename.clone()))?; + + let node_root = node_root_under(packages_root); + let final_dir = install_dir(&node_root, &lock.version, platform); + if runtime_ready(&final_dir, &lock.version).await { + return Ok(()); + } + + let _guard = install_serialization_lock().lock().await; + let _packages_lock = lock_packages_dir(packages_root).await?; + if runtime_ready(&final_dir, &lock.version).await { + return Ok(()); + } + + let plan = InstallPlan { + node_root: &node_root, + version: &lock.version, + platform, + filename: &artifact.filename, + sha256: &artifact.sha256, + base_url, + max_archive_bytes, + }; + install_runtime(&plan).await?; + + if runtime_ready(&final_dir, &lock.version).await { + Ok(()) + } else { + Err(ManagedNodeError::IncompleteRuntime( + "installed runtime failed the readiness probe".to_string(), + )) + } +} + +fn install_serialization_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +/// Held cross-process advisory lock on `/.lock`; dropping it +/// (closing the descriptor) releases the lock, and a crashed holder's lock +/// dies with its process. +pub(crate) struct PackagesDirLock { + _file: std::fs::File, +} + +/// Take the exclusive cross-process lock serializing mutations of the shared +/// `~/.staged/packages` tree. An in-process mutex (this module's +/// [`install_serialization_lock`], or `managed_acp_tools`' tool-install +/// mutex) must already be held so at most one task per process parks a +/// blocking thread waiting here. Blocks until whichever other Staged process +/// holds the lock finishes. Never acquire while already holding a +/// [`PackagesDirLock`]: flock ownership is per open file description, so the +/// second acquisition in the same process deadlocks against the first. +pub(crate) async fn lock_packages_dir( + packages_root: &Path, +) -> Result { + let packages_root = packages_root.to_path_buf(); + tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&packages_root) + .map_err(|error| ManagedNodeError::Io(format!("create packages dir: {error}")))?; + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + // The file carries no content — it exists only to be flocked — + // but truncating on open would be a needless mutation of a file + // other processes hold open. + .truncate(false) + .open(packages_root.join(PACKAGES_LOCK_FILENAME)) + .map_err(|error| ManagedNodeError::Io(format!("open packages lock file: {error}")))?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(ManagedNodeError::Io(format!( + "lock packages dir: {}", + std::io::Error::last_os_error() + ))); + } + Ok(PackagesDirLock { _file: file }) + }) + .await + .map_err(|error| ManagedNodeError::Io(format!("packages lock task failed: {error}")))? +} + +/// Fast-path readiness probe: `bin/npm` is present and the installed +/// `bin/node` runs and reports exactly the pinned version. +async fn runtime_ready(final_dir: &Path, version: &str) -> bool { + let bin = final_dir.join("bin"); + if !bin.join("npm").is_file() { + return false; + } + let node = bin.join("node"); + if !node.is_file() { + return false; + } + let output = tokio::process::Command::new(&node) + .arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + output + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim() == version) + .unwrap_or(false) +} + +struct InstallPlan<'a> { + node_root: &'a Path, + version: &'a str, + platform: &'a str, + filename: &'a str, + sha256: &'a str, + base_url: &'a str, + max_archive_bytes: u64, +} + +async fn install_runtime(plan: &InstallPlan<'_>) -> Result<(), ManagedNodeError> { + let final_dir = install_dir(plan.node_root, plan.version, plan.platform); + let temp_dir = plan + .node_root + .join(format!("{}.{}.tmp", plan.version, plan.platform)); + let archive_path = plan.node_root.join(format!("{}.download", plan.filename)); + + if temp_dir.exists() { + std::fs::remove_dir_all(&temp_dir) + .map_err(|error| ManagedNodeError::Io(format!("remove stale temp dir: {error}")))?; + } + if let Some(parent) = final_dir.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + ManagedNodeError::Io(format!("create runtime version dir: {error}")) + })?; + } + + let url = format!( + "{}/{}/{}", + plan.base_url.trim_end_matches('/'), + plan.version, + plan.filename + ); + download_archive(&url, &archive_path, plan.sha256, plan.max_archive_bytes).await?; + + log::info!("Extracting managed Node.js runtime"); + std::fs::create_dir_all(&temp_dir) + .map_err(|error| ManagedNodeError::Io(format!("create temp dir: {error}")))?; + let extract_result = { + let archive_path = archive_path.clone(); + let temp_dir = temp_dir.clone(); + tokio::task::spawn_blocking(move || extract_archive(&archive_path, &temp_dir)) + .await + .map_err(|error| ManagedNodeError::Io(format!("extract task failed: {error}")))? + }; + let _ = std::fs::remove_file(&archive_path); + extract_result?; + + // Node tarballs unpack into a single `node--` dir. + let extracted_dir = temp_dir.join(format!("node-{}-{}", plan.version, plan.platform)); + let source_dir = if extracted_dir.is_dir() { + extracted_dir + } else { + temp_dir.clone() + }; + verify_runtime_tree(&source_dir)?; + + log::info!("Installing managed Node.js runtime"); + let old_dir = final_dir.with_extension("old"); + if old_dir.exists() { + std::fs::remove_dir_all(&old_dir) + .map_err(|error| ManagedNodeError::Io(format!("remove stale old dir: {error}")))?; + } + if final_dir.exists() { + std::fs::rename(&final_dir, &old_dir) + .map_err(|error| ManagedNodeError::Io(format!("stage previous runtime: {error}")))?; + } + if let Err(error) = std::fs::rename(&source_dir, &final_dir) { + if old_dir.exists() { + let _ = std::fs::rename(&old_dir, &final_dir); + } + return Err(ManagedNodeError::Io(format!( + "install Node.js runtime: {error}" + ))); + } + let _ = std::fs::remove_dir_all(&old_dir); + let _ = std::fs::remove_dir_all(&temp_dir); + // Superseded version dirs are deliberately NOT pruned here: bridge shims + // exec the absolute path of the Node version they were installed against, + // so the old runtime must survive until every shim has been rewritten + // onto this one. The reconcile epilogue prunes via + // `prune_superseded_node_runtimes` once every bridge install succeeded. + Ok(()) +} + +/// Remove every managed runtime version under `/node` except the +/// embedded pin, along with stale temp dirs and orphaned downloads. Callers +/// must only prune once nothing execs a superseded version anymore — i.e. +/// after a reconcile in which every managed bridge reinstalled (and +/// re-shimmed) onto the pin. Serialized against in-flight installs — in this +/// process and in every other Staged process sharing the tree — so a +/// concurrent install's temp artifacts are never swept out from under it. +pub async fn prune_superseded_node_runtimes(packages_root: &Path) { + let _guard = install_serialization_lock().lock().await; + let _packages_lock = match lock_packages_dir(packages_root).await { + Ok(lock) => lock, + Err(error) => { + log::warn!("skipping managed Node.js prune: {error}"); + return; + } + }; + prune_superseded( + &node_root_under(packages_root), + &node_runtime_lock().version, + ); +} + +/// Everything under the node root that is not the kept version dir — +/// superseded version dirs, stale temp dirs, orphaned downloads — is garbage +/// once no shim points into it. Best-effort only; a locked file just logs and +/// is retried on the next successful reconcile. +fn prune_superseded(node_root: &Path, version: &str) { + let Ok(entries) = std::fs::read_dir(node_root) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy() == version { + continue; + } + let path = entry.path(); + let result = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + if let Err(error) = result { + log::warn!( + "failed to prune superseded managed Node.js entry {}: {error}", + path.display() + ); + } + } +} + +async fn download_archive( + url: &str, + dest: &Path, + expected_sha256: &str, + max_bytes: u64, +) -> Result<(), ManagedNodeError> { + let result = stream_archive(url, dest, expected_sha256, max_bytes).await; + if result.is_err() { + let _ = tokio::fs::remove_file(dest).await; + } + result +} + +async fn stream_archive( + url: &str, + dest: &Path, + expected_sha256: &str, + max_bytes: u64, +) -> Result<(), ManagedNodeError> { + let client = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .map_err(|error| ManagedNodeError::Network(format!("build download client: {error}")))?; + let mut response = client + .get(url) + .send() + .await + .map_err(|error| ManagedNodeError::Network(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(ManagedNodeError::HttpStatus(status.as_u16())); + } + let total_bytes = response.content_length(); + if let Some(total) = total_bytes { + if total > max_bytes { + return Err(ManagedNodeError::ArchiveTooLarge { + limit_bytes: max_bytes, + }); + } + } + + let mut file = tokio::fs::File::create(dest) + .await + .map_err(|error| ManagedNodeError::Io(format!("create archive file: {error}")))?; + let mut hasher = Sha256::new(); + let mut received_bytes = 0_u64; + let mut last_logged_step = 0_u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| ManagedNodeError::Network(error.to_string()))? + { + received_bytes += chunk.len() as u64; + if received_bytes > max_bytes { + return Err(ManagedNodeError::ArchiveTooLarge { + limit_bytes: max_bytes, + }); + } + hasher.update(&chunk); + file.write_all(&chunk) + .await + .map_err(|error| ManagedNodeError::Io(format!("write archive file: {error}")))?; + let step = received_bytes / PROGRESS_LOG_STEP_BYTES; + if step > last_logged_step { + last_logged_step = step; + let received_mb = received_bytes / (1024 * 1024); + match total_bytes { + Some(total) => log::info!( + "Downloading managed Node.js: {received_mb} MB of {} MB", + total.div_ceil(1024 * 1024) + ), + None => log::info!("Downloading managed Node.js: {received_mb} MB"), + } + } + } + file.flush() + .await + .map_err(|error| ManagedNodeError::Io(format!("flush archive file: {error}")))?; + drop(file); + + let actual = hex::encode(hasher.finalize()); + if !actual.eq_ignore_ascii_case(expected_sha256) { + return Err(ManagedNodeError::Sha256Mismatch { + expected: expected_sha256.to_string(), + actual, + }); + } + Ok(()) +} + +fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<(), ManagedNodeError> { + // Two passes over the (seekable) file: validate every entry path before a + // single byte is written, then unpack. + let file = std::fs::File::open(archive_path) + .map_err(|error| ManagedNodeError::Io(format!("open archive: {error}")))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + validate_archive_entries(&mut archive)?; + + let file = std::fs::File::open(archive_path) + .map_err(|error| ManagedNodeError::Io(format!("open archive for extraction: {error}")))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + archive + .unpack(dest_dir) + .map_err(|error| ManagedNodeError::Io(format!("extract archive: {error}"))) +} + +fn validate_archive_entries( + archive: &mut tar::Archive, +) -> Result<(), ManagedNodeError> { + let entries = archive + .entries() + .map_err(|error| ManagedNodeError::Io(format!("read archive entries: {error}")))?; + for entry in entries { + let entry = + entry.map_err(|error| ManagedNodeError::Io(format!("read archive entry: {error}")))?; + let path = entry + .path() + .map_err(|error| ManagedNodeError::Io(format!("read archive entry path: {error}")))?; + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(ManagedNodeError::UnsafeArchiveEntry( + path.to_string_lossy().into_owned(), + )); + } + } + Ok(()) +} + +fn verify_runtime_tree(dir: &Path) -> Result<(), ManagedNodeError> { + for binary in ["node", "npm"] { + if !dir.join("bin").join(binary).is_file() { + return Err(ManagedNodeError::IncompleteRuntime(format!( + "archive is missing bin/{binary}" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const TEST_VERSION: &str = "v9.9.9"; + const TEST_PLATFORM: &str = "testos-testarch"; + + fn target() -> &'static str { + current_target_triple().expect("tests only run on supported targets") + } + + fn test_lock(sha256: &str) -> NodeRuntimeLock { + let mut artifacts = BTreeMap::new(); + artifacts.insert( + target().to_string(), + NodeRuntimeArtifact { + filename: format!("node-{TEST_VERSION}-{TEST_PLATFORM}.tar.gz"), + sha256: sha256.to_string(), + }, + ); + NodeRuntimeLock { + version: TEST_VERSION.to_string(), + artifacts, + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + fn node_script(version: &str) -> String { + format!("#!/bin/sh\necho {version}\n") + } + + fn gzip(tar_bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(tar_bytes).unwrap(); + encoder.finish().unwrap() + } + + fn append_file(builder: &mut tar::Builder>, path: &str, contents: &str, mode: u32) { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + builder + .append_data(&mut header, path, contents.as_bytes()) + .unwrap(); + } + + /// A minimal but shape-faithful Node release tarball: executable + /// `bin/node` stub plus the `bin/npm` symlink into `lib/node_modules`. + fn node_tarball(version: &str) -> Vec { + let prefix = format!("node-{version}-{TEST_PLATFORM}"); + let mut builder = tar::Builder::new(Vec::new()); + append_file( + &mut builder, + &format!("{prefix}/bin/node"), + &node_script(version), + 0o755, + ); + append_file( + &mut builder, + &format!("{prefix}/lib/node_modules/npm/bin/npm-cli.js"), + "#!/usr/bin/env node\n", + 0o755, + ); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + builder + .append_link( + &mut header, + format!("{prefix}/bin/npm"), + "../lib/node_modules/npm/bin/npm-cli.js", + ) + .unwrap(); + gzip(&builder.into_inner().unwrap()) + } + + /// `tar::Builder` refuses to author unsafe paths, so write the name field + /// into the raw header bytes. + fn raw_entry_tar(name: &str) -> Vec { + let mut header = tar::Header::new_gnu(); + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + header.set_size(4); + header.set_mode(0o644); + header.set_cksum(); + let mut builder = tar::Builder::new(Vec::new()); + builder.append(&header, &b"evil"[..]).unwrap(); + builder.into_inner().unwrap() + } + + /// One-shot HTTP server; without a Content-Length header the body is + /// delimited by connection close, which exercises the streaming size cap. + async fn serve_once(body: Vec, with_content_length: bool) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 1024]; + let _ = socket.read(&mut request).await; + let head = if with_content_length { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + } else { + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_string() + }; + socket.write_all(head.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + let _ = socket.shutdown().await; + }); + format!("http://{addr}") + } + + fn test_node_root(packages_root: &Path) -> PathBuf { + node_root_under(packages_root) + } + + fn write_ready_runtime(node_root: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + let bin = install_dir(node_root, version, TEST_PLATFORM).join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let node = bin.join("node"); + std::fs::write(&node, node_script(version)).unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bin.join("npm"), "").unwrap(); + } + + #[test] + fn embedded_lock_pins_every_supported_target() { + let lock = node_runtime_lock(); + assert!(lock.version.starts_with('v'), "version: {}", lock.version); + for target in [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + ] { + let artifact = lock + .artifacts + .get(target) + .unwrap_or_else(|| panic!("lock is missing {target}")); + assert_eq!(artifact.sha256.len(), 64, "{target}"); + assert!( + artifact.sha256.chars().all(|c| c.is_ascii_hexdigit()), + "{target}" + ); + assert!(artifact.platform(&lock.version).is_some(), "{target}"); + } + } + + #[test] + fn artifact_platform_derives_from_filename() { + let artifact = NodeRuntimeArtifact { + filename: "node-v24.11.0-darwin-arm64.tar.gz".to_string(), + sha256: String::new(), + }; + assert_eq!(artifact.platform("v24.11.0"), Some("darwin-arm64")); + assert_eq!(artifact.platform("v24.12.0"), None); + } + + #[test] + fn base_url_follows_registry_feature() { + if cfg!(feature = "no-block-npm-registry") { + assert_eq!(node_dist_base_url(), UPSTREAM_NODE_DIST_BASE_URL); + } else { + assert_eq!(node_dist_base_url(), BLOCK_NODE_DIST_BASE_URL); + } + } + + #[test] + fn archive_validation_rejects_traversal_and_absolute_paths() { + for name in ["../evil.sh", "/abs/evil.sh"] { + let mut archive = tar::Archive::new(std::io::Cursor::new(raw_entry_tar(name))); + let error = validate_archive_entries(&mut archive).unwrap_err(); + assert!( + matches!(error, ManagedNodeError::UnsafeArchiveEntry(_)), + "{name}: {error}" + ); + } + } + + #[tokio::test] + async fn packages_lock_excludes_other_holders_until_dropped() { + let packages_dir = tempfile::tempdir().unwrap(); + let guard = lock_packages_dir(packages_dir.path()).await.unwrap(); + + // A second open file description — the same shape another Staged + // process's flock takes — must not get the lock while the guard lives. + let contender = std::fs::OpenOptions::new() + .write(true) + .open(packages_dir.path().join(PACKAGES_LOCK_FILENAME)) + .unwrap(); + assert_eq!( + unsafe { libc::flock(contender.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK) + ); + + drop(guard); + // Retry briefly instead of asserting a single non-blocking attempt: + // a child process forked by a concurrently-running test can hold a + // duplicate of the just-closed lock fd until its exec closes it + // (CLOEXEC), keeping the flock alive for a moment after the drop. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if unsafe { libc::flock(contender.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "dropped packages lock was never released to the contender" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn install_keeps_superseded_versions_until_reconcile_prunes() { + let packages_dir = tempfile::tempdir().unwrap(); + let packages_root = packages_dir.path(); + let node_root = test_node_root(packages_root); + // Leftovers from a superseded install and a crashed one. + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + std::fs::create_dir_all(node_root.join(format!("{TEST_VERSION}.{TEST_PLATFORM}.tmp"))) + .unwrap(); + std::fs::write(node_root.join("node-v9.9.8-old.tar.gz.download"), b"stale").unwrap(); + + let archive = node_tarball(TEST_VERSION); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + ensure_managed_node_runtime_at(packages_root, &base_url, &lock, MAX_ARCHIVE_BYTES) + .await + .unwrap(); + + let bin = install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).join("bin"); + assert!(bin.join("node").is_file()); + assert!(bin.join("npm").is_file()); + // The install cleans up its own temp dir but leaves the superseded + // version (and the other install's orphaned download) alone: shims + // written against v9.9.8 must keep working until the reconcile + // epilogue confirms every bridge migrated and prunes. + assert!(node_root.join("v9.9.8").exists()); + assert!(!node_root + .join(format!("{TEST_VERSION}.{TEST_PLATFORM}.tmp")) + .exists()); + assert!(node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + + prune_superseded(&node_root, TEST_VERSION); + assert!(bin.join("node").is_file()); + assert!(!node_root.join("v9.9.8").exists()); + assert!(!node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + } + + #[tokio::test] + async fn fast_path_skips_download_when_runtime_matches_pin() { + let packages_dir = tempfile::tempdir().unwrap(); + write_ready_runtime(&test_node_root(packages_dir.path()), TEST_VERSION); + + // An unroutable base URL: any download attempt fails the test. + ensure_managed_node_runtime_at( + packages_dir.path(), + "http://127.0.0.1:1", + &test_lock(&"0".repeat(64)), + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn sha_mismatch_fails_and_preserves_previous_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let node_root = test_node_root(packages_dir.path()); + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + + let lock = test_lock(&sha256_hex(b"something else entirely")); + let base_url = serve_once(node_tarball(TEST_VERSION), true).await; + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::Sha256Mismatch { .. }), + "{error}" + ); + assert!(!install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).exists()); + // The failed download is cleaned up and the previous install is only + // pruned by a later fully-successful reconcile. + assert!(install_dir(&node_root, "v9.9.8", TEST_PLATFORM).exists()); + let downloads = std::fs::read_dir(&node_root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".download")) + .count(); + assert_eq!(downloads, 0); + } + + #[tokio::test] + async fn download_size_cap_aborts_stream() { + let packages_dir = tempfile::tempdir().unwrap(); + let body = vec![0_u8; 4096]; + let lock = test_lock(&sha256_hex(&body)); + let base_url = serve_once(body, false).await; + + let error = ensure_managed_node_runtime_at(packages_dir.path(), &base_url, &lock, 1024) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::ArchiveTooLarge { .. }), + "{error}" + ); + let leftovers = std::fs::read_dir(test_node_root(packages_dir.path())) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".download")) + .count(); + assert_eq!(leftovers, 0); + } + + #[tokio::test] + async fn traversal_entry_fails_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let archive = gzip(&raw_entry_tar("../evil.sh")); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::UnsafeArchiveEntry(_)), + "{error}" + ); + let node_root = test_node_root(packages_dir.path()); + assert!(!install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).exists()); + // `../evil.sh` would have escaped the temp dir into the node root. + assert!(!node_root.join("evil.sh").exists()); + } + + #[tokio::test] + async fn archive_missing_npm_fails_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let prefix = format!("node-{TEST_VERSION}-{TEST_PLATFORM}"); + let mut builder = tar::Builder::new(Vec::new()); + append_file( + &mut builder, + &format!("{prefix}/bin/node"), + &node_script(TEST_VERSION), + 0o755, + ); + let archive = gzip(&builder.into_inner().unwrap()); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::IncompleteRuntime(_)), + "{error}" + ); + assert!(!install_dir( + &test_node_root(packages_dir.path()), + TEST_VERSION, + TEST_PLATFORM + ) + .exists()); + } + + #[test] + fn pinned_install_dir_follows_the_embedded_lock() { + let lock = node_runtime_lock(); + let artifact = &lock.artifacts[target()]; + let platform = artifact.platform(&lock.version).unwrap(); + assert_eq!( + pinned_install_dir(Path::new("/data/packages/node")), + Some( + Path::new("/data/packages/node") + .join(&lock.version) + .join(platform) + ) + ); + } + + #[tokio::test] + async fn pinned_runtime_ready_probes_the_embedded_pin() { + let node_root_dir = tempfile::tempdir().unwrap(); + let node_root = node_root_dir.path(); + assert!(!pinned_runtime_ready(node_root).await); + + // A runtime matching the real embedded pin at the pinned install dir. + use std::os::unix::fs::PermissionsExt; + let bin = pinned_install_dir(node_root).unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let node = bin.join("node"); + std::fs::write(&node, node_script(&node_runtime_lock().version)).unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bin.join("npm"), "").unwrap(); + assert!(pinned_runtime_ready(node_root).await); + } + + #[tokio::test] + async fn prune_superseded_node_runtimes_keeps_only_the_embedded_pin() { + let packages_dir = tempfile::tempdir().unwrap(); + let node_root = test_node_root(packages_dir.path()); + let pinned_bin = pinned_install_dir(&node_root).unwrap().join("bin"); + std::fs::create_dir_all(&pinned_bin).unwrap(); + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + std::fs::write(node_root.join("node-v9.9.8-old.tar.gz.download"), b"stale").unwrap(); + + prune_superseded_node_runtimes(packages_dir.path()).await; + + assert!(pinned_bin.exists()); + assert!(!node_root.join("v9.9.8").exists()); + assert!(!node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + // The prune stays inside /node: the cross-process lock file + // it holds lives beside the node root and must survive. + assert!(packages_dir.path().join(PACKAGES_LOCK_FILENAME).exists()); + } + + #[tokio::test] + async fn readiness_probe_requires_exact_pinned_version_and_npm() { + let node_root_dir = tempfile::tempdir().unwrap(); + let node_root = node_root_dir.path(); + let final_dir = install_dir(node_root, TEST_VERSION, TEST_PLATFORM); + assert!(!runtime_ready(&final_dir, TEST_VERSION).await); + + write_ready_runtime(node_root, TEST_VERSION); + assert!(runtime_ready(&final_dir, TEST_VERSION).await); + assert!(!runtime_ready(&final_dir, "v9.9.8").await); + + // A runtime whose npm is gone is damaged even if node still runs. + std::fs::remove_file(final_dir.join("bin").join("npm")).unwrap(); + assert!(!runtime_ready(&final_dir, TEST_VERSION).await); + } +} diff --git a/apps/staged/src-tauri/src/paths.rs b/apps/staged/src-tauri/src/paths.rs index 32be34448..efb27f340 100644 --- a/apps/staged/src-tauri/src/paths.rs +++ b/apps/staged/src-tauri/src/paths.rs @@ -21,6 +21,15 @@ pub fn clone_path_for(github_repo: &str) -> Option { repos_dir().map(|d| d.join(github_repo)) } +/// Root for app-managed runtime packages (the managed Node.js runtime and, +/// eventually, npm-installed ACP bridges): `~/.staged/packages/` +/// +/// Shared by every running Staged instance; mutations must hold the +/// cross-process lock (see `managed_node`). +pub fn packages_dir() -> Option { + data_dir().map(|d| d.join("packages")) +} + /// Root directory for workspace-scoped local data: `~/.staged/workspaces/` pub fn workspaces_dir() -> Option { data_dir().map(|d| d.join("workspaces")) diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index f2648ac5c..90d11d877 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -486,10 +486,6 @@ pub fn start_session( let cancel_token = registry.register(&config.session_id); - // Resolve the bundled ACP tools dir before the AppHandle moves into the - // session thread; the snapshots built there prepend it to the agent PATH. - let bundled_acp_tools_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); - // The agent protocol may use !Send futures, so we spin up a dedicated // thread with its own single-threaded Tokio runtime + LocalSet. let session_id_for_status = config.session_id.clone(); @@ -520,24 +516,20 @@ pub fn start_session( // cwd capture failure is non-fatal: log it and let the driver fall // back to spawning `$SHELL -ils` and exec'ing the agent. // - // Both snapshots are shaped through `apply_bundled_tools_env` - // after capture, so the bundled ACP bridges win over + // Both snapshots are shaped through `apply_managed_tools_env` + // after capture, so the managed ACP bridges win over // user-installed copies (and over `GOOSE_SEARCH_PATHS` values // from the user's shell) for the agent and everything it spawns. let driver = if config.workspace_name.is_none() { let cache = shell_env_cache(); let mut home_snapshot = crate::shell_env::home_env_vars_with_extended_path(cache.as_ref()).await; - if let Some(dir) = bundled_acp_tools_dir.as_deref() { - crate::acp_tools::apply_bundled_tools_env(&mut home_snapshot, dir); - } + crate::acp_tools::apply_managed_tools_env(&mut home_snapshot); let driver = driver.with_interpreter_env_snapshot(home_snapshot); match cache.get(&config.working_dir).await { Ok(snapshot) => { let mut vars = snapshot.vars().to_vec(); - if let Some(dir) = bundled_acp_tools_dir.as_deref() { - crate::acp_tools::apply_bundled_tools_env(&mut vars, dir); - } + crate::acp_tools::apply_managed_tools_env(&mut vars); driver.with_env_snapshot(vars) } Err(e) => { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 1cfcc4880..283136046 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3755,25 +3755,24 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { - let report = crate::doctor::run_doctor(app_handle.clone()).await; + let report = crate::doctor::run_doctor().await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_freshness" => { - let report = crate::doctor::run_doctor_freshness(app_handle.clone()).await; + let report = crate::doctor::run_doctor_freshness().await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_fix" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; - crate::doctor::run_doctor_fix(app_handle.clone(), check_id, fix_type).await?; + crate::doctor::run_doctor_fix(check_id, fix_type).await?; Ok(Value::Null) } "run_doctor_update" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; let command: String = arg(&args, "command")?; - crate::doctor::run_doctor_update(app_handle.clone(), check_id, fix_type, command) - .await?; + crate::doctor::run_doctor_update(check_id, fix_type, command).await?; Ok(Value::Null) } diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index e0f58500b..a632f8d2b 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -44,8 +44,7 @@ "icons/icon.ico" ], "resources": [ - "resources/pikchr/grammar.md", - "resources/acp" + "resources/pikchr/grammar.md" ], "macOS": { "infoPlist": "Info.plist" diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 9f3c50da5..b916f4595 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -48,6 +48,7 @@ import { listenForSessionStatus } from './lib/listeners/sessionStatusListener'; import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener'; import { listenForPageLifecycle } from './lib/listeners/pageLifecycleListener'; + import { listenForAcpToolsReconciled } from './lib/listeners/acpToolsListener'; import { darkMode } from './lib/stores/isDark.svelte'; import * as prPollingService from './lib/services/prPollingService'; import { reposUiEnabled } from './lib/featureFlags'; @@ -68,6 +69,7 @@ let unlistenSessionStatus: UnlistenFn | undefined; let unlistenCacheInvalidation: UnlistenFn | undefined; let unlistenPageLifecycle: (() => void) | undefined; + let unlistenAcpToolsReconciled: UnlistenFn | undefined; let unregisterShortcuts: (() => void) | null = null; let stopUpdaterLoop: (() => void) | null = null; let storeIncompat = $state(null); @@ -299,6 +301,9 @@ unlistenSessionStatus = listenForSessionStatus(); unlistenCacheInvalidation = listenForCacheInvalidation(); unlistenPageLifecycle = listenForPageLifecycle(); + // Refresh provider discovery (and any loaded doctor report) once the + // backend finishes installing/upgrading the managed ACP bridges. + unlistenAcpToolsReconciled = listenForAcpToolsReconciled(); try { await initPreferences(); @@ -487,6 +492,7 @@ unlistenSessionStatus?.(); unlistenCacheInvalidation?.(); unlistenPageLifecycle?.(); + unlistenAcpToolsReconciled?.(); stopUpdaterLoop?.(); }); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 79e52fa06..2663936ae 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1332,8 +1332,8 @@ export interface AgentVersionInfo { updateCommand: string | null; /** 'updateMain' or 'updateBridge', matching this readout's slot. */ updateFixType: 'updateMain' | 'updateBridge' | null; - /** True when this binary ships bundled with Staged (resolved from the app's - * bundled ACP tools dir rather than a user install). Stamped by the doctor + /** True when this binary is managed by Staged (resolved from the managed + * bridge shim dir rather than a user install). Stamped by the doctor * crate alongside installSource === 'bundled'. */ bundled: boolean | null; } diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index da0735b9c..f54026081 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -167,9 +167,11 @@ {check.label} {check.message} + {#if check.path} {#if check.main?.bundled} - Bundled with Staged + Managed by Staged {:else} {check.path} {/if} @@ -177,7 +179,7 @@ {/if} {#if check.bridgePath} {#if check.bridge?.bundled} - Bundled with Staged + Managed by Staged {:else} {check.bridgePath} {/if} diff --git a/apps/staged/src/lib/listeners/acpToolsListener.ts b/apps/staged/src/lib/listeners/acpToolsListener.ts new file mode 100644 index 000000000..27487e7dd --- /dev/null +++ b/apps/staged/src/lib/listeners/acpToolsListener.ts @@ -0,0 +1,37 @@ +/** + * Listener for the backend's ACP tools reconcile completion. + * + * The backend installs/upgrades the Staged-managed ACP bridges (claude, codex) + * in the background at launch and once a day thereafter + * (`acp_tools_reconciler.rs`). On a fresh profile, provider discovery and any + * doctor report are cached long before the launch pass finishes, and nothing + * re-probes on its own — without this signal the agent picker keeps reporting + * missing bridges that are already installed until a manual refresh or restart; + * the daily pass likewise surfaces a freshly-published bridge version without a + * restart. The event also fires on partial failure: the bridges that did land + * should become selectable. + */ + +import { listenToEvent, type UnlistenFn } from '../transport'; +import { refreshProviders } from '../features/agents/agent.svelte'; +import { doctorState, runChecks } from '../features/doctor/doctor.svelte'; + +/** Mirrors `ACP_TOOLS_RECONCILED_EVENT` in `acp_tools_reconciler.rs`. */ +interface AcpToolsReconciledEvent { + /** False when at least one managed bridge install failed this pass. */ + ok: boolean; + /** Managed tool ids the reconciler handled (e.g. `claude-acp`). */ + providerIds: string[]; +} + +export function listenForAcpToolsReconciled(): UnlistenFn { + return listenToEvent('acp-tools-reconciled', () => { + // Force: discover_acp_providers sits behind a 30-minute SWR cache, and + // the pre-reconcile discovery it holds is exactly what is stale now. + void refreshProviders({ force: true }); + // Re-run doctor checks only when a report has been loaded — running them + // just paints doctorState for the settings panel, so there is nothing to + // refresh before the user first opens it. + if (doctorState.report) void runChecks(); + }); +}