From aa51cbb9ff876aa917b055f2aa815fa2bd5d88e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 16 Sep 2026 23:48:08 +0200 Subject: [PATCH] fix(opencode): support earlier and modern V2 runtime contracts (#695) Closes #694 ## Status This PR contains the stable 2.0.4 client migration, authenticated V2 discovery fallback and shared cross-runtime compatibility implementation. The validation results below are local execution evidence; the newly added CI matrix must also run on the updated PR head. ## Published changes - Pin UI/server to official `@opencode/client@2.0.4` and the bundled pruning plugin to `@opencode/plugin@2.0.4`, with the corresponding lockfile migration. - Probe authenticated `/api/status` first. Fall back to earlier V2 `/api/health` **only on HTTP 404**, retaining the endpoint, credentials, 64 KiB response bound and absolute deadline. Authentication, transport, server and malformed-status failures do not trigger fallback. - Migrate the canonical UI/server contract to stable Forms, permission decisions, command names, interrupts, renaming, forks, message lookup and inbox delivery. - Update the guarded proxy for stable methods/paths, native active-session envelopes, native `cursor.next`, global Form directory headers and current resource/plugin events. - Follow the renamed upstream platform binary `bin/opencode(.exe)` while retaining public CLI aliases. Discovery success alone does not establish compatibility with an earlier runtime's conversation APIs. ## Shared cross-runtime compatibility ### Shared cross-runtime integration - Introduce one connection-scoped adapter under `packages/server/src/opencode/compatibility/`, used by both the guarded UI proxy and direct server callers, including Yolo. - Select the contract using authenticated daemon identity rather than the CLI executable's version. Recognize unknown publications through authenticated, bounded OpenAPI negotiation instead of an exact runtime-version gate. - Bind authorization, client, transport and profile to one connection generation. Fence actual mutation dispatch after asynchronous body preparation; prevent late old streams from publishing or invalidating their replacement. - Translate earlier V2 routes, methods and payloads before sending. Never retry a mutation speculatively after 400, 404, authentication or transport failures. - Normalize legacy HTTP inbox timestamps, including compaction admission, before the pinned Solid reducer. Preserve the distinct native SSE inbox schema and durable event metadata. - Restore legacy catalog/content invalidation and permission-event convergence. Preserve meaningful method/path/status/profile diagnostics and native 401 error envelopes. ### Location authority and pruning - Preserve legacy workspace identity internally through request serialization, catalog/cache keys, Forms, imports, cursors, events and exact move rollback. Modern public APIs continue to reject obsolete workspace selectors. - Authorize complete locations before translating directories or synthesizing upstream Form/location headers. Keep native cursor tokens unchanged. - Preserve session/path ownership, worktree mutation fences and the restricted proxy allowlist. Generic RPC, plugin management and destructive revert commit remain blocked. - Carry verified legacy identity through pruning membership checks, preview and the existing transactional storage/claim fence; do not relax deletion checks. - Treat native idle records as control records, retaining identity/outcome without fabricated assistant text. - Add a common multi-runtime CI matrix and the audit/evidence register in `dev-docs/OPENCODE_V2_COMPATIBILITY.md`, including preserved historical architecture findings. ## Local validation All native tests use isolated synthetic daemons, configuration and databases, never the shared daemon or user storage. | Check | Result | | --- | --- | | Same native suite on beta-19271, 2.0.3, 2.0.4 and 2.0.5 with pinned client/plugin 2.0.4 | Passed | | Full native pruning suite with non-null legacy workspace identity on beta-19271 and 2.0.3 | Passed | | Rendered UI on modern 2.0.5 and beta-19271 with non-null identity | Passed | | Native 2.0.5 suite inside Ubuntu/WSL, using a temporary Linux filesystem root | Passed | | Server tests (after CI follow-up) | 500 passed, 2 skipped | | UI store tests | 441 passed | | Electron native tests | 194 passed, 4 skipped | | UI/server/Electron typechecks | Passed | | UI/server and Electron production builds | Passed | | Windows Tauri release executable (`--no-bundle`) | Passed | | Electron/Tauri packaged-resource smoke checks | Passed | | Desktop packaging invariants | 4 passed | | `git diff --check` | Passed | The native matrix exercises the actual shared connection, generated client, UI fetch adapter and guarded proxy: voice-off/on instructions, prompts, commands, session Shell, background Shell output cursors/removal, UI/Yolo permission acceptance and rejection, busy inbox delivery/cancellation, interrupt/wait, staged revert clearing, session/global/worktree Forms, native pagination, export/import, same-directory legacy identities, scoped events, foreign authority refusal and exact rollback. Pruning coverage includes actual storage identity and execution claims, busy refusal, idempotency, competing admission, next-model payload, fork isolation, pre-compaction history, restart, backend presence disposal/reopening and crash expiry. Rendered tests cover individual/group/response/session cleanup, confirmation counts, persistence and reload. ## Autonomous gatekeeper review Review/fix/retest loops concluded with no remaining actionable findings in the reviewed transport/lifecycle/proxy, identity/UI/events/pruning/rollback, scripts/CI and rendered-fixture scopes. Corrections included dispatch-generation races, compaction admission normalization, independent negotiation cancellation, first-request profile resolution, preservation of streamed 401 envelopes and direct Shell/PTY identity propagation. A final rendered failure was traced to the fixture awaiting only the first RPC of a cross-message deletion; the fixture and regression now await every affected message. Production deletion guards were not relaxed. This scoped approval does not close the separately preserved historical architecture findings. ## Validation boundaries ### CI follow-up The first run on `a0c1c943` ([35142559235](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35142559235)) failed in five native jobs. The local bundle tests had missed the standalone npm archive's missing sibling import, and Windows legacy jobs exposed a short-path versus canonical-path project comparison. The dependent artifact-comment job reported that same build failure. The follow-up makes the plugin's identity primitives self-contained, adds an isolated npm-file-list regression for all exported entrypoints, and corrects project identity selection with alias and nested-repository regressions. The packed-plugin failure was reproduced before correction; the corrected installed package passes the native suite on Windows 2.0.4 and Linux/WSL 2.0.4/2.0.5. The corrected 2.0.4 shipped bundle also passes rendered/native pruning. Fresh full beta-19271 and 2.0.3 `--legacy-pruning` suites pass under a reproducing Windows short-path TEMP root after the final exact-directory fix. Independent final reviews approve packaging/documentation and worktree identity with zero actionable findings. Remote checks on the new head remain the CI authority; these are local follow-up results. - Full interaction with the installed desktop application through Developer Mode was not verified because Developer Mode is inactive. The release executable and packaged-resource checks above are build/smoke evidence, not a fresh-install interactive certification. - Remote run [35146390688](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35146390688) at `486e856d` passes all runtime-contract jobs, Windows/Linux native pruning, service compatibility, general tests and both Tauri checks. macOS passes packed-plugin loading/pruning, then times out on late bundle discovery. The fixture now canonicalizes its root before daemon startup to align `/var` configuration paths with `/private/var` FSEvents paths. Late installation remains mandatory and asserts the plugin was initially absent; failures now upload isolated diagnostic logs/inventory/captures. Actual macOS confirmation remains pending on the new head. - Interactive native TUI validation was not performed. - The passing WSL result is Linux-native execution inside WSL, not a complete Windows-host/WSL desktop traversal. With the fixture's configuration directory under `/mnt/c`, conversation/proxy checks passed but late plugin discovery timed out; the otherwise equivalent Linux-filesystem run passed. Mounted-configuration late discovery remains a documented environment limitation. - Other audited publications are declaration-reviewed, not individually native-certified. The four-runtime matrix does not imply universal V2 compatibility. - Earlier PR artifacts do not contain the completed cross-runtime implementation; use artifacts built from the updated head. Local passing results do not imply that the updated remote CI has completed. --- .github/workflows/pr-build.yml | 66 +++- .../codenomad-architecture-guide/SKILL.md | 16 +- .../references/architecture-overview.md | 2 +- .../references/feature-traces.md | 2 +- .../references/sdk-api-reference.md | 24 +- .../references/sdk-critical-behaviors.md | 5 +- .../references/sdk-integration-patterns.md | 4 +- .../references/server-conventions.md | 2 +- CONTRIBUTING.md | 4 +- DESKTOP_V2_COMPARISON.md | 24 +- MIGRATION_V2.md | 39 +- dev-docs/INDEX.md | 6 +- dev-docs/OPENCODE_V2_COMPATIBILITY.md | 339 ++++++++++++++++++ dev-docs/SESSION_PRUNING_RPC.md | 10 +- dev-docs/SUMMARY.md | 4 +- dev-docs/architecture.md | 2 +- dev-docs/technical-implementation.md | 6 +- package-lock.json | 169 ++++----- packages/server/README.md | 4 +- packages/server/package.json | 4 +- packages/server/src/api-types.ts | 2 +- .../compatibility/contract-fixture.ts | 14 + .../src/opencode/compatibility/events.ts | 10 + .../opencode/compatibility/location.test.ts | 98 +++++ .../src/opencode/compatibility/location.ts | 80 +++++ .../opencode/compatibility/negotiate.test.ts | 59 +++ .../src/opencode/compatibility/negotiate.ts | 64 ++++ .../compatibility/proxy-locations.test.ts | 44 +++ .../opencode/compatibility/proxy-locations.ts | 116 ++++++ .../src/opencode/compatibility/proxy.test.ts | 41 +++ .../src/opencode/compatibility/proxy.ts | 57 +++ .../src/opencode/compatibility/requests.ts | 49 +++ .../src/opencode/compatibility/runtime.ts | 33 ++ .../opencode/compatibility/transport.test.ts | 76 ++++ .../src/opencode/compatibility/transport.ts | 101 ++++++ .../src/opencode/session-pruning/location.ts | 16 + .../src/opencode/session-pruning/package.json | 4 +- .../opencode/session-pruning/package.test.ts | 36 ++ .../src/opencode/session-pruning/plugin.ts | 8 +- .../opencode/session-pruning/preview-store.ts | 7 +- .../opencode/session-pruning/pruning.test.ts | 12 +- .../opencode/session-pruning/service.test.ts | 20 +- .../src/opencode/session-pruning/service.ts | 10 +- .../src/permissions/opencode-replier.test.ts | 4 +- .../src/permissions/opencode-replier.ts | 4 +- .../opencode-yolo-metadata.test.ts | 2 +- .../src/permissions/opencode-yolo-metadata.ts | 2 +- .../__tests__/instance-proxy-legacy.test.ts | 104 ++++++ .../server/__tests__/instance-proxy.test.ts | 83 +++-- packages/server/src/server/http-server.ts | 325 ++++++----------- .../src/server/routes/session-pruning.test.ts | 18 +- .../src/server/routes/session-pruning.ts | 6 +- .../src/server/routes/worktrees.test.ts | 57 ++- .../server/src/server/routes/worktrees.ts | 1 + .../workspaces/host-opencode-service.test.ts | 65 +++- .../server/src/workspaces/instance-client.ts | 2 +- .../src/workspaces/instance-events.test.ts | 126 ++++++- .../server/src/workspaces/instance-events.ts | 128 ++++--- .../server/src/workspaces/manager.test.ts | 56 ++- packages/server/src/workspaces/manager.ts | 66 ++-- .../src/workspaces/opencode-cli-service.ts | 84 +++-- .../opencode-connection-generation.test.ts | 61 ++++ .../src/workspaces/opencode-service.test.ts | 11 +- .../server/src/workspaces/opencode-service.ts | 92 ++++- .../worktree-session-evacuation.test.ts | 218 ++++++++++- .../workspaces/worktree-session-evacuation.ts | 45 ++- .../workspaces/wsl-opencode-service.test.ts | 92 +++-- packages/ui/package.json | 2 +- .../components/form-request-tool-target.ts | 2 +- packages/ui/src/components/form-request.tsx | 2 +- .../components/instance-service-status.tsx | 2 +- .../shell/right-panel/git-changes-model.ts | 2 +- .../shell/right-panel/tabs/StatusTab.tsx | 25 +- .../right-panel/tabs/file-v2-adapters.ts | 2 +- packages/ui/src/components/message-block.tsx | 2 +- packages/ui/src/components/message-item.tsx | 2 +- .../ui/src/components/message-section.tsx | 2 +- .../ui/src/components/prompt-input/types.ts | 2 +- .../prompt-input/usePromptPicker.ts | 2 +- .../provider-auth/provider-auth-form.tsx | 2 +- .../provider-auth/provider-manager-modal.tsx | 35 +- .../provider-auth/provider-options.ts | 2 +- .../src/components/session/session-view.tsx | 6 +- .../ui/src/components/settings-screen.tsx | 2 +- .../settings/providers-settings-section.tsx | 2 +- .../ui/src/components/shell-output-dialog.tsx | 6 +- packages/ui/src/components/unified-picker.tsx | 2 +- packages/ui/src/lib/command-utils.ts | 2 +- packages/ui/src/lib/form-schema.ts | 2 +- .../lib/hooks/use-instance-metadata.test.ts | 24 +- .../ui/src/lib/hooks/use-instance-metadata.ts | 22 +- .../ui/src/lib/native/preferences-window.ts | 2 +- packages/ui/src/lib/provider-auth.test.ts | 2 +- packages/ui/src/lib/provider-auth.ts | 2 +- packages/ui/src/lib/sdk-manager.ts | 4 +- packages/ui/src/lib/sse-manager.ts | 2 +- packages/ui/src/stores/commands.ts | 8 +- packages/ui/src/stores/forms.test.ts | 72 ++-- packages/ui/src/stores/forms.ts | 11 +- .../src/stores/instance-invalidation.test.ts | 3 +- .../ui/src/stores/instance-invalidation.ts | 6 +- packages/ui/src/stores/instances.ts | 24 +- .../src/stores/legacy-runtime-cache.test.ts | 41 +++ .../src/stores/message-v2/normalizers.test.ts | 2 +- .../ui/src/stores/message-v2/normalizers.ts | 9 +- packages/ui/src/stores/opencode-data.test.ts | 17 +- packages/ui/src/stores/opencode-data.ts | 4 +- .../src/stores/permission-lifecycle.test.ts | 20 +- .../ui/src/stores/plugin-activation.test.ts | 54 --- packages/ui/src/stores/plugin-activation.ts | 47 --- .../ui/src/stores/request-locations.test.ts | 22 +- packages/ui/src/stores/request-locations.ts | 42 ++- .../ui/src/stores/session-actions.test.ts | 51 ++- packages/ui/src/stores/session-actions.ts | 14 +- packages/ui/src/stores/session-api.ts | 27 +- packages/ui/src/stores/session-events.ts | 2 +- packages/ui/src/stores/session-models.ts | 6 +- .../src/stores/session-pruning-events.test.ts | 7 + .../ui/src/stores/session-pruning-events.ts | 12 +- .../stores/session-pruning-pagination.test.ts | 4 +- .../stores/session-pruning-projection.test.ts | 4 +- packages/ui/src/stores/session-pruning.ts | 4 +- .../stores/session-request-authority.test.ts | 10 +- packages/ui/src/stores/shell-store.test.ts | 44 ++- packages/ui/src/stores/shell-store.ts | 46 +-- packages/ui/src/types/global.d.ts | 2 +- packages/ui/src/types/instance.ts | 4 +- packages/ui/src/types/message.ts | 2 +- packages/ui/src/types/permission.ts | 2 +- packages/ui/src/types/session.test.ts | 4 +- packages/ui/src/types/session.ts | 4 +- .../ui/tests/browser/fixtures/pruning.tsx | 12 +- scripts/test-opencode-location-native.mjs | 234 ++++++++++++ scripts/test-opencode-proxy-native.mjs | 94 +++++ scripts/test-session-pruning-native.mjs | 206 +++++++++-- scripts/test-session-pruning-ui.mjs | 117 ++++-- 136 files changed, 3676 insertions(+), 1125 deletions(-) create mode 100644 dev-docs/OPENCODE_V2_COMPATIBILITY.md create mode 100644 packages/server/src/opencode/compatibility/contract-fixture.ts create mode 100644 packages/server/src/opencode/compatibility/events.ts create mode 100644 packages/server/src/opencode/compatibility/location.test.ts create mode 100644 packages/server/src/opencode/compatibility/location.ts create mode 100644 packages/server/src/opencode/compatibility/negotiate.test.ts create mode 100644 packages/server/src/opencode/compatibility/negotiate.ts create mode 100644 packages/server/src/opencode/compatibility/proxy-locations.test.ts create mode 100644 packages/server/src/opencode/compatibility/proxy-locations.ts create mode 100644 packages/server/src/opencode/compatibility/proxy.test.ts create mode 100644 packages/server/src/opencode/compatibility/proxy.ts create mode 100644 packages/server/src/opencode/compatibility/requests.ts create mode 100644 packages/server/src/opencode/compatibility/runtime.ts create mode 100644 packages/server/src/opencode/compatibility/transport.test.ts create mode 100644 packages/server/src/opencode/compatibility/transport.ts create mode 100644 packages/server/src/opencode/session-pruning/location.ts create mode 100644 packages/server/src/opencode/session-pruning/package.test.ts create mode 100644 packages/server/src/server/__tests__/instance-proxy-legacy.test.ts create mode 100644 packages/server/src/workspaces/opencode-connection-generation.test.ts create mode 100644 packages/ui/src/stores/legacy-runtime-cache.test.ts delete mode 100644 packages/ui/src/stores/plugin-activation.test.ts delete mode 100644 packages/ui/src/stores/plugin-activation.ts create mode 100644 scripts/test-opencode-location-native.mjs create mode 100644 scripts/test-opencode-proxy-native.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index b6425bfae..061afb608 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -52,6 +52,7 @@ jobs: - tests-tauri-windows - tests-opencode-compatibility - tests-session-pruning-native + - tests-runtime-contracts - tests-tauri-macos if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} uses: ./.github/workflows/build-and-upload.yml @@ -90,7 +91,7 @@ jobs: New-Item -ItemType Directory -Force $sandbox | Out-Null $package = npm pack ./packages/server/src/opencode/session-pruning --pack-destination $sandbox --json | ConvertFrom-Json if ($LASTEXITCODE) { exit $LASTEXITCODE } - npm install --prefix $sandbox (Join-Path $sandbox $package.filename) '@opencode/cli@beta' --ignore-scripts --no-audit --no-fund + npm install --prefix $sandbox (Join-Path $sandbox $package.filename) '@opencode/cli@2.0.4' --ignore-scripts --no-audit --no-fund if ($LASTEXITCODE) { exit $LASTEXITCODE } "PRUNING_NATIVE_INSTALL=$sandbox" >> $env:GITHUB_ENV - name: Test the installed plugin with an isolated official runtime @@ -99,8 +100,10 @@ jobs: $platform = node -p "process.platform === 'win32' ? 'windows' : process.platform" $arch = node -p "process.arch" $extension = node -p "process.platform === 'win32' ? '.exe' : ''" - $cli = Join-Path $env:PRUNING_NATIVE_INSTALL "node_modules/@opencode/cli-$platform-$arch/bin/opencode2$extension" + $cli = Join-Path $env:PRUNING_NATIVE_INSTALL "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension" $plugin = Join-Path $env:PRUNING_NATIVE_INSTALL 'node_modules/@neuralnomads/codenomad-session-pruning' + $fixtures = node -p "require('node:path').join(require('node:fs').realpathSync(require('node:os').tmpdir()), 'opencode')" + "PRUNING_NATIVE_FIXTURES=$fixtures" >> $env:GITHUB_ENV node scripts/test-session-pruning-native.mjs $cli $plugin if ($LASTEXITCODE) { exit $LASTEXITCODE } npm run build:pruning --workspace @neuralnomads/codenomad @@ -108,6 +111,18 @@ jobs: node --import tsx --test packages/server/src/opencode/pruning-installation.test.ts if ($LASTEXITCODE) { exit $LASTEXITCODE } node scripts/test-session-pruning-native.mjs $cli --ui + - name: Upload isolated native failure diagnostics + if: ${{ failure() && env.PRUNING_NATIVE_FIXTURES != '' }} + uses: actions/upload-artifact@v4 + with: + name: session-pruning-failure-${{ matrix.os }} + path: | + ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/server.log + ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/before-restart.log + ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/discovery.json + ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/ui-*.png + if-no-files-found: ignore + retention-days: 7 - name: Upload opt-in plugin package if: success() uses: actions/upload-artifact@v4 @@ -117,6 +132,51 @@ jobs: if-no-files-found: error retention-days: 7 + tests-runtime-contracts: + needs: authorize + if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} + runs-on: windows-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - version: 0.0.0-beta-19271 + scope: '@opencode-ai' + binary: opencode2 + - version: 2.0.3 + scope: '@opencode' + binary: opencode + - version: 2.0.5 + scope: '@opencode' + binary: opencode + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run build:pruning --workspace @neuralnomads/codenomad + - name: Cross the pinned client with an isolated runtime + shell: pwsh + env: + RUNTIME_VERSION: ${{ matrix.version }} + RUNTIME_SCOPE: ${{ matrix.scope }} + RUNTIME_BINARY: ${{ matrix.binary }} + run: | + $sandbox = Join-Path $env:RUNNER_TEMP 'opencode-contract-runtime' + npm install --prefix $sandbox "$env:RUNTIME_SCOPE/cli@$env:RUNTIME_VERSION" --ignore-scripts --no-audit --no-fund + if ($LASTEXITCODE) { exit $LASTEXITCODE } + $cli = Join-Path $sandbox "node_modules/$env:RUNTIME_SCOPE/cli-windows-x64/bin/$env:RUNTIME_BINARY.exe" + node scripts/test-session-pruning-native.mjs $cli + if ($LASTEXITCODE) { exit $LASTEXITCODE } + if ($env:RUNTIME_VERSION -eq '0.0.0-beta-19271' -or $env:RUNTIME_VERSION -eq '2.0.3') { + node scripts/test-session-pruning-native.mjs $cli --legacy-pruning + } + tests: needs: authorize if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} @@ -211,7 +271,7 @@ jobs: packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts - packages/ui/src/stores/plugin-activation.test.ts + packages/ui/src/stores/request-locations.test.ts packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index e4c56b429..aa4fa8e67 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -16,12 +16,12 @@ description: | ## Native OpenCode V2 Baseline -- The only OpenCode client dependency is the experimental `@opencode-ai/client@beta` protocol. Server and UI follow that dependency together; refresh the client lock before API audits or release validation. The runtime CLI is managed independently and startup has no exact version gate. The public `@opencode-ai/sdk` describes an alternative embedded host. -- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode-ai/client` declarations. -- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The reviewed project-local Developer Mode adapter is the sole narrow exception; see `dev-docs/DEVELOPER_MODE.md`. +- Server and UI pin the official `@opencode/client@2.0.4` together; the pruning plugin pins `@opencode/plugin@2.0.4`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. The runtime CLI is managed independently and startup has no exact version gate. +- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations. +- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The reviewed exceptions are the project-local Developer Mode adapter and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md` and `dev-docs/SESSION_PRUNING_RPC.md`. - The server uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Forms, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. Question request/reply/reject routes are compatibility-only; new interruption flows use `client.form.*`. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. +- OpenCode owns session APIs, native Forms, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. Forms list through `client.form.list({ location })` and settle through `client.session.form.reply/cancel`; global Forms use the encoded directory header. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. - CodeNomad owns explicit Stop Workspace eviction, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. Tab/window close only detaches local UI and never evicts. - OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only to `service start` for a missing daemon; an existing daemon is unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. - Native desktop identity is channel plus config profile: one singleton process/backend per profile and multiple UUID windows. A second launch opens another window by default; Advanced settings can restore MRU focus, while `--new-window` always requests another window. Stable/dev/non-default profiles isolate native state; OpenCode sessions/messages are shared while tabs/drafts/views are per-window. @@ -51,8 +51,8 @@ description: | ## Rules -- Inspect installed declarations under `node_modules/@opencode-ai/client/dist/promise/`; generated names are the source of truth. -- Preserve `LocationRef` and explicit directory routing. Never infer workspace ownership from a client-provided path. +- Inspect installed declarations under `node_modules/@opencode/client/dist/promise/`; generated names are the source of truth. +- Public locations are directory-only `LocationPublicRef`; `location.get().project` owns project metadata. Internal/event `LocationRef.workspaceID` is not a public request selector. Validate directory ownership at the server boundary. - Send CodeNomad operations through `/api/*`; send OpenCode operations through `/workspaces/:id/instance/api/*`. - Consume the multiplexed CodeNomad SSE stream at `/api/events`; do not create one OpenCode process or event stream per workspace. Native events are volatile, so reconnect must reconcile authoritative state. - Treat the instance proxy allowlist as an integration boundary. Upstream routes are not exposed automatically. @@ -64,10 +64,10 @@ description: | | Avoid | Use | |---|---| -| Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | +| Legacy SDK or beta-client examples | Installed stable `@opencode/client` declarations and generated routes | | One `opencode serve` per workspace | One externally owned global daemon through the official CLI lifecycle | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing the V1 `packages/opencode-plugin` or general server plugin/background-process paths | Native OpenCode APIs; the reviewed project-local Developer Mode adapter only for desktop feedback | +| Reintroducing the V1 `packages/opencode-plugin` or general server plugin/background-process paths | Native OpenCode APIs; narrow reviewed Developer Mode and session-pruning integrations | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index 46e6833eb..489cf30fd 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -20,7 +20,7 @@ Client-state V3 is a per-window envelope over the V2 content-addressed partition | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | latest experimental `@opencode-ai/client@beta` contract across server and UI | +| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.4` contract across server and UI | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing, Developer Mode bridge | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index cb006242d..af058b573 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -5,7 +5,7 @@ 1. UI posts a folder to `/api/workspaces`. 2. `WorkspaceManager` resolves the binary launch spec and calls the single `OpenCodeSharedService`. 3. The adapter runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle and validates the authenticated loopback endpoint. WSL requires Windows localhost forwarding and stays inside the Linux namespace. -4. `client.location.get` validates the directory and returns native location/workspace identity. +4. `client.location.get` validates the directory and returns its native project metadata and directory-only public location. 5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy. 6. Explicit Stop Workspace evicts the location/resources and removes the logical workspace. Ordinary tab/window close only detaches local UI; backend shutdown clears connection state. Neither close nor shutdown stops the daemon. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index bb74de9f7..b579004aa 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -2,12 +2,14 @@ ## Package -CodeNomad server and UI follow `@opencode-ai/client@beta`. Refresh the client lock before API audits or release validation. The runtime CLI is managed independently; startup validates its authenticated loopback endpoint and health response without an exact version gate, while contract parity is reviewed at upgrade and release time. The public `@opencode-ai/sdk` provides the same generated Promise contract through an alternative embedded host. +CodeNomad server and UI pin `@opencode/client@2.0.4`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, falling back only on HTTP 404 to earlier V2 `/api/health` with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading. -- Promise client: `import { OpenCode } from "@opencode-ai/client"` -- Service authentication headers: `import { Service } from "@opencode-ai/client/service"` +Cross-runtime adaptation lives in `packages/server/src/opencode/compatibility/`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Known published contracts select their serializer directly; unknown versions require authenticated bounded OpenAPI recognition before calls. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for the evidence matrix and maintained issue register. + +- Promise client: `import { OpenCode } from "@opencode/client"` +- Service authentication headers: `import { Service } from "@opencode/client/service"` - Client construction: `OpenCode.make({ baseUrl, headers?, fetch? })` -- Declarations: `node_modules/@opencode-ai/client/dist/promise/` +- Declarations: `node_modules/@opencode/client/dist/promise/` Do not replace the shared network service with `@opencode-ai/sdk` unless CodeNomad intentionally changes to an embedded, process-owned host. @@ -15,17 +17,23 @@ Do not replace the shared network service with `@opencode-ai/sdk` unless CodeNom | Area | Calls | CodeNomad caller | |---|---|---| -| Service | CLI `service status/start/get password`; `Service.headers` for authenticated health/API calls | `packages/server/src/workspaces/opencode-service.ts`, `packages/server/src/workspaces/opencode-cli-service.ts`, `packages/server/src/workspaces/host-opencode-service.ts`, `packages/server/src/workspaces/wsl-opencode-service.ts` | +| Service | CLI `service status/start/get password`; authenticated `/api/status`, then `/api/health` only on 404; `Service.headers` for probes and API calls | `packages/server/src/workspaces/opencode-service.ts`, `packages/server/src/workspaces/opencode-cli-service.ts`, `packages/server/src/workspaces/host-opencode-service.ts`, `packages/server/src/workspaces/wsl-opencode-service.ts` | | Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper | | Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` | -| Sessions | `list/get/create/fork/remove/rename/prompt/command/shell/interrupt` | UI session stores | +| Sessions | `list/get/create/fork/remove/update/prompt/command/shell/interrupt` | UI session stores | | Instructions | `client.session.instructions.entry.put/remove` | conversation-mode prompt setup | | Permissions | `permission.request.list`, `permission.reply` | UI and server Yolo replier | -| Forms | `client.form.request.list`, `client.form.reply`, `client.form.cancel` | `packages/ui/src/stores/instances.ts`, `forms.ts` | +| Forms | `client.form.list`, `client.session.form.reply`, `client.session.form.cancel` | `packages/ui/src/stores/instances.ts`, `forms.ts` | Native methods return decoded Promise values. Follow the installed declarations and existing callers; do not wrap calls in stale SDK response-unwrapping helpers. -Native Forms own pending interruption state. The allowlisted Question request/reply/reject routes are compatibility-only. The Question tool renderer may display compatible output, but no Question queue/state architecture should return. +Native Forms own pending interruption state. Global Forms use `sessionID: "global"` and `x-opencode-directory: encodeURIComponent(directory)`; ordinary session Forms derive location from the session. Question tool output rendering is independent of pending Forms. + +Earlier V2 location identity must survive modern generated-client field selection. Use `locationRequestOptions` (server) / `requestLocationOptions` (UI) for the explicit private context channel; the proxy authorizes the complete pair, translates its directory, and the selected transport serializes the appropriate legacy slots. Modern public APIs still reject workspace selectors. Session move/rollback uses `moveSessionToLocation`, not a cast adding fields to the modern method input. + +Stable mutations use `permission.reply({ decision })`, `session.command({ name })`, `session.interrupt({ resume })`, `session.fork({ before? })`, `session.inbox.update({ delivery })` and `session.message.get(...)`. Credential removal is global and takes only `credentialID`. There is no plugin activation-wait endpoint; catalog reads and `plugin.updated` supply native state. + +Wait, instructions, import/export, stats and log use `/api/experimental/session/...` paths. Cancellation is `DELETE /api/session/:sessionID/form/:formID`. Preserve generated response envelopes in the proxy: `session.active` consumes `{ data }`, while `project.list` consumes an array. Native `cursor.next` remains the sole continuation authority. ## Routing diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index e402e3cfd..f3bd498d4 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -2,10 +2,13 @@ ## Contract -- Server and UI follow `@opencode-ai/client@beta`; refresh the client lock before API audits or release validation. Manage the runtime CLI independently: startup checks the authenticated loopback endpoint and health shape without an exact version gate, while documentation, installed declarations, and proxy/API parity are reviewed whenever the client contract changes. +- Server and UI pin `@opencode/client@2.0.4`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, falling back only on HTTP 404 to earlier V2 `/api/health`. Both probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed status failures do not trigger fallback. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes. - The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples. - Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy. - That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically. +- Proxy authorization and forwarding share one acquired connection. A stale generation must be rejected at actual HTTP dispatch, including after asynchronous body preparation; late streams cannot invalidate a replacement connection. +- Adapt legacy HTTP inbox timestamps before the native Solid reducer. Native `session.inbox.enqueued` is a different shape: its timestamp belongs to the event metadata and must not be treated as an HTTP inbox record. +- Compatibility never retries a write using another contract after a 400/404/transport failure. Unknown version numbers are recognized through authenticated OpenAPI structure rather than an exact runtime-version gate. ## Location Is Authority diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index ee7103985..c827538e9 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -8,7 +8,9 @@ OpenCode owns standard state/database. Allowed configured environment variables ## Locations And Directories -Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts. +Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory. Its `project` field supplies project metadata. Public locations have no workspace selector. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts. + +The paragraph above describes the canonical modern wire contract. Preserve any native legacy `workspaceID` internally and authorize it through the connection-scoped adapter; directory-only serialization must not erase identity from cache keys, pending Forms, imported history or move rollback. See `opencode/compatibility/location.ts` for the explicit context channel that survives generated serialization. The instance proxy is method/path allowlisted, rejects unowned paths, `directory`, `location.directory`, and `location[directory]` values, and verifies session location before forwarding. Keep this check at the server trust boundary; new upstream routes require explicit review. diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index 07103bc19..0860c708f 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -12,7 +12,7 @@ - Use `OpenCodeSharedService` in `packages/server/src/workspaces/opencode-service.ts`. - Keep one shared-service adapter and one event subscription for all workspaces. Use the selected host or WSL CLI's official status/start/password lifecycle, own no private service state/PID, and never stop the externally owned global daemon on backend shutdown. - Model workspaces with native `LocationRef`/directories in `packages/server/src/workspaces/manager.ts`. -- Never spawn or stop OpenCode per workspace and never add general plugin installation/packaging. Developer Mode's reviewed project-local, execution-gated adapter is the sole exception. +- Never spawn or stop OpenCode per workspace or add general plugin installation/packaging. The reviewed exceptions are Developer Mode's project-local adapter and the bundled session-pruning RPC under `src/opencode/session-pruning/`. - Explicit Stop Workspace evicts the location; ordinary UI close never calls workspace deletion. WSL requires localhost forwarding and no cross-namespace PID operations. - Leave global service state/database ownership to OpenCode. Pass allowed environment only when starting a missing daemon; leave an existing daemon unchanged and ignore `OPENCODE_DB`/`XDG_STATE_HOME`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8f195b15..75738d722 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,8 +111,8 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries -- Server and UI follow `@opencode-ai/client@beta`. Refresh the client lock before API audits or release validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode-ai/client/dist/promise/`. +- Server and UI pin `@opencode/client@2.0.4`; the pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade them together with the lockfile and isolated native validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes. +- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode/client/dist/promise/`. - `packages/server/src/workspaces/opencode-service.ts` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown. - WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows. - OpenCode owns the global daemon's standard state and database. Configured allowed environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md index f0f73e6f3..7ba60ae8f 100644 --- a/DESKTOP_V2_COMPARISON.md +++ b/DESKTOP_V2_COMPARISON.md @@ -9,7 +9,7 @@ This review compares: - The latest published OpenCode V2 beta source at `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` (2026-09-04), which produced `beta-19059`. - The matching `upstream/beta` head, with no later unpublished contract delta at review time. -CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-19059`. The independently managed runtime used to verify the compatibility fallback still reports `beta-18999`; startup intentionally has no exact client/runtime version gate. +This comparison records the historical September 4 beta review. The current integration pins `@opencode/client@2.0.4` in UI/server and `@opencode/plugin@2.0.4` for pruning. PR #695 migrates the full wire contract, not just discovery; see [the stable contract notes](MIGRATION_V2.md#stable-204-contract-pr-695). Runtime selection remains independent and startup has no exact version gate. The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. @@ -45,7 +45,7 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec **Official behavior:** `packages/desktop/src/main/service/background-service.ts` connects to a wildcard-bound service through `127.0.0.1`. -**Resolution:** CodeNomad rewrites only the advertised wildcard hostname to loopback before authenticated health checks and client construction. Existing loopback URL strings retain their previous identity and formatting. Non-loopback remote addresses remain rejected. +**Resolution:** CodeNomad rewrites only the advertised wildcard hostname to loopback before the authenticated `/api/status` check and client construction. Existing loopback URL strings retain their previous identity and formatting. Non-loopback remote addresses remain rejected. ### Failed plugin inventory @@ -55,7 +55,7 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec **Resolution:** CodeNomad projects only non-builtin plugins with a string ID and `state.status === "active"`. Failed records remain available in the native inventory but are no longer represented as healthy in the legacy name-only status list. -### Asynchronous plugin activation +### Asynchronous plugin activation (historical beta behavior) **Previous behavior:** Initial agent, provider, model, command, and plugin reads could run while a Location's configured plugins were still installing or activating. CodeNomad could therefore retain a transiently incomplete catalog until another event forced a refresh. @@ -63,13 +63,15 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec **Resolution:** The proxy exposes only the non-mutating activation wait, catalog and plugin-status reads wait for it, concurrent waits for one client and Location coalesce, and an unsupported lagging runtime falls back to authoritative reads. `plugin.updated` now refreshes agents, providers, commands, and metadata; the obsolete `plugin.added` branch is removed. `plugin.check`, `plugin.update`, and generic plugin RPC remain blocked. +**Stable 2.0.4 update:** The activation endpoint is removed. CodeNomad uses native catalog reads and current resource/plugin update events, with no legacy endpoint fallback. + ### Active-location MCP and plugin status **Previous behavior:** Metadata requests and their loaded-state check used the instance root even when the selected session belonged to a worktree with different `.opencode` configuration. -**Official behavior:** Location-scoped status follows the selected session, and request inputs encode native `workspaceID` values as the wire-level `location[workspace]` selector. +**Official behavior:** Location-scoped status follows the selected session. Stable 2.0.4 public locations contain only `directory`; the beta `workspaceID`/`location[workspace]` selectors are no longer public inputs. -**Resolution:** MCP, plugin activation, and plugin inventory reads now use the active `SessionInfo.location`; metadata readiness is keyed by the location returned from MCP; session switches trigger a new status load; and MCP toggles continue to use that same returned location. Replaced clients and superseded locations cannot commit stale metadata. +**Resolution:** MCP and plugin inventory reads use the active `SessionInfo.location`; metadata readiness is keyed by its directory; session switches trigger a new status load; and MCP toggles use that same location. Replaced clients and superseded locations cannot commit stale metadata. ### Filesystem list ownership @@ -83,7 +85,7 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec **Previous behavior:** The local session adapter reconstructed `SessionInfo` without its durable JSON `metadata`, `session.created` also dropped that metadata, and the local message-info time shape omitted the new `streamed` boundary. A recovered `session.step.streamed` event alone did not mark an idle local session as working. -**Official behavior:** Published V2 sessions carry optional `SessionMetadata`; assistant messages carry `time.streamed`; `session.step.streamed` and `session.message.content.updated` are native durable events reduced by `@opencode-ai/client/solid`. +**Official behavior:** Published V2 sessions carry optional `SessionMetadata`; assistant messages carry `time.streamed`; `session.step.streamed` and `session.message.content.updated` are native durable events reduced by `@opencode/client/solid`. **Resolution:** REST and event session projections retain metadata, message projection retains streamed time, a streamed step restores working status after an event gap, and focused tests verify authoritative assistant-content replacement through the generated Solid reducer. @@ -126,11 +128,11 @@ The review reconfirmed these areas and found no current incompatibility: - Native `session.background` control plus background Shell listing, bounded output, removal, and ownership-checked Shell/PTY proxy routes. - Root/worktree location ownership, physical-identity mutation fencing and session evacuation before worktree deletion, and WSL translation. - Independent multi-window tabs and content-addressed restore state across Electron and Tauri. -- Strict proxy route allowlisting, traversal protection, validated native workspace selectors, authentication isolation, and location ownership checks. +- Strict proxy route allowlisting, traversal protection, directory-only public selectors, authentication isolation, and location ownership checks. Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change. -## Latest Published Beta Audit +## September 4 Published Beta Audit (Historical) The official `anomalyco/opencode-beta` repository published 22 beta tags from `beta-18230` through `beta-19059`. Their GitHub release bodies are empty, so there are no prose release notes to review. This audit instead matched every successful publish workflow to its source commit, read the intervening official commits, compared npm artifacts and generated declarations, and checked the official V2 documentation index and relevant API/client pages. @@ -163,11 +165,11 @@ The reviewed Windows release artifacts are not Authenticode-signed, and the macO This is distribution hardening rather than V2 API parity. Release jobs should fail unless Windows signatures, macOS identity, and notarization validate against the expected publisher. -## Beta Channel Policy +## Stable Client Policy -CodeNomad server and UI follow `@opencode-ai/client@beta`. The root workspace lock keeps each build reproducible after resolving that dependency; Electron selects npm optional dependencies for the requested OS/CPU target, while Tauri requires a completed root `npm ci --workspaces --include=optional` and never repairs dependencies during prebuild. The runtime CLI is managed independently, and startup validates its authenticated loopback health response without an exact version gate. +CodeNomad server and UI pin `@opencode/client@2.0.4`. The root workspace lock keeps builds reproducible; Electron selects npm optional dependencies for the requested OS/CPU target, while Tauri requires a completed root `npm ci --workspaces --include=optional` and never repairs dependencies during prebuild. The runtime CLI is managed independently, and startup validates its authenticated loopback `/api/status` response without an exact version gate. -Generated types, proxy routes, events, plugin inventory, Forms, sessions, and real workspace behavior must be checked whenever the beta channel advances. +Generated types, proxy routes, events, plugin inventory, Forms, sessions, and real workspace behavior must be checked whenever the pinned client changes. ## Optional Feature Gaps diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 92de3942a..ed27a9169 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -2,16 +2,31 @@ ## Summary -This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspace runtime architecture with the experimental native OpenCode V2 client and one shared OpenCode service. It intentionally provides no V1 runtime fallback. +This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspace runtime architecture with the native OpenCode V2 client and one shared OpenCode service. It intentionally provides no V1 runtime fallback. The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. -Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The 2026-09-04 client snapshot is `beta-19059`; the independently managed runtime used for backward-compatibility validation still reports `beta-18999`. +Server and UI pin the official `@opencode/client@2.0.4`; the bundled pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade the client, plugin and lock together using official V2 documentation, installed declarations and generated wire paths. The runtime CLI is independently managed: production startup validates authenticated `/api/status` (`version`, `pid`, `urls`), falling back only on HTTP 404 to the earlier V2 `/api/health` contract (`healthy: true`, `version`, positive `pid`). Both probes share the endpoint, credentials, response-size bound and absolute deadline. There is no exact version gate. Discovery compatibility alone does not establish compatibility for the remaining client APIs. The older beta reviews below are historical. The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). +The current cross-version issue register, published API-change timeline and connection-scoped compatibility architecture are maintained in [`dev-docs/OPENCODE_V2_COMPATIBILITY.md`](dev-docs/OPENCODE_V2_COMPATIBILITY.md). The server integration module adapts earlier V2 requests/responses for both the guarded UI proxy and direct server callers; native regression runs cross the pinned client with beta-19271, 2.0.3, 2.0.4 and 2.0.5. Discovery success alone does not certify an older runtime's conversation, event or pruning contract. + ## Native V2 Adoption +### Stable 2.0.4 contract (PR #695) + +The former `@opencode-ai/client` beta contract cannot load normal sessions from a fresh 2.0.4 daemon. Fixing discovery alone is insufficient. This migration updates the server, UI, pruning plugin and guarded proxy together: + +- Public locations are `{ directory }`; project metadata comes from `location.get().project`. Internal/event `LocationRef.workspaceID` is not a public request selector. Obsolete selectors are rejected by the proxy. +- Forms lists remain location-scoped through `form.list`. Settlement uses `session.form.reply/cancel`, with the native encoded directory header for `sessionID: "global"`. Cancellation is DELETE on the form resource. +- Permission replies send `decision`; commands send `name`; interrupt sends `resume`; fork sends optional `before`. Rename uses `session.update`, message lookup uses `session.message.get`, and inbox delivery uses `session.inbox.update`. +- Wait, instructions and import routes use `/api/experimental/session/...`. Active-session responses retain the generated `{ data }` envelope. Session and message traversal follows only native `cursor.next`. +- Catalogs use authoritative native reads and current `plugin.updated`, `provider.updated` and `model.updated` events. The removed activation-wait endpoint and obsolete Question routes are not exposed. +- Native pruning CI installs CLI 2.0.4. Platform packages contain `bin/opencode(.exe)`; upstream still provides both public CLI aliases, `opencode` and `opencode2`. + +Validation uses synthetic data in isolated daemons/databases, including the actual UI fetch adapter and guarded proxy, native pagination, Forms, pruning concurrency/payload/fork/restart checks and rendered pruning controls. No test targets the shared daemon or user storage. + ### Bundled session-pruning replacement This branch replaces the removed `session.messageUpdate` callers with the narrow @@ -27,14 +42,14 @@ shared daemon is never stopped by this lifecycle. - Use native locations and `SessionInfo.location` as the authority for workspace, session, file, event, Shell, PTY, and Git worktree ownership. - Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. -- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.message.content.updated`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. -- Use `@opencode-ai/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. -- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-19059` declares no Question client API and its runtime does not serve those routes. +- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. Stable V2's event union no longer includes `session.message.content.updated`; pruning uses its reviewed RPC invalidation and authoritative native rereads. +- Use `@opencode/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. +- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains independent of pending interruption state. - Replace shell-mode prompts with native `session.shell`. -- Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists, displays bounded output for, and removes Shells; create/output/timeout routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. +- Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists, displays bounded output for, and removes Shells; create/output routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. - Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls. - Inherit native durable JSON `SessionMetadata` directly from `SessionInfo`. Do not widen it to arbitrary `unknown` values or maintain a parallel CodeNomad-only metadata contract. -- Wait for location-scoped plugin activation before retaining agent, provider, model, command, or plugin catalog reads. Treat `plugin.updated` as the settled catalog invalidation; `plugin.added` no longer exists. +- Read location-scoped native catalogs and refresh on `plugin.updated` and resource-specific updates. Stable 2.0.4 has no activation-wait endpoint. - Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-19059`. ### Beta 18866 Contract Review (Historical) @@ -64,7 +79,7 @@ The final published range from `beta-18866` (`519cd8c7712fc2ca6d2ca1d356d7f52cbd The same audit reconfirmed the cumulative published additions already in use: native `session.messageUpdate`, durable session metadata, `time.streamed`, `session.step.streamed`, `session.message.content.updated`, current plugin state records, and the generated Solid event reducer. Provider/model canonical resolution and materialized skill text remain upstream runtime concerns; CodeNomad sends native IDs and prompt attachments without duplicating that lowering. Plugin package checks/updates, generic RPC, persistent PTYs, VCS review-base reads, and session transfer UI remain explicit product/security decisions rather than accidental omissions. -### Beta 19059 Contract Review +### Beta 19059 Contract Review (Historical) The successful 2026-09-04 publish from `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` produced `beta-19059`, the 22nd official beta in the reviewed range. Its release body is also empty. The audit therefore reviewed all 52 commits after `beta-18999`, compared both npm tarballs, refreshed the installed declarations and lock, and compared the official V2 OpenAPI and documentation. @@ -79,14 +94,14 @@ No route or schema was added or removed: the OpenAPI remains at 119 paths, 140 o The renewed OpenAPI review also made explicit that `fs.list` accepts absolute paths and `..` traversal relative to a Location. The CodeNomad proxy now resolves that target, rejects duplicate selectors and paths outside owned worktrees, and translates an accepted path for WSL before forwarding it. -Runtime changes in the same release—settling abandoned compactions before resume, retrying failed Location initialization, disabling plugins after transform failures, backgrounding command subagents, restoring Windows terminal interruption, detecting new ecosystem config roots, and live provider/model fixes—are acquired automatically when the independently managed `opencode2` runtime is updated. CodeNomad does not duplicate those internals and does not reject an older healthy runtime at startup. At the time of this audit `upstream/beta` equals the published `beta-19059` source, so there is no later unpublished contract delta. +Runtime changes in the same release—settling abandoned compactions before resume, retrying failed Location initialization, disabling plugins after transform failures, backgrounding command subagents, restoring Windows terminal interruption, detecting new ecosystem config roots, and live provider/model fixes—are acquired automatically when the independently managed `opencode2` runtime is updated. CodeNomad does not duplicate those internals or impose an exact version gate. At the time of this audit `upstream/beta` equals the published `beta-19059` source, so there is no later unpublished contract delta. ## Shared Service Model - OpenCode V2 explicitly confirms that the intended architecture is [one shared process for all workspaces and clients](https://github.com/anomalyco/opencode/issues/43898#issuecomment-5372607267); workload slowdowns must be profiled and fixed within that topology rather than worked around with private servers. - Replace one OpenCode runtime per workspace with one externally owned global service in the selected host or WSL environment. - Discover or start it through the selected CLI's official `service status`, `service start`, and `service get password` commands. -- Accept only bounded, authenticated loopback health endpoints and pin one service identity while connected. +- Validate the bounded `/api/status` response over the authenticated loopback endpoint, use earlier V2 `/api/health` only after a status 404, and pin one service identity while connected. - Use OpenCode's standard service registration, state, and database. CodeNomad owns no private daemon port, database, registration, or PID. - Pass configured startup environment variables and `NODE_EXTRA_CA_CERTS` only when starting a missing service. Strip legacy `OPENCODE_DB` and `XDG_STATE_HOME` overrides rather than taking ownership of OpenCode storage. - Never stop the global daemon during CodeNomad shutdown. Backend shutdown clears only CodeNomad's cached connection and logical workspace state. @@ -246,6 +261,6 @@ At the 2026-09-04 `beta-19059` branch gate: ## Review Notes -- The generated V2 client remains experimental. Review its current documentation, installed declarations, proxy/API parity, runtime health, and `/api/plugin` failures whenever the beta contract changes. The SDK documentation describes an alternative embedded host; CodeNomad uses the network client. +- Review official V2 documentation, installed declarations, generated routes, proxy/API parity and `/api/plugin` failures whenever the pinned stable client changes. CodeNomad uses the network client. - V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-19059`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. -- Upgrade references: [OpenCode beta releases](https://github.com/anomalyco/opencode-beta/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. +- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), and the resolved `node_modules/@opencode/client/dist/promise/` declarations and generated implementation. diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 4dd4388b8..7fdfbd422 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -20,6 +20,10 @@ Executive summary of the entire project - **start here!** Incremental comparison with official OpenCode Desktop V2, including parity, closed defects, remaining correctness work, runtime upgrade scope, and optional feature gaps. +### [OPENCODE_V2_COMPATIBILITY.md](OPENCODE_V2_COMPATIBILITY.md) + +September 2026 published API-contract census, verified compatibility failures, connection-scoped adapter proposal, support targets and cross-runtime acceptance roadmap. Start here for OpenCode upgrade and backward-compatibility work. + ### [DEV_V2_CODE_REDUCTION.md](DEV_V2_CODE_REDUCTION.md) Measured DEV-v2 growth analysis, maintainer position on test volume, ranked reduction candidates, and guardrails for later simplification without product regressions. @@ -63,7 +67,7 @@ Persistent Electron/Tauri Developer Mode, OpenCode feedback tools, reconnect beh - File structure - TypeScript interfaces - Shared OpenCode service and location ownership -- Native `@opencode-ai/client` integration +- Native `@opencode/client` integration - IPC communication - Error handling strategies diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md new file mode 100644 index 000000000..508e4e952 --- /dev/null +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -0,0 +1,339 @@ +# OpenCode V2 compatibility: audit and implementation roadmap + +**Audit date:** 2026-09-16. **CodeNomad baseline:** PR #695 at `bcfe4d24`. +**Status:** connection-scoped compatibility is implemented. Autonomous gatekeeper loops approved transport, identity, scripts/CI and rendered-fixture scopes with no remaining actionable findings. The original audit findings below remain as the change rationale; the implementation results section records their current disposition. + +## Decision summary + +At `bcfe4d24`, CodeNomad had a working modern-client path and backward-compatible service discovery, **not a complete backward-compatible V2 integration**. The follow-up change keeps that discovery fix and addresses the remaining issues through one connection-scoped integration module and one cross-runtime acceptance matrix. + +The primary split is the pre-2.0.4 V2 contract versus the 2.0.4 contract. Earlier changes also matter, particularly message editing, catalog activation, location identity and native cache behavior. Support should be defined by tested contract families and features, not inferred from a healthy daemon, a package name, a beta-number comparison or a passing TypeScript build. + +Recommended initial acceptance targets: + +- `2.0.4` and `2.0.5`: modern contract; both have native integration evidence. +- `2.0.3`: immediately preceding stable contract; known incompatibilities must be fixed. +- `beta-19271`: the client previously pinned by CodeNomad; known incompatibilities must be fixed. +- `beta-19059` and `beta-18866`: historical/early-window contract candidates. Audit their native behavior before claiming support; they are not certified by this review. +- Development snapshots: diagnostic evidence only until a particular contract is explicitly supported. + +These were the audit's initial acceptance targets. The following matrix records the actual completed checks. A runtime can support normal conversations while lacking a particular optional feature; pruning additionally requires independently verified storage capabilities. + +### Implementation results + +The adapter lives in `packages/server/src/opencode/compatibility/`. `OpenCodeSharedService.acquire()` pins endpoint, canonical client, authenticated runtime identity, forwarding transport and invalidation to one connection. Generation checks run again at actual dispatch after asynchronous body preparation. Unknown version numbers undergo authenticated, bounded `/openapi.json` recognition; they are not rejected solely because their version is absent from a list. + +The native fixture runs the **same scenarios** against beta-19271, 2.0.3, 2.0.4 and 2.0.5 with client/plugin 2.0.4. All four pass. It uses the production shared-service connection, real guarded proxy and real UI fetch adapter. It covers voice instruction removal/addition before prompt, commands, session Shell, background Shell cursor output/removal, UI permissions, server Yolo, rejection, busy inbox delivery/cancellation, interrupt/wait, staged revert clearing, global/session Forms, native session cursors, export/import and pruning concurrency/payload/fork/restart. Native identity cases additionally cover same-directory legacy identities, worktree/global Forms, scoped SSE, Shell/PTY location checks, foreign cursor/session refusal and exact move rollback. + +HTTP inbox responses are normalized before the stable Solid reducer, including compaction admission. **SSE enqueued items have a different shape:** their timestamp comes from `event.created`; the integration preserves that native event schema. An actual stable reducer regression exercises both paths. Legacy permission events are renamed without changing durable metadata, and legacy catalog/content edits trigger authoritative invalidation. + +An explicit private location-context header preserves legacy identity through the modern generated client's field selection. The proxy authorizes it before reconstructing upstream selectors, directory translation and forwarding. Move rollback and pruning retain exact identity. Modern public selectors remain directory-only. Native `idle` records retain identity/outcome but no longer become invented assistant text. + +The CI matrix retains modern cross-platform packaging/pruning validation and adds Windows runs for beta-19271, 2.0.3 and 2.0.5. Typechecks/mocks are supplemental to those real-runtime scenarios. + +| Runtime / check | Declaration-reviewed | Native-verified | Packaged-verified | +| --- | --- | --- | --- | +| beta-19271 | Yes | Full shared fixture; non-null identity pruning rerun | Shipped pruning bundle in isolated runtime; desktop resources checked separately | +| 2.0.3 | Yes | Full shared fixture; non-null identity pruning rerun | Shipped pruning bundle in isolated runtime; desktop resources checked separately | +| 2.0.4 | Yes | Full shared fixture; modern selector rejection | Shipped pruning bundle in isolated runtime; desktop resources checked separately | +| 2.0.5 | Yes | Full shared fixture; modern selector rejection | Shipped pruning bundle in isolated runtime; desktop resources checked separately | +| Other audited beta/stable publications | Yes | Not certified individually | Not certified individually | +| Unknown publication | Authenticated structural negotiation required | Not certified by version alone | Not certified by version alone | + +Current issue disposition: + +| Issues | Result | +| --- | --- | +| C01 | Discovery fallback preserved, bounded/authenticated, no downgrade except 404. | +| C02–C04 | Shared request adaptation; native prompt/voice/command/Shell/permission/Yolo/inbox/revert/Form scenarios pass on both families. | +| C05 | HTTP list/admission/compaction normalization plus real stable reducer pending/SSE regressions. | +| C06 | Legacy catalog/content invalidation and permission-event translation; original durable metadata retained. | +| C07 | Full internal identity, private context serialization, proxy/native authorization, original cursor bytes, exact rollback. | +| C08 | Native full pruning suite also passes with **non-null workspace identity** on beta-19271 and 2.0.3 (`--legacy-pruning`); no claim-fence relaxation. | +| C09 | Use current `plugin.updated` and restored `catalog.updated` invalidation, not removed activation-wait calls. Existing in-flight/trailing catalog regressions and real late plugin installation/disposal/reopening pass. | +| C10 | Idle control records retain ID/time/outcome without fabricated assistant text or a pending spinner. | +| C11 | Method/path/status/profile diagnostics; mutation no-retry, dispatch fencing, cancellation and native 401 envelope regressions. | +| C12 | Common four-runtime CI/native matrix added; remaining environment-specific validation boundaries are stated below. | + +### Gatekeeper record + +The autonomous loop corrected full-location authority/serialization, generation pinning, dispatch-after-body-read races, compaction admission normalization, subscriber cancellation during negotiation, first-request negotiation ordering, native 401 envelope truncation, and direct Shell/PTY scope propagation. Final independent reviews approved both server transport/lifecycle/proxy and identity/UI/events/pruning/rollback scopes with **zero remaining actionable findings**. Their final focused suites passed 90 server tests and 101 identity/server plus 39 identity/UI tests respectively. This is approval of the current compatibility change, not a closure of the historical architecture findings below. + +The final rendered run exposed a fixture sequencing error: it awaited only the first prune of a cross-message group, then started response cleanup against stale selected content. Production guards correctly rejected that selection. The fixture now waits for every message's commit/projection, has a gated regression and independently tests whole-response confirmation counts. The final rendered-fixture review approved the correction; modern 2.0.5 and beta-19271 with non-null identity both pass. A further modern rerun passes using the exact shared connection throughout browser proxy and event transport. Test cleanup was also hardened so an import failure cannot leave the proxy listening or be replaced by a cleanup 404. + +### First published CI run and corrective follow-up + +The first CI run of the completed implementation (`a0c1c943`, [run 35142559235](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35142559235)) failed despite the local bundle validation. It exposed two gaps: + +- The standalone npm pruning package imported identity helpers from the sibling `compatibility/` directory, which was absent from its archive. All three packaged-plugin jobs failed to load it. Pure identity validation/equality now live inside the pruning package and are re-exported by the HTTP adapter. A fast packaging regression resolves every exported entrypoint from only the files selected by `npm pack`, outside the checkout. The actual packed/installed native test reproduced the failure before the fix and passes afterward on Windows 2.0.4 and Linux-under-WSL 2.0.4/2.0.5. +- The Windows legacy jobs encountered NTFS short-path TEMP directories while Git returned the long canonical project root. Project selection compared strings before evacuation could reach its rollback test. It now resolves exact owned directories through `getServiceDirectoryForPath`, preserving aliases and WSL translation. The review rejected an initial attempt to reuse containing-worktree identity: that could select a nested repository and miss the intended project's active sessions. Exact project/sandbox/destination comparisons are now separate from descendant-session ownership. Regressions cover nested projects listed first, active-session refusal through the real route, unresolved/foreign destinations, short/long paths, real junction/symlink aliases and WSL translation. + +The corrected 2.0.4 shipped bundle also passes the full rendered/native suite on Windows. Fresh full beta-19271 and 2.0.3 runs with `--legacy-pruning` pass under a reproducing NTFS short-path TEMP root **after** the exact-directory correction. Independent final packaging/documentation and worktree-identity reviews approve the follow-up with zero actionable findings. The final full server suite passes **500 tests, with 2 skipped**, including the new packaging and directory-identity regressions. The `comment` job failed only because the required build-validation run failed; it did not expose an additional defect. These follow-up results are local evidence, not a claim that the next remote CI run has completed. + +### macOS late-discovery follow-up + +The next remote run at `486e856d` ([35146390688](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35146390688)) passes the general tests, all runtime-contract jobs, both Tauri checks, all service-compatibility jobs, and the Windows/Linux native pruning jobs. macOS passes the installed-package suite and presence tests, then times out discovering the bundle installed after the daemon/location already exist. The dependent artifact-comment job reports that failure. + +Source review of OpenCode `v2.0.4` (`466b3e594d3df396a57340249590a70c5c358c8a`) identifies an alias-path mismatch consistent with this failure: global configuration retains `OPENCODE_CONFIG_DIR` verbatim, while Parcel's macOS FSEvents backend emits native physical paths. Plugin-source filtering uses lexical `FSUtil.contains`. A `/private/var/...` event therefore falls outside a configured `/var/...` root. Parcel watcher 2.5.1 itself canonicalizes temporary roots in its tests. Linux inotify constructs paths from the watched alias, so a passing Linux symlink experiment does not validate macOS event spelling. + +The native fixture now resolves its temporary root with `realpath` **before** constructing any daemon paths. Installation still happens after daemon/location startup; an explicit assertion verifies that the bundled plugin was absent beforehand. Discovery failures retain the last plugin inventory and phase context. CI uploads only these isolated fixtures' daemon logs, discovery snapshots and rendered captures on failure. + +The corrected full native suite passes on Linux/WSL 2.0.4 with a symlinked TEMP root, and the Windows 2.0.4 rendered/native suite passes. Script syntax, workflow YAML parsing and whitespace checks pass; an independent gatekeeper review reports no actionable findings. This corrects the fixture namespace rather than preinstalling the plugin, restarting the daemon, or extending the timeout. Actual macOS confirmation requires the next CI run; the prior runner's raw watcher paths were not retained. Existing user daemons configured through symlinked config roots remain an upstream watcher limitation, not a production fix claimed by this fixture change. + +### Validation boundaries + +UI/server/Electron typechecks and production builds pass. The Windows Tauri release executable also builds (`npm run build --workspace @codenomad/tauri-app -- --no-bundle`). Electron and Tauri packaged-resource smoke checks pass. Full desktop interaction through Developer Mode could not run: the visible application reports Developer Mode inactive. Interactive TUI validation is not claimed. No shared daemon or user database is used by the native fixtures. + +The full 2.0.5 native suite also passes inside Ubuntu/WSL using an isolated Linux Node 22.20.0 (verified against the published SHA-256) and a temporary Linux filesystem root. This is a native Linux-under-WSL result, not a full Windows-host/WSL desktop traversal. A preceding run with the **configuration directory on `/mnt/c`** passed conversations, Forms, permissions and the proxy suite, but timed out discovering a plugin installed after location startup. The otherwise identical native-filesystem run passed late discovery, presence disposal/reopening and pruning. Mounted-drive late-plugin discovery remains an explicit environment limitation, not a claimed fix. Host/WSL lifecycle and path-translation regressions pass. + +## 1. Scope and evidence + +### Publication inventory + +Compared **all 44 beta/stable client publications** from 2026-09-02 through the audit's 2026-09-16 registry snapshot, plus `beta-18743` (2026-08-31) as the preceding release baseline: + +- `@opencode-ai/client`: 23 publications, `beta-18866` through `beta-19271`. +- `@opencode/client`: 21 publications, `beta-19275` through `2.0.5`. +- Tarball SHA-512 integrity was checked against npm metadata before inspecting files. +- Compared generated Promise operations, exported type declarations and native Solid `data.js`. +- Operation comparisons ignore ordering-only changes in declared HTTP status arrays. Textual type changes are investigation signals, not automatically breaking changes. +- This is a client-contract census of release channels. It is not a source-level review of every runtime commit or every `dev-*` publication, nor a native test of all 44 releases. + +Official reference sources: + +- [V2 client documentation](https://opencode.ai/v2/docs/build/client) +- [V2 API reference](https://opencode.ai/v2/docs/api) and [OpenAPI](https://opencode.ai/v2/openapi.json) +- [V2 plugin RPC documentation](https://opencode.ai/v2/docs/build/plugins/rpc) +- npm metadata: [old client namespace](https://registry.npmjs.org/@opencode-ai/client), [current client namespace](https://registry.npmjs.org/@opencode/client) +- Exact published tarballs, especially `@opencode-ai/client@0.0.0-beta-19271`, `@opencode/client@2.0.3`, `2.0.4` and `2.0.5`. Current website documentation cannot establish historical contracts. + +### Reproductions + +1. Ran official Windows CLIs `beta-19271`, `2.0.3` and `2.0.4` against separate synthetic directories/configs/databases. Exercised the real CodeNomad proxy and actual UI `createInstanceFetch` with client 2.0.4. +2. Discovery HTTP validation used the real isolated daemon, with synthetic CLI status/password command results. This did not discover or modify the user's shared service. +3. Confirmed the native Solid reducer throws on a pre-2.0.4 inbox response; the equivalent modern response succeeds. +4. Confirmed a matching non-null legacy workspace identity is rejected by the current pruning service in an isolated synthetic SQLite fixture; the fence accepts the same identity when it is actually supplied. +5. Ran the existing complete native proxy/pruning fixture against **CLI 2.0.5** using CodeNomad's pinned client 2.0.4 and shipped plugin bundle. It passed catalogs, sessions, native cursors, active envelope, instructions, wait, session/global Forms, storage/claim refusal, retry, concurrency, next-model payload, fork isolation, pre-compaction history, restart, backend presence and crash expiry. +6. Confirmed the local message normalizer turns native `idle/outcome: failed` into synthetic text `idle` with status `complete`. Visible treatment needs a rendering decision/test. + +No native WSL, interactive TUI or fresh packaged desktop validation was performed in this audit. Earlier host/WSL service unit coverage remains valid; it is not a substitute for those runtime checks. + +## 2. Timeline: the changes are not one migration + +Dates below are npm client publication times in UTC. + +| First relevant publication | Change | CodeNomad consequence | +| --- | --- | --- | +| Sep 2, `beta-18866` | `plugin.check/update`; plugin state/source and provider/model/config types evolve | Plugin inventory must follow actual states. New upstream APIs do not automatically enter the proxy allowlist. | +| Sep 3, `beta-18955` | Add `plugin.awaitActivation`; remove `plugin.added` in favor of current plugin lifecycle | Initial catalog readiness is part of the contract, not just a route name. | +| Sep 4, `beta-19059` | Compaction model/provider state; reducer connection/disposal handling; config changes | Preserve compaction data and cancellation/reconnect semantics. | +| Sep 4–5, `beta-19124`, `19133`, `19135` | Worktree routes change project-path to location-scoped, revert, then change again | A monotonic beta-number heuristic is insufficient. CodeNomad-owned Git worktree operations should remain independent. | +| Sep 7, `beta-19215` | Project update input; additional interruption reason | Review consumers rather than assuming every declaration change is a failure. | +| Sep 7, `beta-19266` | Provider compaction/context provenance types | Preserve provider state; execution semantics remain native. | +| Sep 7, `beta-19275` | `@opencode-ai` to `@opencode` namespace | Generated HTTP operations/types are unchanged from `beta-19271`; package rename is not the HTTP compatibility split. | +| Sep 9, `beta-19365` | Remove `session.messageUpdate` and its public event-union membership | Direct content editing needs the reviewed pruning RPC; HTTP name translation cannot recreate a removed capability. | +| Sep 9, `beta-19398` / `19419` | More compaction accounting/state and native reducer changes | Client cache behavior changes even when URLs do not. | +| Sep 10, `beta-19422` | Add message-list type filter | Retain native cursor authority; do not derive pagination from displayed/filtered row counts. | +| Sep 11, `2.0.0` | Session permissions/create inputs and permission-rules operation | New permission event/input shapes join the legacy HTTP family. | +| Sep 12, `2.0.2` | Config preferences/shell catalog; typed file-not-found response | Mostly optional surfaces; errors are also part of the client contract. | +| Sep 12, `2.0.3` | `session.diff`; native `idle` transcript record | A new message union member can compile through a generic fallback while losing its meaning. | +| Sep 16, `2.0.4` | Broad route, payload, response, event and location changes | Main current compatibility break; see matrix below. | +| Sep 16, `2.0.5` | No delta in generated Promise operations/types or Solid `data.js` versus 2.0.4 | Same inspected client contract. Native 2.0.5 integration also passes. Other runtime changes are not ruled out. | + +The generated client has 142 leaf operations in 2.0.3 and 132 in 2.0.4: 11 new names, 21 removed names and 36 changed existing operation implementations. These are client-method counts, not a count of broken CodeNomad features or of OpenAPI routes. + +## 3. Original audit issue register (`bcfe4d24`) + +Evidence: **Native** = real isolated runtime/proxy; **Fixture** = actual module with synthetic inputs; **Contract** = published declarations/generated code plus caller inspection. P1 blocks intended compatibility; P2 is narrower correctness/feature work. + +| ID | Priority / state | Finding and effect | Evidence / implementation locations | +| --- | --- | --- | --- | +| C01 | Fixed | Discovery used only `/api/status`, rejecting healthy earlier V2 services with 404. `bcfe4d24` adds authenticated health fallback only after status 404, retaining bounds/deadline. | Native on 19271, 2.0.3, 2.0.4; 35 lifecycle tests. `workspaces/opencode-cli-service.ts`. | +| C02 | P1 open | Earlier runtimes return 404 for experimental instruction/wait paths. Every prompt/command/session-shell send first synchronizes the voice instruction, including removing it when voice mode is off. Discovery can succeed while ordinary sending still fails. | Native + caller inspection. UI `stores/session-actions.ts:111-130,375-436`; server `server/http-server.ts`. | +| C03 | P1 open | Permission `decision` vs `reply`, command `name` vs `command`, fork optional `before` vs required `boundary`; session rename/inbox/revert methods and paths differ. UI permission replies **and server Yolo replies** are affected. `resume` vs `continue` also needs behavioral validation on a busy session. | Native 400 missing `reply`, `command`, `boundary`; 404 rename/revert. UI `stores/instances.ts`, `stores/session-actions.ts`, `stores/session-api.ts`, `components/session/session-view.tsx`; server `permissions/opencode-replier.ts`. | +| C04 | P1 open | Pending Forms list moved from `/api/form/request` to `/api/form`; cancel changed POST `.../cancel` to DELETE resource. Earlier runtimes cannot list/cancel through the current client. Reply's session URL itself remains compatible. Global Forms require the correct location headers. | Native list/cancel failures and session reply success; modern session/global Forms suite passes. UI `stores/instances.ts`, `stores/forms.ts`; guarded proxy. | +| C05 | P1 open | Earlier inbox responses contain `timeCreated`; stable reducer dereferences `item.time.created`. Reopening/reconciling a session with queued user/synthetic input throws even if all URLs are translated. | Fixture reproduced with real `@opencode/client/solid`. UI `stores/opencode-data.ts:156-170`; native `data.js` materialization. | +| C06 | P1 open | Older `catalog.updated` is dropped when global and ignored by UI invalidation when scoped. Stable reducer also expects `session.permissions`, not earlier `session.permissions.updated`. Pre-removal content-update events need authoritative message invalidation. | Contract + dispatch inspection. Server `workspaces/instance-events.ts`; UI `stores/instance-invalidation.ts`, `opencode-data.ts`; native Solid reducer. | +| C07 | P1 open, conditional | Legacy `workspaceID` identity is discarded/rejected in request locations, ownership, cursor/import checks, catalog/request keys and move rollback. A directory-only patch must not silently collapse distinct legacy locations. Existing default-directory probes do not validate these cases. | Contract; current code deliberately rejects non-null selectors. Server `workspaces/manager.ts`, `workspaces/worktree-session-evacuation.ts`, proxy cursor/import guards; UI `stores/request-locations.ts`, `stores/forms.ts`, `stores/session-api.ts`. | +| C08 | P2 open, conditional | Current pruning omits the legacy workspace identity passed to the SQLite fence, which then requires `workspace_id = NULL`. Legitimate non-null legacy sessions are refused; the broker may reject them even earlier. This is a refusal, not evidence of unauthorized deletion. | Fixture reproduced. Server `opencode/session-pruning/service.ts:39-42`, `claim-fence.ts:26-28`, `server/routes/session-pruning.ts:18`. | +| C09 | P2 validation gap | Activation-wait removal needs an older-runtime readiness policy. A successful early catalog read need not mean plugins have settled. Current `plugin.updated` refreshes help but need a race test. | Contract + removed `plugin-activation.ts`; catalog callers. Not reproduced as a permanent missing catalog. | +| C10 | P2 open | Native `idle` records introduced in 2.0.3 lose their outcome in CodeNomad normalization and become generic assistant text. Decide whether these are control records or explicit status rows; test all outcomes. | Fixture confirmed projection; visible rendering not yet established. UI `stores/message-v2/normalizers.ts:112-205`. | +| C11 | P2 open | Empty route-404 responses surface as `UnsupportedContentType` for several generated operations, concealing the real mismatch. Diagnostics should retain method/path/status and selected contract. Such errors must not trigger speculative mutation retries. | Native old-runtime rename/wait/cancel failures. Generated Promise error handling + both transports. | +| C12 | P1 coverage gap | CI pins a single modern native runtime. Updated mocks and typechecks confirm the modern interface, not compatibility with independently managed earlier daemons. | `.github/workflows/pr-build.yml`, `scripts/test-opencode-proxy-native.mjs`, `scripts/test-session-pruning-native.mjs`. | + +Paths in this historical table are relative to `packages/server/src` or `packages/ui/src` as labeled. C02–C12 were not fixed by the discovery commit; their follow-up disposition is recorded above. + +### The two main wire families + +| Concern | Earlier V2 / 2.0.3 | 2.0.4 / 2.0.5 | Treatment | +| --- | --- | --- | --- | +| Discovery | `/api/health` plus `/api/server` metadata | `/api/status` | Already fixed for discovery; preserve authenticated runtime identity for negotiation. | +| Instructions, wait, import/export, stats; MCP connect/disconnect | Non-experimental paths | Experimental paths | Explicit route mapping for consumed operations after ownership checks. | +| Rename | POST `session/:id/rename` | PATCH `session/:id` | Translate supported title mutation, not arbitrary PATCH fields. | +| Permission reply | `{ reply }` | `{ decision }` | Map once for UI and Yolo. | +| Command | `{ command }` | `{ name }` | Preserve all other prompt fields/delivery. | +| Fork | `{ boundary: {type:'before',messageID} }` or `{ boundary: {type:'through'} }` | `{ before? }` | Preserve exact cut semantics; never silently fork all history. | +| Interrupt | `continue` query | `resume` query | Map before sending and test interruption/cleanup/inbox behavior. | +| Inbox delivery | POST `.../steer` or `.../queue` | PATCH `{ delivery }` | One operation; no trial mutation sequence. | +| Clear staged revert | POST `.../revert/clear` | DELETE `.../revert` | Keep ownership and worktree mutation fencing. | +| Inbox response | `timeCreated` | `time.created` | Normalize list and admission responses before the stable reducer sees them. | +| Forms | Location list `form/request`; POST cancel | Location list `form`; DELETE cancel | Preserve session/global distinction and validated encoded location headers. | +| Project metadata | `location.get().project` already exists; also `project.current` | `location.get().project` | Current metadata read is compatible in native probes; no fallback is needed for this read. | +| Location | Public directory + optional workspace selectors | Public directory only | Preserve internal legacy authority; never invent a modern workspace selector. | +| Credentials | Location query accepted | Global ID-only mutation | Inspect earlier native invalidation/location behavior; do not infer semantics from unchanged URL. | +| Catalog invalidation | `catalog.updated` | `provider.updated`, `model.updated` | Normalize to internal refresh intent covering both affected resources. | +| Message lookup | `session.message(...)` | `session.message.get(...)` | Same HTTP GET URL; this name change alone needs no wire fallback. | +| Pruning | Direct edit removed starting beta-19365 | Reviewed plugin RPC | Keep one pruning implementation with independently verified storage/claim semantics. | + +Background `shell.*`, interactive `pty.*`, basic project/session/message reads and native cursor continuation remain distinct concerns. Removed Shell timeout, workspace provisioning, native Git worktree management, generic RPC/plugin management and unused experimental APIs should not be exposed merely to mirror the upstream surface. + +## 4. Why the patch-by-patch approach fails + +1. **Discovery and application compatibility are conflated.** A valid status response establishes reachability/authentication, not the meaning of the next POST or SSE event. +2. **There are two native HTTP paths.** UI calls go through Fastify's proxy, while Yolo, ownership checks and worktree evacuation use the server's shared client. Fixing only the proxy leaves direct callers inconsistent. +3. **The native Solid reducer is executable contract logic.** It depends on response shapes, events, cache publication and pagination; it is not just a set of TypeScript definitions. +4. **Authority is mixed with serialization.** Dropping a now-absent public field also removed information used for older location identity, cache keys and rollback. +5. **A newer lockfile can make mocks agree with one another.** Native tests must deliberately cross client/runtime generations and exercise the real transports. +6. **Our earlier migration widened the change while narrowing validation to modern V2.** Its modern-runtime results are useful, but they did not justify a backward-compatibility claim. The discovery fallback fixes only C01. + +## 5. Recommended design: one integration module + +### External seam + +Place the integration module under `packages/server/src/opencode/compatibility/`, connected at `OpenCodeSharedService`. Its interface should expose a **single connection-scoped integration**: authenticated runtime identity/capabilities, the canonical client used by server callers, and the transport/event adaptation needed by the guarded proxy/event bridge. + +Keep the current modern Promise client and Solid reducer as the canonical consumer contract. The legacy adapter performs the measured translations. Do not spread `if (version...)`, endpoint retries or legacy casts through stores, dialogs and Yolo code. + +```text +UI stores/components + pinned native Solid reducer + | + canonical CodeNomad-facing contract + | + guarded Fastify proxy -- authorization / mutation fence + | | + +---- integration module -+---- server callers (Yolo, inventory) + | + selected runtime-contract adapter + | + existing shared OpenCode daemon + +Native SSE --> same integration module --> CodeNomad refresh intents / compatible events +``` + +This module earns its depth by keeping route/payload/response/event variations behind one interface. Internally separate connection negotiation, explicit HTTP translations, response normalization and event adaptation; callers should not coordinate those pieces themselves. + +### Runtime selection and lifetime + +- Use the **authenticated daemon's** version/identity, not merely the selected CLI executable's version. An already-running shared service can differ from the CLI on disk. +- Recognize verified contract profiles from recorded declarations/OpenAPI signatures. Investigate daemon `/openapi.json` availability as a read-only capability check; do not assume it exists on every supported release. +- Health/status chooses a discovery route, not every capability. Earlier releases sharing health can differ in message editing, permissions or plugin/cache APIs. +- Resolve capabilities before mutations. A resource 404, 400 validation failure, timeout or 401 must not cause a second speculative write using another contract. +- Bind selection to connection generation plus daemon identity. On restart/upgrade/reconnect, revalidate and discard stale capability state even if URL/password stay the same. +- On an unknown contract, surface an explicit unsupported capability/error for the operation; do not pretend compatibility by silently dropping required fields. Avoid an exact-version-only production startup gate. + +### Data and authority rules + +- Normalize old inbox objects on **all relevant responses**, including list and admission. Validate required fields rather than blindly decorating arbitrary JSON. +- Preserve the exact native `cursor.next`. Cursor authorization may understand each supported family, but cannot recreate or strip the continuation token. +- Preserve legacy location identity in the internal model and validate it using the selected native adapter. Adapt public modern requests to directory-only at serialization time. Finalize this representation before implementing legacy global Forms or move rollback. +- Keep Form listing location-scoped. Resolve ordinary session Forms by session, and global Forms by their validated, translated, encoded directory and any supported legacy identity. +- Convert obsolete catalog/content-change notifications into explicit CodeNomad invalidation/re-read intent. Do not manufacture upstream durable event IDs or replay old writes to resemble a modern event stream. +- Keep worktree deletion fences and native session/directory/path ownership checks ahead of forwarding. Translation must not expand the externally exposed route allowlist. +- Keep pruning's DB identity challenge, SQLite transaction and execution claim verification independent of the HTTP profile. Plugin loading successfully is not proof that a destructive write is supported. + +### Architecture tradeoff + +The recommendation is a server-side normalization adapter because both transport paths can share it and the UI already has a pinned reducer. This avoids maintaining two browser state machines. It requires adapting responses and events as well as requests, and explicitly resolving the legacy location-identity representation. + +Do not wrap the entirety of OpenCode or implement a speculative universal compatibility framework. Start with CodeNomad's consumed operations and the two demonstrated families. Only add a distinct feature capability when a measured difference requires it. The native acceptance matrix must exercise callers through the module's real interface, including proxy and direct-client paths. + +## 6. Work packages and completion criteria + +| Order | Work package | Issues | Completion criterion | +| --- | --- | --- | --- | +| 0 | Record this audit, exact profiles, support targets and evidence | C12 | One maintained register; runtime evidence separated from declaration comparisons. | +| 1 | Extract isolated runtime fixture and add a common acceptance matrix | C02–C08, C12 | Same scenario suite runs old and modern runtimes; current old-runtime failures are reproducible. Model traffic uses a local deterministic provider. | +| 2 | Introduce connection-scoped profile selection and internal location identity | C07, C11 | Reconnect/same-port upgrade invalidates profiles; old identity is retained; unknown capability and auth failures are explicit. | +| 3 | Integrate request/response adaptation through proxy **and** direct client | C02–C05 | Normal prompt, voice off/on, command, session Shell, Yolo, permission reply, rename, fork, interrupt/wait, inbox delivery and restart-with-pending-input pass on both families. | +| 4 | Complete location-sensitive Forms/import/cursors/moves and event/cache convergence | C04, C06, C07, C09 | Global/worktree Forms settle correctly; foreign locations are denied; native cursors unchanged; failed move restores full identity; delayed HTTP/SSE/reconnect converges. | +| 5 | Validate optional pruning/storage and modern transcript control records | C08, C10 | Non-null legacy identity has an explicit supported/refused outcome; storage/concurrency/payload/fork tests pass wherever pruning is advertised; idle records have intentional rendering. | +| 6 | Validate packaged hosts and make contract review a release practice | C12 | Electron/Tauri and WSL smoke results recorded; oldest supported/latest runtimes covered in CI; new publication deltas classified before dependency upgrades. | + +Work packages are reviewable increments of one design, not independent fallback patches. Package 3 depends on the identity decision in package 2; package 4 must not be marked complete by testing only default-directory sessions. + +### Required acceptance scenarios + +- Fresh and already-running service; authentication errors; missing status; bounded responses; deadline exhaustion; reconnect to a changed daemon at the same URL. +- Fresh project, existing sessions, native session/message pagination, messages older than 200, moved sessions and multiple worktrees. +- Prompt, command and session Shell with voice mode enabled/disabled; attachments; pending input restored after reconnect. +- Permission acceptance/rejection and server Yolo; session/global Forms including worktree directory translation. +- Rename, full/partial fork, busy interrupt followed by wait, staged revert/clear, inbox queue/steer/cancel. +- Provider/model/plugin catalog changes while initial reads are in flight; credentials and MCP connect/disconnect; raw legacy events reaching stable cache reconciliation. +- Background Shell list/output/remove with native cursors; separate interactive PTY creation/connect lifecycle. +- For supported pruning: actual native storage identity, busy claim refusal, idempotency, concurrent admission, next-model payload, independent fork, pre-compaction content, restart, backend shutdown/expiry and TUI cache convergence. +- Negative cases: foreign session/directory/cursor/Form, obsolete selectors on modern APIs, malformed payloads, unsupported capabilities and transport failure without duplicate mutation. + +### Release discipline + +Maintain a feature/profile matrix with four explicit states: **declaration-reviewed**, **native-verified**, **packaged-verified**, **unsupported**. A successful health probe only fills the discovery cell. Do not label the entire application compatible because that cell is green. + +For an OpenCode update: snapshot exact package versions/integrities; compare paths/methods, request fields, response shapes, errors, events and reducer behavior; map deltas to this register; run the oldest-supported/current/latest acceptance targets; update the matrix. No automatic import/allowlist expansion for unused native APIs. + +The current PR's fallback can remain committed. The remaining compatibility work should follow this register, with a complete older-runtime conversation scenario required before describing PR #695 as backward-compatible. + +## 7. Evidence archive and reproducibility + +Research files on the audit machine live under `C:/Users/Admin/AppData/Local/Temp/opencode/`: + +- `v2-fortnight-audit/`: extracted integrity-checked client tarballs, `contracts.json`, `changes.json`, normalized `inventory.json`. +- `audit-v2-contracts.mjs`: read-only registry/tarball inventory script used for this census. +- `pr695-review-native-results.json`: real-proxy failures on earlier runtimes and modern comparison, before the discovery-only fix. +- `pr695-discovery-fallback-results.json`: discovery fixed on beta-19271, 2.0.3 and 2.0.4. +- `review-pr695-reducer.mjs`: real stable reducer with old/new inbox response fixtures. +- `review-pr695-pruning.mts`: isolated legacy-identity refusal fixture. +- `v2-fortnight-native-205.log` and `codenomad-pruning-native-Xgg9tK/`: passing CLI 2.0.5 integration output and synthetic fixture. +- `compat-native-beta.log`, `compat-native-203.log`, `compat-native-204.log`, `compat-native-205.log`: passing integrated four-runtime compatibility/location/pruning matrix. +- `compat-pruning-identity-beta.log`, `compat-pruning-identity-203.log`: passing full pruning suite with non-null native workspace identity. +- `compat-server-tests.log`: 493 passed, 2 skipped; `compat-ui-stores.log`: 441 passed. Final targeted `session-actions.test.ts`: 33 passed. Electron native suite: 194 passed, 4 skipped. +- `compat-tauri-build.log`, `compat-electron-build.log`, `compat-tauri-smoke.log`, `compat-electron-smoke.log`: desktop build/resource evidence. +- `compat-rendered-ui.log`: final 2.0.5 rendered suite using the exact shared connection; captures in `codenomad-pruning-native-2Fea1N/` (`ui-before.png`, `ui-busy.png`, `ui-after.png`). +- `compat-rendered-ui-agent-legacy.log`: beta-19271 rendered suite with non-null identity; captures in `codenomad-pruning-native-rGgcVF/`. +- `compat-wsl-native-linuxfs.log`: passing Ubuntu/WSL native 2.0.5 suite. `compat-wsl-native.log` records the mounted-configuration late-discovery limitation. +- `pr695-ci-failed.log`: first published CI failures; `pr695-packed-before.log` and `pr695-packed-fixed.log`: failing then passing standalone packed-plugin native runs. `pr695-packed-linux.log` and `pr695-packed-linux204.log`: passing installed-plugin native suites under Linux/WSL. `pr695-ci-ui204.log`: passing corrected 2.0.4 rendered/native bundle run. +- `pr695-ci-server-final.log`: full server suite after the packaging/exact-project corrections, 500 passed and 2 skipped. +- `pr695-final-exact-short-beta19271-legacy.log` and `pr695-final-exact-short-203-legacy.log`: full native suites with non-null legacy identity and short-path TEMP, after the final exact-directory correction. +- `pr695-ci-macos-failed.log`: macOS late-discovery failure after the standalone package suite passes. `pr695-macos-symlink-before.log`: Linux inotify preserves alias-path discovery; this does not reproduce macOS FSEvents semantics. +- `pr695-macos-symlink-after.log`: Linux/WSL full native 2.0.4 with the fixture canonicalized from a symlinked TEMP root. `pr695-canonical-ui204.log`: passing Windows 2.0.4 rendered/native suite after canonicalization and the explicit pre-install absence assertion. + +The tarball inventory is reproducible with `npm view time --json` and `npm pack @ --ignore-scripts`, then comparison of `dist/promise/generated/client.js`, `types.d.ts` and `dist/solid/data.js`. Downloading a tarball does not require launching its CLI or loading a user database. + +Portable native regression already in the repository: + +```text +node scripts/test-session-pruning-native.mjs +node scripts/test-session-pruning-native.mjs +node scripts/test-session-pruning-native.mjs --legacy-pruning +node scripts/test-session-pruning-native.mjs --ui +``` + +The full publication set inspected (beta versions abbreviated): + +- Old namespace beta set: `18866, 18955, 18965, 18985, 18992, 18999, 19059, 19086, 19124, 19129, 19133, 19135, 19151, 19157, 19187, 19192, 19215, 19213, 19228, 19234, 19242, 19266, 19271`. +- Current namespace beta set: `19275, 19278, 19283, 19288, 19289, 19296, 19365, 19378, 19381, 19398, 19419, 19422, 19425, 19500, 19507`; stable set: `2.0.0, 2.0.1, 2.0.2, 2.0.3, 2.0.4, 2.0.5`. +- Additional preceding baseline: old namespace `beta-18743`. + +## 8. Preserved historical architecture findings + +Review target: `upstream/DEV-v2@f03a17a4`, covering `5484f9c9..f03a17a4`. These findings are preserved for follow-up, not asserted as current regressions or marked fixed by this compatibility change: + +- High: Tauri shutdown races already-authorized navigation (`shutdown.rs`, `client_state/navigation.rs`). +- High: Non-authoritative workspace disappearance overwrites current drafts/attachments (`use-app-session-capture.ts`, `app-session-snapshot-merge.ts`). +- High: Tauri removes persisted state before native close succeeds (`client_state.rs`). +- Medium: Refresh/reconnect may remove transcript pages older than the latest 200 (`message-v2/instance-store.ts`). +- Medium: Session-list reset may hide roots concurrently added by SSE/restoration. +- Medium: Scroll restoration cannot find anchors older than the initial 200-message page (`message-section.tsx`, `virtual-follow-list.tsx`). +- Medium: Electron swallows persisted-record deletion failure before close, enabling ghost restoration (`multiwindow-lifecycle.ts`). +- Medium: Windows `NODE_EXTRA_CA_CERTS` paths are passed literally into WSL (`wsl-opencode-service.ts`). +- Medium: Conversation search excludes unloaded transcript pages. + +Initial failed-service-identity pinning is historical only; the current base already includes recovery tests. The isolated partitioned-restore prototype and unrelated worktrees were not modified or published. diff --git a/dev-docs/SESSION_PRUNING_RPC.md b/dev-docs/SESSION_PRUNING_RPC.md index 1d800f4a3..8a632f271 100644 --- a/dev-docs/SESSION_PRUNING_RPC.md +++ b/dev-docs/SESSION_PRUNING_RPC.md @@ -90,14 +90,8 @@ re-read or retry the **same** request, not assume no mutation occurred. ## Version and loading notes -- UI/server client lock refreshed to the last `@opencode-ai/client@beta` publication, - `beta-19271`. The published low-level `rpc.call` transport is used by the broker. -- Plugin definition uses `@opencode/plugin@beta` with reproducible workspace lock resolution, - a development-only dependency bundled into the shipped plugin payload. No npm - installation is performed at app startup. Migration of the rest of CodeNomad to `@opencode/client` - is separate work. The native integration test uses the actual `beta-19271` client - against an explicitly supplied official runtime, including RPC and custom events. - The native test and TUI cache tests do not impose a beta-number allowlist. +- UI/server pin `@opencode/client@2.0.4`. The published low-level `rpc.call` transport is used by the broker. +- Plugin definition pins `@opencode/plugin@2.0.4`, a development-only dependency bundled into the shipped plugin payload. No npm installation is performed at app startup. The native integration test uses the stable client against an explicitly supplied isolated official runtime, including RPC, custom events and the guarded UI proxy. CI uses CLI 2.0.4; production discovery has no exact runtime-version gate. - In that plugin contract, `ctx.session.message` and `ctx.db` do not exist. - SQLite unit tests use Node's `node:sqlite`; the native test also exercises that driver **inside the official compiled runtime**, not an embedded SDK substitute. diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 26e70235e..18d76cddf 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -69,7 +69,7 @@ dev-docs/ Development documentation - Project file structure - State management patterns - Shared OpenCode service and location ownership -- Native `@opencode-ai/client` integration +- Native `@opencode/client` integration - `/api/events` multiplexing - IPC communication - Error handling strategies @@ -165,7 +165,7 @@ dev-docs/ Development documentation ## Current OpenCode Baseline -- Experimental protocol client: server and UI follow `@opencode-ai/client@beta`; the runtime CLI is managed independently and startup validates its authenticated loopback health endpoint without an exact version gate +- Stable protocol client: server and UI pin `@opencode/client@2.0.4`; the runtime CLI is managed independently and startup validates its authenticated loopback `/api/status` endpoint without an exact version gate - Service: one externally owned global endpoint with a CodeNomad-pinned host or WSL identity - Workspaces: native locations/directories - Database: OpenCode's global default; configured startup environment applies only when CodeNomad starts a missing daemon diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 2d76f1b49..f31717002 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates with the latest experimental `@opencode-ai/client@beta` contract in both server and UI. The public `@opencode-ai/sdk` is an alternative embedded host; CodeNomad uses the network client. +CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. Both server and UI pin the official `@opencode/client@2.0.4` network contract. The runtime CLI is independently managed. ```text Desktop host -> CodeNomad server -> one shared OpenCode service diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 14d13c6c3..0bfdeb584 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -2,9 +2,9 @@ ## OpenCode Dependency -Server and UI follow `@opencode-ai/client@beta`. Import the generated Promise client from `@opencode-ai/client` and refresh the client lock before API audits or release validation. The runtime CLI is managed independently; startup validates its authenticated loopback endpoint and health response without requiring an exact version string. Review current documentation, installed declarations, and proxy/API parity whenever the client contract changes. +Server and UI pin `@opencode/client@2.0.4`. Import the generated Promise client from `@opencode/client` and upgrade both packages and the lockfile together. The runtime CLI is managed independently; startup validates its authenticated loopback `/api/status` response without requiring an exact version string. Review current documentation, installed declarations, and proxy/API parity whenever the client contract changes. -Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpencodeClient()`, or a `packages/opencode-plugin` package. Verify method signatures in `node_modules/@opencode-ai/client/dist/promise/`. +Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpencodeClient()`, or a `packages/opencode-plugin` package. Verify method signatures in `node_modules/@opencode/client/dist/promise/`. ## Server Integration @@ -18,7 +18,7 @@ Workspace creation passes a native location: await client.location.get({ location: { directory } }) ``` -`WorkspaceManager` records the returned directory/workspace ID. Explicit **Stop Workspace** evicts that location/resources and removes the logical workspace without stopping the global daemon. Ordinary tab/window close only detaches local UI state and does not call the delete/eviction path. +`WorkspaceManager` records the returned directory; public locations are directory-only and `location.get().project` provides project metadata. Explicit **Stop Workspace** evicts that location/resources and removes the logical workspace without stopping the global daemon. Ordinary tab/window close only detaches local UI state and does not call the delete/eviction path. ## Native Windows And Restore State diff --git a/package-lock.json b/package-lock.json index 4d6e29ac8..a22aa5cf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4226,9 +4226,9 @@ } }, "node_modules/@npmcli/arborist/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4478,9 +4478,9 @@ } }, "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4641,9 +4641,9 @@ } }, "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4843,57 +4843,15 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@opencode-ai/client": { - "version": "0.0.0-beta-19271", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-19271.tgz", - "integrity": "sha512-hx9aWS6B6CptcQvasmEjfYzSKcl1XsWNQIA2g53MKXVfmx9jdk7+sQWR3FunxMXvFSUSTPNAUTt0RDzO8O8Ylg==", - "license": "MIT", - "dependencies": { - "@opencode-ai/protocol": "0.0.0-beta-19271", - "@opencode-ai/schema": "0.0.0-beta-19271" - }, - "peerDependencies": { - "effect": "4.0.0-rc.112", - "solid-js": ">=1.9.0" - }, - "peerDependenciesMeta": { - "effect": { - "optional": true - }, - "solid-js": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-beta-19271", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-19271.tgz", - "integrity": "sha512-pmOrj34z1wK+EwWDI27ymtUR1sDDGvWTYRyl4m66yllQuqsohW1webLIi6J4+UwNqnjKsGVeXIgZJBqblqR2tQ==", - "license": "MIT", - "dependencies": { - "@opencode-ai/schema": "0.0.0-beta-19271", - "effect": "4.0.0-rc.112" - } - }, - "node_modules/@opencode-ai/schema": { - "version": "0.0.0-beta-19271", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-19271.tgz", - "integrity": "sha512-5i2EnveTUMB277O5JrMxdVLDi8K8JnSYm6rn1KdBrgJEAQmy182nOQBkZ3X7eBQTK6FDxz+byuiXksz0pmQptQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "1.1.0", - "effect": "4.0.0-rc.112" - } - }, "node_modules/@opencode/ai": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-0.0.0-beta-19425.tgz", - "integrity": "sha512-NDzzD1I1NQR22h6CFkPXI/OwmnplBZYnOImkHG5cMq4GxWpwdboaQRmcwSHdDBtHqC7eXPZzH8FrNUpOXwRGGg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.4.tgz", + "integrity": "sha512-T/2wTuhy/I1Wxdy/GdhtMqugpl1OWKgVp+8A45mtS1jDrTSeHq2x5YhkComkJZ6BwdG/OvU6afTGDPA7/vd4RA==", "dev": true, "license": "MIT", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", - "@opencode/schema": "0.0.0-beta-19425", + "@opencode/schema": "2.0.4", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -4902,14 +4860,13 @@ } }, "node_modules/@opencode/client": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/client/-/client-0.0.0-beta-19425.tgz", - "integrity": "sha512-doWMGr/r8dWvl9mLbWVUpSHMfATfJy8O6Jphr17yoioAU8brxrfsuRWxHRYQnXwTiiiYduwPI6o71J8CSOKwDA==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.4.tgz", + "integrity": "sha512-GBFWT2os+gEtGXpHRvDUNkf0H9x9njDUVbUvvc4drH2CuAihVcHqdi9Wm4UiwkBrSLjrKPs4U1NJBh2Z7/x4tQ==", "license": "MIT", "dependencies": { - "@opencode/protocol": "0.0.0-beta-19425", - "@opencode/schema": "0.0.0-beta-19425" + "@opencode/protocol": "2.0.4", + "@opencode/schema": "2.0.4" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -4925,24 +4882,24 @@ } }, "node_modules/@opencode/plugin": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-0.0.0-beta-19425.tgz", - "integrity": "sha512-wduIQNF9RMZpei6NDutlS8EBivp9uKNdya9Vpv87IfD+F7/++nZnWCwXtr9IgZ7DUN7hYep3mEMsNepX5LMhwQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.4.tgz", + "integrity": "sha512-NsC1STARfXxe+ZoXjXzpxvtS9l4h9Aen8ZccaCd1d9+1eyYdp+q8DU3Rxh7Ch0jiwiQMybS2Z+KgAfZJBnTBbA==", "dev": true, "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode/ai": "0.0.0-beta-19425", - "@opencode/client": "0.0.0-beta-19425", - "@opencode/protocol": "0.0.0-beta-19425", - "@opencode/schema": "0.0.0-beta-19425", - "@opencode/util": "0.0.0-beta-19425", + "@opencode/ai": "2.0.4", + "@opencode/client": "2.0.4", + "@opencode/protocol": "2.0.4", + "@opencode/schema": "2.0.4", + "@opencode/util": "2.0.4", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { - "@opencode/theme": "0.0.0-beta-19425", + "@opencode/theme": "2.0.4", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" @@ -4973,21 +4930,19 @@ } }, "node_modules/@opencode/protocol": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-0.0.0-beta-19425.tgz", - "integrity": "sha512-aUsuJCLHwryvyqb/5PmorVra0XDs0vL3bOP2LiO/ZkpXYCevuSwKXRS7WvKnpK6lYGt61kNDloF6pE9JEf+UeQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.4.tgz", + "integrity": "sha512-uWe2dmMouprSkfBu29XLR6GCWckkhm0YOo9ve07kTRqJCSoJzgQDF/8pouWG0cDCJ6oWj3bRQajXriBQyWSpUw==", "license": "MIT", "dependencies": { - "@opencode/schema": "0.0.0-beta-19425", + "@opencode/schema": "2.0.4", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode/schema": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-0.0.0-beta-19425.tgz", - "integrity": "sha512-marzLr5dBvNNFg6CaNqGXUk6Tl6vVQ7sYOmyYTbyhYRe/M0tGdhGVF8AJEMESHieUKBZHwUXmsXJ+sfjRu96RA==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.4.tgz", + "integrity": "sha512-UyGcCsRb2hTH98rnBqfJogFve8kJsIbsEreRxqhkBIEGrtDTqcUd5NnqB7qlJp2khesMvpOH0GmCHlgz44C+uQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", @@ -4995,9 +4950,9 @@ } }, "node_modules/@opencode/util": { - "version": "0.0.0-beta-19425", - "resolved": "https://registry.npmjs.org/@opencode/util/-/util-0.0.0-beta-19425.tgz", - "integrity": "sha512-UR7QFtJy4ZiZCBFWXgCsZAIpFXLbGwKvJAbmyi2lN7UoFyOITsvjFz1kjr3UwcoqDfl+56p/xXAPUJVNINutgg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.4.tgz", + "integrity": "sha512-jo6zhWqJ4WaU+fUjM8/Ea4i06gxTQFEFgfq2XG9J8gSDUhfsj4l9wJ3cLtSuKFQGeUeWqJJwRgDhCW+rUbis6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5031,9 +4986,9 @@ } }, "node_modules/@opencode/util/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6089,13 +6044,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", - "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.17.2", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -6881,9 +6836,9 @@ } }, "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8200,9 +8155,9 @@ } }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11276,9 +11231,9 @@ } }, "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11347,9 +11302,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { @@ -13610,9 +13565,9 @@ } }, "node_modules/p-map": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", - "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", "dev": true, "license": "MIT", "engines": { @@ -18605,7 +18560,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "beta", + "@opencode/client": "2.0.4", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -18621,7 +18576,7 @@ "codenomad": "dist/bin.js" }, "devDependencies": { - "@opencode/plugin": "beta", + "@opencode/plugin": "2.0.4", "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", @@ -18661,7 +18616,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/client": "beta", + "@opencode/client": "2.0.4", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/server/README.md b/packages/server/README.md index 136a4e13b..3efe4013f 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,7 +20,7 @@ ## Prerequisites -- **OpenCode V2**: Install a compatible `opencode2` CLI. CodeNomad uses the CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to OpenCode's externally owned global daemon, then validates its authenticated loopback health response rather than enforcing an exact CLI version string. +- **OpenCode V2**: Install a compatible `opencode2` CLI. CodeNomad uses the CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to OpenCode's externally owned global daemon, then validates its authenticated loopback `/api/status` response rather than enforcing an exact CLI version string. - **OpenCode data**: The global daemon owns its platform-default storage, database, and service registration. Configured startup environment applies only when CodeNomad starts a missing daemon; an existing daemon is unchanged. - **Windows to WSL**: A configured WSL UNC binary uses Linux `service status`, `service start`, and `service get password`; Windows must have WSL localhost forwarding enabled to reach its loopback service. - Node.js 18+ and npm (for running or building from source). @@ -232,7 +232,7 @@ Explicit **Stop Workspace** evicts that location and its resources from the glob CodeNomad holds one shared OpenCode V2 `client.event.subscribe()` stream. It routes native location-scoped events to logical workspaces and multiplexes them with CodeNomad events over `GET /api/events` for browser `EventSource` clients. -The stream is volatile and has no replay guarantee. After reconnecting, clients must refetch authoritative sessions and pending permission, question, and form requests; file and config consumers must also refetch after `filesystem.changed` and `config.updated` invalidations. +The stream is volatile and has no replay guarantee. After reconnecting, clients must refetch authoritative sessions and pending permission and Form requests; file and config consumers must also refetch after `filesystem.changed` and `config.updated` invalidations. ### Provider Plan Usage diff --git a/packages/server/package.json b/packages/server/package.json index b0e9d923b..c866b2ca0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,7 +28,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "beta", + "@opencode/client": "2.0.4", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -42,7 +42,7 @@ }, "devDependencies": { "esbuild": "^0.25.12", - "@opencode/plugin": "beta", + "@opencode/plugin": "2.0.4", "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 7575ef5a5..f0e396219 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -6,7 +6,7 @@ import type { Preferences, RecentFolder, } from "./config/schema" -import type { OpenCodeEvent } from "@opencode-ai/client" +import type { OpenCodeEvent } from "@opencode/client" /** * Canonical HTTP/SSE contract for the CLI server. diff --git a/packages/server/src/opencode/compatibility/contract-fixture.ts b/packages/server/src/opencode/compatibility/contract-fixture.ts new file mode 100644 index 000000000..11eaa938d --- /dev/null +++ b/packages/server/src/opencode/compatibility/contract-fixture.ts @@ -0,0 +1,14 @@ +// Minimal structural excerpts from the published earlier V2 OpenAPI document. +// For test fixtures only; real-runtime tests also negotiate full native schemas. +export const legacyContractFixture = { + paths: { + "/api/session/{sessionID}/rename": { post: {} }, + "/api/form/request": { get: {} }, + "/api/session/{sessionID}/form/{formID}/cancel": { post: {} }, + "/api/session/{sessionID}/wait": { post: {} }, + "/api/session/{sessionID}/fork": { post: { requestBody: { content: { "application/json": { schema: { properties: { boundary: {} } } } } } } }, + "/api/session/{sessionID}/command": { post: { requestBody: { content: { "application/json": { schema: { properties: { command: {} } } } } } } }, + "/api/session/{sessionID}/permission/{requestID}/reply": { post: { requestBody: { content: { "application/json": { schema: { properties: { reply: {} } } } } } } }, + }, + components: { schemas: { "Session.Inbox.User": { properties: { timeCreated: { type: "number" } } } } }, +} diff --git a/packages/server/src/opencode/compatibility/events.ts b/packages/server/src/opencode/compatibility/events.ts new file mode 100644 index 000000000..82c63cbc3 --- /dev/null +++ b/packages/server/src/opencode/compatibility/events.ts @@ -0,0 +1,10 @@ +import type { OpenCodeEvent } from "@opencode/client" + +export function normalizeRuntimeEvent(event: OpenCodeEvent): OpenCodeEvent { + // Native metadata/durable identity is retained. Catalog/content changes stay + // invalidation intents; no synthetic upstream durable records are invented. + if ((event.type as string) === "session.permissions.updated") { + return { ...event, type: "session.permissions" } as OpenCodeEvent + } + return event +} diff --git a/packages/server/src/opencode/compatibility/location.test.ts b/packages/server/src/opencode/compatibility/location.test.ts new file mode 100644 index 000000000..93b07be59 --- /dev/null +++ b/packages/server/src/opencode/compatibility/location.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { OpenCode } from "@opencode/client" +import { createRuntimeFetch } from "./transport" +import { rememberRuntime } from "./runtime" +import { + LOCATION_CONTEXT_HEADER, applyLocationContext, locationRequestOptions, + moveSessionToLocation, readLocationContext, readLocationRef, sameLocation, +} from "./location" + +test("identity context survives real modern generated move serialization", async () => { + const original = { directory: "/work/100% 工作", workspaceID: "workspace-1" } + let calls = 0 + const client = OpenCode.make({ baseUrl: "http://localhost:1234", fetch: async (input, init) => { + calls++ + const request = new Request(input, init) + const before = await request.json() + assert.deepEqual(before, { directory: original.directory }) + const headers = new Headers(request.headers) + const after = applyLocationContext(new URL(request.url), request.method, before, headers, "legacy") + assert.deepEqual(after, original) + assert.equal(headers.has(LOCATION_CONTEXT_HEADER), false) + return new Response(null, { status: 204 }) + } }) + await moveSessionToLocation(client, "s", original) + assert.equal(calls, 1) +}) + +test("context parser refuses malformed, duplicate, unknown and modern identity selectors", () => { + const encoded = locationRequestOptions({ directory: "/repo", workspaceID: "w" })!.headers[LOCATION_CONTEXT_HEADER] + assert.deepEqual(readLocationContext(encoded, "legacy"), { directory: "/repo", workspaceID: "w" }) + for (const profile of ["modern", "unknown"] as const) assert.throws(() => readLocationContext(encoded, profile), /Unsupported/) + assert.throws(() => readLocationContext([encoded, encoded], "legacy")) + assert.throws(() => readLocationContext("%", "legacy")) + assert.throws(() => readLocationContext(encodeURIComponent('{"directory":"/repo","workspace":"w"}'), "legacy")) + assert.throws(() => readLocationRef({ directory: "/repo", workspaceID: null })) + assert.equal(sameLocation({ directory: "/repo", workspaceID: "one" }, { directory: "/repo", workspaceID: "two" }), false) +}) + +test("global Forms and catalog requests preserve explicit identity without forwarding private headers", () => { + const ref = { directory: "/工作/100% ready", workspaceID: "worktree" } + const options = locationRequestOptions(ref)! + const headers = new Headers({ ...options.headers, "x-opencode-directory": encodeURIComponent(ref.directory) }) + applyLocationContext(new URL("http://localhost/api/session/global/form/f/reply"), "POST", { answer: {} }, headers, "legacy") + assert.equal(headers.get("x-opencode-workspace"), "worktree") + assert.equal(headers.has(LOCATION_CONTEXT_HEADER), false) + const url = new URL("http://localhost/api/form") + url.searchParams.set("location[directory]", ref.directory) + applyLocationContext(url, "GET", undefined, new Headers(options.headers), "legacy") + assert.equal(url.searchParams.get("location[workspace]"), "worktree") + const foreign = new URL("http://localhost/api/form?location[directory]=/other") + assert.throws(() => applyLocationContext(foreign, "GET", undefined, new Headers(options.headers), "legacy"), /does not match/) + assert.throws(() => applyLocationContext(url, "GET", undefined, new Headers(options.headers), "modern"), /Unsupported/) +}) + +test("real generated list, RPC, eviction and credential calls use their distinct legacy wire locations", async () => { + const endpoint = { url: "http://localhost:1234" } + rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" }) + const location = { directory: "/repo", workspaceID: "wrk_one" } + const calls: URL[] = [] + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { + const request = new Request(input, init) + assert.equal(request.headers.has(LOCATION_CONTEXT_HEADER), false) + const url = new URL(request.url) + calls.push(url) + if (url.pathname === "/api/session") { + assert.equal(url.searchParams.get("directory"), "/repo") + assert.equal(url.searchParams.get("workspace"), "wrk_one") + assert.equal(url.searchParams.has("location[directory]"), false) + return Response.json({ data: [], cursor: {} }) + } + assert.equal(url.searchParams.get("location[directory]"), "/repo") + assert.equal(url.searchParams.get("location[workspace]"), "wrk_one") + return url.pathname.startsWith("/api/rpc/") ? Response.json({ output: true }) : new Response(null, { status: 204 }) + }) }) + const options = locationRequestOptions(location) + await client.session.list({ directory: location.directory }, options) + await client.rpc.call({ rpcID: "fixture", method: "check", input: {}, location: { directory: location.directory } }, options) + await client.debug.location.evict({ location: { directory: location.directory } }, options) + await client.credential.remove({ credentialID: "credential" }, options) + assert.equal(calls.length, 4) + await assert.rejects(client.session.list({ cursor: "native-cursor" }, options)) + assert.equal(calls.length, 4, "conflicting cursor context never dispatches") +}) + +test("directory-only credential context is native-scoped on legacy and global on modern", async () => { + for (const version of ["2.0.3", "2.0.4"]) { + const endpoint = { url: "http://localhost:1234" } + rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" }) + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { + const request = new Request(input, init) + assert.equal(request.headers.has(LOCATION_CONTEXT_HEADER), false) + assert.equal(new URL(request.url).search, version === "2.0.3" ? "?location%5Bdirectory%5D=%2Frepo" : "") + return new Response(null, { status: 204 }) + }) }) + await client.credential.remove({ credentialID: "credential" }, locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })) + } +}) diff --git a/packages/server/src/opencode/compatibility/location.ts b/packages/server/src/opencode/compatibility/location.ts new file mode 100644 index 000000000..e7cdb03de --- /dev/null +++ b/packages/server/src/opencode/compatibility/location.ts @@ -0,0 +1,80 @@ +import type { LocationRef, OpenCodeClient } from "@opencode/client" +import type { ContractProfile } from "./runtime" +import { readLocationRef } from "../session-pruning/location" +export { readLocationRef, sameLocation } from "../session-pruning/location" + +// An explicit CodeNomad context channel survives the generated modern client's +// field selection. Never forward this header to OpenCode unchanged. The guarded +// proxy must authorize its complete location before transport serialization. +export const LOCATION_CONTEXT_HEADER = "x-codenomad-location" + +export function locationRequestOptions(location: LocationRef, options?: { includeDirectory?: boolean }): { headers: Record } | undefined { + const resolved = readLocationRef(location) + return resolved.workspaceID === undefined && !options?.includeDirectory ? undefined : { + headers: { [LOCATION_CONTEXT_HEADER]: encodeURIComponent(JSON.stringify(resolved)) }, + } +} + +export function readLocationContext(value: string | string[] | undefined, profile: ContractProfile): LocationRef | undefined { + if (value === undefined) return undefined + if (typeof value !== "string" || value.length > 16_384) throw new Error("Invalid location context") + const input: unknown = JSON.parse(decodeURIComponent(value)) + if (!input || typeof input !== "object" || Object.keys(input).some(key => key !== "directory" && key !== "workspaceID")) { + throw new Error("Invalid location context") + } + const location = readLocationRef(input) + if (location.workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + return location +} + +export async function moveSessionToLocation(client: OpenCodeClient, sessionID: string, location: LocationRef): Promise { + await client.session.move({ sessionID, directory: location.directory }, locationRequestOptions(location)) +} + +// The selected transport calls this AFTER authorization. Only the explicitly +// supported wire slots are adapted; this is not a general header-to-body merge. +export function applyLocationContext(url: URL, method: string, body: unknown, headers: Headers, profile: ContractProfile): unknown { + const location = readLocationContext(headers.get(LOCATION_CONTEXT_HEADER) ?? undefined, profile) + headers.delete(LOCATION_CONTEXT_HEADER) + if (!location) return body + const input = body && typeof body === "object" && !Array.isArray(body) ? body as Record : undefined + const assertDirectory = (directory: unknown) => { + if (directory !== location.directory) throw new Error("Location context does not match request") + } + if (method === "POST" && /^\/api\/session\/[^/]+\/move\/?$/.test(url.pathname)) { + assertDirectory(input?.directory) + if (input?.workspaceID !== undefined && input.workspaceID !== location.workspaceID) throw new Error("Conflicting workspace selector") + return { ...input, workspaceID: location.workspaceID } + } + if (method === "POST" && /^\/api\/(?:experimental\/)?session(?:\/import)?\/?$/.test(url.pathname)) { + const destination = readLocationRef(input?.location) + assertDirectory(destination.directory) + if (destination.workspaceID !== undefined && destination.workspaceID !== location.workspaceID) throw new Error("Conflicting workspace selector") + return { ...input, location: { ...destination, workspaceID: location.workspaceID } } + } + if (/^\/api\/session\/global\/form(?:\/[^/]+(?:\/(?:reply|cancel|state))?)?\/?$/.test(url.pathname)) { + assertDirectory(decodeURIComponent(headers.get("x-opencode-directory") ?? "")) + if (location.workspaceID !== undefined) headers.set("x-opencode-workspace", location.workspaceID) + return body + } + if (method === "GET" && /^\/api\/session\/?$/.test(url.pathname)) { + // The cursor is the complete native query, not a location selector to amend. + if (url.searchParams.has("cursor")) throw new Error("Cursor already carries session list scope") + assertDirectory(url.searchParams.get("directory")) + if (url.searchParams.has("workspace")) throw new Error("Conflicting workspace selector") + if (location.workspaceID !== undefined) url.searchParams.set("workspace", location.workspaceID) + return body + } + if (/^\/api\/credential\/[^/]+(?:\/activate)?\/?$/.test(url.pathname)) { + // Modern credential mutations are global; legacy invalidation is scoped. + // The generated modern client has no location query to preserve for us. + if (profile !== "legacy") return body + if (url.searchParams.has("location[directory]")) assertDirectory(url.searchParams.get("location[directory]")) + url.searchParams.set("location[directory]", location.directory) + } + const directory = url.searchParams.get("location[directory]") + assertDirectory(directory) + if (url.searchParams.has("location[workspace]")) throw new Error("Conflicting workspace selector") + if (location.workspaceID !== undefined) url.searchParams.set("location[workspace]", location.workspaceID) + return body +} diff --git a/packages/server/src/opencode/compatibility/negotiate.test.ts b/packages/server/src/opencode/compatibility/negotiate.test.ts new file mode 100644 index 000000000..d379de7a1 --- /dev/null +++ b/packages/server/src/opencode/compatibility/negotiate.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createRuntimeFetch } from "./transport" +import { rememberRuntime } from "./runtime" +import { legacyContractFixture } from "./contract-fixture" + +test("unknown contracts perform authenticated bounded read negotiation before any mutation", async () => { + for (const kind of ["unauthorized", "unrecognized", "oversized"] as const) { + const endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "opencode", password: "fixture" } } + rememberRuntime(endpoint, { version: "2.0.100", pid: 1, discovery: "status" }) + let calls = 0, cancelled = false + const transport = createRuntimeFetch(endpoint, async (input, init) => { + calls++ + assert.equal(new URL(String(input)).pathname, "/openapi.json") + assert.equal(init?.method ?? "GET", "GET") + assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:fixture").toString("base64")}`) + assert.equal(init?.redirect, "error") + if (kind === "unauthorized") return new Response(null, { status: 401 }) + if (kind === "unrecognized") return Response.json({ paths: {} }) + return new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)) }, + cancel() { cancelled = true }, + })) + }) + await assert.rejects(transport(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' }), /contract|HTTP 401/) + assert.equal(calls, 1, "negotiation failure must never dispatch a mutation") + if (kind === "oversized") assert.equal(cancelled, true) + } +}) + +test("shared negotiation has independent subscriber cancellation and never retries a mutation", async () => { + const endpoint = { url: "http://127.0.0.1:4321" } + rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" }) + let complete!: (response: Response) => void + let negotiationSignal: AbortSignal | null | undefined + const calls: string[] = [] + const transport = createRuntimeFetch(endpoint, async (input, init) => { + const pathname = new URL(String(input)).pathname + calls.push(pathname) + if (pathname === "/openapi.json") { + negotiationSignal = init?.signal + return new Promise(resolve => { complete = resolve }) + } + assert.equal(pathname, "/api/session/s/rename") + return new Response(null, { status: 204 }) + }) + const first = new AbortController(), third = new AbortController() + const a = transport(`${endpoint.url}/api/location`, { signal: first.signal }) + const b = transport(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' }) + const c = transport(`${endpoint.url}/api/location`, { signal: third.signal }) + first.abort() + third.abort() + await assert.rejects(a, { name: "AbortError" }) + await assert.rejects(c, { name: "AbortError" }) + assert.equal(negotiationSignal?.aborted, false) + complete(Response.json(legacyContractFixture)) + assert.equal((await b).status, 204) + assert.deepEqual(calls, ["/openapi.json", "/api/session/s/rename"]) +}) diff --git a/packages/server/src/opencode/compatibility/negotiate.ts b/packages/server/src/opencode/compatibility/negotiate.ts new file mode 100644 index 000000000..b6ce090e2 --- /dev/null +++ b/packages/server/src/opencode/compatibility/negotiate.ts @@ -0,0 +1,64 @@ +import { Service, type Endpoint } from "@opencode/client/service" +import { runtimeIdentity, type ContractProfile } from "./runtime" + +type ObjectValue = Record +function object(value: unknown): ObjectValue { + return value && typeof value === "object" && !Array.isArray(value) ? value : {} +} + +// Unknown release numbers are not a startup/version gate. Read their declared +// HTTP contract before selecting a serializer; never trial a write operation. +export async function negotiateRuntime(endpoint: Endpoint, fetcher: typeof fetch, signal: AbortSignal): Promise> { + const response = await fetcher(new URL("/openapi.json", endpoint.url), { + headers: Service.headers(endpoint), redirect: "error", + signal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]), + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Cannot negotiate OpenCode contract (HTTP ${response.status})`) + } + const reader = response.body?.getReader() + if (!reader) throw new Error("Missing OpenCode contract") + const chunks: Uint8Array[] = [] + let length = 0 + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + length += chunk.value.byteLength + if (length > 8 * 1024 * 1024) throw new Error("OpenCode contract exceeds limit") + chunks.push(chunk.value) + } + } catch (error) { await reader.cancel().catch(() => {}); throw error } + finally { reader.releaseLock() } + const document = object(JSON.parse(Buffer.concat(chunks, length).toString("utf8"))) + const paths = object(document.paths) + const resolve = (input: unknown) => { + const schema = object(input) + const ref = schema.$ref + return typeof ref === "string" && ref.startsWith("#/components/schemas/") + ? object(document.components?.schemas?.[ref.slice("#/components/schemas/".length)]) : schema + } + const has = (path: string, method: string) => Boolean(object(paths[path])[method]) + const body = (path: string, field: string) => { + const operation = object(object(paths[path]).post) + return field in object(resolve(operation.requestBody?.content?.["application/json"]?.schema).properties) + } + const session = "/api/session/{sessionID}" + const inbox = object(resolve(document.components?.schemas?.["Session.Inbox.User"]).properties) + const modern = has(session, "patch") && has("/api/form", "get") + && has(`${session}/form/{formID}`, "delete") && has(`/api/experimental/session/{sessionID}/wait`, "post") + && body(`${session}/fork`, "before") && body(`${session}/command`, "name") + && body(`${session}/permission/{requestID}/reply`, "decision") + && "time" in inbox && !("timeCreated" in inbox) + const legacy = has(`${session}/rename`, "post") && has("/api/form/request", "get") + && has(`${session}/form/{formID}/cancel`, "post") && has(`${session}/wait`, "post") + && body(`${session}/fork`, "boundary") && body(`${session}/command`, "command") + && body(`${session}/permission/{requestID}/reply`, "reply") + && "timeCreated" in inbox && !("time" in inbox) + if (modern === legacy) throw new Error(`Unsupported OpenCode contract: ${runtimeIdentity(endpoint)?.version ?? "unknown"}`) + const profile = modern ? "modern" : "legacy" + const identity = runtimeIdentity(endpoint) + if (identity?.contract) identity.contract.profile = profile + return profile +} diff --git a/packages/server/src/opencode/compatibility/proxy-locations.test.ts b/packages/server/src/opencode/compatibility/proxy-locations.test.ts new file mode 100644 index 000000000..c54339a67 --- /dev/null +++ b/packages/server/src/opencode/compatibility/proxy-locations.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { decodeSessionListScope, prepareLocationImport, readRequestLocations } from "./proxy-locations" + +test("request selectors preserve legacy authority and retain modern rejection", () => { + const url = new URL("http://localhost/api/form?location[directory]=/repo&location[workspace]=one") + assert.deepEqual(readRequestLocations(url, undefined, "/root", "legacy"), { + directories: ["/repo"], locations: [{ directory: "/repo", workspaceID: "one" }], invalid: false, + }) + assert.equal(readRequestLocations(url, undefined, "/root", "modern").invalid, true) + url.searchParams.append("location[workspace]", "two") + assert.equal(readRequestLocations(url, undefined, "/root", "legacy").invalid, true) + for (const body of [{ location: { directory: "/repo", workspaceID: "one" } }, { directory: "/repo", workspaceID: "one" }]) { + const result = readRequestLocations(new URL("http://localhost/api/session"), body, "/root", "legacy") + assert.deepEqual(result.locations, [{ directory: "/repo", workspaceID: "one" }]) + assert.equal(readRequestLocations(new URL("http://localhost/api/session"), body, "/root", "modern").invalid, true) + } +}) + +test("imports authorize each full historical identity without rewriting it", () => { + const body = { + location: { directory: "/root", workspaceID: "root" }, + info: { location: { directory: "/repo", workspaceID: "one" } }, + messages: [{ type: "location-switched", location: { directory: "/repo", workspaceID: "two" }, previous: { location: { directory: "/repo", workspaceID: "one" } } }], + } + const result = prepareLocationImport(body, "/default", "legacy") + assert.equal(result.invalid, false) + assert.deepEqual(result.locations.map(location => location.workspaceID), ["root", "one", "two", "one"]) + assert.deepEqual(result.body, body) + assert.notEqual(result.body, body) + assert.equal(prepareLocationImport(body, "/default", "modern").invalid, true) +}) + +test("cursor parsing preserves native scope while rejecting foreign-schema authority", () => { + const value = { directory: "/repo", workspace: "one", anchor: { id: "s", time: 1, direction: "next" } } + const cursor = Buffer.from(JSON.stringify(value)).toString("base64url") + assert.deepEqual(decodeSessionListScope(cursor, "legacy"), { directory: "/repo", workspaceID: "one" }) + assert.equal(decodeSessionListScope(cursor, "modern"), null) + const withFields = (fields: object) => Buffer.from(JSON.stringify({ ...value, ...fields })).toString("base64url") + assert.equal(decodeSessionListScope(withFields({ project: "other" }), "legacy"), null) + assert.equal(decodeSessionListScope(withFields({ workspaceID: "other" }), "legacy"), null) + assert.equal(decodeSessionListScope(withFields({ directory: undefined }), "legacy"), null) + assert.deepEqual(JSON.parse(Buffer.from(cursor, "base64url").toString()), value, "the native token is not rewritten") +}) diff --git a/packages/server/src/opencode/compatibility/proxy-locations.ts b/packages/server/src/opencode/compatibility/proxy-locations.ts new file mode 100644 index 000000000..dcc2aaf81 --- /dev/null +++ b/packages/server/src/opencode/compatibility/proxy-locations.ts @@ -0,0 +1,116 @@ +import path from "node:path" +import type { LocationRef } from "@opencode/client" +import type { ContractProfile } from "./runtime" +import { readLocationRef } from "./location" + +export interface RequestLocations { + directories: string[] + locations: LocationRef[] + invalid: boolean +} + +function object(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !Buffer.isBuffer(value) +} + +// Call on the canonical request before route translation. Public legacy query +// selectors use "workspace"; stored/body locations use "workspaceID". +export function readRequestLocations(url: URL, body: unknown, defaultDirectory: string, profile: ContractProfile): RequestLocations { + const result: RequestLocations = { directories: [], locations: [], invalid: false } + const add = (directory: unknown, workspaceID?: unknown) => { + try { + const ref = readLocationRef({ directory, workspaceID }) + if (workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + result.directories.push(ref.directory) + result.locations.push(ref) + } catch { result.invalid = true } + } + for (const key of ["directory", "location[directory]", "workspace", "location[workspace]"]) { + if (url.searchParams.getAll(key).length > 1) result.invalid = true + } + if (url.searchParams.has("workspaceID") || url.searchParams.has("location[workspaceID]")) result.invalid = true + for (const [directoryKey, workspaceKey] of [["directory", "workspace"], ["location[directory]", "location[workspace]"]]) { + const directory = url.searchParams.get(directoryKey) ?? undefined + const workspaceID = url.searchParams.get(workspaceKey) ?? undefined + if (workspaceID !== undefined && workspaceKey === "workspace" && url.pathname.replace(/\/$/, "") !== "/api/session") result.invalid = true + if (directory !== undefined || workspaceID !== undefined) add(directory ?? defaultDirectory, workspaceID) + } + if (object(body)) { + if ("workspace" in body) result.invalid = true + if ("directory" in body || "workspaceID" in body) add(body.directory ?? defaultDirectory, body.workspaceID) + if (body.location !== undefined && body.location !== null) { + if (!object(body.location) || "workspace" in body.location) result.invalid = true + else add(body.location.directory ?? defaultDirectory, body.location.workspaceID) + } + } + return result +} + +// Preserve the export/history identities; never rewrite every historical +// location to the import destination. Each is independently authorized by the +// caller and subsequently directory-translated at the existing proxy boundary. +export function prepareLocationImport(body: unknown, directory: string, profile: ContractProfile): RequestLocations & { body: unknown } { + const result: RequestLocations & { body: unknown } = { body, directories: [], locations: [], invalid: false } + if (!object(body)) return { ...result, invalid: true } + const addLocation = (owner: Record, key: string) => { + const input = owner[key] ?? { directory } + if (!object(input) || "workspace" in input) { result.invalid = true; return } + try { + const location = readLocationRef({ ...input, directory: input.directory ?? directory }) + if (location.workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + owner[key] = location + result.directories.push(location.directory) + result.locations.push(location) + } catch { result.invalid = true } + } + // A copy keeps failed authorization from mutating the incoming request. + const input = { ...body } + result.body = input + addLocation(input, "location") + if (object(input.info)) { input.info = { ...input.info }; addLocation(input.info as Record, "location") } + if (Array.isArray(input.messages)) input.messages = input.messages.map((message) => { + if (!object(message) || message.type !== "location-switched") return message + const next = { ...message } + addLocation(next, "location") + if (object(next.previous)) { next.previous = { ...next.previous }; addLocation(next.previous as Record, "location") } + return next + }) + return result +} + +export interface SessionListScope { + directory?: string + project?: string + subpath?: string + workspaceID?: string +} + +export function decodeSessionListScope(cursor: string, profile: ContractProfile): SessionListScope | null { + if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null + try { + const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) + if (!object(value) || value.workspaceID !== undefined) return null + const workspaceID = value.workspace + if (workspaceID !== undefined && (profile !== "legacy" || typeof workspaceID !== "string" || !workspaceID.trim())) return null + const anchor = value.anchor + if (!object(anchor) || typeof anchor.id !== "string" || !anchor.id + || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) + || (anchor.direction !== "previous" && anchor.direction !== "next")) return null + if (value.search !== undefined && typeof value.search !== "string") return null + if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null + if (value.parentID !== undefined && value.parentID !== null && typeof value.parentID !== "string") return null + const legacy = workspaceID === undefined ? {} : { workspaceID: workspaceID as string } + if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { + return { directory: value.directory, ...legacy } + } + if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { + if (value.subpath === undefined) return { project: value.project, ...legacy } + if (typeof value.subpath === "string" && !path.posix.isAbsolute(value.subpath) + && !path.win32.isAbsolute(value.subpath) && !value.subpath.split(/[\\/]/).includes("..")) { + return { project: value.project, subpath: value.subpath, ...legacy } + } + } + // Workspace-only/unscoped tokens cannot prove directory authority. + return null + } catch { return null } +} diff --git a/packages/server/src/opencode/compatibility/proxy.test.ts b/packages/server/src/opencode/compatibility/proxy.test.ts new file mode 100644 index 000000000..7bf533b89 --- /dev/null +++ b/packages/server/src/opencode/compatibility/proxy.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Readable } from "node:stream" +import { setImmediate } from "node:timers/promises" +import Fastify from "fastify" +import { OpenCodeSharedService } from "../../workspaces/opencode-service" +import { forwardRuntimeRequest } from "./proxy" + +test("retiring the actual connection preserves complete and streamed native 401 envelopes", async () => { + for (const delayed of [false, true]) { + const upstream = Fastify() + const envelope = { _tag: "UnauthorizedError", message: "fixture" } + upstream.get("/api/denied", async (_request, reply) => { + reply.code(401) + if (!delayed) return envelope + return reply.type("application/json").send(Readable.from((async function* () { + yield '{"_tag":"UnauthorizedError",' + await setImmediate() + yield '"message":"fixture"}' + })())) + }) + await upstream.listen({ host: "127.0.0.1", port: 0 }) + const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` } + const service = new OpenCodeSharedService() + await service.client({ kind: "lifecycle", identity: "fixture", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) + const connection = await service.acquire() + const app = Fastify() + let releases = 0 + app.get("/fixture", (request, reply) => forwardRuntimeRequest({ + request, reply, url: `${endpoint.url}/api/denied`, body: undefined, headers: {}, + fetch: connection.fetch, invalidate: connection.invalidate, release: () => { releases++ }, + })) + try { + const response = await app.inject("/fixture") + assert.equal(response.statusCode, 401) + assert.deepEqual(response.json(), envelope) + assert.equal(releases, 1) + assert.throws(connection.assertCurrent, /connection changed/) + } finally { await service.shutdown(); await app.close(); await upstream.close() } + } +}) diff --git a/packages/server/src/opencode/compatibility/proxy.ts b/packages/server/src/opencode/compatibility/proxy.ts new file mode 100644 index 000000000..1e8245c71 --- /dev/null +++ b/packages/server/src/opencode/compatibility/proxy.ts @@ -0,0 +1,57 @@ +import { Readable } from "node:stream" +import type { FastifyReply, FastifyRequest } from "fastify" + +export async function forwardRuntimeRequest(args: { + request: FastifyRequest + reply: FastifyReply + url: string + body: unknown + headers: Record + fetch: typeof fetch + release?: () => void + invalidate: () => void +}) { + const controller = new AbortController() + let released = false + let unauthorized = false + const release = () => { + if (released) return + released = true + args.release?.() + args.reply.raw.off("close", disconnect) + // Retiring the connection aborts its fetch bodies. Preserve the received + // native 401 envelope until its body has been consumed by the downstream. + if (unauthorized) args.invalidate() + } + const disconnect = () => { controller.abort(); release() } + args.reply.raw.once("close", disconnect) + if (args.reply.raw.destroyed || args.request.raw.aborted) controller.abort() + try { + const headers = new Headers() + for (const [key, value] of Object.entries(args.headers)) { + if (typeof value === "string") headers.set(key, value) + else if (Array.isArray(value)) for (const item of value) headers.append(key, String(item)) + } + headers.delete("content-length") + const body = args.body === undefined || args.body === null ? undefined + : typeof args.body === "string" ? args.body : Buffer.isBuffer(args.body) ? Uint8Array.from(args.body).buffer : JSON.stringify(args.body) + if (body === undefined) headers.delete("content-type") + const response = await args.fetch(args.url, { method: args.request.method, headers, body, signal: controller.signal }) + unauthorized = response.status === 401 + args.reply.code(response.status) + // Fetch has decompressed the body. Do not relay its original framing, + // cookies, redirect destinations or content-encoding/content-length. + for (const name of ["content-type", "cache-control", "etag", "last-modified"]) { + const value = response.headers.get(name) + if (value !== null) args.reply.header(name, value) + } + if (!response.body) { release(); return args.reply.send() } + const stream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream) + stream.once("end", release).once("close", release).once("error", release) + return args.reply.send(stream) + } catch (error) { + release() + if (!controller.signal.aborted) args.invalidate() + throw error + } +} diff --git a/packages/server/src/opencode/compatibility/requests.ts b/packages/server/src/opencode/compatibility/requests.ts new file mode 100644 index 000000000..cd0831204 --- /dev/null +++ b/packages/server/src/opencode/compatibility/requests.ts @@ -0,0 +1,49 @@ +// Only translate consumed, reviewed operations. The external proxy allowlist is +// checked before reaching this module; translation never expands that allowlist. +export function legacyRequest(url: URL, method: string, body: Record | undefined) { + const original = url.pathname + if (original === "/api/status" && method === "GET") url.pathname = "/api/health" + if (/^\/api\/experimental\/session\/(?:stats|import|[^/]+\/(?:export|wait|instructions\/entries(?:\/[^/]+)?))$/.test(original) + || /^\/api\/experimental\/mcp\/[^/]+\/(?:connect|disconnect)$/.test(original)) { + url.pathname = original.replace("/experimental", "") + } + if (original === "/api/form" && method === "GET") url.pathname = "/api/form/request" + if (/^\/api\/session\/[^/]+$/.test(original) && method === "PATCH") { + if (!body || typeof body.title !== "string" || Object.keys(body).some(key => key !== "title")) { + throw new Error("Earlier OpenCode V2 supports title-only session updates") + } + url.pathname += "/rename" + method = "POST" + } + if (/^\/api\/session\/[^/]+\/fork$/.test(original) && method === "POST") { + if (body?.before !== undefined && typeof body.before !== "string") throw new Error("Invalid fork boundary") + body = { boundary: body?.before === undefined ? { type: "through" } : { type: "before", messageID: body.before } } + } + if (/^\/api\/session\/[^/]+\/command$/.test(original) && method === "POST" && body) { + const { name, ...rest } = body + body = { ...rest, command: name } + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(original) && method === "POST" && body) { + const { decision, ...rest } = body + body = { ...rest, reply: decision } + } + if (/^\/api\/session\/[^/]+\/interrupt$/.test(original) && method === "POST" && url.searchParams.has("resume")) { + url.searchParams.set("continue", url.searchParams.get("resume")!) + url.searchParams.delete("resume") + } + if (/^\/api\/session\/[^/]+\/inbox\/[^/]+$/.test(original) && method === "PATCH") { + if (body?.delivery !== "steer" && body?.delivery !== "queue") throw new Error("Invalid inbox delivery") + url.pathname += `/${body.delivery}` + method = "POST" + body = undefined + } + if (/^\/api\/session\/[^/]+\/revert$/.test(original) && method === "DELETE") { + url.pathname += "/clear" + method = "POST" + } + if (/^\/api\/session\/[^/]+\/form\/[^/]+$/.test(original) && method === "DELETE") { + url.pathname += "/cancel" + method = "POST" + } + return { url, method, body } +} diff --git a/packages/server/src/opencode/compatibility/runtime.ts b/packages/server/src/opencode/compatibility/runtime.ts new file mode 100644 index 000000000..5354518de --- /dev/null +++ b/packages/server/src/opencode/compatibility/runtime.ts @@ -0,0 +1,33 @@ +import type { Endpoint } from "@opencode/client/service" + +export interface RuntimeIdentity { + version: string + pid: number + discovery: "status" | "health" + /** Filled only by a successful, authenticated schema negotiation. */ + contract?: { profile?: Exclude } +} + +// Keep authenticated daemon metadata out of public endpoints and credentials. +const RUNTIME_IDENTITY = Symbol.for("codenomad.opencode.runtime") +export function rememberRuntime(endpoint: Endpoint, identity: RuntimeIdentity): void { + Object.defineProperty(endpoint, RUNTIME_IDENTITY, { value: Object.freeze({ ...identity, contract: {} }) }) +} +export function runtimeIdentity(endpoint: Endpoint): RuntimeIdentity | undefined { + return (endpoint as Endpoint & { [RUNTIME_IDENTITY]?: RuntimeIdentity })[RUNTIME_IDENTITY] +} + +export type ContractProfile = "modern" | "legacy" | "unknown" +export function contractProfile(identity: RuntimeIdentity | undefined): ContractProfile { + // Custom embedded/test lifecycles that don't negotiate use the pinned contract. + if (!identity) return "modern" + if (identity.contract?.profile) return identity.contract.profile + if (/^2\.0\.[0-3]$/.test(identity.version)) return "legacy" + if (/^2\.0\.[45]$/.test(identity.version)) return "modern" + const beta = /^0\.0\.0-beta-(\d+)$/.exec(identity.version)?.[1] + const audited = new Set([18866, 18955, 18965, 18985, 18992, 18999, 19059, 19086, + 19124, 19129, 19133, 19135, 19151, 19157, 19187, 19192, 19215, 19213, + 19228, 19234, 19242, 19266, 19271, 19275, 19278, 19283, 19288, 19289, + 19296, 19365, 19378, 19381, 19398, 19419, 19422, 19425, 19500, 19507]) + return beta && audited.has(Number(beta)) ? "legacy" : "unknown" +} diff --git a/packages/server/src/opencode/compatibility/transport.test.ts b/packages/server/src/opencode/compatibility/transport.test.ts new file mode 100644 index 000000000..263ceff68 --- /dev/null +++ b/packages/server/src/opencode/compatibility/transport.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import { test } from "node:test" +import { OpenCode } from "@opencode/client" +import type { Endpoint } from "@opencode/client/service" +import { rememberRuntime } from "./runtime" +import { createRuntimeFetch } from "./transport" + +test("the pinned client uses the earlier wire contract and retains admission/list timestamps", async () => { + const seen: Array<{ method: string; path: string; body: unknown }> = [] + const item = { id: "inbox-fixture", sessionID: "s", type: "user", payload: { text: "fixture" }, delivery: "queue", timeCreated: 123 } + const server = createServer(async (req, res) => { + let raw = "" + for await (const data of req) raw += data + assert.equal(req.headers.authorization, `Basic ${Buffer.from("opencode:fixture").toString("base64")}`) + seen.push({ method: req.method!, path: req.url!, body: raw ? JSON.parse(raw) : undefined }) + res.setHeader("content-type", "application/json") + if (req.url === "/api/session/s/prompt") return res.end(JSON.stringify({ data: item })) + if (req.url === "/api/session/s/inbox") return res.end(JSON.stringify({ data: [item] })) + if (req.url === "/api/session/s/fork") return res.end(JSON.stringify({ data: { id: "fork" } })) + if (req.url?.startsWith("/api/session/s/interrupt")) return res.end("{}") + res.writeHead(204).end() + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const endpoint: Endpoint = { url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } } + rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" }) + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint) }) + try { + assert.equal((await client.session.prompt({ sessionID: "s", text: "fixture" })).time.created, 123) + assert.equal((await client.session.inbox.list({ sessionID: "s" }))[0].time.created, 123) + await client.permission.reply({ sessionID: "s", requestID: "p", decision: "once" }) + await client.session.command({ sessionID: "s", name: "test", text: "args", delivery: "queue" }) + await client.session.fork({ sessionID: "s", before: "m" }) + await client.session.interrupt({ sessionID: "s", resume: true }) + await client.session.inbox.update({ sessionID: "s", inboxID: "i", delivery: "steer" }) + await client.session.form.cancel({ sessionID: "s", formID: "f" }) + await client.session.instructions.entry.remove({ sessionID: "s", key: "voice" }) + assert.deepEqual(seen.slice(2), [ + { method: "POST", path: "/api/session/s/permission/p/reply", body: { reply: "once" } }, + { method: "POST", path: "/api/session/s/command", body: { command: "test", text: "args", delivery: "queue" } }, + { method: "POST", path: "/api/session/s/fork", body: { boundary: { type: "before", messageID: "m" } } }, + { method: "POST", path: "/api/session/s/interrupt?continue=true", body: undefined }, + { method: "POST", path: "/api/session/s/inbox/i/steer", body: undefined }, + { method: "POST", path: "/api/session/s/form/f/cancel", body: undefined }, + { method: "DELETE", path: "/api/session/s/instructions/entries/voice", body: undefined }, + ]) + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) } +}) + +test("errors never retry a mutation, unsupported fields are rejected, and abort/auth are preserved", async () => { + const endpoint: Endpoint = { url: "http://127.0.0.1:4321" } + rememberRuntime(endpoint, { version: "2.0.3", pid: 2, discovery: "health" }) + let calls = 0 + for (const status of [400, 401, 404, 500]) { + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status }) }) }) + await assert.rejects(client.session.update({ sessionID: "s", title: "title" })) + } + assert.equal(calls, 4) + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status: 204 }) }) }) + await assert.rejects(client.session.update({ sessionID: "s", title: "title", permissions: [] }), (error: Error) => { + assert.match(String((error as Error & { cause?: unknown }).cause), /title-only/) + return true + }) + assert.equal(calls, 4) + const controller = new AbortController() + controller.abort() + const transport = createRuntimeFetch(endpoint, async (_url, init) => { + assert.equal(init?.redirect, "error") + init?.signal?.throwIfAborted() + throw new Error("unexpected live request") + }) + await assert.rejects(transport(new Request(`${endpoint.url}/api/session/s/wait`, { method: "POST", signal: controller.signal })), { name: "AbortError" }) + await assert.rejects(transport("http://127.0.0.1:4322/api/session"), /origin mismatch/) +}) diff --git a/packages/server/src/opencode/compatibility/transport.ts b/packages/server/src/opencode/compatibility/transport.ts new file mode 100644 index 000000000..c9c364b46 --- /dev/null +++ b/packages/server/src/opencode/compatibility/transport.ts @@ -0,0 +1,101 @@ +import { Service, type Endpoint } from "@opencode/client/service" +import { contractProfile, runtimeIdentity } from "./runtime" +import { legacyRequest } from "./requests" +import { negotiateRuntime } from "./negotiate" +import { applyLocationContext, LOCATION_CONTEXT_HEADER } from "./location" + +function object(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function normalizeInbox(value: unknown): unknown { + if (!object(value)) throw new Error("Invalid native inbox item") + if (object(value.time) && typeof value.time.created === "number") return value + if (typeof value.timeCreated !== "number" || !Number.isFinite(value.timeCreated)) { + throw new Error("Invalid native inbox creation time") + } + const { timeCreated, ...rest } = value + return { ...rest, time: { created: timeCreated } } +} + +// Both the server's generated client and the authorized browser proxy use this +// transport. No error classification here retries a mutation. +export function createRuntimeFetch(endpoint: Endpoint, fetcher: typeof fetch = globalThis.fetch): typeof fetch { + return createRuntimeTransport(endpoint, fetcher).fetch +} + +export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch = globalThis.fetch, lifetime = new AbortController().signal) { + const identity = runtimeIdentity(endpoint) + let profile = contractProfile(identity) + let negotiation: Promise<"modern" | "legacy"> | undefined + const resolveProfile = async (signal?: AbortSignal) => { + signal?.throwIfAborted() + lifetime.throwIfAborted() + if (profile !== "unknown") return profile + negotiation ??= negotiateRuntime(endpoint, fetcher, lifetime).then(value => { + profile = value + return value + }).catch(error => { negotiation = undefined; throw error }) + return awaitLocal(negotiation, signal) + } + const adaptedFetch: typeof fetch = async (input, init) => { + const request = new Request(input, init) + const url = new URL(request.url) + if (url.origin !== new URL(endpoint.url).origin) throw new Error("OpenCode transport origin mismatch") + const profile = await resolveProfile(request.signal) + const headers = new Headers(request.headers) + for (const [name, value] of Object.entries(Service.headers(endpoint) ?? {})) headers.set(name, value) + // Credentialed native requests must never follow a redirect to another host. + const options: RequestInit = { method: request.method, headers, signal: AbortSignal.any([request.signal, lifetime]), redirect: "error" } + const originalPath = url.pathname + if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) { + const text = request.body ? await request.text() : undefined + let body: unknown = text ? JSON.parse(text) : undefined + body = applyLocationContext(url, request.method, body, headers, profile) + if (body !== undefined && !object(body)) throw new Error("Invalid OpenCode request body") + const translated = profile === "legacy" ? legacyRequest(url, request.method, body) : { method: request.method, body } + options.method = translated.method + options.body = translated.body === undefined ? undefined : JSON.stringify(translated.body) + headers.delete("content-length") + if (options.body === undefined) headers.delete("content-type") + } else if (request.body) { + options.body = await request.arrayBuffer() + } + const response = await fetcher(url, options) + if (!response.ok && !response.headers.get("content-type")?.includes("application/json")) { + await response.body?.cancel() + return Response.json({ + _tag: "OpenCodeContractError", message: `OpenCode ${identity?.version ?? profile}: ${request.method} ${originalPath} returned HTTP ${response.status}`, + status: response.status, profile, method: request.method, path: originalPath, + }, { status: response.status }) + } + if (profile === "legacy" && response.ok && originalPath === "/api/status") { + const health = await response.json() as { version: string; pid: number } + return Response.json({ version: health.version, pid: health.pid, urls: [endpoint.url] }) + } + if (profile !== "legacy" || !response.ok || !/^\/api\/session\/[^/]+\/(?:inbox|prompt|synthetic|compact)$/.test(originalPath) + || response.status === 204) return response + const value: unknown = await response.json() + if (!object(value)) throw new Error("Invalid native inbox response") + const normalized = { ...value, data: Array.isArray(value.data) ? value.data.map(normalizeInbox) : normalizeInbox(value.data) } + const responseHeaders = new Headers(response.headers) + responseHeaders.delete("content-length") + responseHeaders.delete("content-encoding") + return new Response(JSON.stringify(normalized), { status: response.status, headers: responseHeaders }) + } + return { fetch: adaptedFetch, profile: resolveProfile } +} + +// A cancelled subscriber must not cancel the connection's negotiation or wait +// for another subscriber's request to finish before observing its own abort. +function awaitLocal(pending: Promise, signal?: AbortSignal): Promise { + if (!signal) return pending + return new Promise((resolve, reject) => { + const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason) } + if (signal.aborted) { reject(signal.reason); return } + signal.addEventListener("abort", abort, { once: true }) + pending.then(value => { signal.removeEventListener("abort", abort); resolve(value) }, error => { + signal.removeEventListener("abort", abort); reject(error) + }) + }) +} diff --git a/packages/server/src/opencode/session-pruning/location.ts b/packages/server/src/opencode/session-pruning/location.ts new file mode 100644 index 000000000..632a4ce21 --- /dev/null +++ b/packages/server/src/opencode/session-pruning/location.ts @@ -0,0 +1,16 @@ +import type { LocationRef } from "@opencode/client" + +// Keep identity primitives inside the standalone plugin's package boundary. +// The HTTP compatibility adapter reuses these without making the plugin depend on it. +export function readLocationRef(value: unknown): LocationRef { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid location") + const input = value as Record + if (typeof input.directory !== "string" || !input.directory.trim() || input.directory.includes("\0") + || (input.workspaceID !== undefined && (typeof input.workspaceID !== "string" + || !input.workspaceID.trim() || input.workspaceID.includes("\0")))) throw new Error("Invalid location") + return { directory: input.directory, ...(input.workspaceID === undefined ? {} : { workspaceID: input.workspaceID }) } +} + +export function sameLocation(left: LocationRef, right: LocationRef): boolean { + return left.directory === right.directory && left.workspaceID === right.workspaceID +} diff --git a/packages/server/src/opencode/session-pruning/package.json b/packages/server/src/opencode/session-pruning/package.json index 5b5783020..463468371 100644 --- a/packages/server/src/opencode/session-pruning/package.json +++ b/packages/server/src/opencode/session-pruning/package.json @@ -9,12 +9,12 @@ "./tui": "./tui.ts" }, "files": [ - "index.ts", "claim-fence.ts", "contract.ts", "database-path.ts", "planner.ts", "plugin.ts", + "index.ts", "claim-fence.ts", "contract.ts", "database-path.ts", "location.ts", "planner.ts", "plugin.ts", "preview-store.ts", "revision.ts", "service.ts", "storage-path.ts", "transaction.ts", "tui.ts", "tui-reload.ts" ], "dependencies": { - "@opencode/plugin": "beta", + "@opencode/plugin": "2.0.4", "zod": "^3.25.76" } } diff --git a/packages/server/src/opencode/session-pruning/package.test.ts b/packages/server/src/opencode/session-pruning/package.test.ts new file mode 100644 index 000000000..b59d3b835 --- /dev/null +++ b/packages/server/src/opencode/session-pruning/package.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" +import { fileURLToPath } from "node:url" +import { build } from "esbuild" + +test("every packed plugin entrypoint resolves without sibling checkout sources", async () => { + const source = fileURLToPath(new URL("./", import.meta.url)) + const temporaryRoot = path.join(os.tmpdir(), "opencode") + await mkdir(temporaryRoot, { recursive: true }) + const isolated = await mkdtemp(path.join(temporaryRoot, "pruning-package-")) + try { + const output = execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", [ + "pack", "--dry-run", "--json", "--ignore-scripts", + ], { cwd: source, encoding: "utf8", shell: process.platform === "win32" }) + const [archive] = JSON.parse(output) as { files: { path: string }[] }[] + for (const file of archive.files) { + const destination = path.join(isolated, file.path) + await mkdir(path.dirname(destination), { recursive: true }) + await copyFile(path.join(source, file.path), destination) + } + const manifest = JSON.parse(await readFile(path.join(isolated, "package.json"), "utf8")) + const result = await build({ + absWorkingDir: isolated, + entryPoints: Object.values(manifest.exports) as string[], + outdir: "dist", bundle: true, write: false, platform: "node", format: "esm", + packages: "external", logLevel: "silent", + }) + assert.equal(result.outputFiles.length, Object.keys(manifest.exports).length) + } finally { + await rm(isolated, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/opencode/session-pruning/plugin.ts b/packages/server/src/opencode/session-pruning/plugin.ts index 80f48072b..758061b57 100644 --- a/packages/server/src/opencode/session-pruning/plugin.ts +++ b/packages/server/src/opencode/session-pruning/plugin.ts @@ -5,6 +5,7 @@ import { previewContent } from "./planner" import { readPruningPreview } from "./preview-store" import { pruneBoundMessage } from "./service" import { pruningDatabasePath } from "./database-path" +import { readLocationRef, sameLocation } from "./location" export const SessionPruningRpc = Rpc.define(pruningRpcDefinition) @@ -18,10 +19,13 @@ export default Plugin.define({ const session = await ctx.session.get({ sessionID: target.sessionID }) // Location membership must be checked again inside the plugin: clients // other than CodeNomad may invoke this RPC directly. - if (session.location.directory !== ctx.location.directory) return { status: "blocked", reason: "not_deletable" } as const + const location = readLocationRef(session.location) + if (!sameLocation(location, readLocationRef(ctx.location)) || session.projectID !== ctx.location.project.id) { + return { status: "blocked", reason: "not_deletable" } as const + } // Plugin Context does not expose session.message in beta-19398. Read // the explicit DB in query-only mode, including pre-compaction history. - const data = await readPruningPreview(pruningDatabasePath(ctx.options.databasePath, ctx.app.channel), target, session.location.directory) + const data = await readPruningPreview(pruningDatabasePath(ctx.options.databasePath, ctx.app.channel), target, location, session.projectID) const preview = data ? previewContent(data) : { status: "blocked", reason: "unavailable" } as const return preview.status === "preview" ? { ...preview, liveMutation: true } diff --git a/packages/server/src/opencode/session-pruning/preview-store.ts b/packages/server/src/opencode/session-pruning/preview-store.ts index 02bf165a0..e6dc10b05 100644 --- a/packages/server/src/opencode/session-pruning/preview-store.ts +++ b/packages/server/src/opencode/session-pruning/preview-store.ts @@ -2,9 +2,10 @@ import path from "node:path" import type { z } from "zod" import type { messageTargetSchema } from "./contract" import { storageDirectory } from "./storage-path" +import type { LocationRef } from "@opencode/client" export async function readPruningPreview( - filename: unknown, target: z.infer, directory: string, + filename: unknown, target: z.infer, location: LocationRef, projectID: string, ): Promise { // Deliberately no default ~/.local/share path or UI-supplied override. if (typeof filename !== "string" || !path.isAbsolute(filename)) return undefined @@ -15,7 +16,9 @@ export async function readPruningPreview( const row = db.prepare(`SELECT m.data FROM session_message m JOIN session_v2 s ON s.id = m.session_id WHERE m.id = ? AND m.session_id = ? AND m.type = 'assistant' AND s.directory = ? - AND length(CAST(m.data AS BLOB)) <= 16777216`).get(target.messageID, target.sessionID, storageDirectory(directory)) + AND s.project_id = ? AND s.workspace_id IS ? + AND length(CAST(m.data AS BLOB)) <= 16777216`).get(target.messageID, target.sessionID, + storageDirectory(location.directory), projectID, location.workspaceID ?? null) return typeof row?.data === "string" ? JSON.parse(row.data) : undefined } finally { db.close() } } diff --git a/packages/server/src/opencode/session-pruning/pruning.test.ts b/packages/server/src/opencode/session-pruning/pruning.test.ts index c80806c4e..51971a0e5 100644 --- a/packages/server/src/opencode/session-pruning/pruning.test.ts +++ b/packages/server/src/opencode/session-pruning/pruning.test.ts @@ -20,10 +20,10 @@ const data = { content, time: { created: 1, completed: 3 }, tokens: { input: 123 const input = (indexes = [0, 1]) => ({ sessionID: "s", messageID: "m", revision: revision(content), indexes }) function fixture(filename = ":memory:") { const db = new DatabaseSync(filename) - db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY, directory TEXT); + db.exec(`CREATE TABLE session_v2(id TEXT PRIMARY KEY, directory TEXT, project_id TEXT, workspace_id TEXT); CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT); CREATE TABLE event(aggregate_id TEXT,data TEXT); - INSERT INTO session_v2 VALUES ('s','/work');`) + INSERT INTO session_v2 VALUES ('s','/work','p',NULL);`) db.prepare("INSERT INTO session_message VALUES ('m','s','assistant',7,?)").run(JSON.stringify(data)) return db } @@ -133,9 +133,11 @@ test("refuses file-backed writes, but supports explicit read-only preview", asyn const db = fixture(filename) try { assert.deepEqual(pruneIsolatedMessage(db, input(), "/work"), { status: "blocked", reason: "maintenance_required" }) - assert.deepEqual(await readPruningPreview(filename, input(), "/work"), data) - assert.equal(await readPruningPreview(filename, input(), "/other"), undefined) - assert.equal(await readPruningPreview(undefined, input(), "/work"), undefined) + assert.deepEqual(await readPruningPreview(filename, input(), { directory: "/work" }, "p"), data) + assert.equal(await readPruningPreview(filename, input(), { directory: "/other" }, "p"), undefined) + assert.equal(await readPruningPreview(filename, input(), { directory: "/work", workspaceID: "foreign" }, "p"), undefined) + assert.equal(await readPruningPreview(filename, input(), { directory: "/work" }, "foreign"), undefined) + assert.equal(await readPruningPreview(undefined, input(), { directory: "/work" }, "p"), undefined) assert.deepEqual(stored(db), data) } finally { db.close(); await rm(dir, { recursive: true, force: true }) } }) diff --git a/packages/server/src/opencode/session-pruning/service.test.ts b/packages/server/src/opencode/session-pruning/service.test.ts index c12b71b17..af930f59f 100644 --- a/packages/server/src/opencode/session-pruning/service.test.ts +++ b/packages/server/src/opencode/session-pruning/service.test.ts @@ -12,7 +12,8 @@ import { revision } from "./planner" // Exercise the real RPC handlers, storage challenge and file-backed transaction. // No shared service, user configuration or real conversation is involved. for (const version of ["0.0.0-beta-19425", "future-release", undefined]) { - test(`pruning uses storage capabilities, not runtime version ${version}`, async () => { + for (const workspaceID of [undefined, "legacy-native-workspace"]) { + test(`pruning uses storage capabilities, not runtime version ${version}, workspace ${workspaceID}`, async () => { const root = await mkdtemp(path.join(os.tmpdir(), "pruning-capabilities-")) const filename = path.join(root, "fixture.db") const db = new DatabaseSync(filename) @@ -29,13 +30,14 @@ for (const version of ["0.0.0-beta-19425", "future-release", undefined]) { CREATE TABLE event(aggregate_id TEXT); CREATE TABLE kv(key TEXT PRIMARY KEY,value TEXT,time_created INTEGER,time_updated INTEGER); CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,data TEXT);`) - db.prepare("INSERT INTO session_v2 VALUES('s',?,'p',NULL,NULL,NULL,NULL)").run(storageDirectory(root)) + db.prepare("INSERT INTO session_v2 VALUES('s',?,'p',?,NULL,NULL,NULL)").run(storageDirectory(root), workspaceID ?? null) db.prepare("INSERT INTO session_message VALUES('m','s','assistant',?)").run(JSON.stringify(data)) + const location = { directory: root, workspaceID, project: { id: "p" } } cleanup = await plugin.setup({ app: { version }, options: { databasePath: filename }, - location: { directory: root, project: { id: "p" } }, - session: { get: async () => ({ location: { directory: root }, projectID: "p" }) }, + location, + session: { get: async () => ({ location: { directory: root, workspaceID }, projectID: "p" }) }, storage: { set: async (key: string, value: unknown) => { db.prepare("INSERT INTO kv VALUES(?,?,1,1)").run(storageKey(key), JSON.stringify(value)) @@ -49,6 +51,15 @@ for (const version of ["0.0.0-beta-19425", "future-release", undefined]) { } as any) assert.equal((await handlers.preview({ sessionID: "s", messageID: "m" })).liveMutation, true) const call = { signal: new AbortController().signal } + location.workspaceID = "foreign-context" + assert.deepEqual(await handlers.preview({ sessionID: "s", messageID: "m" }), { status: "blocked", reason: "not_deletable" }) + assert.deepEqual(await handlers.prune(input, call), { status: "blocked", reason: "not_deletable" }) + assert.equal(db.prepare("SELECT count(*) AS n FROM kv").get()!.n, 0) + location.workspaceID = workspaceID + db.exec("UPDATE session_v2 SET workspace_id='foreign-storage'") + assert.deepEqual(await handlers.preview({ sessionID: "s", messageID: "m" }), { status: "blocked", reason: "unavailable" }) + assert.deepEqual(await handlers.prune(input, call), { status: "blocked", reason: "not_deletable" }) + db.prepare("UPDATE session_v2 SET workspace_id=?").run(workspaceID ?? null) // Native storage capabilities still fail closed irrespective of the label. db.exec("ALTER TABLE session_v2 RENAME COLUMN time_suspended TO missing_claim") assert.deepEqual(await handlers.prune(input, call), { status: "blocked", reason: "unsupported_storage" }) @@ -70,4 +81,5 @@ for (const version of ["0.0.0-beta-19425", "future-release", undefined]) { await rm(root, { recursive: true, force: true }) } }) + } } diff --git a/packages/server/src/opencode/session-pruning/service.ts b/packages/server/src/opencode/session-pruning/service.ts index 9b1e328f5..e4e50b113 100644 --- a/packages/server/src/opencode/session-pruning/service.ts +++ b/packages/server/src/opencode/session-pruning/service.ts @@ -6,6 +6,7 @@ import { storageKey, validateClaimFence } from "./claim-fence" import { pruneTransaction } from "./transaction" import { pruneRequestSchema, type PruneResult } from "./contract" import { pruningDatabasePath } from "./database-path" +import { readLocationRef, sameLocation } from "./location" // Only an explicit pruning RPC calls this service; loading the plugin never prunes. // No database access is exposed through CodeNomad's HTTP broker. @@ -19,8 +20,10 @@ export async function pruneBoundMessage( return { status: "blocked", reason: "unsupported_storage" } } const session = await ctx.session.get({ sessionID: request.sessionID }) - if (session.location.directory !== ctx.location.directory || session.projectID !== ctx.location.project.id - || session.location.workspaceID !== ctx.location.workspaceID) return { status: "blocked", reason: "not_deletable" } + const location = readLocationRef(session.location) + if (!sameLocation(location, readLocationRef(ctx.location)) || session.projectID !== ctx.location.project.id) { + return { status: "blocked", reason: "not_deletable" } + } const filename = await realpath(configured) const { DatabaseSync } = await import("node:sqlite") const key = `pruning/binding/${randomUUID()}` @@ -37,8 +40,7 @@ export async function pruneBoundMessage( try { return pruneTransaction(db, request, () => validateClaimFence(db, request.sessionID, { key: storageKey(key), nonce, - directory: session.location.directory, projectID: session.projectID, - workspaceID: session.location.workspaceID, + directory: location.directory, workspaceID: location.workspaceID, projectID: session.projectID, }), storageKey("pruning/receipt/")) } catch (error) { const code = (error as { errcode?: number; code?: string }).errcode diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index 8671b1d63..cda5a4cd4 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient } from "@opencode-ai/client" +import type { OpenCodeClient } from "@opencode/client" import type { WorkspaceManager } from "../workspaces/manager" import { createOpencodePermissionReplier } from "./opencode-replier" @@ -15,7 +15,7 @@ describe("createOpencodePermissionReplier", () => { const workspaceManager = { get: () => ({ path: "/repo" }), getSharedServiceClient: async () => client, - ownsDirectory: async () => false, + ownsLocation: async () => false, } as unknown as WorkspaceManager const replier = createOpencodePermissionReplier({ workspaceManager }) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 06a5f9970..9a7aae81d 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -18,14 +18,14 @@ export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): Perm } const session = await client.session.get({ sessionID: reply.sessionId }) - if (!(await deps.workspaceManager.ownsDirectory(reply.instanceId, session.location.directory))) { + if (!(await deps.workspaceManager.ownsLocation(reply.instanceId, session.location))) { throw new Error(`Yolo: session ${reply.sessionId} does not belong to workspace ${reply.instanceId}`) } await client.permission.reply({ sessionID: reply.sessionId, requestID: reply.permissionId, - reply: "once", + decision: "once", }) } } diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index a71709988..1729dee42 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient } from "@opencode-ai/client" +import type { OpenCodeClient } from "@opencode/client" import type { SettingsService } from "../settings/service" import type { WorkspaceManager } from "../workspaces/manager" diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index eee343f62..f67de2a78 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -1,4 +1,4 @@ -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" import type { SettingsService } from "../settings/service" import type { WorkspaceManager } from "../workspaces/manager" import { createInstanceClient } from "../workspaces/instance-client" diff --git a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts new file mode 100644 index 000000000..8c2faed52 --- /dev/null +++ b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import Fastify from "fastify" +import type { OpenCodeClient } from "@opencode/client" +import type { Endpoint } from "@opencode/client/service" +import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" +import { createRuntimeFetch } from "../../opencode/compatibility/transport" +import { rememberRuntime } from "../../opencode/compatibility/runtime" +import { LOCATION_CONTEXT_HEADER, locationRequestOptions } from "../../opencode/compatibility/location" +import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { OpenCodeSharedService } from "../../workspaces/opencode-service" +import { legacyContractFixture } from "../../opencode/compatibility/contract-fixture" + +test("legacy authority precedes translation for Forms, session writes, cursors and imported history", async () => { + const upstream = Fastify() + let forwarded = 0 + upstream.all("/*", async request => { forwarded++; return { method: request.method, url: request.url, body: request.body, headers: request.headers } }) + await upstream.listen({ host: "127.0.0.1", port: 0 }) + const endpoint: Endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } } + rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" }) + const client = { session: { get: async ({ sessionID }: { sessionID: string }) => ({ location: { directory: "/native", workspaceID: sessionID === "foreign" ? "two" : "one" } }) } } as unknown as OpenCodeClient + const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent() {}, invalidate() {}, profile: async () => "legacy" as const } + const directories = new Set(["/host", "/native"]) + const manager: InstanceProxyWorkspaceManager = { + get: () => ({ id: "w", path: "/host" }) as never, + getSharedServiceEndpoint: async () => { throw new Error("Pinned request must not reacquire endpoint") }, + getSharedServiceConnection: async () => connection, + getSharedServiceClient: async () => { throw new Error("Pinned request must not reacquire client") }, + getInstanceAuthorizationHeader: () => undefined, + getServiceDirectory: () => "/native", + getServiceDirectoryForPath: async (_id, directory) => directories.has(directory) ? "/native" : undefined, + getWorktreeIdentityForPath: async (_id, directory) => directories.has(directory) ? "root" : undefined, + ownsDirectory: async (_id, directory) => directories.has(directory), ownsPath: async () => false, + ownsLocation: async (_id, location, pinned) => { + assert.equal(pinned, client) + return directories.has(location.directory) && (location.workspaceID === undefined || location.workspaceID === "one") + }, + } + const app = Fastify() + registerInstanceProxyRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence(), logger: { debug() {}, error() {}, isLevelEnabled: () => false } as never }) + const prefix = "/workspaces/w/instance" + const context = (workspaceID: string) => locationRequestOptions({ directory: "/host", workspaceID })!.headers + try { + const form = await app.inject({ method: "DELETE", url: `${prefix}/api/session/global/form/f`, headers: { ...context("one"), "x-opencode-directory": encodeURIComponent("/host"), cookie: "browser=secret" } }) + assert.equal(form.statusCode, 200, form.body) + assert.equal(form.json().url, "/api/session/global/form/f/cancel") + assert.equal(form.json().method, "POST") + assert.equal(form.json().headers["x-opencode-directory"], encodeURIComponent("/native")) + assert.equal(form.json().headers["x-opencode-workspace"], "one") + assert.equal(form.json().headers[LOCATION_CONTEXT_HEADER], undefined) + assert.equal(form.json().headers.cookie, undefined) + const create = await app.inject({ method: "POST", url: `${prefix}/api/session`, headers: context("one"), payload: { location: { directory: "/host" } } }) + assert.equal(create.statusCode, 200, create.body) + assert.deepEqual(create.json().body.location, { directory: "/native", workspaceID: "one" }) + const cursor = (workspace: string) => Buffer.from(JSON.stringify({ directory: "/native", workspace, anchor: { id: "s", time: 1, direction: "next" } })).toString("base64url") + const listed = await app.inject(`${prefix}/api/session?cursor=${cursor("one")}&workspace=ignored&directory=/foreign`) + assert.equal(listed.statusCode, 200, listed.body) + assert.equal(new URL(listed.json().url, endpoint.url).searchParams.get("cursor"), cursor("one")) + assert.equal(new URL(listed.json().url, endpoint.url).searchParams.has("workspace"), false) + const before = forwarded + for (const request of [ + { method: "DELETE" as const, url: `${prefix}/api/session/global/form/f`, headers: { ...context("two"), "x-opencode-directory": encodeURIComponent("/host") } }, + { method: "PATCH" as const, url: `${prefix}/api/session/foreign`, payload: { title: "forbidden" } }, + { method: "GET" as const, url: `${prefix}/api/session?cursor=${cursor("two")}` }, + { method: "POST" as const, url: `${prefix}/api/experimental/session/import`, payload: { + location: { directory: "/host", workspaceID: "one" }, info: { location: { directory: "/host", workspaceID: "one" } }, + messages: [{ type: "location-switched", location: { directory: "/host", workspaceID: "two" } }], + } }, + ]) assert.equal((await app.inject(request)).statusCode, 403) + assert.equal(forwarded, before, "foreign identity is refused before forwarding any mutation") + } finally { await app.close(); await upstream.close() } +}) + +test("the first legacy-context proxy request after reconnect negotiates before authorization", async () => { + const upstream = Fastify() + const calls: string[] = [] + upstream.get("/openapi.json", async () => { calls.push("schema"); return legacyContractFixture }) + upstream.get("/api/form/request", async request => { + calls.push("forms") + assert.equal((request.query as Record)["location[workspace]"], "one") + return { data: [], location: { directory: "/repo", workspaceID: "one" } } + }) + await upstream.listen({ host: "127.0.0.1", port: 0 }) + const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` } + rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" }) + const service = new OpenCodeSharedService() + await service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) + const app = Fastify() + registerInstanceProxyRoutes(app, { workspaceManager: { + get: () => ({ path: "/repo" }) as never, + getSharedServiceConnection: () => service.acquire(), getSharedServiceEndpoint: async () => endpoint, + getSharedServiceClient: () => service.client(), getInstanceAuthorizationHeader: () => undefined, + getWorktreeIdentityForPath: async () => "root", ownsDirectory: async () => true, ownsPath: async () => false, + ownsLocation: async (_id, location) => { + assert.deepEqual(calls, ["schema"], "contract must be selected before profile-dependent authorization") + return location.directory === "/repo" && (location.workspaceID === undefined || location.workspaceID === "one") + }, + }, worktreeDeletionFence: new WorktreeDeletionFence(), logger: { debug() {}, error() {} } as never }) + try { + const response = await app.inject({ url: "/workspaces/w/instance/api/form?location[directory]=/repo", headers: locationRequestOptions({ directory: "/repo", workspaceID: "one" })!.headers }) + assert.equal(response.statusCode, 200, response.body) + assert.deepEqual(calls, ["schema", "forms"]) + } finally { await service.shutdown(); await app.close(); await upstream.close() } +}) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 563671d1a..2de663d47 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -3,10 +3,11 @@ import { afterEach, describe, it } from "node:test" import { Readable } from "node:stream" import Fastify, { type FastifyInstance } from "fastify" import replyFrom from "@fastify/reply-from" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" import type { Logger } from "../../logger" import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { createRuntimeFetch } from "../../opencode/compatibility/transport" const apps: FastifyInstance[] = [] afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))) @@ -114,10 +115,16 @@ async function harness( return { data: { id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 } } } }, }, - } as OpenCodeClient + } as unknown as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { get: () => ({ id: "workspace", path: workspacePath }) as never, getSharedServiceEndpoint: async () => ({ url: `http://127.0.0.1:${address.port}` }), + getSharedServiceConnection: async () => ({ + endpoint: { url: `http://127.0.0.1:${address.port}` }, client, + fetch: createRuntimeFetch({ url: `http://127.0.0.1:${address.port}` }), + assertCurrent: () => {}, invalidate: () => { invalidations += 1 }, + profile: async () => "modern", + }), invalidateSharedServiceConnection: () => { invalidations += 1 }, getInstanceAuthorizationHeader: () => "Basic internal-secret", getServiceDirectory: () => serviceDirectory, @@ -136,8 +143,7 @@ async function harness( }, getSharedServiceClient: async () => client, ownsLocation: async (_id, location) => owned.has(location.directory) - && (!location.workspaceID || location.workspaceID === (location.directory.includes("worktree") ? "worktree-location" : "owned-location")), - ownsLocationWorkspace: async (_id, workspaceID) => workspaceID === "owned-location" || workspaceID === "worktree-location", + && location.workspaceID === undefined, ownsDirectory: async (_id, directory) => owned.has(directory), ownsPath: async (_id, candidate) => { pathOwnershipChecks.push(candidate) @@ -165,16 +171,16 @@ async function harness( describe("instance proxy location enforcement", () => { it("forwards native execution settlement only for a session owned by the workspace", async () => { const { app, requestCount, sessionGets } = await harness() - const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/session/wait" }) + const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/experimental/session/session/wait" }) assert.equal(response.statusCode, 200) - assert.equal(JSON.parse(response.body).url, "/api/session/session/wait") + assert.equal(JSON.parse(response.body).url, "/api/experimental/session/session/wait") assert.deepEqual(sessionGets, ["session"]) assert.equal(requestCount(), 1) }) it("rejects settlement waits for sessions belonging to another workspace", async () => { const { app, requestCount } = await harness("/other") - const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/session/wait" }) + const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/experimental/session/session/wait" }) assert.equal(response.statusCode, 403) assert.equal(requestCount(), 0) }) @@ -214,35 +220,33 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) - it("preserves owned native workspace selectors and rejects mismatched pairs", async () => { + it("rejects removed workspace selectors even alongside owned directories", async () => { const { app, requestCount } = await harness() const query = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=worktree-location", }) - assert.equal(query.statusCode, 200) - assert.match(JSON.parse(query.body).url, /location%5Bworkspace%5D=worktree-location/) + assert.equal(query.statusCode, 400) const create = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session", payload: { location: { directory: "/repo", workspaceID: "owned-location" } }, }) - assert.equal(create.statusCode, 200) - assert.equal(JSON.parse(create.body).body.location.workspaceID, "owned-location") + assert.equal(create.statusCode, 400) const foreign = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=owned-location", }) - assert.equal(foreign.statusCode, 403) + assert.equal(foreign.statusCode, 400) const unsupported = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/model?workspace=owned-location", }) assert.equal(unsupported.statusCode, 400) - assert.equal(requestCount(), 2) + assert.equal(requestCount(), 0) }) it("rejects session admission while a worktree deletion is pending", async () => { @@ -431,7 +435,7 @@ describe("instance proxy location enforcement", () => { const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } }) const response = await app.inject({ method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother&workspace=foreign-location`, + url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother`, }) assert.equal(response.statusCode, 200) const upstreamUrl = JSON.parse(response.body).url as string @@ -455,12 +459,12 @@ describe("instance proxy location enforcement", () => { assert.equal((await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "foreign-location", directory: "/repo", anchor: { id: "session-1", time: 1, direction: "next" } })}`, - })).statusCode, 403) + })).statusCode, 400) assert.equal((await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "owned-location", anchor: { id: "session-1", time: 1, direction: "next" } })}`, - })).statusCode, 200) - assert.equal(requestCount(), 2) + })).statusCode, 400) + assert.equal(requestCount(), 1) }) it("filters PTYs and rejects foreign PTY access", async () => { @@ -558,14 +562,14 @@ describe("instance proxy location enforcement", () => { it("permits only native global Forms actions without session hydration", async () => { const { app, sessionGets, requestCount } = await harness("/other") - for (const action of ["reply", "cancel"]) { + for (const [method, suffix] of [["POST", "/reply"], ["DELETE", ""]] as const) { const response = await app.inject({ - method: "POST", - url: `/workspaces/workspace/instance/api/session/global/form/form-1/${action}`, - payload: action === "reply" ? { answers: {} } : {}, + method, + url: `/workspaces/workspace/instance/api/session/global/form/form-1${suffix}`, + ...(method === "POST" ? { payload: { answer: {} } } : {}), }) assert.equal(response.statusCode, 200) - assert.equal(JSON.parse(response.body).url, `/api/session/global/form/form-1/${action}?location%5Bdirectory%5D=%2Frepo`) + assert.equal(JSON.parse(response.body).url, `/api/session/global/form/form-1${suffix}?location%5Bdirectory%5D=%2Frepo`) } assert.deepEqual(sessionGets, []) assert.equal(requestCount(), 2) @@ -578,14 +582,13 @@ describe("instance proxy location enforcement", () => { assert.deepEqual(sessionGets, ["global"]) }) - it("forwards a validated global Form root location and workspace", async () => { + it("forwards a validated global Form root directory", async () => { const { app } = await harness("/repo/worktree", {}, {}, "/repo", "/srv/repo") const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", headers: { "x-opencode-directory": encodeURIComponent("/repo"), - "x-opencode-workspace": "owned-location", }, payload: { answers: {} }, }) @@ -593,7 +596,7 @@ describe("instance proxy location enforcement", () => { assert.equal(response.statusCode, 200) const upstream = JSON.parse(response.body) assert.equal(upstream.headers["x-opencode-directory"], encodeURIComponent("/srv/repo")) - assert.equal(upstream.headers["x-opencode-workspace"], "owned-location") + assert.equal(upstream.headers["x-opencode-workspace"], undefined) }) it("translates and forwards a validated global Form worktree location", async () => { @@ -602,8 +605,8 @@ describe("instance proxy location enforcement", () => { { "/repo/worktree": "/srv/worktree" }, ) const response = await app.inject({ - method: "POST", - url: "/workspaces/workspace/instance/api/session/global/form/form-1/cancel", + method: "DELETE", + url: "/workspaces/workspace/instance/api/session/global/form/form-1", headers: { "x-opencode-directory": encodeURIComponent("/repo/worktree") }, payload: {}, }) @@ -636,7 +639,7 @@ describe("instance proxy location enforcement", () => { "x-opencode-workspace": "foreign-location", }, payload: { answers: {} }, - })).statusCode, 403) + })).statusCode, 400) assert.equal(requestCount(), 0) }) @@ -691,7 +694,7 @@ describe("instance proxy location enforcement", () => { }) const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" }) assert.equal(response.statusCode, 200) - assert.deepEqual(JSON.parse(response.body), { owned: { type: "running" } }) + assert.deepEqual(JSON.parse(response.body), { data: { owned: { type: "running" } } }) assert.deepEqual(sessionGets.sort(), ["foreign", "owned", "stale"]) assert.equal(requestCount(), 0) }) @@ -726,15 +729,13 @@ describe("instance proxy location enforcement", () => { ["GET", "/workspaces/workspace/instance/api/session/owned/permission"], ["GET", "/workspaces/workspace/instance/api/session/owned/form"], ["POST", "/workspaces/workspace/instance/api/session/owned/background"], - ["POST", "/workspaces/workspace/instance/api/session/owned/revert/clear"], + ["DELETE", "/workspaces/workspace/instance/api/session/owned/revert"], ["GET", "/workspaces/workspace/instance/api/skill"], ["GET", "/workspaces/workspace/instance/api/reference"], ["GET", "/workspaces/workspace/instance/api/mcp/resource"], ["GET", "/workspaces/workspace/instance/api/websearch/provider"], - ["POST", "/workspaces/workspace/instance/api/plugin/await-activation"], ["DELETE", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"], - ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/steer"], - ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/queue"], + ["PATCH", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"], ] as const for (const [method, url] of requests) { @@ -861,7 +862,7 @@ describe("instance proxy location enforcement", () => { const { app, requestCount } = await harness() const accepted = await app.inject({ method: "POST", - url: "/workspaces/workspace/instance/api/session/import", + url: "/workspaces/workspace/instance/api/experimental/session/import", payload: { info: { id: "session-1", metadata: { location: { directory: "/other" } } }, messages: [{ @@ -881,9 +882,19 @@ describe("instance proxy location enforcement", () => { assert.deepEqual(body.messages[0].metadata.location, { directory: "/other" }) assert.equal(body.messages[0].content[0].state.input.location, "/other") + const obsolete = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/experimental/session/import", + payload: { + info: { id: "old-selector", location: { directory: "/repo", workspaceID: "owned-location" } }, + messages: [], + }, + }) + assert.equal(obsolete.statusCode, 400) + const rejected = await app.inject({ method: "POST", - url: "/workspaces/workspace/instance/api/session/import", + url: "/workspaces/workspace/instance/api/experimental/session/import", payload: { info: { id: "session-2", location: { directory: "/repo" } }, messages: [{ type: "location-switched", location: { directory: "/repo/worktree" }, previous: { location: { directory: "/other" } } }], diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 30e8b0b46..c48ab0778 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -10,7 +10,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isInvalidRequestError, isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type LocationRef, type OpenCodeClient } from "@opencode-ai/client" +import { isInvalidRequestError, isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type LocationRef, type OpenCodeClient } from "@opencode/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -43,6 +43,10 @@ import { ClientConnectionManager } from "../clients/connection-manager" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" +import { forwardRuntimeRequest } from "../opencode/compatibility/proxy" +import { contractProfile, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime" +import { LOCATION_CONTEXT_HEADER, locationRequestOptions, readLocationContext } from "../opencode/compatibility/location" +import { decodeSessionListScope, prepareLocationImport, readRequestLocations, type SessionListScope } from "../opencode/compatibility/proxy-locations" import type { RemoteProxySessionManager } from "./remote-proxy" import { createOpenCodeUpdateService } from "../opencode-update/service" import { WorktreeDeletionFence } from "../workspaces/worktree-session-evacuation" @@ -409,6 +413,8 @@ export function createHttpServer(deps: HttpServerDeps) { export interface InstanceProxyWorkspaceManager { get(id: string): ReturnType getSharedServiceEndpoint(id: string): ReturnType + getSharedServiceConnection?(id: string): ReturnType + getSharedServiceFetch?(): Promise invalidateSharedServiceConnection?(): void getInstanceAuthorizationHeader(id: string): string | undefined getServiceDirectory?(id: string): string | undefined @@ -416,8 +422,7 @@ export interface InstanceProxyWorkspaceManager { getWorktreeIdentityForPath(id: string, directory: string): Promise getServicePathForPath?(id: string, candidate: string): Promise getSharedServiceClient(): Promise - ownsLocation(id: string, location: LocationRef): ReturnType - ownsLocationWorkspace(id: string, workspaceID: string): ReturnType + ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType ownsDirectory(id: string, directory: string): Promise ownsPath(id: string, candidate: string): Promise } @@ -647,12 +652,19 @@ async function proxyWorkspaceRequest(args: { return } - const endpoint = await workspaceManager.getSharedServiceEndpoint(workspaceId) + const connection = await workspaceManager.getSharedServiceConnection?.(workspaceId) + const endpoint = connection?.endpoint ?? await workspaceManager.getSharedServiceEndpoint(workspaceId) + const clientForRequest = () => connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient() if (!endpoint) { reply.code(502).send({ error: "OpenCode service is not ready" }) return } + const profile = connection ? await connection.profile() : contractProfile(runtimeIdentity(endpoint)) + let locationContext: LocationRef | undefined + try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER], profile) } + catch { return reply.code(400).send({ error: "Invalid location context" }) } + const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix) if (!targetUrl) { reply.code(400).send({ error: "Invalid workspace instance path" }) @@ -664,21 +676,21 @@ async function proxyWorkspaceRequest(args: { reply.code(405).send({ error: "Method not allowed" }) return } - const client = await workspaceManager.getSharedServiceClient() + const client = await clientForRequest() const active = await client.session.active() const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => { try { const session = await client.session.get({ sessionID: sessionId }) - return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null + return await workspaceManager.ownsLocation(workspaceId, session.location, client) ? [sessionId, status] as const : null } catch { return null } })) - reply.send(Object.fromEntries(entries.filter((entry): entry is NonNullable => entry !== null))) + reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable => entry !== null)) }) return } if (pathname.replace(/\/+$/, "") === "/api/project") { - const projects = await (await workspaceManager.getSharedServiceClient()).project.list() + const projects = await (await clientForRequest()).project.list() const ownedProjects = await Promise.all(projects.map(async (project) => { if (!await workspaceManager.ownsDirectory(workspaceId, project.canonical)) return null const sandboxes = (await Promise.all(project.sandboxes.map(async (directory) => ( @@ -691,8 +703,8 @@ async function proxyWorkspaceRequest(args: { } const sessionListHasScope = request.method === "GET" && pathname.replace(/\/+$/, "") === "/api/session" - && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project") || targetUrl.searchParams.has("workspace")) - const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId) + && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project")) + const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, profile, connection?.client) if (sessionListScope !== "allowed") { reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) return @@ -701,8 +713,7 @@ async function proxyWorkspaceRequest(args: { let globalFormLocation: LocationRef | undefined if (isGlobalFormAction(pathname, request.method)) { const directoryHeader = request.headers["x-opencode-directory"] - const workspaceHeader = request.headers["x-opencode-workspace"] - if (Array.isArray(directoryHeader) || Array.isArray(workspaceHeader)) { + if (Array.isArray(directoryHeader) || request.headers["x-opencode-workspace"] !== undefined) { reply.code(400).send({ error: "Invalid Form location" }) return } @@ -719,24 +730,24 @@ async function proxyWorkspaceRequest(args: { return } } - if (workspaceHeader !== undefined && !workspaceHeader.trim()) { - reply.code(400).send({ error: "Invalid Form location" }) - return - } - if (directory !== undefined || workspaceHeader !== undefined) { - globalFormLocation = { - directory: directory ?? workspace.path, - ...(workspaceHeader ? { workspaceID: workspaceHeader } : {}), - } + if (locationContext && locationContext.directory !== (directory ?? serviceDirectory)) { + return reply.code(400).send({ error: "Conflicting Form location" }) } + if (directory !== undefined) globalFormLocation = { ...locationContext, directory } } const imported = prepareSessionImport( pathname, request.method, stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory), serviceDirectory, + profile, ) - const requestLocations = readRequestDirectories(targetUrl, imported.body, workspace.path) + const requestLocations = readRequestLocations(targetUrl, imported.body, workspace.path, profile) + const explicitLocations = [...requestLocations.locations] + if (locationContext) { + requestLocations.directories.push(locationContext.directory) + requestLocations.locations.push(locationContext) + } if (globalFormLocation) { requestLocations.directories.push(globalFormLocation.directory) requestLocations.locations.push(globalFormLocation) @@ -748,7 +759,7 @@ async function proxyWorkspaceRequest(args: { const promptFiles = readPromptFilePaths(pathname, request.method, imported.body) if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories)) - || !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations))) { + || !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client))) { reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" }) return } @@ -763,6 +774,11 @@ async function proxyWorkspaceRequest(args: { } translatedDirectories.set(directory, translated) } + if (locationContext && explicitLocations.some(location => + translatedDirectories.get(location.directory) !== translatedDirectories.get(locationContext!.directory) + || (location.workspaceID !== undefined && location.workspaceID !== locationContext!.workspaceID))) { + return reply.code(400).send({ error: "Conflicting location context" }) + } const fileListPath = readFileListPath(targetUrl, request.method, requestLocations.directories[0] ?? workspace.path) if (fileListPath.invalid) { reply.code(400).send({ error: "Invalid filesystem path" }) @@ -812,9 +828,13 @@ async function proxyWorkspaceRequest(args: { const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) const requestedDirectory = requestLocations.directories[0] - const runtimeLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + const runtimeLocation = { + directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory, + workspaceID: locationContext?.workspaceID ?? explicitLocations.find(location => location.directory === requestedDirectory)?.workspaceID, + } + const runtimeLocationOptions = locationRequestOptions(runtimeLocation) if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { - const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: runtimeLocation }) + const result = await (await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions) const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))) reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) return @@ -823,7 +843,7 @@ async function proxyWorkspaceRequest(args: { const ptyId = getPtyRouteId(pathname) if (ptyId) { try { - const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: runtimeLocation }) + const pty = await (await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions) if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) { reply.code(403).send({ error: "PTY does not belong to workspace" }) return @@ -838,7 +858,7 @@ async function proxyWorkspaceRequest(args: { } if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") { - const result = await (await workspaceManager.getSharedServiceClient()).shell.list({ location: runtimeLocation }) + const result = await (await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions) const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))) reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) return @@ -847,7 +867,7 @@ async function proxyWorkspaceRequest(args: { const shellId = getShellRouteId(pathname) if (shellId) { try { - const shell = await (await workspaceManager.getSharedServiceClient()).shell.get({ id: shellId, location: runtimeLocation }) + const shell = await (await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions) if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) { reply.code(403).send({ error: "Shell does not belong to workspace" }) return @@ -865,7 +885,7 @@ async function proxyWorkspaceRequest(args: { if (sessionId && !isGlobalFormAction(pathname, request.method)) { let session try { - session = await (await workspaceManager.getSharedServiceClient()).session.get({ sessionID: sessionId }) + session = await (await clientForRequest()).session.get({ sessionID: sessionId }) } catch (error) { if (isInvalidRequestError(error)) { reply.code(400).send({ error: "Invalid session ID" }) @@ -877,7 +897,7 @@ async function proxyWorkspaceRequest(args: { } throw error } - if (!(await workspaceManager.ownsDirectory(workspaceId, session.location.directory))) { + if (!(await workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest()))) { reply.code(403).send({ error: "Session does not belong to workspace" }) return } @@ -901,13 +921,24 @@ async function proxyWorkspaceRequest(args: { logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") try { + connection?.assertCurrent() + if (connection || workspaceManager.getSharedServiceFetch) { + const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader) + delete headers[LOCATION_CONTEXT_HEADER] + if (locationContext) Object.assign(headers, locationRequestOptions({ ...locationContext, directory: translatedDirectories.get(locationContext.directory)! }, { includeDirectory: true })?.headers) + if (globalFormLocation) headers["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!) + return await forwardRuntimeRequest({ + request, reply, url: targetUrl.toString(), body, headers, + fetch: connection?.fetch ?? await workspaceManager.getSharedServiceFetch!(), release: releaseMutation, + invalidate: () => connection ? connection.invalidate() : workspaceManager.invalidateSharedServiceConnection?.(), + }) + } return reply.from(targetUrl.toString(), { ...(body !== request.body ? { body } : {}), rewriteRequestHeaders: (_originalRequest, headers) => { const outgoingHeaders = sanitizeInstanceProxyRequestHeaders(headers, instanceAuthHeader) if (globalFormLocation) { outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!) - if (globalFormLocation.workspaceID) outgoingHeaders["x-opencode-workspace"] = globalFormLocation.workspaceID } if (logger.isLevelEnabled("trace")) { @@ -962,65 +993,6 @@ function appendIncomingQuery(targetUrl: URL, incomingUrl: string): URL { return targetUrl } -function readRequestDirectories( - targetUrl: URL, - body: unknown, - defaultDirectory: string, -): { directories: string[]; locations: LocationRef[]; invalid: boolean } { - const directories: string[] = [] - const locations: LocationRef[] = [] - let invalid = false - for (const key of ["location[directory]", "directory"]) { - for (const value of targetUrl.searchParams.getAll(key)) { - if (value.trim()) directories.push(value) - else invalid = true - } - } - const queryWorkspaces = targetUrl.searchParams.getAll("location[workspace]") - const queryDirectories = targetUrl.searchParams.getAll("location[directory]") - if (queryWorkspaces.length > 1 || queryDirectories.length > 1) invalid = true - if (queryWorkspaces.length === 1) { - const workspaceID = queryWorkspaces[0] - if (!workspaceID.trim()) invalid = true - else locations.push({ directory: queryDirectories[0] ?? defaultDirectory, workspaceID }) - } - if (targetUrl.pathname.replace(/\/+$/, "") !== "/api/session" && targetUrl.searchParams.has("workspace")) invalid = true - if (targetUrl.searchParams.has("workspaceID") || targetUrl.searchParams.has("location[workspaceID]")) invalid = true - - if (body && typeof body === "object" && !Array.isArray(body) && !Buffer.isBuffer(body)) { - const input = body as Record - if ("directory" in input) { - if (typeof input.directory === "string" && input.directory.trim()) directories.push(input.directory) - else invalid = true - } - if ("location" in input) { - const location = input.location - if (location && typeof location === "object" && !Array.isArray(location)) { - const source = location as Record - const directory = source.directory - if (typeof directory === "string" && directory.trim()) directories.push(directory) - else invalid = true - if (source.workspace !== undefined) invalid = true - if (source.workspaceID !== undefined) { - if (typeof source.workspaceID === "string" && source.workspaceID.trim() && typeof directory === "string" && directory.trim()) { - locations.push({ directory, workspaceID: source.workspaceID }) - } else invalid = true - } - } else if (location !== null && location !== undefined) { - invalid = true - } - } - if (/^\/api\/session\/[^/]+\/move\/?$/.test(targetUrl.pathname) && "workspaceID" in input) { - if (typeof input.workspaceID === "string" && input.workspaceID.trim() && typeof input.directory === "string" && input.directory.trim()) { - locations.push({ directory: input.directory, workspaceID: input.workspaceID }) - } else invalid = true - } else if ("workspaceID" in input) { - invalid = true - } - } - return { directories, locations, invalid } -} - function readNativeCwd( targetUrl: URL, body: unknown, @@ -1065,6 +1037,7 @@ function sanitizeInstanceProxyRequestHeaders( "authorization", "connection", "cookie", "forwarded", "host", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "x-forwarded-for", "x-forwarded-host", "x-forwarded-port", "x-forwarded-proto", + LOCATION_CONTEXT_HEADER, ]) const connection = headers.connection for (const name of (Array.isArray(connection) ? connection.join(",") : connection ?? "").split(",")) blocked.add(name.trim().toLowerCase()) @@ -1099,9 +1072,9 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean) } -async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[]) { - const unique = new Map(locations.map((location) => [`${location.directory}\0${location.workspaceID ?? ""}`, location])) - return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location)))).every(Boolean) +async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient) { + const unique = new Map(locations.map((location) => [JSON.stringify([location.directory, location.workspaceID]), location])) + return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client)))).every(Boolean) } async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) { @@ -1117,6 +1090,8 @@ function applyDefaultWorkspaceLocation( sessionRoute: boolean, ): unknown { if (hasLocation || sessionRoute) return body + if (/^\/api\/(?:credential|project)(?:\/|$)/.test(targetUrl.pathname) + || /^\/api\/permission\/saved(?:\/|$)/.test(targetUrl.pathname)) return body if (targetUrl.pathname === "/api/session" && method === "GET") { targetUrl.searchParams.set("directory", directory) return body @@ -1138,7 +1113,8 @@ function getSessionRouteId(pathname: string): string | null { } function isGlobalFormAction(pathname: string, method: string): boolean { - return method === "POST" && /^\/api\/session\/global\/form\/[^/]+\/(?:reply|cancel)\/?$/.test(pathname) + return (method === "POST" && /^\/api\/session\/global\/form\/[^/]+\/reply\/?$/.test(pathname)) + || (method === "DELETE" && /^\/api\/session\/global\/form\/[^/]+\/?$/.test(pathname)) } async function authorizeSessionList( @@ -1146,77 +1122,35 @@ async function authorizeSessionList( method: string, manager: InstanceProxyWorkspaceManager, workspaceId: string, + profile: ContractProfile, + client?: OpenCodeClient, ): Promise<"allowed" | "invalid" | "foreign"> { if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed" const cursors = targetUrl.searchParams.getAll("cursor") if (cursors.length > 1) return "invalid" if (cursors.length === 1) { - const scope = decodeSessionListCursor(cursors[0]) + const scope = decodeSessionListScope(cursors[0], profile) if (!scope) return "invalid" - for (const key of ["directory", "workspace", "location[directory]", "location[workspace]", "project", "subpath"]) { + for (const key of ["directory", "location[directory]", "project", "subpath", ...(profile === "legacy" ? ["workspace", "location[workspace]"] : [])]) { targetUrl.searchParams.delete(key) } - return ownsSessionListScope(manager, workspaceId, scope) + return ownsSessionListScope(manager, workspaceId, scope, client) } const projects = targetUrl.searchParams.getAll("project") const subpaths = targetUrl.searchParams.getAll("subpath") - const workspaces = targetUrl.searchParams.getAll("workspace") const directories = targetUrl.searchParams.getAll("directory") - if (projects.length > 1 || subpaths.length > 1 || workspaces.length > 1 || directories.length > 1 + if (projects.length > 1 || subpaths.length > 1 || directories.length > 1 || (subpaths.length && !projects.length)) return "invalid" - const workspace = workspaces[0] const directory = directories[0] - if ((workspace !== undefined && !workspace.trim()) || (directory !== undefined && !directory.trim())) return "invalid" + if (directory !== undefined && !directory.trim()) return "invalid" if (!projects.length) { - if (workspace && directory) return ownsSessionListScope(manager, workspaceId, { workspace, directory }) - if (workspace) return ownsSessionListScope(manager, workspaceId, { workspace }) return "allowed" } const project = projects[0] const subpath = subpaths[0] if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid" - return ownsSessionListScope(manager, workspaceId, { ...(workspace ? { workspace } : {}), project, subpath }) -} - -type SessionListScope = { - workspace?: string - directory?: string - project?: string - subpath?: string -} - -function decodeSessionListCursor(cursor: string): SessionListScope | null { - if (!cursor || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null - try { - const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record - if (!value || typeof value !== "object" || Array.isArray(value)) return null - const anchor = value.anchor as Record | undefined - if (!anchor || typeof anchor !== "object" || Array.isArray(anchor) - || typeof anchor.id !== "string" || !anchor.id - || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) - || (anchor.direction !== "previous" && anchor.direction !== "next")) return null - if (value.workspace !== undefined && (typeof value.workspace !== "string" || !value.workspace.trim())) return null - if (value.search !== undefined && typeof value.search !== "string") return null - if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null - if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), directory: value.directory } - } - if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { - if (value.subpath === undefined) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project } - } - if (typeof value.subpath === "string" && isSafeRelativePath(value.subpath)) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project, subpath: value.subpath } - } - } - if (typeof value.workspace === "string" && value.directory === undefined && value.project === undefined && value.subpath === undefined) { - return { workspace: value.workspace } - } - return null - } catch { - return null - } + return ownsSessionListScope(manager, workspaceId, { project, subpath, workspaceID: targetUrl.searchParams.get("workspace") ?? undefined }, client) } function isSafeRelativePath(value: string): boolean { @@ -1227,24 +1161,21 @@ async function ownsSessionListScope( manager: InstanceProxyWorkspaceManager, workspaceId: string, scope: SessionListScope, + client?: OpenCodeClient, ): Promise<"allowed" | "foreign"> { if (scope.directory) { - const owned = scope.workspace - ? await manager.ownsLocation(workspaceId, { directory: scope.directory, workspaceID: scope.workspace }) - : await manager.ownsDirectory(workspaceId, scope.directory) + const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory, workspaceID: scope.workspaceID }, client) return owned ? "allowed" : "foreign" } - if (!scope.project) return scope.workspace && await manager.ownsLocationWorkspace(workspaceId, scope.workspace) ? "allowed" : "foreign" - const project = (await (await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project) + if (!scope.project) return "foreign" + const project = (await (client ?? await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project) if (!project) return "foreign" const directory = scope.subpath === undefined ? project.canonical : /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical) ? path.win32.resolve(project.canonical, scope.subpath) : path.posix.resolve(project.canonical, scope.subpath) - const owned = scope.workspace - ? await manager.ownsLocation(workspaceId, { directory, workspaceID: scope.workspace }) - : await manager.ownsDirectory(workspaceId, directory) + const owned = await manager.ownsLocation(workspaceId, { directory, workspaceID: scope.workspaceID }, client) return owned ? "allowed" : "foreign" } @@ -1253,7 +1184,7 @@ function getPtyRouteId(pathname: string): string | null { } function getShellRouteId(pathname: string): string | null { - return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output|\/timeout)?$/)?.[1] ?? null + return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output)?$/)?.[1] ?? null } function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { @@ -1288,43 +1219,45 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider|reference|skill)$/], - ["POST", /^\/api\/plugin\/await-activation$/], ["GET", /^\/api\/(?:mcp\/resource|websearch\/provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], - ["GET", /^\/api\/(?:permission|question)\/request$/], - ["GET", /^\/api\/form\/request$/], - ["GET", /^\/api\/project\/current$/], + ["GET", /^\/api\/permission\/request$/], + ["GET", /^\/api\/form$/], ["GET", /^\/api\/project$/], ["GET", /^\/api\/vcs(?:\/status)?$/], ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], - ["GET", /^\/api\/(?:pty|shell)(?:\/[^/]+(?:\/output)?)?$/], + ["GET", /^\/api\/pty(?:\/[^/]+)?$/], + ["GET", /^\/api\/shell(?:\/[^/]+(?:\/output)?)?$/], ["POST", /^\/api\/(?:pty|shell)$/], ["PUT", /^\/api\/pty\/[^/]+$/], ["DELETE", /^\/api\/pty\/[^/]+$/], ["DELETE", /^\/api\/shell\/[^/]+$/], - ["PATCH", /^\/api\/shell\/[^/]+\/timeout$/], - ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], + ["POST", /^\/api\/experimental\/mcp\/[^/]+\/(?:connect|disconnect)$/], ["DELETE", /^\/api\/credential\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], ["GET", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/], ["DELETE", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/], ["GET", /^\/api\/session(?:\/active)?$/], - ["POST", /^\/api\/session(?:\/import)?$/], + ["POST", /^\/api\/session$/], + ["POST", /^\/api\/experimental\/session\/import$/], ["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/], ["GET", /^\/api\/session\/[^/]+\/inbox$/], ["GET", /^\/api\/session\/[^/]+\/(?:permission|form)$/], ["DELETE", /^\/api\/session\/[^/]+$/], ["DELETE", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/], - ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|wait|background|fork)$/], - ["POST", /^\/api\/session\/[^/]+\/inbox\/[^/]+\/(?:steer|queue)$/], - ["POST", /^\/api\/session\/[^/]+\/revert\/(?:stage|clear)$/], - ["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], - ["DELETE", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], + ["PATCH", /^\/api\/session\/[^/]+$/], + ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|move|prompt|command|shell|compact|interrupt|background|fork)$/], + ["POST", /^\/api\/experimental\/session\/[^/]+\/wait$/], + ["PATCH", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/], + ["POST", /^\/api\/session\/[^/]+\/revert\/stage$/], + ["DELETE", /^\/api\/session\/[^/]+\/revert$/], + ["PUT", /^\/api\/experimental\/session\/[^/]+\/instructions\/entries\/[^/]+$/], + ["DELETE", /^\/api\/experimental\/session\/[^/]+\/instructions\/entries\/[^/]+$/], ["POST", /^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/], - ["POST", /^\/api\/session\/[^/]+\/question\/[^/]+\/(?:reply|reject)$/], - ["POST", /^\/api\/session\/[^/]+\/form\/[^/]+\/(?:reply|cancel)$/], + ["POST", /^\/api\/session\/[^/]+\/form\/[^/]+\/reply$/], + ["DELETE", /^\/api\/session\/[^/]+\/form\/[^/]+$/], ["GET", /^\/api\/experimental\/session\/[^/]+\/log$/], ] return allowed.some(([allowedMethod, pattern]) => method === allowedMethod && pattern.test(route)) @@ -1357,7 +1290,7 @@ function replaceRequestDirectories( if (typeof value === "string" && replacements.has(value)) input[key] = replacements.get(value) } input.location = replaceLocation(input.location) - if (pathname !== "/api/session/import" || method !== "POST") return input + if (pathname !== "/api/experimental/session/import" || method !== "POST") return input input.info = input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info) ? { ...(input.info as Record), location: replaceLocation((input.info as Record).location) } : input.info @@ -1466,58 +1399,10 @@ function replacePromptFileUris(body: unknown, replacements: ReadonlyMap - const addLocation = (owner: Record, key: string) => { - const value = owner[key] - if (value === null || value === undefined) { - owner[key] = { directory } - result.directories.push(directory) - return - } - if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) { - result.invalid = true - return - } - const location = value as Record - if (location.directory === null || location.directory === undefined) location.directory = directory - if (typeof location.directory !== "string" || !location.directory.trim()) { - result.invalid = true - return - } - result.directories.push(location.directory) - if (location.workspace !== undefined) result.invalid = true - if (location.workspaceID !== undefined) { - if (typeof location.workspaceID === "string" && location.workspaceID.trim()) { - result.locations.push({ directory: location.directory, workspaceID: location.workspaceID }) - } else result.invalid = true - } - } - - addLocation(input, "location") - if (input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info)) { - addLocation(input.info as Record, "location") - } - - if (Array.isArray(input.messages)) { - for (const value of input.messages) { - if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) continue - const message = value as Record - if (message.type !== "location-switched") continue - addLocation(message, "location") - if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) { - addLocation(message.previous as Record, "location") - } - } - } - return result + if (pathname !== "/api/experimental/session/import" || method !== "POST") return result + return prepareLocationImport(body, directory, profile) } function normalizeInstanceSuffix(pathSuffix: string | undefined) { diff --git a/packages/server/src/server/routes/session-pruning.test.ts b/packages/server/src/server/routes/session-pruning.test.ts index 630aae911..0861e4197 100644 --- a/packages/server/src/server/routes/session-pruning.test.ts +++ b/packages/server/src/server/routes/session-pruning.test.ts @@ -6,16 +6,18 @@ import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuat const payload = { sessionID: "s", messageID: "m", revision: "a".repeat(64), indexes: [0] } const url = "/api/workspaces/w/session-pruning/prune" -function fixture(options: { owned?: boolean; output?: unknown; fail?: boolean } = {}) { +function fixture(options: { owned?: boolean; output?: unknown; fail?: boolean; workspaceID?: string } = {}) { const calls: any[] = [] + const requestOptions: any[] = [] const app = Fastify() const fence = new WorktreeDeletionFence() registerSessionPruningRoutes(app, { worktreeDeletionFence: fence, workspaceManager: { getSharedServiceClient: async () => ({ - session: { get: async () => ({ location: { directory: "/owned/worktree" } }) }, + session: { get: async () => ({ location: { directory: "/owned/worktree", workspaceID: options.workspaceID } }) }, rpc: { call: async (input: unknown, opts: any) => { assert(opts.signal instanceof AbortSignal) calls.push(input) + requestOptions.push(opts) if (options.fail) throw new Error("plugin unavailable") return { output: options.output ?? { status: "blocked", reason: "maintenance_required" } } } }, @@ -23,7 +25,7 @@ function fixture(options: { owned?: boolean; output?: unknown; fail?: boolean } ownsLocation: async () => options.owned !== false, getWorktreeIdentityForPath: async () => "/owned/worktree", } }) - return { app, calls, fence } + return { app, calls, fence, requestOptions } } test("broker pins RPC, method and native session location", async () => { @@ -44,6 +46,16 @@ test("rejects unowned sessions before any RPC", async () => { } finally { await app.close() } }) +test("broker preserves the authorized native identity through generated request options", async () => { + const { app, requestOptions } = fixture({ workspaceID: "native-worktree" }) + try { + assert.equal((await app.inject({ method: "POST", url, payload })).statusCode, 200) + assert.deepEqual(JSON.parse(decodeURIComponent(requestOptions[0].headers["x-codenomad-location"])), { + directory: "/owned/worktree", workspaceID: "native-worktree", + }) + } finally { await app.close() } +}) + test("rejects caller-selected location, RPC, database and replacement content", async () => { const { app, calls } = fixture() try { diff --git a/packages/server/src/server/routes/session-pruning.ts b/packages/server/src/server/routes/session-pruning.ts index ccf5bfd21..09ba9ed00 100644 --- a/packages/server/src/server/routes/session-pruning.ts +++ b/packages/server/src/server/routes/session-pruning.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify" import { messageTargetSchema, PRUNING_RPC_ID, prunePreviewSchema, pruneRequestSchema, pruneResultSchema } from "../../opencode/session-pruning/contract" import type { WorkspaceManager } from "../../workspaces/manager" import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { locationRequestOptions, readLocationRef } from "../../opencode/compatibility/location" interface RouteDeps { workspaceManager: Pick @@ -15,7 +16,8 @@ export function registerSessionPruningRoutes(app: FastifyInstance, deps: RouteDe if (!input.success) return reply.code(400).send({ error: "Invalid pruning request" }) const client = await deps.workspaceManager.getSharedServiceClient() const session = await client.session.get({ sessionID: input.data.sessionID }) - if (!await deps.workspaceManager.ownsLocation(request.params.id, session.location)) { + const location = readLocationRef(session.location) + if (!await deps.workspaceManager.ownsLocation(request.params.id, session.location, client)) { return reply.code(403).send({ error: "Session does not belong to workspace" }) } const identity = await deps.workspaceManager.getWorktreeIdentityForPath(request.params.id, session.location.directory) @@ -27,7 +29,7 @@ export function registerSessionPruningRoutes(app: FastifyInstance, deps: RouteDe // database filename, SQL, or replacement content. Generic RPC stays blocked. const result = await client.rpc.call({ rpcID: PRUNING_RPC_ID, method, input: input.data, location: { directory: session.location.directory }, - }, { signal: AbortSignal.timeout(15_000) }) + }, { ...locationRequestOptions(location), signal: AbortSignal.timeout(15_000) }) const output = (method === "prune" ? pruneResultSchema : prunePreviewSchema).safeParse(result.output) if (!output.success) return { status: "blocked", reason: "unavailable" } if (output.data.status === "pruned" && (output.data.messageID !== input.data.messageID diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 784af16e5..642fb7790 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -1,17 +1,69 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" import Fastify from "fastify" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" import type { WorkspaceDescriptor } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorktreeRoutes } from "./worktrees" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" describe("worktree routes", () => { + it("uses exact project paths despite a nested project sharing the root worktree identity", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-nested-project-")) + const target = path.join(temp, "doomed") + const nested = path.join(temp, "nested") + const app = Fastify({ logger: false }) + try { + execFileSync("git", ["init", "--initial-branch=main", temp]) + writeFileSync(path.join(temp, "README.md"), "test\n") + execFileSync("git", ["-C", temp, "add", "README.md"]) + execFileSync("git", ["-C", temp, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) + execFileSync("git", ["-C", temp, "worktree", "add", "-b", "doomed", target]) + mkdirSync(nested) + const projects: string[] = [] + const exactPaths: string[] = [] + const client = { + location: { get: async () => ({ directory: temp }) }, + project: { list: async () => [ + { id: "nested", canonical: nested, sandboxes: [] }, + { id: "project", canonical: realpathSync(temp), sandboxes: [target] }, + ] }, + session: { + list: async ({ project }: { project: string }) => { + projects.push(project) + return { data: project === "project" ? [{ id: "active", location: { directory: target } }] : [], cursor: {} } + }, + active: async () => ({ active: { type: "running" } }), + move: async () => { assert.fail("Active sessions must not move") }, + }, + } as unknown as OpenCodeClient + const manager = { + get: () => ({ id: "workspace", path: temp, status: "ready" }), + getSharedServiceClient: async () => client, + getServiceDirectory: () => temp, + getServiceDirectoryForPath: async (_id: string, directory: string) => { + exactPaths.push(directory) + return realpathSync(directory) + }, + getWorktreeIdentityForPath: async (_id: string, directory: string) => path.resolve(directory) === path.resolve(target) ? "workspace:doomed" : "workspace:root", + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) + assert.equal(response.statusCode, 400) + assert.match(response.json().error, /Active sessions block worktree deletion: active/) + assert.deepEqual(projects, ["project"]) + assert.ok(exactPaths.includes(nested), "Project candidates must use the exact-path resolver") + assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) + it("fails a direct delete call closed when session evacuation fails", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) const target = path.join(temp, "doomed") @@ -26,6 +78,7 @@ describe("worktree routes", () => { const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo const client = { + location: { get: async ({ location }: { location: { directory: string } }) => location }, project: { list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], }, diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index dde07fce8..60089b4f2 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -134,6 +134,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { targetDirectory, rootDirectory: projectDirectory, resolveDirectoryIdentity: (directory) => deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory), + resolveExactDirectory: (directory) => deps.workspaceManager.getServiceDirectoryForPath(workspace.id, directory), remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), }) )) diff --git a/packages/server/src/workspaces/host-opencode-service.test.ts b/packages/server/src/workspaces/host-opencode-service.test.ts index c3f6b3ead..84b5d9238 100644 --- a/packages/server/src/workspaces/host-opencode-service.test.ts +++ b/packages/server/src/workspaces/host-opencode-service.test.ts @@ -30,13 +30,13 @@ describe("HostOpenCodeService", () => { assert.equal(calls[1]?.options.env?.NODE_EXTRA_CA_CERTS, "/ca.pem") }) - it("authenticates strict bounded health and rejects malformed output", async () => { + it("authenticates strict bounded status and rejects malformed output", async () => { let authorization: string | null = null const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = [] const service = createService(calls, {}, { fetch: async (_input, init) => { authorization = new Headers(init?.headers).get("authorization") - return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + return Response.json({ version: "2.0.4", pid: 1, urls: [url] }) }, execFile: async (file, args, options) => { calls.push({ file, args, options }) @@ -53,20 +53,71 @@ describe("HostOpenCodeService", () => { }) it("connects to wildcard services through loopback", async () => { - let healthUrl = "" + let statusUrl = "" const service = createService([], {}, { execFile: async (_file, args) => ({ stdout: args[args.length - 1] === "password" ? "password\n" : "http://0.0.0.0:4321\n", stderr: "", }), fetch: async (input) => { - healthUrl = String(input) - return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + statusUrl = String(input) + return Response.json({ version: "2.0.4", pid: 1, urls: [url] }) }, }) assert.equal((await service.discover())?.url, "http://127.0.0.1:4321/") - assert.equal(healthUrl, "http://127.0.0.1:4321/api/health") + assert.equal(statusUrl, "http://127.0.0.1:4321/api/status") + }) + + it("falls back on status 404 to authenticated V2 health for discovery and startup", async () => { + for (const operation of ["discover", "ensure"] as const) { + const requests: string[] = [] + let cancelled = false + const service = createService([], {}, { + execFile: async (_file, args) => ({ stdout: args.at(-1) === "password" ? "password\n" : `${url}\n`, stderr: "" }), + fetch: async (input, init) => { + requests.push(String(input)) + assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:password").toString("base64")}`) + if (requests.length === 1) return new Response(new ReadableStream({ cancel() { cancelled = true } }), { status: 404 }) + return Response.json({ healthy: true, version: "2.0.3", pid: 123 }) + }, + }) + assert.equal((await service[operation]())?.url, url) + assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`]) + assert.equal(cancelled, true) + } + }) + + it("does not downgrade on authentication, server, transport or malformed status failures", async () => { + for (const response of [ + () => new Response(null, { status: 401 }), + () => new Response(null, { status: 403 }), + () => new Response(null, { status: 503 }), + () => new Response("invalid JSON"), + () => Response.json({ healthy: true, version: "2.0.3", pid: 123 }), + () => { throw new Error("ECONNREFUSED") }, + ]) { + const requests: string[] = [] + const service = createService([], {}, { + fetch: async (input) => { requests.push(String(input)); return response() }, + }) + await assert.rejects(service.ensure()) + assert.deepEqual(requests, [`${url}/api/status`]) + } + }) + + it("does not give the fallback a fresh deadline", async (context) => { + let now = 1000 + context.mock.method(Date, "now", () => now) + const requests: string[] = [] + const service = createService([], {}, { + fetch: async (input) => { + requests.push(String(input)) + return new Response(new ReadableStream({ cancel() { now = 1500 } }), { status: 404 }) + }, + }) + await assert.rejects(service.ensure(1500), /timed out/) + assert.deepEqual(requests, [`${url}/api/status`]) }) it("redacts startup environment values from failures and hashes identity", async () => { @@ -137,7 +188,7 @@ function createService( if (command === "service start") return { stdout: `${url}\n`, stderr: "" } return { stdout: "password\n", stderr: "" } }, - fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + fetch: async () => Response.json({ version: "2.0.4", pid: 123, urls: [url] }), ...overrides, }) } diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts index eb7c2d23c..aa79b80f1 100644 --- a/packages/server/src/workspaces/instance-client.ts +++ b/packages/server/src/workspaces/instance-client.ts @@ -1,4 +1,4 @@ -import type { OpenCodeClient } from "@opencode-ai/client" +import type { OpenCodeClient } from "@opencode/client" import type { WorkspaceManager } from "./manager" /** diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index c587e33e3..11dfd2f85 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeEvent } from "@opencode-ai/client" +import type { LocationRef, OpenCodeEvent } from "@opencode/client" import { EventBus } from "../events/bus" import type { Logger } from "../logger" import { InstanceEventBridge } from "./instance-events" @@ -38,8 +38,8 @@ function serverConnected(): OpenCodeEvent { function locationlessManager( events: OpenCodeEvent[], - sessionLocations: Record, - workspaces = [{ id: "a", path: "/repo-a" }], + sessionLocations: Record, + workspaces: Array<{ id: string; path: string; workspaceID?: string }> = [{ id: "a", path: "/repo-a" }], ) { let sessionGets = 0 const manager = { @@ -47,13 +47,16 @@ function locationlessManager( ownsDirectory: async (workspaceId: string, directory: string) => ( workspaces.some((workspace) => workspace.id === workspaceId && workspace.path === directory) ), + ownsLocation: async (workspaceId: string, location: LocationRef) => workspaces.some(workspace => ( + workspace.id === workspaceId && workspace.path === location.directory && workspace.workspaceID === location.workspaceID + )), getSharedServiceClient: async () => ({ session: { get: async ({ sessionID }: { sessionID: string }) => { sessionGets++ const location = sessionLocations[sessionID] if (location instanceof Error) throw location if (!location) throw new Error("Session not found") - return { id: sessionID, location: { directory: location } } + return { id: sessionID, location: typeof location === "string" ? { directory: location } : location } } }, }), subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { @@ -66,6 +69,104 @@ function locationlessManager( } describe("InstanceEventBridge", () => { + it("keeps same-directory legacy identities separate for scoped and cached locationless events", async () => { + const one = { directory: "/repo", workspaceID: "one" } + const two = { directory: "/repo", workspaceID: "two" } + const events = [ + { type: "session.text.delta", location: one, data: { sessionID: "s1", delta: "one" } }, + { type: "session.text.delta", location: two, data: { sessionID: "s2", delta: "two" } }, + { type: "session.text.delta", data: { sessionID: "s1", delta: "cached one" } }, + { type: "session.text.delta", data: { sessionID: "s2", delta: "cached two" } }, + { type: "session.text.delta", data: { sessionID: "s3", delta: "resolved two" } }, + { type: "session.text.delta", data: { sessionID: "s3", delta: "cached resolved two" } }, + ] as OpenCodeEvent[] + const { manager, sessionGets } = locationlessManager(events, { s3: two }, [ + { id: "a", path: "/repo", workspaceID: "one" }, { id: "b", path: "/repo", workspaceID: "two" }, + ]) + let checks = 0 + const owns = manager.ownsLocation.bind(manager) + manager.ownsLocation = async (...args) => { checks++; return owns(...args) } + const bus = new EventBus() + const received: string[] = [] + bus.on("instance.event", event => { if (event.event.type !== "server.connected") received.push(event.instanceId) }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] }) + await waitFor(() => received.length === events.length) + assert.deepEqual(received, ["a", "b", "a", "b", "b", "b"]) + assert.equal(checks, 4, "one lookup per full location and logical workspace") + assert.equal(sessionGets(), 1) + } finally { bridge.shutdown() } + }) + + it("retains scoped PTY and Shell identity for later locationless events", async () => { + const location = { directory: "/repo", workspaceID: "two" } + const events = [ + { type: "pty.created", location, data: { info: { id: "p", cwd: "/repo" } } }, + { type: "pty.updated", data: { info: { id: "p", cwd: "/repo/subdirectory" } } }, + { type: "pty.deleted", data: { id: "p" } }, + { type: "shell.created", location, data: { info: { id: "sh", cwd: "/repo" } } }, + { type: "shell.exited", data: { id: "sh" } }, + { type: "shell.deleted", data: { id: "sh" } }, + ] as OpenCodeEvent[] + const { manager } = locationlessManager(events, {}, [ + { id: "a", path: "/repo", workspaceID: "one" }, { id: "b", path: "/repo", workspaceID: "two" }, + ]) + const bus = new EventBus() + const received: string[] = [] + bus.on("instance.event", event => { if (event.event.type !== "server.connected") received.push(event.instanceId) }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] }) + await waitFor(() => received.length === events.length) + assert.deepEqual(received, events.map(() => "b")) + } finally { bridge.shutdown() } + }) + + it("does not retain a moved session's old envelope identity", async () => { + const one = { directory: "/repo", workspaceID: "one" } + const two = { directory: "/repo", workspaceID: "two" } + const events = [ + { type: "session.text.delta", location: one, data: { sessionID: "s", delta: "before" } }, + { type: "session.moved", location: one, data: { sessionID: "s", location: two } }, + { type: "session.text.delta", data: { sessionID: "s", delta: "after" } }, + ] as OpenCodeEvent[] + const { manager, sessionGets } = locationlessManager(events, { s: two }, [ + { id: "a", path: "/repo", workspaceID: "one" }, { id: "b", path: "/repo", workspaceID: "two" }, + ]) + const bus = new EventBus() + const received: string[] = [] + bus.on("instance.event", event => { if (event.event.type !== "server.connected") received.push(event.instanceId) }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] }) + await waitFor(() => received.length === events.length) + assert.deepEqual(received, ["a", "a", "b"]) + assert.equal(sessionGets(), 1) + } finally { bridge.shutdown() } + }) + + it("never treats the global Form sentinel as a session location cache key", async () => { + const location = { directory: "/repo", workspaceID: "one" } + const events = [ + { type: "form.created", location, data: { form: { id: "scoped", sessionID: "global" } } }, + { type: "form.created", data: { form: { id: "unscoped", sessionID: "global" } } }, + { type: "model.updated", data: {} }, + ] as OpenCodeEvent[] + const { manager, sessionGets } = locationlessManager(events, { global: location }, [ + { id: "a", path: "/repo", workspaceID: "one" }, { id: "b", path: "/repo", workspaceID: "two" }, + ]) + const bus = new EventBus() + const received: Array<{ instanceId: string; event: OpenCodeEvent }> = [] + bus.on("instance.event", event => { if (event.event.type !== "server.connected") received.push(event) }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] }) + await waitFor(() => received.length === 3) + assert.equal(received.filter(event => event.event.type === "form.created").length, 1) + assert.equal(sessionGets(), 0) + } finally { bridge.shutdown() } + }) it("does not publish connected until the stream confirms with its first event", async () => { const gate = deferred() const manager = { @@ -312,7 +413,7 @@ describe("InstanceEventBridge", () => { subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { yield serverConnected() yield { type: "permission.asked", location: { directory: "/repo" }, data: { id: "p1" } } as OpenCodeEvent - yield { type: "catalog.updated", data: {} } as OpenCodeEvent + yield { id: "model-update", created: 1, type: "model.updated", data: {} } satisfies OpenCodeEvent await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) })(), } as unknown as WorkspaceManager @@ -329,7 +430,7 @@ describe("InstanceEventBridge", () => { workspaces = [workspaces[1]] retry.resolve(false) await waitFor(() => received.length === 1) - assert.equal(received[0].event.type, "catalog.updated") + assert.equal(received[0].event.type, "model.updated") assert.equal(received[0].instanceId, "flaky") } finally { bridge.shutdown() @@ -393,7 +494,7 @@ describe("InstanceEventBridge", () => { const events = [ { type: "session.status", data: { sessionID: "unknown", status: { type: "idle" } } }, { type: "permission.asked", data: { id: "unknown", sessionID: "unknown" } }, - { type: "catalog.updated", data: {} }, + { type: "model.updated", data: {} }, ] as OpenCodeEvent[] const { manager, sessionGets } = locationlessManager(events, { unknown: new Error("not found") }) const bus = new EventBus() @@ -407,7 +508,7 @@ describe("InstanceEventBridge", () => { bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) await waitFor(() => received.length === 1) assert.equal(sessionGets(), 2) - assert.equal(received[0].event.type, "catalog.updated") + assert.equal(received[0].event.type, "model.updated") } finally { bridge.shutdown() } @@ -433,7 +534,7 @@ describe("InstanceEventBridge", () => { yield { type: "permission.asked", data: { id: "p1", sessionID: "missing" } } as OpenCodeEvent now = 3_001 yield { type: "permission.asked", data: { id: "p2", sessionID: "missing" } } as OpenCodeEvent - yield { type: "catalog.updated", data: {} } as OpenCodeEvent + yield { id: "model-update", created: 1, type: "model.updated", data: {} } satisfies OpenCodeEvent await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) })(), } as unknown as WorkspaceManager @@ -448,7 +549,7 @@ describe("InstanceEventBridge", () => { bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) await waitFor(() => received.length === 1) assert.equal(sessionGets, 2) - assert.equal(received[0].event.type, "catalog.updated") + assert.equal(received[0].event.type, "model.updated") } finally { Date.now = originalNow bridge.shutdown() @@ -578,10 +679,11 @@ describe("InstanceEventBridge", () => { it("broadcasts safe global locationless service events", async () => { const events = [ { type: "agent.updated", data: {} }, - { type: "catalog.updated", data: {} }, + { type: "model.updated", data: {} }, { type: "command.updated", data: {} }, { type: "config.updated", data: {} }, - { type: "integration.connection.updated", data: { integrationID: "test" } }, + { type: "provider.updated", data: {} }, + { type: "plugin.updated", data: {} }, { type: "integration.updated", data: {} }, { type: "mcp.resources.changed", data: { server: "test" } }, { type: "mcp.status.changed", data: { server: "test" } }, diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 50e0d080f..cb3c8aa05 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -1,27 +1,33 @@ -import { isSessionNotFoundError, type OpenCodeEvent } from "@opencode-ai/client" +import { isSessionNotFoundError, type LocationRef, type OpenCodeEvent } from "@opencode/client" +import { readLocationRef } from "../opencode/compatibility/location" import { EventBus } from "../events/bus" import { Logger } from "../logger" import { WorkspaceManager } from "./manager" import { InstanceStreamStatus } from "../api-types" const RECONNECT_DELAY_MS = 1000 -const DIRECTORY_OWNER_CACHE_MS = 2000 -const SESSION_DIRECTORY_CACHE_MS = 2000 +const LOCATION_OWNER_CACHE_MS = 2000 +const SESSION_LOCATION_CACHE_MS = 2000 const GLOBAL_EVENT_TYPES = new Set([ - "agent.updated", "catalog.updated", + "agent.updated", "command.updated", "config.updated", "credential.switched", "credential.updated", - "integration.connection.updated", "integration.updated", "installation.update-available", "installation.updated", "mcp.resources.changed", "mcp.status.changed", "models-dev.refreshed", + "model.updated", + "plugin.updated", + "provider.updated", + "reference.updated", "server.connected", + "skill.updated", + "websearch.updated", ]) interface InstanceEventBridgeOptions { @@ -35,10 +41,10 @@ export class InstanceEventBridge { private status: InstanceStreamStatus = "connecting" private generation = 0 private task?: Promise - private readonly directoryOwners = new Map }>() - private readonly sessionDirectories = new Map }>() - private readonly ptyDirectories = new Map() - private readonly shellDirectories = new Map() + private readonly locationOwners = new Map }>() + private readonly sessionLocations = new Map }>() + private readonly ptyLocations = new Map() + private readonly shellLocations = new Map() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.clearLocationCaches() if (!this.task) this.task = this.run() @@ -104,15 +110,17 @@ export class InstanceEventBridge { const sessionId = this.sessionId(event) const ptyId = this.ptyId(event) const shellId = this.shellId(event) - if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) + if (event.type === "session.moved" && sessionId) this.sessionLocations.delete(sessionId) - const directory = event.location?.directory - ?? this.ptyInfoDirectory(event) - ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) - ?? this.shellInfoDirectory(event) - ?? (shellId ? this.shellDirectories.get(shellId) : undefined) - ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) - if (!directory) { + // A scoped event is native location authority, not merely a cwd hint. Keep + // that full pair for subsequent locationless PTY/Shell/session events. + const location = event.location ? readLocationRef(event.location) + : (ptyId ? this.ptyLocations.get(ptyId) : undefined) + ?? this.ptyInfoLocation(event) + ?? (shellId ? this.shellLocations.get(shellId) : undefined) + ?? this.shellInfoLocation(event) + ?? (sessionId ? await this.resolveSessionLocation(sessionId) : undefined) + if (!location) { if (GLOBAL_EVENT_TYPES.has(event.type)) { this.broadcastEvent(event) return @@ -121,39 +129,41 @@ export class InstanceEventBridge { // Deletion can make session.get return 404 before the event arrives. Session IDs are // service-global, so notifying every logical workspace cannot delete another session. this.broadcastEvent(event) - this.sessionDirectories.delete(sessionId) + this.sessionLocations.delete(sessionId) } return } - if (sessionId) { - this.sessionDirectories.set(sessionId, { - expiresAt: Date.now() + SESSION_DIRECTORY_CACHE_MS, - directory: Promise.resolve(directory), + // The moved envelope can refer to the old location. The next locationless + // event must resolve the native session rather than cache that old owner. + if (sessionId && event.type !== "session.moved") { + this.sessionLocations.set(sessionId, { + expiresAt: Date.now() + SESSION_LOCATION_CACHE_MS, + location: Promise.resolve(location), }) } - if (ptyId) this.ptyDirectories.set(ptyId, directory) - if (shellId) this.shellDirectories.set(shellId, directory) + if (ptyId) this.ptyLocations.set(ptyId, location) + if (shellId) this.shellLocations.set(shellId, location) - const instanceIds = await this.resolveDirectoryOwners(directory) + const instanceIds = await this.resolveLocationOwners(location) if (instanceIds.length === 0) { - if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) - if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) - if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) + if (event.type === "session.deleted" && sessionId) this.sessionLocations.delete(sessionId) + if (event.type === "pty.deleted" && ptyId) this.ptyLocations.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellLocations.delete(shellId) return } for (const instanceId of instanceIds) { this.options.eventBus.publish({ type: "instance.event", instanceId, event }) } - if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) - if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) - if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) + if (event.type === "session.deleted" && sessionId) this.sessionLocations.delete(sessionId) + if (event.type === "pty.deleted" && ptyId) this.ptyLocations.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellLocations.delete(shellId) } private sessionId(event: OpenCodeEvent): string | undefined { const data = event.data as { sessionID?: unknown; form?: { sessionID?: unknown } } const sessionId = data.sessionID ?? (event.type === "form.created" ? data.form?.sessionID : undefined) - return typeof sessionId === "string" && sessionId ? sessionId : undefined + return typeof sessionId === "string" && sessionId && sessionId !== "global" ? sessionId : undefined } private ptyId(event: OpenCodeEvent): string | undefined { @@ -163,10 +173,10 @@ export class InstanceEventBridge { return typeof id === "string" && id ? id : undefined } - private ptyInfoDirectory(event: OpenCodeEvent): string | undefined { + private ptyInfoLocation(event: OpenCodeEvent): LocationRef | undefined { if (event.type !== "pty.created" && event.type !== "pty.updated") return undefined const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd - return typeof cwd === "string" && cwd ? cwd : undefined + return typeof cwd === "string" && cwd ? { directory: cwd } : undefined } private shellId(event: OpenCodeEvent): string | undefined { @@ -176,10 +186,10 @@ export class InstanceEventBridge { return typeof id === "string" && id ? id : undefined } - private shellInfoDirectory(event: OpenCodeEvent): string | undefined { + private shellInfoLocation(event: OpenCodeEvent): LocationRef | undefined { if (event.type !== "shell.created") return undefined const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd - return typeof cwd === "string" && cwd ? cwd : undefined + return typeof cwd === "string" && cwd ? { directory: cwd } : undefined } private broadcastEvent(event: OpenCodeEvent): void { @@ -188,48 +198,52 @@ export class InstanceEventBridge { } } - private resolveSessionDirectory(sessionId: string): Promise { + private resolveSessionLocation(sessionId: string): Promise { const now = Date.now() - const cached = this.sessionDirectories.get(sessionId) - if (cached && cached.expiresAt > now) return cached.directory + const cached = this.sessionLocations.get(sessionId) + if (cached && cached.expiresAt > now) return cached.location const resolve = () => this.options.workspaceManager.getSharedServiceClient() .then((client) => client.session.get({ sessionID: sessionId })) - .then((session) => session.location.directory) - const directory = resolve().catch((error) => { + .then((session) => readLocationRef(session.location)) + const location = resolve().catch((error) => { if (isSessionNotFoundError(error)) return undefined return resolve().catch((retryError) => { this.options.logger.warn({ err: retryError, sessionId }, "Failed to resolve instance event session location") return undefined }) }) - const entry = { expiresAt: Number.POSITIVE_INFINITY, directory } - this.sessionDirectories.set(sessionId, entry) - const settle = () => { entry.expiresAt = Date.now() + SESSION_DIRECTORY_CACHE_MS } - void directory.then(settle, settle) - return directory + const entry = { expiresAt: Number.POSITIVE_INFINITY, location } + this.sessionLocations.set(sessionId, entry) + const settle = () => { entry.expiresAt = Date.now() + SESSION_LOCATION_CACHE_MS } + void location.then(settle, settle) + return location } - private resolveDirectoryOwners(directory: string): Promise { + private resolveLocationOwners(location: LocationRef): Promise { const now = Date.now() - const cached = this.directoryOwners.get(directory) + const key = JSON.stringify([location.directory, location.workspaceID]) + const cached = this.locationOwners.get(key) if (cached && cached.expiresAt > now) return cached.owners const workspaces = this.options.workspaceManager.list() + const owns = (id: string) => location.workspaceID === undefined + ? this.options.workspaceManager.ownsDirectory(id, location.directory) + : this.options.workspaceManager.ownsLocation(id, location) const owners = Promise.allSettled(workspaces.map((workspace) => ( - this.options.workspaceManager.ownsDirectory(workspace.id, directory) + owns(workspace.id) ))) .then(async (ownership) => { if (ownership.some((result) => result.status === "rejected")) { ownership = await Promise.allSettled(ownership.map((result, index) => ( result.status === "fulfilled" ? Promise.resolve(result.value) - : this.options.workspaceManager.ownsDirectory(workspaces[index].id, directory) + : owns(workspaces[index].id) ))) } const failed = ownership.find((result) => result.status === "rejected") if (failed) { - this.options.logger.warn({ err: failed.reason, directory }, "Failed to resolve instance event directory owner") + this.options.logger.warn({ err: failed.reason, location }, "Failed to resolve instance event location owner") } const currentIds = new Set(this.options.workspaceManager.list().map((workspace) => workspace.id)) return ownership.flatMap((result, index) => { @@ -238,17 +252,17 @@ export class InstanceEventBridge { }) }) const entry = { expiresAt: Number.POSITIVE_INFINITY, owners } - this.directoryOwners.set(directory, entry) - const settle = () => { entry.expiresAt = Date.now() + DIRECTORY_OWNER_CACHE_MS } + this.locationOwners.set(key, entry) + const settle = () => { entry.expiresAt = Date.now() + LOCATION_OWNER_CACHE_MS } void owners.then(settle, settle) return owners } private clearLocationCaches(): void { - this.directoryOwners.clear() - this.sessionDirectories.clear() - this.ptyDirectories.clear() - this.shellDirectories.clear() + this.locationOwners.clear() + this.sessionLocations.clear() + this.ptyLocations.clear() + this.shellLocations.clear() } private updateStatus(status: InstanceStreamStatus, reason?: string) { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index cac6ca1ea..5c21deb0b 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import path from "node:path" import { describe, it } from "node:test" -import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" +import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client" import pino from "pino" import { EventBus } from "../events/bus" @@ -32,6 +32,7 @@ class ControlledSharedService { headerFailures = 0 validationCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions }> = [] debugLocations: LocationRef[] = [] + resolvedLocation?: LocationRef shutdownCalls = 0 shutdownGate?: ReturnType> shutdownTimeouts: number[] = [] @@ -76,7 +77,7 @@ class ControlledSharedService { const directory = location.directory return { directory, - workspaceID: location.workspaceID ?? "location-1", + ...this.resolvedLocation, project: { id: "project-1", directory, canonical: directory }, } } @@ -119,7 +120,7 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor } describe("workspace manager shared service lifecycle", () => { - it("validates native workspace identity against an owned directory", async () => { + it("validates native directory ownership and rejects removed workspace selectors", async () => { const service = new ControlledSharedService() service.debugLocations = [ { directory: process.cwd(), workspaceID: "worktree-location" }, @@ -128,10 +129,9 @@ describe("workspace manager shared service lifecycle", () => { const { manager } = createHarness(service) const workspace = await manager.create(process.cwd()) - assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "location-1" }), true) + assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd() }), true) assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "foreign" }), false) assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "mismatch-location" }), false) - assert.equal(await manager.ownsLocationWorkspace(workspace.workspace.id, "worktree-location"), true) }) it("distinguishes WSL service paths from Windows host paths", () => { @@ -158,6 +158,48 @@ describe("workspace manager shared service lifecycle", () => { ) }) + it("retains and validates native identity rather than authorizing an arbitrary owned-directory selector", async () => { + const service = new ControlledSharedService() + service.resolvedLocation = { directory: process.cwd(), workspaceID: "native-one" } + const { manager } = createHarness(service) + const { workspace } = await manager.create(process.cwd()) + assert.equal(await manager.ownsLocation(workspace.id, service.resolvedLocation), true) + assert.equal(await manager.ownsLocation(workspace.id, { directory: process.cwd(), workspaceID: "native-two" }), false) + service.resolvedLocation = { directory: path.join(process.cwd(), "foreign"), workspaceID: "native-one" } + assert.equal(await manager.ownsLocation(workspace.id, { directory: process.cwd(), workspaceID: "native-one" }), false) + await manager.delete(workspace.id) + assert.deepEqual(service.evictionCalls[0].location, { directory: process.cwd(), workspaceID: "native-one" }) + }) + + it("does not share lifecycle ownership between distinct legacy identities at one directory", async () => { + const service = new ControlledSharedService() + const { manager } = createHarness(service) + service.resolvedLocation = { directory: process.cwd(), workspaceID: "one" } + const first = await manager.create(process.cwd()) + service.resolvedLocation = { directory: process.cwd(), workspaceID: "two" } + const second = await manager.create(process.cwd()) + await manager.delete(first.workspace.id) + assert.deepEqual(service.evictionCalls.map(call => call.location.workspaceID), ["one"]) + assert.ok(manager.get(second.workspace.id)) + await manager.delete(second.workspace.id) + assert.deepEqual(service.evictionCalls.map(call => call.location.workspaceID), ["one", "two"]) + }) + + it("uses the caller's pinned client for legacy location authorization", async () => { + const service = new ControlledSharedService() + const { manager } = createHarness(service) + const { workspace } = await manager.create(process.cwd()) + const location = { directory: process.cwd(), workspaceID: "pinned-native" } + const calls = service.validationCalls.length + const client = { location: { get: async (_input: unknown, options: { headers: Record }) => { + assert.deepEqual(JSON.parse(decodeURIComponent(options.headers["x-codenomad-location"])), location) + return location + } } } as unknown as OpenCodeClient + assert.equal(await manager.ownsLocation(workspace.id, location, client), true) + assert.equal(service.validationCalls.length, calls) + await manager.delete(workspace.id) + }) + it("pins a bounded host CLI lifecycle with binary, platform, and startup environment identity", async () => { const service = new ControlledSharedService() let factoryCall: unknown[] | undefined @@ -486,7 +528,7 @@ describe("workspace manager shared service lifecycle", () => { await deletion assert.deepEqual(harness.manager.list(), []) assert.equal(harness.service.evictionCalls.length, 1) - assert.equal(harness.service.evictionCalls[0]?.location.workspaceID, "location-1") + assert.deepEqual(harness.service.evictionCalls[0]?.location, { directory: process.cwd() }) assert.equal(harness.service.evictionCalls[0]?.signal, undefined) }) @@ -498,7 +540,6 @@ describe("workspace manager shared service lifecycle", () => { assert.equal(harness.service.evictionCalls.length, 1) assert.deepEqual(harness.service.evictionCalls[0]?.location, { directory: process.cwd(), - workspaceID: "location-1", }) assert.equal(harness.service.shutdownCalls, 0) }) @@ -685,7 +726,6 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.service.evictionCalls.map(({ location }) => location), [{ directory: process.cwd(), - workspaceID: "location-1", }]) assert.equal((harness.manager as any).workspaces.size, 0) }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index b09a74f89..6c525e2f1 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,8 +2,8 @@ import path from "path" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" import { realpath } from "node:fs/promises" -import type { Endpoint } from "@opencode-ai/client/service" -import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" +import type { Endpoint } from "@opencode/client/service" +import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -31,6 +31,7 @@ import { } from "./opencode-service" import { WslOpenCodeService } from "./wsl-opencode-service" import { isPathOwnedByWorktree, resolveOwnedWorktreePath } from "./worktree-directory" +import { locationRequestOptions, readLocationRef, sameLocation } from "../opencode/compatibility/location" const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 const MAX_ACTIVE_WORKSPACE_CREATIONS = 32 @@ -38,6 +39,8 @@ const WORKSPACE_STATE = Symbol("workspaceState") type ManagerTimeout = number | NodeJS.Timeout interface SharedService { + acquire?: () => Promise + fetch?: () => Promise endpoint: (options?: OpenCodeSharedServiceOptions) => Promise client: (options?: OpenCodeSharedServiceOptions) => Promise headers: (options?: OpenCodeSharedServiceOptions, requestOptions?: { deadlineAt?: number }) => Promise<{ authorization: string } | undefined> @@ -209,6 +212,15 @@ export class WorkspaceManager { return this.sharedService.client() } + getSharedServiceFetch(): Promise { + return this.sharedService.fetch ? this.sharedService.fetch() : Promise.resolve(globalThis.fetch) + } + + async getSharedServiceConnection(id: string): Promise { + if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined + return this.sharedService.acquire?.() + } + invalidateSharedServiceConnection(): void { this.sharedService.invalidate?.() } @@ -223,38 +235,21 @@ export class WorkspaceManager { return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory)) } - async ownsLocation(id: string, location: LocationRef): Promise { + async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): Promise { const record = this.workspaces.get(id) if (!record?.[WORKSPACE_STATE].published) return false - const requested = await this.resolveOwnedWorktree(record, location.directory) - if (!requested) return false - if (!location.workspaceID) return true + const directory = await this.getServiceDirectoryForPath(id, location.directory) + if (!directory) return false + if (location.workspaceID === undefined) return true + // The directory fence alone cannot authorize a legacy native workspace. + // Resolve the pair with the selected runtime and require exact identity. + // Modern transports reject the context before making a native request. try { - const candidates = record.location?.workspaceID === location.workspaceID - ? [record.location] - : (await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list()) - .filter((candidate) => candidate.workspaceID === location.workspaceID) - for (const candidate of candidates) { - const resolved = await this.resolveOwnedWorktree(record, candidate.directory) - if (resolved && canonicalWorktreeIdentity(resolved.worktreeDirectory, this.options.platform) - === canonicalWorktreeIdentity(requested.worktreeDirectory, this.options.platform)) return true - } - return false - } catch { - return false - } - } - - async ownsLocationWorkspace(id: string, workspaceID: string): Promise { - const record = this.workspaces.get(id) - if (!record?.[WORKSPACE_STATE].published) return false - if (record.location?.workspaceID === workspaceID) return true - try { - const locations = await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list() - for (const location of locations) { - if (location.workspaceID === workspaceID && await this.ownsDirectory(id, location.directory)) return true - } - return false + const requested = readLocationRef({ ...location, directory }) + const resolved = readLocationRef(client + ? await client.location.get({ location: { directory } }, locationRequestOptions(requested)) + : await this.sharedService.validateLocation(requested, undefined, record[WORKSPACE_STATE].serviceOptions)) + return sameLocation(requested, resolved) } catch { return false } @@ -562,14 +557,11 @@ export class WorkspaceManager { ), ]) if (state.abortController.signal.aborted && locationResult.status === "fulfilled") { - cancelledLocation = { - directory: locationResult.value.directory, - workspaceID: locationResult.value.workspaceID, - } + cancelledLocation = readLocationRef(locationResult.value) } this.throwIfCancelled(record) if (locationResult.status === "fulfilled") { - record.location = { directory: locationResult.value.directory, workspaceID: locationResult.value.workspaceID } + record.location = readLocationRef(locationResult.value) state.locationOwned = true } if (headersResult.status === "rejected") throw headersResult.reason @@ -826,7 +818,7 @@ export class WorkspaceManager { if (leftRecord[WORKSPACE_STATE].serviceOptions?.identity !== rightRecord[WORKSPACE_STATE].serviceOptions?.identity) { return false } - return left.directory === right.directory && left.workspaceID === right.workspaceID + return sameLocation(left, right) } private async withLocationCreation(operation: () => Promise): Promise { diff --git a/packages/server/src/workspaces/opencode-cli-service.ts b/packages/server/src/workspaces/opencode-cli-service.ts index 1452da5a0..27ace623a 100644 --- a/packages/server/src/workspaces/opencode-cli-service.ts +++ b/packages/server/src/workspaces/opencode-cli-service.ts @@ -1,11 +1,12 @@ import { execFile as nodeExecFile } from "node:child_process" -import { Service, type Endpoint } from "@opencode-ai/client/service" +import { Service, type Endpoint } from "@opencode/client/service" import { OPENCODE_V2_REQUIRED_ERROR_CODE } from "../api-types" import { assertLoopbackServiceUrl } from "./service-state" import { isOpenCodeServiceCommandUnavailable } from "./opencode-cli-compatibility" import type { OpenCodeServiceLifecycle } from "./opencode-service" import type { SpawnSpec } from "./spawn" +import { rememberRuntime } from "../opencode/compatibility/runtime" export const MAX_SERVICE_OUTPUT_BYTES = 64 * 1024 const MAX_ERROR_CHARS = 1_024 @@ -71,7 +72,7 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { if (!password) throw new Error(`${this.options.label} OpenCode service returned an empty password`) const endpoint: Endpoint = { url, auth: { type: "basic", username: "opencode", password } } await this.options.beforeHealth?.(endpoint, deadlineAt) - await this.validateHealth(endpoint, deadlineAt) + await this.validateStatus(endpoint, deadlineAt) return endpoint } @@ -119,47 +120,51 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { return result.stdout } - private async validateHealth(endpoint: Endpoint, deadlineAt: number): Promise { - let response: Response - try { - const timeout = this.remaining(deadlineAt, "health validation") - response = await this.withDeadline(this.dependencies.fetch(new URL("/api/health", endpoint.url), { - headers: Service.headers(endpoint), - signal: AbortSignal.timeout(timeout), - }), deadlineAt, "health validation") - } catch { - const message = this.options.unreachableMessage?.(endpoint.url) - throw new Error(message ?? `Cannot reach the ${this.options.label} OpenCode service at ${endpoint.url}`) + private async validateStatus(endpoint: Endpoint, deadlineAt: number): Promise { + let kind: "status" | "health" = "status" + let response = await this.fetchServiceStatus(endpoint, kind, deadlineAt) + // Earlier V2 runtimes expose health instead of status. Negotiate only on + // route absence, using the same authenticated endpoint and deadline. + if (response.status === 404) { + await this.withDeadline(response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), deadlineAt, "status response") + kind = "health" + response = await this.fetchServiceStatus(endpoint, kind, deadlineAt) } if (response.status === 401) { throw new Error(`${this.options.label} OpenCode service authentication failed at ${endpoint.url} (HTTP 401)`) } if (!response.ok) { - throw new Error(`${this.options.label} OpenCode service health check failed at ${endpoint.url} (HTTP ${response.status})`) + throw new Error(`${this.options.label} OpenCode service ${kind} check failed at ${endpoint.url} (HTTP ${response.status})`) } - let health: unknown + let payload: unknown try { const body = await this.withDeadline( readBoundedBody(response, MAX_SERVICE_OUTPUT_BYTES), deadlineAt, - "health response", + `${kind} response`, ) - health = JSON.parse(body) + payload = JSON.parse(body) } catch { - throw new Error(`${this.options.label} OpenCode service returned an invalid health response at ${endpoint.url}`) + throw new Error(`${this.options.label} OpenCode service returned an invalid ${kind} response at ${endpoint.url}`) } - const value = health as { healthy?: unknown; version?: unknown; pid?: unknown } | null - if ( - !value - || typeof value !== "object" - || value.healthy !== true - || typeof value.version !== "string" - || !value.version.trim() - || !Number.isSafeInteger(value.pid) - || Number(value.pid) <= 0 - ) { - throw new Error(`${this.options.label} OpenCode service returned an invalid health response at ${endpoint.url}`) + if (!(kind === "status" ? isServiceStatusResponse(payload) : isServiceHealthResponse(payload))) { + throw new Error(`${this.options.label} OpenCode service returned an invalid ${kind} response at ${endpoint.url}`) + } + const { version, pid } = payload as { version: string; pid: number } + rememberRuntime(endpoint, { version, pid, discovery: kind }) + } + + private async fetchServiceStatus(endpoint: Endpoint, kind: "status" | "health", deadlineAt: number): Promise { + try { + const timeout = this.remaining(deadlineAt, `${kind} validation`) + return await this.withDeadline(this.dependencies.fetch(new URL(`/api/${kind}`, endpoint.url), { + headers: Service.headers(endpoint), + signal: AbortSignal.timeout(timeout), + }), deadlineAt, `${kind} validation`) + } catch { + const message = this.options.unreachableMessage?.(endpoint.url) + throw new Error(message ?? `Cannot reach the ${this.options.label} OpenCode service at ${endpoint.url}`) } } @@ -218,6 +223,27 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { } } +function isServiceStatusResponse(value: unknown): boolean { + if (!value || typeof value !== "object") return false + const status = value as { version?: unknown; pid?: unknown; urls?: unknown } + return typeof status.version === "string" + && Boolean(status.version.trim()) + && Number.isSafeInteger(status.pid) + && Number(status.pid) >= 0 + && Array.isArray(status.urls) + && status.urls.every((url) => typeof url === "string") +} + +function isServiceHealthResponse(value: unknown): boolean { + if (!value || typeof value !== "object") return false + const health = value as { healthy?: unknown; version?: unknown; pid?: unknown } + return health.healthy === true + && typeof health.version === "string" + && Boolean(health.version.trim()) + && Number.isSafeInteger(health.pid) + && Number(health.pid) > 0 +} + function executeFile(file: string, args: string[], options: ServiceExecOptions): Promise { return new Promise((resolve, reject) => { nodeExecFile(file, args, options, (error, stdout, stderr) => { diff --git a/packages/server/src/workspaces/opencode-connection-generation.test.ts b/packages/server/src/workspaces/opencode-connection-generation.test.ts new file mode 100644 index 000000000..c4b027bbf --- /dev/null +++ b/packages/server/src/workspaces/opencode-connection-generation.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { OpenCodeSharedService } from "./opencode-service" +import { rememberRuntime } from "../opencode/compatibility/runtime" +import type { Endpoint } from "@opencode/client/service" +import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client" + +test("same URL and credentials cannot keep the previous daemon's adapter or invalidate its replacement", async () => { + let now = 0 + const makeEndpoint = (version: string, pid: number): Endpoint => { + const endpoint = { url: "http://127.0.0.1:4321" } + rememberRuntime(endpoint, { version, pid, discovery: version === "2.0.3" ? "health" : "status" }) + return endpoint + } + let endpoint = makeEndpoint("2.0.3", 1) + const service = new OpenCodeSharedService({ headers: () => undefined, makeClient: () => ({}) as never, now: () => now }) + await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) + const old = await service.acquire() + endpoint = makeEndpoint("2.0.4", 2) + now = 30_000 + const current = await service.acquire() + assert.notEqual(current, old) + assert.throws(old.assertCurrent, /connection changed/) + old.invalidate() + current.assertCurrent() + assert.equal(await service.acquire(), current) + await service.shutdown() + assert.throws(current.assertCurrent, /connection changed/) +}) + +test("invalidation during asynchronous mutation preparation prevents upstream dispatch", async (context) => { + const endpoint = { url: "http://127.0.0.1:4321" } + rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" }) + const upstream = context.mock.method(globalThis, "fetch", async () => new Response(null, { status: 204 })) + const service = new OpenCodeSharedService() + await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) + const connection = await service.acquire() + const pending = connection.fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", body: JSON.stringify({ title: "fixture" }) }) + service.invalidate() + await service.acquire() + await assert.rejects(pending, /connection changed/) + assert.equal(upstream.mock.callCount(), 0) +}) + +test("a late old stream cannot publish events or invalidate the replacement connection", async () => { + const endpoint = { url: "http://127.0.0.1:4321" } + const service = new OpenCodeSharedService({ headers: () => undefined, makeClient: () => ({ + event: { subscribe: () => (async function* () { + yield { type: "server.connected", data: {} } as OpenCodeEvent + yield { type: "catalog.updated", data: {} } as unknown as OpenCodeEvent + })() }, + }) as unknown as OpenCodeClient }) + await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) + const iterator = (await service.subscribe())[Symbol.asyncIterator]() + assert.equal((await iterator.next()).value.type, "server.connected") + service.invalidate() + const current = await service.acquire() + await assert.rejects(iterator.next(), /connection changed/) + current.assertCurrent() + assert.equal(await service.acquire(), current) +}) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index ebe7404db..f7e785088 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient } from "@opencode-ai/client" -import type { Endpoint } from "@opencode-ai/client/service" +import type { OpenCodeClient } from "@opencode/client" +import type { Endpoint } from "@opencode/client/service" import { OpenCodeSharedService, @@ -185,7 +185,6 @@ describe("OpenCodeSharedService", () => { return { location: { get: async () => ({ directory: "/repo", - workspaceID: "canonical", project: { id: "project", directory: "/repo", canonical: "/repo" }, }) }, debug: { location: { evict: async (input: unknown, request?: { signal?: AbortSignal }) => { @@ -201,16 +200,16 @@ describe("OpenCodeSharedService", () => { assert.deepEqual(await service.headers(options), { authorization: "Basic proxy" }) await assert.rejects( service.validateLocation({ directory: "/repo", workspaceID: "foreign" }, undefined, options), - /does not match/, + /identified by directory/, ) await service.evictLocation( - { directory: "/repo", workspaceID: "canonical" }, + { directory: "/repo" }, { signal }, options, ) assert.deepEqual(clientHeaders, { authorization: "Basic proxy" }) - assert.deepEqual(evicted, { location: { directory: "/repo", workspace: "canonical" } }) + assert.deepEqual(evicted, { location: { directory: "/repo" } }) assert.equal(evictionSignal, signal) }) }) diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 199383262..fb5350806 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -4,9 +4,13 @@ import { type LocationRef, type OpenCodeClient, type OpenCodeEvent, -} from "@opencode-ai/client" -import { Service, type Endpoint } from "@opencode-ai/client/service" +} from "@opencode/client" +import { Service, type Endpoint } from "@opencode/client/service" import { assertLoopbackServiceUrl } from "./service-state" +import { createRuntimeTransport } from "../opencode/compatibility/transport" +import { contractProfile, rememberRuntime, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime" +import { normalizeRuntimeEvent } from "../opencode/compatibility/events" +import { locationRequestOptions } from "../opencode/compatibility/location" type RequestOptions = { signal?: AbortSignal; deadlineAt?: number } const CONNECTION_RECHECK_INTERVAL_MS = 30_000 @@ -22,9 +26,13 @@ export type OpenCodeSharedServiceOptions = { lifecycle: OpenCodeServiceLifecycle } -interface ServiceConnection { +export interface ServiceConnection { endpoint: Endpoint client: OpenCodeClient + fetch: typeof fetch + assertCurrent: () => void + invalidate: () => void + profile: (signal?: AbortSignal) => Promise } export interface OpenCodeSharedServiceDependencies { @@ -43,6 +51,7 @@ export class OpenCodeSharedService { private readonly now: () => number private connectionValidatedAt?: number private generation = 0 + private readonly negotiationControllers = new WeakMap() constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { headers: Service.headers, @@ -59,6 +68,14 @@ export class OpenCodeSharedService { return this.connect(options, requestOptions?.deadlineAt).then(({ client }) => client) } + async fetch(): Promise { + return (await this.connect()).fetch + } + + acquire(): Promise { + return this.connect() + } + async headers(options?: OpenCodeSharedServiceOptions, requestOptions?: RequestOptions): Promise> { return this.dependencies.headers(await this.endpoint(options, requestOptions)) } @@ -68,9 +85,14 @@ export class OpenCodeSharedService { requestOptions?: RequestOptions, serviceOptions?: OpenCodeSharedServiceOptions, ): Promise { - const result = await this.withClient(serviceOptions, (client) => client.location.get({ - location: { directory: location.directory }, - }, requestOptions?.signal ? { signal: requestOptions.signal } : undefined), requestOptions) + const result = await this.withClient(serviceOptions, (client, connection) => { + if (location.workspaceID !== undefined && contractProfile(runtimeIdentity(connection.endpoint)) === "modern") { + throw new Error("OpenCode V2 locations are identified by directory") + } + return client.location.get({ location: { directory: location.directory } }, { + ...locationRequestOptions(location), ...(requestOptions?.signal ? { signal: requestOptions.signal } : {}), + }) + }, requestOptions) if ( !result || typeof result.directory !== "string" @@ -80,9 +102,6 @@ export class OpenCodeSharedService { ) { throw new Error("OpenCode returned an invalid location") } - if (location.workspaceID && result.workspaceID !== location.workspaceID) { - throw new Error("OpenCode location workspace does not match the canonical location") - } return result } @@ -94,9 +113,8 @@ export class OpenCodeSharedService { await this.withClient(serviceOptions, (client) => client.debug.location.evict({ location: { directory: location.directory, - ...(location.workspaceID ? { workspace: location.workspaceID } : {}), }, - }, requestOptions?.signal ? { signal: requestOptions.signal } : undefined), requestOptions) + }, { ...locationRequestOptions(location), ...(requestOptions?.signal ? { signal: requestOptions.signal } : {}) }), requestOptions) } async subscribe(requestOptions?: RequestOptions, serviceOptions?: OpenCodeSharedServiceOptions): Promise> { @@ -106,7 +124,7 @@ export class OpenCodeSharedService { const nativeRequestOptions = requestOptions?.signal ? { signal: requestOptions.signal } : undefined return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection) } catch (error) { - if (connection) this.invalidateConnection(connection) + if (connection && !requestOptions?.signal?.aborted) this.invalidateConnection(connection) throw error } } @@ -140,7 +158,9 @@ export class OpenCodeSharedService { ) { return Promise.resolve(current) } + const generation = this.generation const check = this.lifecycle().discover(deadlineAt).then((endpoint) => { + if (generation !== this.generation || this.connected !== current) return this.connect(undefined, deadlineAt) if (endpoint && this.sameEndpoint(endpoint, current.endpoint)) { this.connectionValidatedAt = this.now() return current @@ -148,6 +168,7 @@ export class OpenCodeSharedService { this.invalidateConnection(current) return endpoint ? this.createConnection(endpoint, this.generation) : this.startConnection(deadlineAt) }, () => { + if (generation !== this.generation || this.connected !== current) return this.connect(undefined, deadlineAt) this.invalidateConnection(current) return this.startConnection(deadlineAt) }) @@ -181,14 +202,41 @@ export class OpenCodeSharedService { private createConnection(endpoint: Endpoint, generation: number): ServiceConnection { const wildcard = new URL(endpoint.url).hostname === "0.0.0.0" const url = assertLoopbackServiceUrl(endpoint.url) - if (wildcard) endpoint = { ...endpoint, url: url.toString() } - const connection = { + if (wildcard) { + const identity = runtimeIdentity(endpoint) + endpoint = { ...endpoint, url: url.toString() } + if (identity) rememberRuntime(endpoint, identity) + } + const negotiation = new AbortController() + const transport = createRuntimeTransport(endpoint, (input, init) => { + // Preparation can await a request body. Fence the actual dispatch too. + connection.assertCurrent() + return globalThis.fetch(input, init) + }, negotiation.signal) + const fetch: typeof globalThis.fetch = (input, init) => { + connection.assertCurrent() + return transport.fetch(input, init) + } + const connection: ServiceConnection = { endpoint, + fetch, + profile: async (signal?: AbortSignal) => { + connection.assertCurrent() + const profile = await transport.profile(signal) + connection.assertCurrent() + return profile + }, + assertCurrent: () => { + if (this.connected !== connection) throw new Error("OpenCode connection changed; refresh before retrying") + }, + invalidate: () => this.invalidateConnection(connection), client: this.dependencies.makeClient({ baseUrl: endpoint.url, headers: this.dependencies.headers(endpoint), + fetch, }), } + this.negotiationControllers.set(connection, negotiation) if (generation === this.generation) { this.hasValidatedConnection = true this.connected = connection @@ -200,32 +248,36 @@ export class OpenCodeSharedService { private async withClient( options: OpenCodeSharedServiceOptions | undefined, - run: (client: OpenCodeClient) => Promise, + run: (client: OpenCodeClient, connection: ServiceConnection) => Promise, requestOptions?: RequestOptions, ): Promise { let connection: ServiceConnection | undefined try { connection = await this.connect(options, requestOptions?.deadlineAt) - return await run(connection.client) + return await run(connection.client, connection) } catch (error) { - if (connection) this.invalidateConnection(connection) + if (connection && !requestOptions?.signal?.aborted) this.invalidateConnection(connection) throw error } } private async *invalidateAfterStream(events: AsyncIterable, connection: ServiceConnection) { try { - yield* events + for await (const event of events) { + connection.assertCurrent() + yield normalizeRuntimeEvent(event) + } } finally { this.invalidateConnection(connection) } } private invalidateConnection(connection: ServiceConnection): void { - if (this.connected === connection) this.clear() + if (this.connected === connection) { this.generation += 1; this.clear() } } private clear(): void { + if (this.connected) this.negotiationControllers.get(this.connected)?.abort() this.connection = undefined this.connected = undefined this.healthCheck = undefined @@ -234,6 +286,8 @@ export class OpenCodeSharedService { private sameEndpoint(left: Endpoint, right: Endpoint): boolean { return left.url === right.url + && runtimeIdentity(left)?.version === runtimeIdentity(right)?.version + && runtimeIdentity(left)?.pid === runtimeIdentity(right)?.pid && left.auth?.username === right.auth?.username && left.auth?.password === right.auth?.password } diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts index 5d433b843..6e49abd75 100644 --- a/packages/server/src/workspaces/worktree-session-evacuation.test.ts +++ b/packages/server/src/workspaces/worktree-session-evacuation.test.ts @@ -1,12 +1,18 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" import { evacuateWorktreeSessions, WorktreeDeletionFence } from "./worktree-session-evacuation" +import { readLocationContext } from "../opencode/compatibility/location" function session(id: string, directory: string, parentID?: string): SessionInfo { return { id, parentID, projectID: "project", location: { directory }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo } +const location = { get: async ({ location }: { location: { directory: string } }) => location } + describe("evacuateWorktreeSessions", () => { it("serializes deletion attempts for the same worktree", async () => { const fence = new WorktreeDeletionFence() @@ -67,6 +73,7 @@ describe("evacuateWorktreeSessions", () => { const state = new Map([root, child, grandchild].map((item) => [item.id, item])) let removed = false const client = { + location, project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }], }, @@ -104,6 +111,7 @@ describe("evacuateWorktreeSessions", () => { let current = aliased let removed = false const client = { + location, project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, session: { list: async () => ({ data: [current], cursor: {} }), @@ -125,10 +133,166 @@ describe("evacuateWorktreeSessions", () => { assert.equal(removed, true) }) + it("resolves the project canonical path without collapsing containing-worktree identity", async () => { + const shortRoot = "C:\\Users\\RUNNER~1\\Temp\\repo" + const longRoot = "C:/Users/runneradmin/Temp/repo" + const target = `${shortRoot}\\worktree` + const original = { directory: target, workspaceID: "native-original" } + const current = { ...session("session", target), location: original } + const moves: unknown[] = [] + const client = { + location: { get: async () => ({ directory: longRoot }) }, + project: { list: async () => [ + { id: "foreign", canonical: "C:/Users/runneradmin/Temp/repo-other", sandboxes: [] }, + { id: "project", canonical: longRoot, sandboxes: [] }, + ] }, + session: { + list: async ({ project }: { project: string }) => { + assert.equal(project, "project") + return { data: [current], cursor: {} } + }, + active: async () => ({}), + move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { + const location = readLocationContext(options?.headers["x-codenomad-location"], "legacy") ?? { directory } + moves.push(location) + current.location = location as typeof original + }, + }, + } as unknown as OpenCodeClient + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: shortRoot, targetDirectory: target, rootDirectory: shortRoot, + resolveDirectoryIdentity: async directory => directory === shortRoot || directory === longRoot + ? "owned:root" : directory === target ? "owned:worktree" : undefined, + resolveExactDirectory: async directory => directory === shortRoot || directory === longRoot + ? longRoot : directory === target ? target : undefined, + remove: async () => { + assert.deepEqual(current.location, { directory: longRoot }) + throw new Error("Synthetic Git removal refusal") + }, + }), /Synthetic Git removal refusal/) + assert.deepEqual(moves, [{ directory: longRoot }, original]) + assert.deepEqual(current.location, original) + }) + + it("inventories the exact project when nested projects and sandboxes share its worktree identity", async () => { + const inventories: string[] = [] + let removed = false + const client = { + location, + project: { list: async () => [ + { id: "nested", canonical: "/repo/nested", sandboxes: [] }, + { id: "nested-sandbox", canonical: "/other", sandboxes: ["/repo/worktree/nested"] }, + { id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"] }, + ] }, + session: { + list: async ({ project }: { project: string }) => { + inventories.push(project) + return { data: project === "project" ? [session("active", "/repo/worktree/child")] : [], cursor: {} } + }, + active: async () => ({ active: { type: "running" } }), + move: async () => { assert.fail("Active sessions must not move") }, + }, + } as unknown as OpenCodeClient + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", + resolveDirectoryIdentity: async directory => directory.startsWith("/repo/worktree") ? "/repo/worktree" : "/repo", + resolveExactDirectory: async directory => directory.startsWith("/repo") ? directory : undefined, + remove: async () => { removed = true }, + }), /Active sessions block worktree deletion: active/) + assert.deepEqual(inventories, ["project"]) + assert.equal(removed, false) + }) + + it("keeps WSL directory translation exact and refuses an owned nested evacuation destination", async () => { + const hostRoot = "\\\\wsl.localhost\\Ubuntu\\home\\fixture\\repo" + const hostTarget = `${hostRoot}\\worktree` + const translate = async (directory: string) => directory === hostRoot ? "/home/fixture/repo" + : directory === hostTarget ? "/home/fixture/repo/worktree" : directory + let destination = "/home/fixture/repo/nested" + let removed = false + let moved = false + const client = { + location: { get: async () => ({ directory: destination }) }, + project: { list: async () => [{ id: "project", canonical: "/home/fixture/repo", sandboxes: [] }] }, + session: { + list: async () => ({ data: [], cursor: {} }), + active: async () => ({}), + move: async () => { moved = true }, + }, + } as unknown as OpenCodeClient + const params = { + client, projectDirectory: hostRoot, targetDirectory: hostTarget, rootDirectory: hostRoot, + resolveDirectoryIdentity: async (directory: string) => (await translate(directory)).includes("/worktree") ? "owned:worktree" : "owned:root", + resolveExactDirectory: translate, + remove: async () => { removed = true }, + } + await assert.rejects(evacuateWorktreeSessions(params), /foreign evacuation destination/) + assert.equal(removed, false) + assert.equal(moved, false) + destination = "/home/fixture/repo" + await evacuateWorktreeSessions(params) + assert.equal(removed, true) + }) + + it("fails closed when an exact directory cannot be resolved", async () => { + await assert.rejects(evacuateWorktreeSessions({ + client: {} as OpenCodeClient, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", + resolveDirectoryIdentity: async () => "owned:root", + resolveExactDirectory: async () => undefined, + remove: async () => { assert.fail("Unresolved directories must not be deleted") }, + }), /Unable to resolve owned directories/) + }) + + it("resolves a real junction/symlink project alias without admitting a foreign destination", async () => { + const temp = path.join(os.tmpdir(), "opencode") + await mkdir(temp, { recursive: true }) + const root = await mkdtemp(path.join(temp, "evacuation-alias-")) + try { + const canonical = path.join(root, "repository") + const alias = path.join(root, "alias") + const target = path.join(root, "worktree") + const foreign = path.join(root, "foreign") + await Promise.all([canonical, target, foreign].map(directory => mkdir(directory))) + await symlink(canonical, alias, process.platform === "win32" ? "junction" : "dir") + let destination = await realpath(canonical) + let current = session("session", target) + const moves: string[] = [] + const client = { + location: { get: async () => ({ directory: destination }) }, + project: { list: async () => [{ id: "project", canonical: destination, sandboxes: [] }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async ({ directory }: { directory: string }) => { + moves.push(directory) + current = { ...current, location: { directory } } + }, + }, + } as unknown as OpenCodeClient + const params = { + client, projectDirectory: alias, targetDirectory: target, rootDirectory: alias, + resolveDirectoryIdentity: (directory: string) => realpath(directory), + resolveExactDirectory: (directory: string) => realpath(directory), + remove: async () => { throw new Error("Synthetic Git removal refusal") }, + } + await assert.rejects(evacuateWorktreeSessions(params), /Synthetic Git removal refusal/) + assert.deepEqual(moves, [destination, target]) + assert.equal(current.location.directory, target) + moves.length = 0 + destination = foreign + await assert.rejects(evacuateWorktreeSessions(params), /Unable to resolve the OpenCode project/) + assert.deepEqual(moves, []) + client.project.list = async () => [{ id: "project", canonical, sandboxes: [] }] as any + await assert.rejects(evacuateWorktreeSessions(params), /foreign evacuation destination/) + assert.deepEqual(moves, []) + } finally { await rm(root, { recursive: true, force: true }) } + }) + it("evacuates sessions nested under the target worktree identity", async () => { let current = session("nested", "/repo/worktree/nested") let removed = false const client = { + location, project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, session: { list: async () => ({ data: [current], cursor: {} }), @@ -154,6 +318,7 @@ describe("evacuateWorktreeSessions", () => { const current = session("session", "/repo/worktree") const moves: string[] = [] const client = { + location, project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, session: { list: async () => ({ data: [current], cursor: {} }), @@ -179,6 +344,7 @@ describe("evacuateWorktreeSessions", () => { let listCalls = 0 let removed = false const client = { + location, project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, session: { list: async () => { @@ -198,4 +364,54 @@ describe("evacuateWorktreeSessions", () => { assert.equal(removed, false) assert.equal(current.location.directory, "/repo/worktree") }) + + it("uses the resolved destination and restores exact legacy identity on rollback", async () => { + const original = { directory: "/repo/worktree", workspaceID: "native-original" } + const destination = { directory: "/repo", workspaceID: "native-root" } + const current = { ...session("session", original.directory), location: original } + const moves: unknown[] = [] + const client = { + location: { get: async () => destination }, + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: [original.directory] }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { + const resolved = readLocationContext(options?.headers["x-codenomad-location"], "legacy")! + assert.equal(resolved.directory, directory) + moves.push(resolved) + current.location = { directory: resolved.directory, workspaceID: resolved.workspaceID! } + }, + }, + } as unknown as OpenCodeClient + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: original.directory, rootDirectory: destination.directory, + remove: async () => { throw new Error("Git removal failed") }, + }), /Git removal failed/) + assert.deepEqual(moves, [destination, original]) + assert.deepEqual(current.location, original) + }) + + it("does not report rollback success when only the directory was restored", async () => { + const original = { directory: "/repo/worktree", workspaceID: "native-original" } + const current = { ...session("session", original.directory), location: original } + let moves = 0 + const client = { + location, + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: [original.directory] }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async ({ directory }: { directory: string }) => { + moves++ + current.location = { directory, workspaceID: undefined! } + }, + }, + } as unknown as OpenCodeClient + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: original.directory, rootDirectory: "/repo", + remove: async () => { throw new Error("Git removal failed") }, + }), /could not be rolled back/) + assert.equal(moves, 2) + }) }) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts index 4724ba2a4..aba15aea9 100644 --- a/packages/server/src/workspaces/worktree-session-evacuation.ts +++ b/packages/server/src/workspaces/worktree-session-evacuation.ts @@ -1,5 +1,6 @@ -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" import { normalizeWslUncPath } from "./worktree-directory" +import { moveSessionToLocation, readLocationRef, sameLocation } from "../opencode/compatibility/location" const PAGE_SIZE = 200 const MAX_PAGES = 10_000 @@ -126,18 +127,33 @@ export async function evacuateWorktreeSessions(params: { targetDirectory: string rootDirectory: string resolveDirectoryIdentity?: (directory: string) => Promise + resolveExactDirectory?: (directory: string) => Promise remove: () => Promise }): Promise { const identity = async (directory: string) => normalizeDirectory( await params.resolveDirectoryIdentity?.(directory) ?? directory, ) + // Containing-worktree identity intentionally groups descendant sessions. It + // cannot identify a project root or destination: nested repositories differ. + const exactDirectory = async (directory: string) => { + const resolved = params.resolveExactDirectory ? await params.resolveExactDirectory(directory) : directory + return resolved === undefined ? undefined : normalizeDirectory(resolved) + } + const [projectDirectory, targetDirectory, rootDirectory] = await Promise.all([ + exactDirectory(params.projectDirectory), exactDirectory(params.targetDirectory), exactDirectory(params.rootDirectory), + ]) + if (projectDirectory === undefined || targetDirectory === undefined || rootDirectory === undefined) { + throw new Error("Unable to resolve owned directories before deleting worktree") + } const target = await identity(params.targetDirectory) const matchesTarget = async (directory: string) => await identity(directory) === target const projects = await params.client.project.list() let project: (typeof projects)[number] | undefined for (const candidate of projects) { - if (normalizeDirectory(candidate.canonical) === normalizeDirectory(params.projectDirectory) - || (await Promise.all(candidate.sandboxes.map(matchesTarget))).some(Boolean)) { + // Resolve aliases without collapsing a nested repository or nested sandbox + // into its containing worktree. Both comparisons require exact directories. + if (await exactDirectory(candidate.canonical) === projectDirectory + || (await Promise.all(candidate.sandboxes.map(exactDirectory))).includes(targetDirectory)) { project = candidate break } @@ -155,15 +171,24 @@ export async function evacuateWorktreeSessions(params: { await assertInactive() const moved: SessionInfo[] = [] + // Resolve before any move; a directory-only fallback could silently move a + // legacy session into a different native workspace. + const rootLocation = readLocationRef(await params.client.location.get({ location: { directory: params.rootDirectory } })) + if (await exactDirectory(rootLocation.directory) !== rootDirectory) { + throw new Error("OpenCode resolved a foreign evacuation destination") + } try { for (const session of affected) { await assertInactive() const original = { ...session, location: { ...session.location } } - await params.client.session.move({ sessionID: session.id, directory: params.rootDirectory }) + await moveSessionToLocation(params.client, session.id, rootLocation) moved.push(original) } await waitForInventory(params.client, project.id, async (current) => ( - !(await Promise.all(current.map((session) => matchesTarget(session.location.directory)))).some(Boolean) + moved.every((original) => { + const session = current.find((candidate) => candidate.id === original.id) + return session && sameLocation(readLocationRef(session.location), rootLocation) + }) && !(await Promise.all(current.map((session) => matchesTarget(session.location.directory)))).some(Boolean) )) const finalInventory = await inventorySessions(params.client, project.id) const finalAffected = (await Promise.all(finalInventory.map(async (session) => ( @@ -176,21 +201,17 @@ export async function evacuateWorktreeSessions(params: { const rollbackErrors: unknown[] = [] for (const session of moved.reverse()) { try { - await params.client.session.move({ - sessionID: session.id, - directory: session.location.directory, - workspaceID: session.location.workspaceID, - }) + await moveSessionToLocation(params.client, session.id, readLocationRef(session.location)) } catch (rollbackError) { rollbackErrors.push(rollbackError) } } try { - const expected = new Set(moved.map((session) => session.id)) + const expected = new Map(moved.map((session) => [session.id, readLocationRef(session.location)])) await waitForInventory(params.client, project.id, async (current) => { const restored = current.filter((session) => expected.has(session.id)) return restored.length === expected.size - && (await Promise.all(restored.map((session) => matchesTarget(session.location.directory)))).every(Boolean) + && restored.every((session) => sameLocation(readLocationRef(session.location), expected.get(session.id)!)) }) } catch (rollbackError) { rollbackErrors.push(rollbackError) diff --git a/packages/server/src/workspaces/wsl-opencode-service.test.ts b/packages/server/src/workspaces/wsl-opencode-service.test.ts index 9368f5eff..1c294c1f4 100644 --- a/packages/server/src/workspaces/wsl-opencode-service.test.ts +++ b/packages/server/src/workspaces/wsl-opencode-service.test.ts @@ -59,15 +59,15 @@ describe("WslOpenCodeService", () => { } }) - it("starts through the Linux CLI, fetches the password, and authenticates Windows health", async () => { - let healthRequest: { url: string; authorization: string | null } | undefined + it("starts through the Linux CLI, fetches the password, and authenticates Windows status", async () => { + let statusRequest: { url: string; authorization: string | null } | undefined const test = harness({ start: `${url}\r\n`, password: "start-secret\r\n" }, { fetch: async (input, init) => { - healthRequest = { + statusRequest = { url: String(input), authorization: new Headers(init?.headers).get("authorization"), } - return Response.json({ healthy: true, version: "2.0.0", pid: 987654 }) + return Response.json({ version: "2.0.4", pid: 987654, urls: [url] }) }, }) @@ -77,8 +77,8 @@ describe("WslOpenCodeService", () => { ["service", "start"], ["service", "get", "password"], ]) - assert.deepEqual(healthRequest, { - url: `${url}/api/health`, + assert.deepEqual(statusRequest, { + url: `${url}/api/status`, authorization: `Basic ${Buffer.from("opencode:start-secret").toString("base64")}`, }) assert.deepEqual(endpoint, { @@ -132,7 +132,7 @@ describe("WslOpenCodeService", () => { ) }) - it("fails closed with actionable forwarding or health failures", async () => { + it("fails closed with actionable forwarding or status failures", async () => { await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { fetch: async () => { throw new Error("ECONNREFUSED") }, }).service.discover(), /Enable WSL localhost forwarding/) @@ -143,30 +143,72 @@ describe("WslOpenCodeService", () => { await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { fetch: async () => new Response(null, { status: 503 }), - }).service.discover(), /health check failed.*503/) + }).service.discover(), /status check failed.*503/) await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { - fetch: async () => Response.json({ healthy: false, pid: 12 }), - }).service.discover(), /invalid health response/) + fetch: async () => Response.json({ version: "2.0.4", pid: 12 }), + }).service.discover(), /invalid status response/) }) - it("requires the complete compatible health shape", async () => { - for (const health of [ - { healthy: true, pid: 1 }, - { healthy: true, version: "", pid: 1 }, - { healthy: true, version: " ", pid: 1 }, - { healthy: true, version: "2.0.0", pid: 0 }, - { healthy: true, version: "2.0.0", pid: 1.5 }, - { healthy: true, version: "2.0.0", pid: Number.MAX_SAFE_INTEGER + 1 }, - ]) { + it("falls back to authenticated Windows health for an older WSL V2 service", async () => { + const requests: string[] = [] + const endpoint = await harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async (input, init) => { + requests.push(String(input)) + assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:secret").toString("base64")}`) + return requests.length === 1 ? new Response(null, { status: 404 }) + : Response.json({ healthy: true, version: "0.0.0-beta-19271", pid: 123 }) + }, + }).service.discover() + assert.equal(endpoint?.url, url) + assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`]) + }) + + it("rejects missing, unauthenticated, malformed and oversized fallback health responses", async () => { + for (const [response, expected] of [ + [() => new Response(null, { status: 404 }), /health check failed.*404/], + [() => new Response(null, { status: 401 }), /authentication failed.*401/], + [() => new Response(null, { status: 503 }), /health check failed.*503/], + [() => new Response("invalid JSON"), /invalid health response/], + ...[ + { healthy: false, version: "2.0.3", pid: 123 }, + { healthy: true, version: " ", pid: 123 }, + { healthy: true, version: "2.0.3", pid: 0 }, + { healthy: true, version: "2.0.3", pid: 1.5 }, + { version: "2.0.4", pid: 123, urls: [url] }, + ].map(value => [() => Response.json(value), /invalid health response/] as const), + [() => new Response(" ".repeat(64 * 1024 + 1)), /invalid health response/], + ] as const) { + const requests: string[] = [] await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { - fetch: async () => Response.json(health), - }).service.discover(), /invalid health response/) + fetch: async (input) => { + requests.push(String(input)) + return requests.length === 1 ? new Response(null, { status: 404 }) : response() + }, + }).service.discover(), expected) + assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`]) } }) - it("streams at most 64 KiB of health data and cancels an oversized body", async () => { - const valid = JSON.stringify({ healthy: true, version: "2.0.0", pid: 123 }).padEnd(64 * 1024, " ") + it("requires the complete compatible status shape", async () => { + for (const status of [ + { pid: 1, urls: [url] }, + { version: "", pid: 1, urls: [url] }, + { version: " ", pid: 1, urls: [url] }, + { version: "2.0.4", pid: -1, urls: [url] }, + { version: "2.0.4", pid: 1.5, urls: [url] }, + { version: "2.0.4", pid: Number.MAX_SAFE_INTEGER + 1, urls: [url] }, + { version: "2.0.4", pid: 1 }, + { version: "2.0.4", pid: 1, urls: [1] }, + ]) { + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => Response.json(status), + }).service.discover(), /invalid status response/) + } + }) + + it("streams at most 64 KiB of status data and cancels an oversized body", async () => { + const valid = JSON.stringify({ version: "2.0.4", pid: 123, urls: [url] }).padEnd(64 * 1024, " ") await harness({ status: `${url}\n`, password: "secret\n" }, { fetch: async () => new Response(valid), }).service.discover() @@ -181,7 +223,7 @@ describe("WslOpenCodeService", () => { }) await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { fetch: async () => new Response(oversized), - }).service.discover(), /invalid health response/) + }).service.discover(), /invalid status response/) assert.equal(cancelled, true) }) @@ -320,7 +362,7 @@ function harness( const key = operation === "status" || operation === "start" ? operation : "password" return { stdout: output[key] ?? "", stderr: "" } }, - fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + fetch: async () => Response.json({ version: "2.0.4", pid: 123, urls: [url] }), ...overrides, } return { diff --git a/packages/ui/package.json b/packages/ui/package.json index df58ad84f..eab450d9c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/client": "beta", + "@opencode/client": "2.0.4", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/components/form-request-tool-target.ts b/packages/ui/src/components/form-request-tool-target.ts index 4b07628cc..9114abe30 100644 --- a/packages/ui/src/components/form-request-tool-target.ts +++ b/packages/ui/src/components/form-request-tool-target.ts @@ -1,4 +1,4 @@ -import type { FormInfo } from "@opencode-ai/client" +import type { FormInfo } from "@opencode/client" interface MessagePart { id?: string diff --git a/packages/ui/src/components/form-request.tsx b/packages/ui/src/components/form-request.tsx index 755c2fcde..81eca3c01 100644 --- a/packages/ui/src/components/form-request.tsx +++ b/packages/ui/src/components/form-request.tsx @@ -1,5 +1,5 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js" -import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode-ai/client" +import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode/client" import { useI18n } from "../lib/i18n" import { isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema" diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index dc62488f3..6ac462562 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -85,7 +85,7 @@ const InstanceServiceStatus: Component = (props) => try { const resolved = metadata()?.mcpStatus?.location const location = resolved - ? { directory: resolved.directory, ...(resolved.workspaceID ? { workspace: resolved.workspaceID } : {}) } + ? { directory: resolved.directory } : { directory: instance().folder } if (shouldEnable) { await client.mcp.connect({ server: serverName, location }) diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts index 31db17af9..628324397 100644 --- a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts @@ -1,4 +1,4 @@ -import type { VcsFileStatus } from "@opencode-ai/client" +import type { VcsFileStatus } from "@opencode/client" import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types" import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types" diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 109c1f921..aabd20fd0 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,5 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, on, type Accessor, type Component } from "solid-js" -import type { ShellInfo } from "@opencode-ai/client" +import type { LocationRef, ShellInfo } from "@opencode/client" import type { ToolState } from "../../../../../types/tool-state" import { DragDropProvider, @@ -85,13 +85,13 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) - const shellDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) - const shellState = createMemo(() => shellStore.getState(props.instanceId, shellDirectory())) - const [outputShell, setOutputShell] = createSignal(null) + const shellLocation = createMemo(() => props.activeSession()?.location ?? { directory: props.instance.folder }) + const shellState = createMemo(() => shellStore.getState(props.instanceId, shellLocation().directory, shellLocation().workspaceID)) + const [outputShell, setOutputShell] = createSignal<{ shell: ShellInfo; location: LocationRef; instanceId: string } | null>(null) createEffect(on( - () => [props.instanceId, shellDirectory()] as const, - ([instanceId, directory]) => void shellStore.load(instanceId, directory), + () => [props.instanceId, shellLocation().directory, shellLocation().workspaceID] as const, + ([instanceId, directory, workspaceID]) => void shellStore.load(instanceId, directory, workspaceID), )) const renderYoloModeSection = () => { @@ -145,6 +145,8 @@ const StatusTab: Component = (props) => { } const removeShell = async (shellId: string, command: string, running: boolean) => { + const instanceId = props.instanceId + const location = { ...shellLocation() } const confirmed = await showConfirmDialog( props.t("instanceShell.backgroundProcesses.remove.message", { title: command }), { @@ -154,7 +156,7 @@ const StatusTab: Component = (props) => { : "instanceShell.backgroundProcesses.actions.remove"), }, ) - if (confirmed && !await shellStore.remove(props.instanceId, shellDirectory(), shellId)) { + if (confirmed && !await shellStore.remove(instanceId, location.directory, shellId, location.workspaceID)) { showToastNotification({ message: props.t("instanceShell.backgroundProcesses.error"), variant: "error" }) } } @@ -198,7 +200,7 @@ const StatusTab: Component = (props) => {