diff --git a/.agents/skills/verify-mcode/references/features/README.md b/.agents/skills/verify-mcode/references/features/README.md index 54d6bde6d..25ec9e88f 100644 --- a/.agents/skills/verify-mcode/references/features/README.md +++ b/.agents/skills/verify-mcode/references/features/README.md @@ -41,7 +41,7 @@ | Queued composer messages continue in FIFO order after completion and stay paused after Stop | [Composer queue](composer-queue.md) | `composer-queue proof --cursor-model --allow-enable-cursor --confirm-provider-calls --confirm-cleanup` | | Enter submits the Composer and accepts a follow-up Enter while a prior send is still in flight | [Composer Enter to send](composer-enter-send.md) | Electron live-testing Enter proof plus focused Composer submit tests | | Composer context usage shows a compact ring and tooltip whose ring and usage bar share normal, warning, and critical color tiers | [Composer context tracker](context-tracker.md) | Electron Composer tooltip proof with the stable live-testing interface | -| An OpenCode thread streams a pooled-serve turn to completion, stops to aborted, shares one server per worktree, routes supervised permission and question cards through the shared request flow, and renders canonical notices once | [OpenCode pooled serve](opencode-pooled-serve.md) | `runtime live --provider opencode --model --scenario completion` and `--scenario stop` | +| An OpenCode thread streams a pooled-serve turn to completion, stops to aborted, shares one server per worktree, routes supervised permission and question cards through the shared request flow, renders canonical notices once, and attaches to an external serve URL that it never closes | [OpenCode pooled serve](opencode-pooled-serve.md) | `runtime live --provider opencode --model --scenario completion` and `--scenario stop`; `runtime live --provider opencode --scenario opencode-attach --confirm-provider-call` | | Thread Overview defaults open when the right panel opens; at 824 pixels or wider its rails reserve 344 pixels independently of panel visibility, while narrower panes use an intentional overlay, and it is unavailable while the panel is maximized | [Thread Overview and right panel](thread-overview-right-panel.md) | Electron public UI proof with the stable live-testing interface | | An existing thread title is renamed or cancelled from the Project tree | [Thread-list inline rename](thread-list-inline-rename.md) | Electron public UI proof with the stable live-testing interface | | A thread workspace opens in each detected external editor without showing a Windows console window | [Open in editor](open-in-editor.md) | Electron Open in menu proof and focused Windows launch tests | diff --git a/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md b/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md index 82ac18326..47d1aa555 100644 --- a/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md +++ b/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md @@ -47,12 +47,12 @@ Use this journey when a change affects turn diffs, Review, provider events, auto 1. Run `provider-completeness health`, then `provider-completeness proof --confirm-provider-call --confirm-cleanup`. The proof creates a unique fixture workspace for each client and deletes only registrations it owns. Record the workspace ID, exact thread IDs, baseline fixture contents, provider and model, and current commit without credentials. 2. In the public web Composer, start a Codex turn that changes a marked line. While Last turn is Live, make a distinct external edit to the same file. Capture Review and the public comparison response. The rendered native patch must contain only the agent marker while disk content contains both markers. 3. Before completion, after completion, after closing and reopening Review, after client reload, and after reconnect, capture Review state and public turn state. Live must settle once; the patch, source or fidelity label, and one settled row must remain. A spinner or duplicate row after settlement fails the journey. -4. Repeat the settled Review observation for Cursor native evidence and Claude fallback. For every row, record provider, model, source or fidelity label, rendered patch, disk result, and public comparison result. An unavailable provider, account, or model is a coverage gap for that row. +4. Repeat the settled Review observation for Cursor native evidence, Claude fallback, and OpenCode native evidence. For every row, record provider, model, source or fidelity label, rendered patch, disk result, and public comparison result. An unavailable provider, account, or model is a coverage gap for that row. 5. Exercise an empty effect, forced invalidation, and interruption. Confirm volatile evidence clears, the correct fallback remains when file effects remain, and the previous settled turn remains readable. Retry the turn and replay stale review and diff events; they must not change the retry decision or its settled evidence. 6. Trigger available warnings and reroutes around a Review turn. Confirm each notice is durable without erasing or duplicating the current diff. Record approved, denied, and strict-manual automatic-review outcomes and their diff state. A managed-required provider must block unavailable or incompatible access before dispatch. Full Access must never show review. A strict notice must wait for a real permission request before a permission control or waiting state appears. 7. Open Files, `@` autocomplete, local loopback Preview, and Review for the fixture workspace. Make one external change and capture the refreshed result on all four surfaces. Disconnect the owning client, make another change, and confirm its owned watch has closed. Reconnect and confirm one new subscription refreshes the active client. 8. Repeat the observable Review, notice, permission, and right-panel state in Electron. Do not substitute web evidence when Electron is unavailable. -9. Store redacted screenshots, public responses, trigger and stable states, provider matrix, cleanup result, coverage gaps, current application commit, and upstream Codex commit in `.dev/verification/provider-completeness/`. Delete only recorded threads, review requests, fixture edits, subscriptions, watches, and workspace. Retain a cleanup failure in the receipt. +9. Store redacted screenshots, public responses, trigger and stable states, provider matrix, cleanup result, coverage gaps, current application commit, upstream Codex commit, and `upstreamOpenCode` (`opencode --version`, the serve's `/global/health` version, and `sdk: "none (hand-written HTTP client)"`) in `.dev/verification/provider-completeness/`. With no attached `provider.opencode.serveUrl`, the proof reads the server version from one owned probe serve that it stops by its captured PID. Delete only recorded threads, review requests, fixture edits, subscriptions, watches, and workspace. Retain a cleanup failure in the receipt. Use [turn-diff Review](turn-diff-review.md), [provider events and durability](provider-events-and-durability.md), and [local workspace invalidation](workspace-file-invalidation.md) for controls and focused proof limits. Focused tests support these observations; they do not replace a live client proof. @@ -63,6 +63,7 @@ Use [turn-diff Review](turn-diff-review.md), [provider events and durability](pr | Codex native | Agent-only same-file patch with a native source label | Live, settled, reopen, reload, and reconnect preserve one row and no spinner | | Cursor native | Native source label and normalized Review controls | Settled evidence survives the same client lifecycle observations | | Claude fallback | Fallback source or fidelity label with the remaining file effect | Empty, invalidated, and interrupted paths preserve the previous settled turn | +| OpenCode native | `native` source with `agent` fidelity from upstream `session.diff` snapshots | Automatic review resolves to manual because OpenCode reports it unavailable; the live Automatic route is an informational coverage gap backed by the `opencode-provider-routes` focused gate | Record `blocked` with the missing provider, account, model, workspace, or client control instead of treating an unrun row as a pass. diff --git a/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md b/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md index 3e6e4b9b4..94e6ee697 100644 --- a/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md +++ b/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md @@ -13,6 +13,7 @@ - Supervised permissions (#1624): permission and question asks surface as inline cards through the existing permission flow (`permission.request` → `permission.respond` → upstream reply). A shell approval relays once. A question reply carries the user’s ordered selected labels; questions do not offer session approval. Reject relays once and the turn settles cleanly. Duplicate replies and terminal outcomes are blocked. - Provider notices (#1624): reroute (`session.next.model.switched`) uses the canonical model-fallback event. Warning/error toasts, configuration signals, and authentication failures use bounded provider-neutral notices with replay deduplication. The screen and thread state stay intact. - Resolved dispatch modes (#1626): every turn carries the resolved permission and review modes. Full Access auto-answers upstream permission asks with `always` and shows no card; questions still card; supervised behavior is unchanged. `getApprovalReviewSupport` honestly reports `unavailable` — OpenCode has no native turn-review verdict — so the resolved review mode is always manual and the turn footer reflects it. The approval-review capability stays undeclared, so the Composer never offers Auto for OpenCode. The adapter is registered in the shared conformance suite (`opencode-core.synthetic.json`). +- External attach (#1627): a non-empty `provider.opencode.serveUrl` attaches every OpenCode thread to that `opencode serve` instead of the pool. The app never spawns, pools, or closes an attached server. Every OpenCode HTTP request carries `?directory=`, so one shared server keeps each thread in its own working directory. A second client of the same server sees the sessions. An empty URL restores the pooled spawn. ## How to get to it (user POV) @@ -27,6 +28,9 @@ 9. Supervised question: send a prompt that asks a question. Select an option or enter a custom answer, submit the ordered answers through `permission.respond`, and confirm the provider continues. Confirm no session-wide approval control appears. 10. Supervised rejection: reject either card (`deny`) and confirm the turn settles with one terminal outcome. 11. Unknown-event diagnostic (focused tests only; no public seam injects upstream events by design): the mapper tests cover unknown valid types across both envelope shapes and oversized-envelope rejection. Record live unknown-event injection as a coverage gap, not a pass. +12. External attach: start `opencode serve --port 4096 --hostname 127.0.0.1` yourself. In Settings > Providers, expand OpenCode and enter `http://127.0.0.1:4096` in **Serve URL**. The value saves on blur or Enter. Send a turn on a new OpenCode thread and confirm it completes. +13. From a second client, request `GET http://127.0.0.1:4096/session?directory=`. Confirm that the thread's session is listed and that its `directory` is the project path. +14. Quit Mcode. Confirm that the external server still answers `GET /global/health`. Clear **Serve URL** to return to the pooled spawn. ## Driving it with verify-mcode @@ -36,12 +40,15 @@ Run `runtime health`, then run: bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime live --provider opencode --model opencode/muse-spark-1.3-contributor-free --scenario completion --confirm-provider-call bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime live --provider opencode --model opencode/muse-spark-1.3-contributor-free --scenario stop --confirm-provider-call bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime live --provider opencode --model opencode/muse-spark-1.3-contributor-free --scenario opencode-resume --confirm-provider-call +bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime live --provider opencode --scenario opencode-attach --confirm-provider-call ``` Completion requires `turnComplete` or `ended`, then a durable assistant message in `conversation.page` and `message.list`. Stop requires the cancelled settle with `agent.activeCount` at zero. The receipt omits assistant text and provider-private payloads. The OpenCode resume proof creates and removes an owned temporary workspace with two owned direct threads. It restarts only this worktree runtime, continues the first thread on its original upstream session, deletes the second thread's owned upstream session through `opencode session delete`, and checks the public `sdk_session_invalidated` event plus both persisted session identities. It deletes the threads and workspace after the proof. The focused web store test covers the generic reset notice. This command does not claim browser proof for the notice. +The OpenCode attach proof uses the Muse Spark free model unless `--model` overrides it. It spawns its own `opencode serve` on a free loopback port in an owned temp directory and captures the PID at spawn. It records the prior `provider.opencode.serveUrl`, points the setting at that server, and runs one completion turn on a new direct thread in `.dev/fixture-repo`. The verifier then acts as a second client. It lists `GET /session?directory=` and requires the thread's persisted session with a matching `directory`. It restarts only this worktree runtime. The same PID must still be alive and must answer `/global/health` with the same version. Cleanup restores the prior serve URL and deletes the owned thread. It stops only the captured process tree (`taskkill /T /F /PID` on Windows) and removes the temp directory. The receipt's `opencodeAttach` block records the binary version, the server version, `sdk: "none (hand-written HTTP client)"`, one result per assertion, and each cleanup outcome. It omits assistant text and provider payloads. + Focused OpenCode HTTP-client tests prove the 1-200 page bound, default limit, timeout, caller abort, 404 discrimination, malformed-response failure, and cleanup. The live resume proof confirms that app restart uses the bounded resume path, but it does not inspect the upstream query string. Serve-pool mechanics without a model call (spawn, health, session create, abort, idle close) are covered by the focused pool tests; use them when no provider account is logged in. @@ -68,5 +75,8 @@ Requires a running agent runtime and the Playwright scratch install - OpenCode quota was exhausted for the #1624 verification run. The primary completion, stop, resume, permission, and question proof is blocked until quota is available. Focused mapper, HTTP-client, provider, RPC, and inline-card tests support component behavior only; they do not prove the live public path. - Model IDs are `provider/model` slugs from `provider.listModels` for `opencode` (for example `opencode/muse-spark-1.3-contributor-free`), not bare model names. - Free-tier models can interrupt a turn under load; the harness records the phase and the next turn still dispatches. Treat isolated interruptions as flakiness, repeats as a defect. -- The pool keys on binary path, working directory, and hostname. Pool isolation needs two different worktrees; the resume journey instead checks per-thread session isolation in one owned workspace. +- The pool keys on binary path, working directory, and hostname. Pool isolation needs two different worktrees; the resume journey instead checks per-thread session isolation in one owned workspace. A non-empty serve URL bypasses the pool entirely, so pool keying, idle close, and process-tree termination do not apply to an attached server. +- Mcode never closes an attached server. Quitting the app, restarting the runtime, or clearing the serve URL leaves it running. Whoever started it owns stopping it. The attach proof stops only the PID it spawned. +- An attached server serves every directory it is asked about. Directory routing depends on the `?directory=` query on each request, not on the server's own cwd. A `GET /session` without that query omits the thread's session (observed on 1.18.28), so a second client must pass the thread's directory. +- On Windows `opencode` is an npm `.cmd` shim. The captured PID belongs to the shell wrapper, so cleanup must kill the tree (`/T`), never match processes by name. - A desktop reload needs `$electorn-live-testing`; this harness proves the public server conversation RPC only. diff --git a/.agents/skills/verify-mcode/scripts/opencode-serve.mjs b/.agents/skills/verify-mcode/scripts/opencode-serve.mjs new file mode 100644 index 000000000..05043fb0f --- /dev/null +++ b/.agents/skills/verify-mcode/scripts/opencode-serve.mjs @@ -0,0 +1,200 @@ +/** Owns verifier-spawned `opencode serve` processes and reads upstream OpenCode versions. */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +/** Mcode talks to `opencode serve` through its own HTTP client, not `@opencode-ai/sdk`. */ +export const OPENCODE_SDK_LABEL = "none (hand-written HTTP client)"; +const SERVE_HOSTNAME = "127.0.0.1"; +const READY_TIMEOUT_MS = 20_000; +const REQUEST_TIMEOUT_MS = 5_000; +const STOP_TIMEOUT_MS = 10_000; + +/** Reads `opencode --version`. Windows resolves the npm `.cmd` shim only through a shell. */ +export function readOpenCodeBinaryVersion(execute = NodeChildProcess.execFileSync, platform = process.platform) { + const [command, args] = openCodeCommand(["--version"], platform); + const output = execute(command, args, { encoding: "utf8", timeout: 10_000, windowsHide: true, shell: platform === "win32" }); + const version = String(output ?? "").trim().split(/\r?\n/)[0]?.slice(0, 128) ?? ""; + if (!version) throw new Error("Condition: opencode --version printed no version."); + return version; +} + +/** Accepts only the documented `/global/health` shape and returns the server version. */ +export function parseOpenCodeHealth(payload) { + if (payload?.healthy !== true || typeof payload.version !== "string" || payload.version.length === 0) { + throw new Error("Condition: /global/health did not report healthy with a version."); + } + return payload.version.slice(0, 128); +} + +/** GETs `/global/health` from an OpenCode serve base URL. */ +export async function fetchOpenCodeHealth(baseUrl) { + const response = await fetch(new URL("/global/health", baseUrl), { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`Condition: /global/health returned HTTP ${response.status}.`); + return parseOpenCodeHealth(await response.json()); +} + +/** GETs the sessions an OpenCode serve routes to one directory, as any second client would. */ +export async function listOpenCodeSessions(baseUrl, directory) { + const url = new URL("/session", baseUrl); + url.searchParams.set("directory", directory); + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`Condition: /session returned HTTP ${response.status}.`); + const sessions = await response.json(); + if (!Array.isArray(sessions)) throw new Error("Condition: /session did not return a session list."); + return sessions; +} + +/** + * Reports whether a session list contains one upstream session and whether + * its directory is the expected thread cwd. `sameDirectory` owns path + * comparison so Windows case and separator rules stay with the caller. + */ +export function findOpenCodeSession(sessions, sessionId, directory, sameDirectory) { + const session = Array.isArray(sessions) ? sessions.find((candidate) => candidate?.id === sessionId) : undefined; + return { + visible: Boolean(session), + directoryMatches: Boolean(session) && typeof session.directory === "string" && sameDirectory(session.directory, directory), + }; +} + +/** Resolves binary and server versions without failing the caller; blockers are recorded instead. */ +export async function resolveUpstreamOpenCode({ attachedUrl = "", readBinaryVersion = readOpenCodeBinaryVersion, readServerVersion = fetchOpenCodeHealth, startServe = startOwnedOpenCodeServe, stopServe = stopOwnedOpenCodeServe } = {}) { + const upstream = { binaryVersion: null, serverVersion: null, sdk: OPENCODE_SDK_LABEL }; + try { + upstream.binaryVersion = readBinaryVersion(); + } catch (error) { + return { ...upstream, auditBlocker: `opencode --version failed: ${errorMessage(error)}` }; + } + if (attachedUrl) return withServerVersion(upstream, () => readServerVersion(attachedUrl), "attached serve"); + return withServerVersion(upstream, () => readProbeServeVersion(startServe, stopServe), "verifier-owned probe serve"); +} + +async function readProbeServeVersion(startServe, stopServe) { + const serve = await startServe(); + try { + return serve.version; + } finally { + await stopServe(serve).catch(() => {}); + } +} + +async function withServerVersion(upstream, read, source) { + try { + return { ...upstream, serverVersion: await read(), serverSource: source }; + } catch (error) { + return { ...upstream, auditBlocker: `${source} version read failed: ${errorMessage(error)}` }; + } +} + +/** + * Spawns `opencode serve` in an owned temp directory and waits for health. + * The PID is captured at spawn so cleanup never matches processes by name. + */ +export async function startOwnedOpenCodeServe({ env = process.env } = {}) { + const tempDirectory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcode-verify-opencode-serve-")); + const port = await findFreePort(); + const url = `http://${SERVE_HOSTNAME}:${port}`; + const [command, args] = openCodeCommand(["serve", "--port", String(port), "--hostname", SERVE_HOSTNAME]); + const child = NodeChildProcess.spawn(command, args, { + cwd: tempDirectory, + env, + stdio: "ignore", + windowsHide: true, + shell: process.platform === "win32", + // POSIX: a new process group lets stop signal the whole serve tree by PID. + detached: process.platform !== "win32", + }); + const exit = watchExit(child); + const serve = { pid: child.pid ?? null, url, tempDirectory, child, exit, version: null }; + try { + if (!Number.isInteger(serve.pid)) throw new Error("Condition: opencode serve did not report a PID at spawn."); + serve.version = await waitForHealth(url, exit); + return serve; + } catch (error) { + await stopOwnedOpenCodeServe(serve).catch(() => {}); + throw error; + } +} + +/** Stops only the captured serve process tree, then removes its temp directory. */ +export async function stopOwnedOpenCodeServe(serve) { + const result = { stopped: false, tempDirectoryRemoved: false }; + if (Number.isInteger(serve?.pid) && serve.exit.code === undefined) { + killProcessTree(serve.pid); + await Promise.race([serve.exit.promise, delay(STOP_TIMEOUT_MS)]); + } + result.stopped = serve?.exit?.code !== undefined || !Number.isInteger(serve?.pid); + result.tempDirectoryRemoved = await removeDirectory(serve?.tempDirectory); + return result; +} + +function killProcessTree(pid) { + if (process.platform === "win32") { + // The shell wrapper owns the PID; /T reaches the node child behind the .cmd shim. + NodeChildProcess.spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true }); + return; + } + try { process.kill(-pid, "SIGTERM"); } catch { /* The group may already have exited. */ } +} + +function watchExit(child) { + const exit = { code: undefined, promise: null }; + exit.promise = new Promise((resolve) => { + child.once("exit", (code, signal) => { exit.code = code ?? signal ?? null; resolve(); }); + child.once("error", (error) => { exit.code = `error:${error.code ?? "spawn"}`; resolve(); }); + }); + return exit; +} + +async function waitForHealth(url, exit) { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (exit.code !== undefined) throw new Error(`Condition: opencode serve exited before health (${String(exit.code)}).`); + try { + return await fetchOpenCodeHealth(url); + } catch { /* Not listening yet. */ } + await delay(250); + } + throw new Error(`Condition: opencode serve did not answer /global/health within ${READY_TIMEOUT_MS / 1000} seconds.`); +} + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.once("error", reject); + server.listen(0, SERVE_HOSTNAME, () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +async function removeDirectory(directory) { + if (typeof directory !== "string") return false; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + NodeFS.rmSync(directory, { recursive: true, force: true }); + return !NodeFS.existsSync(directory); + } catch (error) { + // Windows releases the serve's cwd handle shortly after taskkill returns. + if (!["EBUSY", "EPERM", "ENOTEMPTY"].includes(error?.code)) return false; + await delay(250); + } + } + return false; +} + +/** + * Windows resolves the npm `.cmd` shim only through a shell, and Node rejects + * separate args with `shell: true`. Every argument here is a verifier constant + * or an integer port, so joining them cannot inject shell syntax. + */ +function openCodeCommand(args, platform = process.platform) { + return platform === "win32" ? [["opencode", ...args].join(" "), []] : ["opencode", args]; +} + +function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function errorMessage(error) { return (error instanceof Error ? error.message : String(error)).replace(/[A-Za-z]:\\[^\s]*/g, "[path]").slice(0, 320); } diff --git a/.agents/skills/verify-mcode/scripts/provider-completeness.mjs b/.agents/skills/verify-mcode/scripts/provider-completeness.mjs index 86e9d3fb1..93f8ff2c7 100644 --- a/.agents/skills/verify-mcode/scripts/provider-completeness.mjs +++ b/.agents/skills/verify-mcode/scripts/provider-completeness.mjs @@ -8,8 +8,12 @@ import * as NodeURL from "node:url"; import * as NodeChildProcess from "node:child_process"; import { getRuntimePaths, readPortsFile, resolveRepoRoot } from "../../../../scripts/agent/runtime-contract.mjs"; import { assertRuntimeFreshness, deleteLiveWorkspace, openRuntimeVerificationSocket, openVerificationSocketUrl } from "./runtime.mjs"; +import { resolveUpstreamOpenCode } from "./opencode-serve.mjs"; const MODEL = "gpt-5.6-terra"; +// Codex must use the Terra model the Codex journeys were tuned for; OpenCode prefers its free model so an account without paid access still runs. +const PREFERRED_PROVIDER_MODELS = { codex: { id: MODEL, required: true }, opencode: { id: "opencode/muse-spark-1.3-contributor-free", required: false } }; +const PREREQUISITE_PROVIDERS = ["codex", "cursor", "claude", "opencode"]; const EVIDENCE_DIRECTORY = ".dev/verification/provider-completeness"; const HEALTH_TIMEOUT_MS = 10_000; const TIMEOUT_MS = 120_000; @@ -31,6 +35,7 @@ const FOCUSED_GATES = [ { name: "web-composer-and-files", control: "apps/web focused component tests", workspace: "apps/web", files: ["src/features/conversation/composer/controls/__tests__/ComposerAccessControls.test.tsx", "src/features/projects/files/useWorkspaceFileRefresh.test.tsx", "src/components/diff/__tests__/DiffPanel.files.test.tsx"], rows: ["fullAccessControl", "fileSurfaces"] }, { name: "web-permission-handoff", control: "apps/web focused permission handoff tests", workspace: "apps/web", files: ["src/transport/ws-events.test.ts"], rows: ["strictReviewNoticeOnly", "realProviderRequestCard"] }, { name: "codex-permission-handoff", control: "packages/providers focused permission handoff tests", workspace: "packages/providers", files: ["src/__tests__/codex/codex-provider-permission.test.ts"], rows: ["providerResponseSettlementRemoval"] }, + { name: "opencode-provider-routes", control: "apps/server focused OpenCode adapter tests", workspace: "apps/server", options: ["--no-file-parallelism"], files: ["src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts", "src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts"], rows: ["opencodeNativeDiff", "opencodeReviewUnavailable"], limitation: "OpenCode reports approval review unavailable for every input, so Automatic resolves to manual; it never reports required, so the managed block is proven only by the provider-neutral server-approval-review-policy gate." }, ]; const HELP = `Verify provider completeness @@ -42,6 +47,10 @@ Commands: Check the matching runtime, idle-agent gate, and separate Chromium setup. proof --confirm-provider-call --confirm-cleanup Drive the normal Codex Composer journey in separate web Chromium and Electron clients. + Each available Cursor, Claude, or OpenCode provider also runs one Composer and Review journey; + an unavailable provider is recorded as a coverage gap. The receipt records upstreamOpenCode + versions from the attached provider.opencode.serveUrl, or from one owned probe opencode serve + that the proof starts and stops by its captured PID. cleanup --confirm-cleanup Delete only exact resources named in incomplete provider-completeness receipts.`; @@ -172,6 +181,7 @@ async function openProofClients(repoRoot, receipt, dependencies, state) { const workspace = await createOwnedFixtureWorkspace(state.socket, repoRoot, receipt); receipt.workspace = workspaceIdentity(workspace); applyProviderPrerequisites(receipt.matrix, await inspectProviderPrerequisites(state.socket, workspace)); + receipt.upstreamOpenCode = await (dependencies.resolveUpstreamOpenCode ?? resolveProofUpstreamOpenCode)(state.socket); const ports = readPortsFile(repoRoot); const playwright = dependencies.playwright ?? requirePlaywright(repoRoot); state.web = dependencies.web ?? await openWeb(playwright, ports, findChromiumPath()); @@ -301,7 +311,7 @@ export function createReceipt(repoRoot) { const directory = NodePath.join(repoRoot, EVIDENCE_DIRECTORY, runId); const fixtureDirectory = NodePath.join(getRuntimePaths(repoRoot).fixtureRepoDir, `provider-completeness-${runId}`); NodeFS.mkdirSync(directory, { recursive: true }); - return { runId, phase: "initializing", path: NodePath.join(directory, "receipt.json"), directory, fixtureDirectory, fixtureFile: NodePath.join(fixtureDirectory, "target.txt"), applicationCommit: "not reached", upstreamCodex: "not reached", provider: "codex", model: MODEL, baseline: "not reached", publicComparison: "not reached", fetchedPatch: "not reached", disk: "not reached", renderedEvidence: [], run: { ownedWorkspaceId: null, ownedFixtureDirectory: null, ownedFile: null, threadId: null, ownedThreadIds: [] }, electron: { matrix: providerMatrix("electron") }, journeys: {}, screenshots: [], observations: {}, comparison: {}, diagnostics: { liveComparisons: { states: [], omitted: 0 } }, focusedGates: focusedGateMatrix(), matrix: providerMatrix("web"), watcherOwnership: { kind: "live-rpc-required", control: "public file.refresh RPC and files.changed push", status: "not-run" }, cleanup: { complete: false, failures: [] }, failure: null }; + return { runId, phase: "initializing", path: NodePath.join(directory, "receipt.json"), directory, fixtureDirectory, fixtureFile: NodePath.join(fixtureDirectory, "target.txt"), applicationCommit: "not reached", upstreamCodex: "not reached", upstreamOpenCode: "not reached", provider: "codex", model: MODEL, baseline: "not reached", publicComparison: "not reached", fetchedPatch: "not reached", disk: "not reached", renderedEvidence: [], run: { ownedWorkspaceId: null, ownedFixtureDirectory: null, ownedFile: null, threadId: null, ownedThreadIds: [] }, electron: { matrix: providerMatrix("electron") }, journeys: {}, screenshots: [], observations: {}, comparison: {}, diagnostics: { liveComparisons: { states: [], omitted: 0 } }, focusedGates: focusedGateMatrix(), matrix: providerMatrix("web"), watcherOwnership: { kind: "live-rpc-required", control: "public file.refresh RPC and files.changed push", status: "not-run" }, cleanup: { complete: false, failures: [] }, failure: null }; } function createSurfaceRun(repoRoot, receipt, surface) { @@ -476,7 +486,7 @@ function recordWorkspaceCleanupGap(receipt, gap) { export async function inspectProviderPrerequisites(socket, workspace, execute = NodeChildProcess.execFileSync) { const availability = await fetchProviderAvailability(socket); const matrix = {}; - for (const provider of ["codex", "cursor", "claude"]) { + for (const provider of PREREQUISITE_PROVIDERS) { matrix[providerMatrixKey(provider)] = await inspectProviderPrerequisite(socket, workspace, provider, availability, execute); } return matrix; @@ -575,7 +585,16 @@ function providerEvidence(provider, observed, models, catalog, modelList, accoun } function selectableProviderModel(provider, modelList) { - return provider === "codex" ? modelList.find((model) => model?.id === MODEL) : modelList[0]; + const preferred = PREFERRED_PROVIDER_MODELS[provider]; + const match = preferred ? modelList.find((model) => model?.id === preferred.id) : undefined; + return match ?? (preferred?.required ? undefined : modelList[0]); +} + +/** Records the OpenCode binary and the serve this runtime would use: the attached URL, or an owned probe serve. */ +export async function resolveProofUpstreamOpenCode(socket, resolve = resolveUpstreamOpenCode) { + const settings = await socket.rpc("settings.get", {}).catch(() => null); + const attachedUrl = settings?.provider?.opencode?.serveUrl; + return resolve({ attachedUrl: typeof attachedUrl === "string" ? attachedUrl : "" }); } function providerReady(observed, models, catalog, model, unavailableClaudeAccount) { @@ -2569,7 +2588,7 @@ function optionalStrings(values) { } function isExactOrRedacted(value, expected) { return value === expected || value === "[path]"; } -function isOwnedFixtureFile(value, directory) { return value === "[path]" || (typeof value === "string" && isWithin(value, directory) && /^(?:target(?:-(?:codex|cursor|claude))?\.(?:txt|md)|(?:approved|denied)-review-codex\.md|full-access-codex\.md|watch-(?:owner|observer)-sentinel\.txt)$/i.test(NodePath.basename(value))); } +function isOwnedFixtureFile(value, directory) { return value === "[path]" || (typeof value === "string" && isWithin(value, directory) && /^(?:target(?:-(?:codex|cursor|claude|opencode))?\.(?:txt|md)|(?:approved|denied)-review-codex\.md|full-access-codex\.md|watch-(?:owner|observer)-sentinel\.txt)$/i.test(NodePath.basename(value))); } function hydrateOwnedReceipt(receipt, repoRoot) { const fixtureDirectory = NodePath.join(getRuntimePaths(repoRoot).fixtureRepoDir, `provider-completeness-${receipt.runId}`); @@ -2599,6 +2618,7 @@ function providerMatrix(surface) { return { codexNative: requiredEvidence({ kind: "live-proof-required", control: `${surface} Composer, Review, and public turn comparison`, fields: ["provider", "model", "observations.live", "observations.settled", "observations.reopened", "observations.reloaded", "observations.reconnected", "comparison", "disk"] }), cursorNative: requiredEvidence({ kind: "pending-observation", control: "providers.listAvailability, provider.listModels, and provider.catalog" }), claudeFallback: requiredEvidence({ kind: "pending-observation", control: "providers.listAvailability, provider.listModels, and provider.catalog" }), + opencodeNative: requiredEvidence({ kind: "pending-observation", control: "providers.listAvailability, provider.listModels, and provider.catalog" }), ...(surface === "web" ? { warningStability: informationalEvidence({ @@ -2631,6 +2651,14 @@ function providerMatrix(surface) { return { reason: "Mcode has no deterministic public trigger for a native transient retry, so the verifier records focused retry-dispatch evidence instead of claiming a live retry.", focusedEvidence: { frozenRetryDecision: "server-retry-decision-freeze" }, }), + opencodeReviewRoute: informationalEvidence({ + kind: "coverage-gap", + control: "web Composer Automatic review request on an OpenCode thread", + provider: "opencode", + prerequisite: "a public Composer route that requests Automatic review for OpenCode and reads the resolved mode from canonical recovery", + reason: "OpenCode reports approval review unavailable, so an Automatic request must resolve to the visible manual-required fallback, never Approved or Denied. The verifier has no live OpenCode Automatic route, so it records focused adapter and policy evidence instead.", + focusedEvidence: { opencodeReviewUnavailable: "opencode-provider-routes", managedRequired: "server-approval-review-policy" }, + }), staleRetryEvents: informationalEvidence({ kind: "coverage-gap", control: "web Composer retry event stream", diff --git a/.agents/skills/verify-mcode/scripts/provider-completeness.test.mjs b/.agents/skills/verify-mcode/scripts/provider-completeness.test.mjs index 1c7e70ca7..57047d809 100644 --- a/.agents/skills/verify-mcode/scripts/provider-completeness.test.mjs +++ b/.agents/skills/verify-mcode/scripts/provider-completeness.test.mjs @@ -3,7 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeTest from "node:test"; -import { aggregateEvidenceFailures, applyProviderPrerequisites, approvedReviewComposerPrompt, assertApprovedReviewReload, assertApprovedReviewTerminal, assertDeniedReviewComparison, assertDeniedReviewTerminal, assertDiskContent, assertExactApprovedReviewDisk, assertExactDeniedReviewDisk, assertExactFullAccessDisk, assertFullAccessRecovery, assertFullAccessSnapshot, assertLiveObservation, assertNoApprovalReviewFooter, assertNoApprovalReviewLifecycle, assertObservation, assertPatchAttribution, assertSeparateClients, assertWarningStabilityEvidence, captureCodexTraceEvidence, captureDeniedReview, captureFullAccessReview, captureLiveObservation, captureReview, captureSettledReviewState, classifyLiveDiffFailure, cleanup, cleanupOwned, closeReview, composerPrompt, createClientInvalidationTrace, createOwnedFixtureWorkspace, createReceipt, deniedReviewComposerPrompt, emptyComposerPrompt, fullAccessComposerPrompt, inspectClaudeAccountStatus, inspectProviderPrerequisites, installWebSocketDisconnect, openDesktop, openNewThreadForWorkspace, parseArguments, proof, readExactApprovedReviewComparison, readRenderedReview, readSettledPublicComparison, recordElectronRightPanelEvidence, recordLiveComparisonDiagnostic, resolveUpstreamCodex, reviewRowCount, runApprovedReviewJourney, runComposerReviewJourney, runDeniedReviewJourney, runEmptyDiffJourney, runFocusedEvidenceGates, runFourSurfaceRefreshJourney, runFullAccessJourney, runInterruptionJourney, runProviderJourneys, runWorkspaceInvalidationJourney, selectAutomaticReview, selectFullAccess, waitForExactReview, waitForInterruptionTerminal, waitForLiveAgentDiff, waitForNewThread, waitForNewThreadWelcome, writeReceipt } from "./provider-completeness.mjs"; +import { aggregateEvidenceFailures, applyProviderPrerequisites, approvedReviewComposerPrompt, assertApprovedReviewReload, assertApprovedReviewTerminal, assertDeniedReviewComparison, assertDeniedReviewTerminal, assertDiskContent, assertExactApprovedReviewDisk, assertExactDeniedReviewDisk, assertExactFullAccessDisk, assertFullAccessRecovery, assertFullAccessSnapshot, assertLiveObservation, assertNoApprovalReviewFooter, assertNoApprovalReviewLifecycle, assertObservation, assertPatchAttribution, assertSeparateClients, assertWarningStabilityEvidence, captureCodexTraceEvidence, captureDeniedReview, captureFullAccessReview, captureLiveObservation, captureReview, captureSettledReviewState, classifyLiveDiffFailure, cleanup, cleanupOwned, closeReview, composerPrompt, createClientInvalidationTrace, createOwnedFixtureWorkspace, createReceipt, deniedReviewComposerPrompt, emptyComposerPrompt, fullAccessComposerPrompt, inspectClaudeAccountStatus, inspectProviderPrerequisites, installWebSocketDisconnect, resolveProofUpstreamOpenCode, openDesktop, openNewThreadForWorkspace, parseArguments, proof, readExactApprovedReviewComparison, readRenderedReview, readSettledPublicComparison, recordElectronRightPanelEvidence, recordLiveComparisonDiagnostic, resolveUpstreamCodex, reviewRowCount, runApprovedReviewJourney, runComposerReviewJourney, runDeniedReviewJourney, runEmptyDiffJourney, runFocusedEvidenceGates, runFourSurfaceRefreshJourney, runFullAccessJourney, runInterruptionJourney, runProviderJourneys, runWorkspaceInvalidationJourney, selectAutomaticReview, selectFullAccess, waitForExactReview, waitForInterruptionTerminal, waitForLiveAgentDiff, waitForNewThread, waitForNewThreadWelcome, writeReceipt } from "./provider-completeness.mjs"; const asRequiredEvidence = (entry) => ({ ...entry, requirement: "required" }); @@ -335,7 +335,7 @@ NodeTest.test("records focused gates once under their true owner and preserves t return { exitCode: 0, output: "all passed" }; }); NodeAssertStrict.deepEqual(failures, []); - NodeAssertStrict.equal(calls.length, 10); + NodeAssertStrict.equal(calls.length, 11); NodeAssertStrict.deepEqual(calls.slice(0, 4).map((call) => call.args.at(-1)), [ "src/features/agents/turns/__tests__/turn-diff-service.test.ts", "src/features/agents/orchestration/__tests__/agent-service-turn-started.test.ts", @@ -349,6 +349,10 @@ NodeTest.test("records focused gates once under their true owner and preserves t NodeAssertStrict.ok(calls[6].args.includes("src/__tests__/codex/codex-provider-first-turn.test.ts")); NodeAssertStrict.ok(calls[8].args.includes("src/transport/ws-events.test.ts")); NodeAssertStrict.ok(calls[9].args.includes("src/__tests__/codex/codex-provider-permission.test.ts")); + NodeAssertStrict.deepEqual(calls[10].args.slice(-2), [ + "src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts", + "src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts", + ]); NodeAssertStrict.deepEqual(receipt.focusedGates.map(({ control, rows, limitation }) => ({ control, rows, limitation })), [ { control: "apps/server focused integration tests", rows: ["empty", "interruption"], limitation: undefined }, { control: "apps/server focused integration tests", rows: ["strictManual", "managedRequired", "fullAccessDispatch"], limitation: undefined }, @@ -360,8 +364,9 @@ NodeTest.test("records focused gates once under their true owner and preserves t { control: "apps/web focused component tests", rows: ["fullAccessControl", "fileSurfaces"], limitation: undefined }, { control: "apps/web focused permission handoff tests", rows: ["strictReviewNoticeOnly", "realProviderRequestCard"], limitation: undefined }, { control: "packages/providers focused permission handoff tests", rows: ["providerResponseSettlementRemoval"], limitation: undefined }, + { control: "apps/server focused OpenCode adapter tests", rows: ["opencodeNativeDiff", "opencodeReviewUnavailable"], limitation: "OpenCode reports approval review unavailable for every input, so Automatic resolves to manual; it never reports required, so the managed block is proven only by the provider-neutral server-approval-review-policy gate." }, ]); - for (const row of ["empty", "invalidationDelta", "interruption", "warningsReroutes", "strictManual", "managedRequired", "managedRequiredDispatch", "fullAccessDispatch", "fullAccessControl", "fileSurfaces", "nonGitScope", "noDeltaSilent", "frozenRetryDecision", "staleRetryReview", "staleRetryDiff", "strictReviewNoticeOnly", "realProviderRequestCard", "providerResponseSettlementRemoval"]) { + for (const row of ["empty", "invalidationDelta", "interruption", "warningsReroutes", "strictManual", "managedRequired", "managedRequiredDispatch", "fullAccessDispatch", "fullAccessControl", "fileSurfaces", "nonGitScope", "noDeltaSilent", "frozenRetryDecision", "staleRetryReview", "staleRetryDiff", "strictReviewNoticeOnly", "realProviderRequestCard", "providerResponseSettlementRemoval", "opencodeNativeDiff", "opencodeReviewUnavailable"]) { NodeAssertStrict.equal(receipt.matrix[row], undefined, `${row} only belongs to its focused-test owner`); NodeAssertStrict.equal(receipt.electron.matrix[row], undefined, `${row} is not copied to Electron`); } @@ -372,8 +377,8 @@ NodeTest.test("records focused gates once under their true owner and preserves t }; applyProviderPrerequisites(receipt.matrix, prerequisites); applyProviderPrerequisites(receipt.electron.matrix, prerequisites); - NodeAssertStrict.deepEqual(receipt.focusedGates.map((gate) => gate.kind), ["focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof"]); - NodeAssertStrict.deepEqual(receipt.focusedGates.map((gate) => gate.exitCode), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + NodeAssertStrict.deepEqual(receipt.focusedGates.map((gate) => gate.kind), ["focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof", "focused-proof"]); + NodeAssertStrict.deepEqual(receipt.focusedGates.map((gate) => gate.exitCode), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); NodeAssertStrict.equal(receipt.matrix.reviewApproved.kind, "coverage-gap"); NodeAssertStrict.equal(receipt.electron.matrix.electronRightPanel.kind, "blocked"); NodeAssertStrict.equal(receipt.matrix.codexNative.provider, "codex"); @@ -500,7 +505,7 @@ NodeTest.test("retains all focused gate evidence before reporting nonzero gates" let calls = 0; const failures = await runFocusedEvidenceGates("root", receipt, async () => ({ exitCode: calls++ === 5 ? 1 : 0, output: "failed C:\\secret\\token" })); NodeAssertStrict.deepEqual(failures, ["codex-protocol exited 1"]); - NodeAssertStrict.equal(receipt.focusedGates.length, 10); + NodeAssertStrict.equal(receipt.focusedGates.length, 11); NodeAssertStrict.equal(receipt.focusedGates[5].control, "packages/providers focused protocol tests"); NodeAssertStrict.equal(receipt.focusedGates[5].kind, "focused-proof-failed"); NodeAssertStrict.equal(receipt.focusedGates[5].exitCode, 1); @@ -1086,8 +1091,51 @@ NodeTest.test("records public provider prerequisites without hardcoded provider NodeAssertStrict.equal(matrix.cursorNative.observedPrerequisites.availability.enabled, true); NodeAssertStrict.equal(matrix.claudeFallback.observedPrerequisites.availability.enabled, false); NodeAssertStrict.equal(matrix.claudeFallback.observedPrerequisites.account, null); - NodeAssertStrict.equal(calls.filter(({ method }) => method === "provider.catalog").length, 3); - NodeAssertStrict.equal(calls.filter(({ method }) => method === "provider.listModels").length, 3); + NodeAssertStrict.equal(matrix.opencodeNative.kind, "coverage-gap"); + NodeAssertStrict.equal(matrix.opencodeNative.provider, "opencode"); + NodeAssertStrict.equal(calls.filter(({ method }) => method === "provider.catalog").length, 4); + NodeAssertStrict.equal(calls.filter(({ method }) => method === "provider.listModels").length, 4); +}); + +NodeTest.test("proves an available OpenCode provider with its free model and records missing access as a coverage gap", async () => { + const availability = (opencode) => ({ rpc: async (method, params) => { + if (method === "providers.listAvailability") return [{ id: "opencode", hasAdapter: true, ...opencode }]; + if (method === "provider.listModels") return params.providerId === "opencode" ? [{ id: "opencode/paid-model", name: "Paid" }, { id: "opencode/muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Free" }] : []; + if (method === "provider.catalog") return { freshness: "fresh", selectableAgents: [] }; + throw new Error(`unexpected ${method}`); + } }); + const available = await inspectProviderPrerequisites(availability({ enabled: true, comingSoon: false, cli: { status: "found" } }), { id: "workspace" }, () => ""); + NodeAssertStrict.equal(available.opencodeNative.kind, "required-live-proof"); + NodeAssertStrict.equal(available.opencodeNative.model, "opencode/muse-spark-1.3-contributor-free"); + NodeAssertStrict.equal(available.opencodeNative.modelName, "Muse Spark 1.3 Free"); + NodeAssertStrict.equal(available.opencodeNative.observedPrerequisites.account, null); + + const missingCli = await inspectProviderPrerequisites(availability({ enabled: true, comingSoon: false, cli: { status: "not_found" } }), { id: "workspace" }, () => ""); + NodeAssertStrict.equal(missingCli.opencodeNative.kind, "coverage-gap"); + NodeAssertStrict.match(missingCli.opencodeNative.coverageGap, /missing provider, account, model, or catalog prerequisite/); + NodeAssertStrict.deepEqual(aggregateEvidenceFailures([], { web: { opencodeNative: missingCli.opencodeNative } }), ["web/opencode"]); +}); + +NodeTest.test("starts the OpenCode row and review route unproven and records its upstream versions", async () => { + const receipt = createReceipt(NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "provider-completeness-opencode-"))); + NodeAssertStrict.equal(receipt.upstreamOpenCode, "not reached"); + for (const matrix of [receipt.matrix, receipt.electron.matrix]) { + NodeAssertStrict.equal(matrix.opencodeNative.kind, "pending-observation"); + NodeAssertStrict.equal(matrix.opencodeNative.requirement, "required"); + } + NodeAssertStrict.equal(receipt.matrix.opencodeReviewRoute.kind, "coverage-gap"); + NodeAssertStrict.equal(receipt.matrix.opencodeReviewRoute.requirement, "informational"); + NodeAssertStrict.match(receipt.matrix.opencodeReviewRoute.reason, /manual-required fallback, never Approved or Denied/); + NodeAssertStrict.deepEqual(receipt.matrix.opencodeReviewRoute.focusedEvidence, { opencodeReviewUnavailable: "opencode-provider-routes", managedRequired: "server-approval-review-policy" }); + NodeAssertStrict.equal(receipt.electron.matrix.opencodeReviewRoute, undefined); + + const requests = []; + const resolve = async (request) => { requests.push(request); return { binaryVersion: "1.18.28", serverVersion: "1.18.28", sdk: "none (hand-written HTTP client)" }; }; + const attached = { rpc: async (method) => method === "settings.get" ? { provider: { opencode: { serveUrl: "http://127.0.0.1:4096" } } } : null }; + const settingsFailed = { rpc: async () => { throw new Error("settings unavailable"); } }; + NodeAssertStrict.deepEqual(await resolveProofUpstreamOpenCode(attached, resolve), { binaryVersion: "1.18.28", serverVersion: "1.18.28", sdk: "none (hand-written HTTP client)" }); + await resolveProofUpstreamOpenCode(settingsFailed, resolve); + NodeAssertStrict.deepEqual(requests, [{ attachedUrl: "http://127.0.0.1:4096" }, { attachedUrl: "" }]); }); NodeTest.test("marks an available Claude provider as a coverage gap only when its CLI reports no account", async () => { diff --git a/.agents/skills/verify-mcode/scripts/runtime.mjs b/.agents/skills/verify-mcode/scripts/runtime.mjs index 5703f4278..d8be4dbb2 100644 --- a/.agents/skills/verify-mcode/scripts/runtime.mjs +++ b/.agents/skills/verify-mcode/scripts/runtime.mjs @@ -12,6 +12,15 @@ import { readPortsFile, resolveRepoRoot, } from "../../../../scripts/agent/runtime-contract.mjs"; +import { + fetchOpenCodeHealth, + findOpenCodeSession, + listOpenCodeSessions, + OPENCODE_SDK_LABEL, + readOpenCodeBinaryVersion, + startOwnedOpenCodeServe, + stopOwnedOpenCodeServe, +} from "./opencode-serve.mjs"; const HEALTH_TIMEOUT_MS = 15_000; const LIVE_TIMEOUT_MS = 120_000; @@ -43,10 +52,16 @@ const RUNTIME_SOURCE_DIRECTORIES = [ ["packages", "thread-orchestration", "src"], ]; const PROVIDERS = new Set(["codex", "claude", "cursor", "opencode", "devin"]); -const SCENARIOS = new Set(["completion", "stop", "subagent", "opencode-resume", "devin-permission"]); +const SCENARIOS = new Set(["completion", "stop", "subagent", "opencode-resume", "opencode-attach", "devin-permission"]); const OPENCODE_RESUME_MODEL = "opencode/muse-spark-1.3-contributor-free"; const OPENCODE_RESUME_WORKSPACE_NAME = "Verify OpenCode resume"; const OPENCODE_SESSION_INVALIDATED_SUBTYPE = "sdk_session_invalidated"; +const SCENARIO_REQUIREMENTS = { + "devin-permission": { provider: "devin" }, + subagent: { provider: "codex", model: "gpt-5.6-terra" }, + "opencode-resume": { provider: "opencode", model: OPENCODE_RESUME_MODEL }, + "opencode-attach": { provider: "opencode" }, +}; const FOCUSED_TEST_FILES = [ "src/features/agents/composition/__tests__/agent-service-container.test.ts", "src/features/agents/orchestration/__tests__/agent-service-child-stop.test.ts", @@ -85,6 +100,7 @@ const FIXED_PROMPTS = { stop: "Inspect this repository with read-only file-search and file-reading tools. Do not write files, change settings, or run mutating commands. Explain the repository structure in detail.", subagent: "Use exactly one subagent through provider-native collaboration. Give it this task: VERIFY_SUBAGENT_PARENT_TASK: wait five seconds without modifying files, then reply exactly VERIFY_SUBAGENT_CHILD_MESSAGE. After it finishes, reply exactly VERIFY_SUBAGENT_PARENT_DONE.", "opencode-resume": "Reply with exactly: Agent runtime verification complete. Do not edit files or invoke tools.", + "opencode-attach": "Reply with exactly: Agent runtime verification complete. Do not edit files or invoke tools.", "devin-permission": "Run this exact shell command once: mkdir verify_devin_permission_tmp. Do not use file tools or edit files. After the command finishes, reply with exactly: VERIFY_DEVIN_PERMISSION_DONE.", }; @@ -105,7 +121,7 @@ Commands: --watch polls while the runtime starts or stops to catch transient flashes. inspect Read active runtime and workspace summaries through the authenticated WebSocket RPC API. - live --provider --model --scenario --confirm-provider-call [--keep-thread] [--allow-enable-devin] + live --provider --model --scenario --confirm-provider-call [--keep-thread] [--allow-enable-devin] Make one confirmed provider call in an owned or registered workspace. Does not start a runtime. Devin defaults to disabled; --allow-enable-devin enables it for the proof and restores the original setting. The devin-permission scenario runs supervised: it answers Devin's real permission prompt @@ -132,7 +148,16 @@ Stop proof: The stop scenario requires agent.activeCount to reach 0 and agent.listRunning to retain the matching cancelled snapshot for reconnect hydration. OpenCode resume proof: - The opencode-resume scenario requires --provider opencode and --model ${OPENCODE_RESUME_MODEL}. It creates and removes an owned temporary workspace, creates two owned direct threads, restarts only this worktree runtime, deletes one owned upstream session, and removes both threads after proof.`; + The opencode-resume scenario requires --provider opencode and --model ${OPENCODE_RESUME_MODEL}. It creates and removes an owned temporary workspace, creates two owned direct threads, restarts only this worktree runtime, deletes one owned upstream session, and removes both threads after proof. + +OpenCode attach proof: + live --provider opencode --scenario opencode-attach --confirm-provider-call [--model ] + The model defaults to ${OPENCODE_RESUME_MODEL}. The proof spawns an external opencode serve on a free + loopback port in an owned temp directory and captures its PID at spawn. It sets provider.opencode.serveUrl + to that server, runs one turn on a new direct thread in .dev/fixture-repo, and lists the server's sessions + for the fixture directory as a second client. It restarts only this worktree runtime and checks that the + same serve PID still answers /global/health. Cleanup restores the prior serveUrl, deletes owned threads, + stops only the captured serve process tree, and removes its temp directory.`; async function main() { const parsed = parseArguments(process.argv.slice(2)); @@ -146,7 +171,8 @@ async function main() { return result.exitCode; } -function parseArguments(argv) { +/** Parses runtime CLI arguments; exported for contract tests. */ +export function parseArguments(argv) { if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { help: true }; const [command, ...rest] = argv; validateCommand(command); @@ -207,8 +233,8 @@ function parseOptionlessCommand(command, rest) { function parseLiveArguments(rest) { const options = readLiveOptions(rest); const provider = options.get("--provider"); - const model = options.get("--model"); const scenario = options.get("--scenario"); + const model = options.get("--model") ?? (scenario === "opencode-attach" ? OPENCODE_RESUME_MODEL : undefined); validateLiveOptions(options, provider, model, scenario); return { command: "live", provider, model, scenario, keepThread: options.has("--keep-thread"), allowEnableDevin: options.has("--allow-enable-devin") }; } @@ -255,16 +281,11 @@ function validateLiveOptions(options, provider, model, scenario) { } function validateLiveScenario(provider, model, scenario) { - if (!SCENARIOS.has(scenario)) throw cliError("--scenario must be completion, stop, subagent, opencode-resume, or devin-permission"); - if (scenario === "devin-permission" && provider !== "devin") { - throw cliError("the devin-permission scenario requires --provider devin"); - } - if (scenario === "subagent" && (provider !== "codex" || model !== "gpt-5.6-terra")) { - throw cliError("the subagent scenario requires --provider codex --model gpt-5.6-terra"); - } - if (scenario === "opencode-resume" && (provider !== "opencode" || model !== OPENCODE_RESUME_MODEL)) { - throw cliError(`the opencode-resume scenario requires --provider opencode --model ${OPENCODE_RESUME_MODEL}`); - } + if (!SCENARIOS.has(scenario)) throw cliError("--scenario must be completion, stop, subagent, opencode-resume, opencode-attach, or devin-permission"); + const required = SCENARIO_REQUIREMENTS[scenario]; + if (!required || (required.provider === provider && (required.model === undefined || required.model === model))) return; + const flags = required.model ? `--provider ${required.provider} --model ${required.model}` : `--provider ${required.provider}`; + throw cliError(`the ${scenario} scenario requires ${flags}`); } async function execute(parsed, repoRoot) { @@ -527,6 +548,8 @@ async function live(repoRoot, options) { ownedThreadIds: [], eventsByThread: new Map(), seenCanonicalEventIds: new Set(), + opencodeServe: null, + opencodePriorServeUrl: null, }; try { await prepareLiveRun(repoRoot, options, run); @@ -1322,6 +1345,7 @@ function createLiveReport(options) { subagentParentMessagePromptAbsent: false, subagentMessageRetained: false, opencodeResume: null, + opencodeAttach: null, stopResults: [], sharedStopResult: null, activeCountCleared: false, @@ -1340,6 +1364,7 @@ async function prepareLiveRun(repoRoot, options, run) { await health(repoRoot); run.socket = await openSocket(repoRoot, readRuntime(repoRoot), (push) => recordPush(run, push)); await prepareDevinForLive(run.socket, options, run); + await prepareOpenCodeAttach(run.socket, options, run); run.proofDeadline = Date.now() + LIVE_TIMEOUT_MS; const workspace = await findLiveWorkspace(run.socket, repoRoot, options.scenario, run); run.workspace = workspace; @@ -1400,12 +1425,13 @@ async function proveLiveScenario(repoRoot, scenario, run) { if (scenario === "completion") return proveCompletion(run); if (scenario === "subagent") return proveSubagent(run); if (scenario === "opencode-resume") return proveOpenCodeResume(repoRoot, run); + if (scenario === "opencode-attach") return proveOpenCodeAttach(repoRoot, run); return proveStop(run); } async function findLiveWorkspace(socket, repoRoot, scenario, run) { if (scenario === "opencode-resume") return createOpenCodeResumeWorkspace(socket, repoRoot, run); - if (scenario !== "subagent") return findCurrentWorkspace(socket, repoRoot); + if (scenario !== "subagent" && scenario !== "opencode-attach") return findCurrentWorkspace(socket, repoRoot); const fixtureRepo = getRuntimePaths(repoRoot).fixtureRepoDir; const workspaces = await socket.rpc("workspace.list", {}); const workspace = Array.isArray(workspaces) @@ -1586,7 +1612,7 @@ async function proveOpenCodeResume(repoRoot, run) { }; run.report.opencodeResume = resume; - await restartOpenCodeResumeRuntime(repoRoot, run); + await restartLiveRuntime(repoRoot, run); resume.runtimeRestarted = true; const primaryResumeStart = liveEventCount(run, primaryThreadId); await sendLiveMessage(run.socket, primaryThreadId, run.proofDeadline, run.report); @@ -1616,6 +1642,98 @@ async function proveOpenCodeResume(repoRoot, run) { ); } +/** Creates the redacted attach receipt block; every assertion starts unproven. */ +export function createOpenCodeAttachReport(upstream) { + return { + upstream, + assertions: { + turnCompletedWithDurableAssistant: false, + sessionVisibleToSecondClient: false, + sessionDirectoryMatchesThreadCwd: false, + serveSurvivedRuntimeRestart: false, + }, + cleanup: { serveUrlRestored: null, externalServeStopped: null, tempDirectoryRemoved: null }, + }; +} + +/** True only when every attach assertion passed. */ +export function openCodeAttachPassed(report) { + return Boolean(report) && Object.values(report.assertions).every((value) => value === true); +} + +function openCodeServeUrlFromSettings(settings, method) { + const serveUrl = settings?.provider?.opencode?.serveUrl; + if (typeof serveUrl === "string") return serveUrl; + throw actionable(`${method} returned no provider.opencode.serveUrl`, "Rebuild and restart this worktree runtime with bun run agent:setup and bun run --shell system agent:up, then retry the attach proof."); +} + +/** Spawns the owned external serve and points provider.opencode.serveUrl at it before the thread exists. */ +async function prepareOpenCodeAttach(socket, options, run) { + if (options.scenario !== "opencode-attach") return; + const settings = await socket.rpc("settings.get", {}); + // A disabled provider fails createAndSend server-side, which the harness would only see as a silent 120-second timeout. + if (settings?.provider?.enabled?.opencode !== true) { + throw actionable("OpenCode is disabled in this runtime", "Enable OpenCode in Settings > Providers, then retry the attach proof."); + } + const priorServeUrl = openCodeServeUrlFromSettings(settings, "settings.get"); + const binaryVersion = readOpenCodeBinaryVersion(); + run.opencodeServe = await startOwnedOpenCodeServe({ env: childCommandEnvironment() }); + run.report.opencodeAttach = createOpenCodeAttachReport({ binaryVersion, serverVersion: run.opencodeServe.version, sdk: OPENCODE_SDK_LABEL }); + run.opencodePriorServeUrl = priorServeUrl; + const updated = await socket.rpc("settings.update", { provider: { opencode: { serveUrl: run.opencodeServe.url } } }); + if (openCodeServeUrlFromSettings(updated, "settings.update") === run.opencodeServe.url) return; + throw actionable("settings.update did not store the external OpenCode serve URL", "Inspect the OpenCode serveUrl setting contract, then retry the attach proof."); +} + +async function proveOpenCodeAttach(repoRoot, run) { + const attach = run.report.opencodeAttach; + const serve = run.opencodeServe; + await proveCompletion(run); + attach.assertions.turnCompletedWithDurableAssistant = true; + const sessionId = await requireOpenCodeSessionId(run.socket, run.workspace.id, run.threadId, run.proofDeadline); + const threadCwd = run.workspace.path; + const session = findOpenCodeSession(await listOpenCodeSessions(serve.url, threadCwd), sessionId, threadCwd, pathsMatch); + attach.assertions.sessionVisibleToSecondClient = session.visible; + attach.assertions.sessionDirectoryMatchesThreadCwd = session.directoryMatches; + await restartLiveRuntime(repoRoot, run); + const serverVersionAfterRestart = await fetchOpenCodeHealth(serve.url).catch(() => null); + attach.assertions.serveSurvivedRuntimeRestart = isProcessAlive(serve.pid) && serverVersionAfterRestart === attach.upstream.serverVersion; + if (openCodeAttachPassed(attach)) return; + throw actionable( + "The OpenCode attach turn, second-client session listing, directory routing, or restart survival proof did not pass", + "Inspect opencodeAttach.assertions in the redacted receipt, then retry with an authenticated OpenCode account.", + ); +} + +/** Restores the serveUrl captured before the proof whenever this proof may have changed it. */ +async function restoreOpenCodeServeUrl(run) { + if (run.opencodePriorServeUrl === null || !run.report.opencodeAttach) return; + const cleanup = run.report.opencodeAttach.cleanup; + try { + if (!run.socket) throw actionable("No runtime socket was available to restore provider.opencode.serveUrl", "Set the OpenCode serve URL back in Mcode Settings."); + const updated = await run.socket.rpc("settings.update", { provider: { opencode: { serveUrl: run.opencodePriorServeUrl } } }); + cleanup.serveUrlRestored = openCodeServeUrlFromSettings(updated, "settings.update") === run.opencodePriorServeUrl; + if (!cleanup.serveUrlRestored) run.report.cleanup.failure ??= safeError(actionable("settings.update did not restore the prior OpenCode serve URL", "Set the OpenCode serve URL back in Mcode Settings.")); + } catch (error) { + cleanup.serveUrlRestored = false; + run.report.cleanup.failure ??= safeError(error); + } +} + +/** Stops only the serve process tree this proof spawned and removes its temp directory. */ +async function stopOpenCodeAttachServe(run) { + if (!run.opencodeServe) return; + const stopped = await stopOwnedOpenCodeServe(run.opencodeServe); + run.opencodeServe = null; + if (run.report.opencodeAttach) { + run.report.opencodeAttach.cleanup.externalServeStopped = stopped.stopped; + run.report.opencodeAttach.cleanup.tempDirectoryRemoved = stopped.tempDirectoryRemoved; + } + if (!stopped.stopped || !stopped.tempDirectoryRemoved) { + run.report.cleanup.failure ??= safeError(actionable("The verifier-owned opencode serve did not stop or its temp directory remains", "Stop only the PID this proof spawned, then remove its mcode-verify-opencode-serve temp directory.")); + } +} + async function createOpenCodeResumeWorkspace(socket, repoRoot, run) { const name = `${OPENCODE_RESUME_WORKSPACE_NAME} ${fileStamp()}`; const workspace = await socket.rpc("workspace.create", { name, path: repoRoot }, run.proofDeadline); @@ -1668,7 +1786,7 @@ async function requireOpenCodeSessionId(socket, workspaceId, threadId, deadline) throw actionable("The OpenCode thread did not persist its upstream session ID", "Inspect the redacted receipt, then retry with an authenticated OpenCode account."); } -async function restartOpenCodeResumeRuntime(repoRoot, run) { +async function restartLiveRuntime(repoRoot, run) { await run.socket?.close(); run.socket = null; await runWorktreeRuntimeCommand(repoRoot, "agent:down"); @@ -1687,7 +1805,7 @@ async function restartOpenCodeResumeRuntime(repoRoot, run) { const hydrationRequired = Array.isArray(subscription?.hydrationRequiredThreadIds) && subscription.hydrationRequiredThreadIds.some((threadId) => run.ownedThreadIds.includes(threadId)); if (!hydrationRequired) return; - throw actionable("The restarted runtime requires hydration for an OpenCode proof thread", "Run runtime inspect, then retry the OpenCode resume proof."); + throw actionable("The restarted runtime requires hydration for an OpenCode proof thread", "Run runtime inspect, then retry the OpenCode proof."); } async function runWorktreeRuntimeCommand(repoRoot, script) { @@ -1699,7 +1817,7 @@ async function runWorktreeRuntimeCommand(repoRoot, script) { stderr: "ignore", }); if (await child.exited === 0) return; - throw actionable(`${script} failed for this worktree runtime`, "Run runtime health, then retry the OpenCode resume proof."); + throw actionable(`${script} failed for this worktree runtime`, "Run runtime health, then retry the OpenCode proof."); } async function deleteOwnedOpenCodeSession(repoRoot, sessionId) { @@ -1862,8 +1980,10 @@ async function disposeLiveRun(run, keepThread) { await deleteLiveThread(run, keepThread); await deleteLiveWorkspace(run, keepThread); await restoreDevinAfterLive(run); + await restoreOpenCodeServeUrl(run); } finally { if (run.socket) await run.socket.close(); + await stopOpenCodeAttachServe(run); } } @@ -2152,6 +2272,7 @@ function redactReceipt(report) { subagentParentMessagePromptAbsent: report.subagentParentMessagePromptAbsent, subagentMessageRetained: report.subagentMessageRetained, opencodeResume: report.opencodeResume, + opencodeAttach: report.opencodeAttach, stopResults: report.stopResults.map((result) => ({ status: result?.status ?? null, phase: result?.snapshot?.phase ?? null, dispatchState: result?.dispatchState ?? null })), sharedStopResult: report.sharedStopResult, activeCountCleared: report.activeCountCleared, @@ -2284,7 +2405,7 @@ function cleanup(repoRoot) { export function isRuntimeHarnessEvidenceFile(name) { const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-/; if (!timestamp.test(name)) return false; - return /^(?:\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(?:completion|stop|opencode-resume)-(?:receipt\.json|timeline\.html)|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-worktree-setup-receipt\.json|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(?:focused-agent-runtime|lint)\.log)$/.test(name); + return /^(?:\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(?:completion|stop|opencode-resume|opencode-attach)-(?:receipt\.json|timeline\.html)|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-worktree-setup-receipt\.json|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(?:focused-agent-runtime|lint)\.log)$/.test(name); } function ensureEvidenceDirectory(repoRoot) { diff --git a/.agents/skills/verify-mcode/scripts/runtime.test.mjs b/.agents/skills/verify-mcode/scripts/runtime.test.mjs index e3fa1516f..7f396d437 100644 --- a/.agents/skills/verify-mcode/scripts/runtime.test.mjs +++ b/.agents/skills/verify-mcode/scripts/runtime.test.mjs @@ -6,7 +6,8 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeTest from "node:test"; -import { assertRuntimeFreshness, canonicalFrameToLiveEvents, isOpenCodeSessionInvalidatedEvent, isRuntimeHarnessEvidenceFile, openVerificationSocketUrl, runBun } from "./runtime.mjs"; +import { findOpenCodeSession, parseOpenCodeHealth, resolveUpstreamOpenCode } from "./opencode-serve.mjs"; +import { assertRuntimeFreshness, canonicalFrameToLiveEvents, createOpenCodeAttachReport, isOpenCodeSessionInvalidatedEvent, isRuntimeHarnessEvidenceFile, openCodeAttachPassed, openVerificationSocketUrl, parseArguments, runBun } from "./runtime.mjs"; const CLI = NodePath.join(import.meta.dirname, "verify-mcode.mjs"); const BROWSER_PROOF = NodePath.join(import.meta.dirname, "browser-opencode-proof.mjs"); @@ -140,6 +141,69 @@ NodeTest.test("cleans only OpenCode resume artifacts created by the runtime veri NodeAssertStrict.equal(isRuntimeHarnessEvidenceFile("2026-09-04T12-34-56-789Z-opencode-resume-notes.txt"), false); }); +NodeTest.test("parses the OpenCode attach scenario with its default model and rejects other providers", () => { + NodeAssertStrict.deepEqual(parseArguments(["live", "--provider", "opencode", "--scenario", "opencode-attach", "--confirm-provider-call"]), { + command: "live", + provider: "opencode", + model: "opencode/muse-spark-1.3-contributor-free", + scenario: "opencode-attach", + keepThread: false, + allowEnableDevin: false, + }); + NodeAssertStrict.equal(parseArguments(["live", "--provider", "opencode", "--model", "opencode/other", "--scenario", "opencode-attach", "--confirm-provider-call"]).model, "opencode/other"); + NodeAssertStrict.throws(() => parseArguments(["live", "--provider", "codex", "--scenario", "opencode-attach", "--confirm-provider-call"]), /opencode-attach scenario requires --provider opencode/); + NodeAssertStrict.throws(() => parseArguments(["live", "--provider", "opencode", "--scenario", "opencode-attach"]), /Provider confirmation is missing/); + NodeAssertStrict.throws(() => parseArguments(["live", "--provider", "opencode", "--scenario", "completion", "--confirm-provider-call"]), /--model must be a non-empty ID/); +}); + +NodeTest.test("cleans only OpenCode attach artifacts created by the runtime verifier", () => { + NodeAssertStrict.equal(isRuntimeHarnessEvidenceFile("2026-10-06T12-34-56-789Z-opencode-attach-receipt.json"), true); + NodeAssertStrict.equal(isRuntimeHarnessEvidenceFile("2026-10-06T12-34-56-789Z-opencode-attach-timeline.html"), true); + NodeAssertStrict.equal(isRuntimeHarnessEvidenceFile("2026-10-06T12-34-56-789Z-opencode-attach-serve.log"), false); +}); + +NodeTest.test("passes the attach receipt only when every assertion is proven", () => { + const report = createOpenCodeAttachReport({ binaryVersion: "1.18.28", serverVersion: "1.18.28", sdk: "none (hand-written HTTP client)" }); + NodeAssertStrict.equal(openCodeAttachPassed(report), false); + report.assertions.turnCompletedWithDurableAssistant = true; + report.assertions.sessionVisibleToSecondClient = true; + report.assertions.sessionDirectoryMatchesThreadCwd = true; + NodeAssertStrict.equal(openCodeAttachPassed(report), false); + report.assertions.serveSurvivedRuntimeRestart = true; + NodeAssertStrict.equal(openCodeAttachPassed(report), true); + NodeAssertStrict.deepEqual(report.upstream, { binaryVersion: "1.18.28", serverVersion: "1.18.28", sdk: "none (hand-written HTTP client)" }); + NodeAssertStrict.deepEqual(report.cleanup, { serveUrlRestored: null, externalServeStopped: null, tempDirectoryRemoved: null }); +}); + +NodeTest.test("reads the OpenCode server version only from a healthy /global/health payload", () => { + NodeAssertStrict.equal(parseOpenCodeHealth({ healthy: true, version: "1.18.28" }), "1.18.28"); + NodeAssertStrict.throws(() => parseOpenCodeHealth({ healthy: false, version: "1.18.28" }), /did not report healthy/); + NodeAssertStrict.throws(() => parseOpenCodeHealth({ healthy: true }), /did not report healthy/); +}); + +NodeTest.test("finds the thread session in a second client's listing and checks its directory", () => { + const sameDirectory = (left, right) => left.toLowerCase() === right.toLowerCase(); + const sessions = [{ id: "ses_other", directory: "C:/elsewhere" }, { id: "ses_thread", directory: "C:/Fixture-Repo" }]; + NodeAssertStrict.deepEqual(findOpenCodeSession(sessions, "ses_thread", "c:/fixture-repo", sameDirectory), { visible: true, directoryMatches: true }); + NodeAssertStrict.deepEqual(findOpenCodeSession(sessions, "ses_other", "c:/fixture-repo", sameDirectory), { visible: true, directoryMatches: false }); + NodeAssertStrict.deepEqual(findOpenCodeSession(sessions, "ses_missing", "c:/fixture-repo", sameDirectory), { visible: false, directoryMatches: false }); +}); + +NodeTest.test("records upstream OpenCode versions from an attached URL, a probe serve, or a blocker", async () => { + const attached = await resolveUpstreamOpenCode({ attachedUrl: "http://127.0.0.1:4096", readBinaryVersion: () => "1.18.28", readServerVersion: async (url) => url === "http://127.0.0.1:4096" ? "1.18.27" : "wrong" }); + NodeAssertStrict.deepEqual(attached, { binaryVersion: "1.18.28", serverVersion: "1.18.27", sdk: "none (hand-written HTTP client)", serverSource: "attached serve" }); + + const stopped = []; + const probed = await resolveUpstreamOpenCode({ readBinaryVersion: () => "1.18.28", startServe: async () => ({ pid: 7, version: "1.18.28" }), stopServe: async (serve) => { stopped.push(serve.pid); } }); + NodeAssertStrict.deepEqual(probed, { binaryVersion: "1.18.28", serverVersion: "1.18.28", sdk: "none (hand-written HTTP client)", serverSource: "verifier-owned probe serve" }); + NodeAssertStrict.deepEqual(stopped, [7]); + + const missing = await resolveUpstreamOpenCode({ readBinaryVersion: () => { throw new Error("spawn opencode ENOENT"); } }); + NodeAssertStrict.equal(missing.binaryVersion, null); + NodeAssertStrict.equal(missing.serverVersion, null); + NodeAssertStrict.match(missing.auditBlocker, /opencode --version failed: spawn opencode ENOENT/); +}); + NodeTest.test("recognizes the provider-neutral OpenCode session invalidation subtype", () => { NodeAssertStrict.equal(isOpenCodeSessionInvalidatedEvent({ type: "system", subtype: "sdk_session_invalidated" }), true); NodeAssertStrict.equal(isOpenCodeSessionInvalidatedEvent({ type: "system", subtype: "opencode:session-recreated" }), false); diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7c0b8e1f2..574a8fcb3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -165,7 +165,7 @@ The registered runtime implementations are split between two locations: - Claude, Codex, Copilot, Cursor, and Devin implementations live privately in [`packages/providers`](packages/providers/src/private) and enter the server through usable public factories. - OpenCode remains in [`server-local adapters`](apps/server/src/features/providers/adapters). -The OpenCode factory currently prepares a descriptor rather than a usable runtime adapter. Gemini is a coming-soon catalog entry. +The OpenCode factory resolves the server-local `OpenCodeProvider`, a usable runtime adapter. Gemini is a coming-soon catalog entry. The six registered providers use different transports. diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-http-client.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-http-client.test.ts index 9ee22d033..e048a9e83 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-http-client.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-http-client.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultOpenCodeHttpClient, OpenCodeReplySessionNotFoundError } from "../opencode-http-client.js"; const BASE = "http://127.0.0.1:4096"; +const ENDPOINT = { baseUrl: BASE, directory: "/w/a" }; +const DIR = "directory=%2Fw%2Fa"; afterEach(() => { vi.unstubAllGlobals(); @@ -20,40 +22,72 @@ function okJson(data: unknown): Response { return { ok: true, status: 200, json: async () => data } as Response; } +describe("directory routing", () => { + it("scopes every request to the endpoint directory, encoding spaces and non-ASCII", async () => { + const { urls } = stubFetch((url) => { + if (url.includes("/event")) return { ok: true, status: 200, body: null } as unknown as Response; + if (url.includes("/config/providers")) return okJson({ providers: [] }); + if (url.includes("/session/status")) return okJson({}); + if (url.includes("/message")) return okJson([]); + return okJson({ id: "ses_1" }); + }); + const endpoint = { baseUrl: BASE, directory: "/home/Zoë Ñ/my repo" }; + await defaultOpenCodeHttpClient.createSession(endpoint); + await defaultOpenCodeHttpClient.promptAsync(endpoint, "ses_1", {}); + await defaultOpenCodeHttpClient.abortSession(endpoint, "ses_1"); + await defaultOpenCodeHttpClient.getSessionStatus(endpoint); + await defaultOpenCodeHttpClient.listModels(endpoint); + await defaultOpenCodeHttpClient.subscribeEvents(endpoint, new AbortController().signal, () => {}); + await defaultOpenCodeHttpClient.listSessionMessages(endpoint, "ses_1", { limit: 5 }); + + const encoded = "directory=%2Fhome%2FZo%C3%AB%20%C3%91%2Fmy%20repo"; + expect(urls).toEqual([ + `${BASE}/session?${encoded}`, + `${BASE}/session/ses_1/prompt_async?${encoded}`, + `${BASE}/session/ses_1/abort?${encoded}`, + `${BASE}/session/status?${encoded}`, + `${BASE}/config/providers?${encoded}`, + `${BASE}/event?${encoded}`, + `${BASE}/session/ses_1/message?limit=5&${encoded}`, + ]); + expect(new URL(urls[0]!).searchParams.get("directory")).toBe("/home/Zoë Ñ/my repo"); + }); +}); + describe("listSessionMessages paged history", () => { it("always sends a bounded limit (never an unbounded fetch)", async () => { const { urls } = stubFetch(() => okJson([])); - await defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", {}); + await defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", {}); expect(urls).toHaveLength(1); expect(urls[0]).toContain("/session/ses_1/message"); expect(urls[0]).toMatch(/limit=\d+/); - await defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", { limit: 100_000 }); + await defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", { limit: 100_000 }); const clamped = Number(new URL(urls[1]).searchParams.get("limit")); expect(clamped).toBeLessThanOrEqual(200); }); it("surfaces a missing session as a 404 so resume can start fresh", async () => { stubFetch(() => ({ ok: false, status: 404, json: async () => ({}) }) as Response); - await expect(defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_gone", { limit: 1 })) + await expect(defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_gone", { limit: 1 })) .rejects.toThrow(/404/); }); it("surfaces broken history as an error, never a hang", async () => { stubFetch(() => ({ ok: false, status: 500, json: async () => ({}) }) as Response); - await expect(defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", { limit: 1 })) + await expect(defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", { limit: 1 })) .rejects.toThrow(/history/i); }); it("rejects malformed payloads instead of hanging", async () => { stubFetch(() => okJson({ not: "a list" })); - await expect(defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", { limit: 1 })) + await expect(defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", { limit: 1 })) .rejects.toThrow(); }); it("times out a stalled history fetch", async () => { stubFetch(() => new Promise(() => {})); await expect( - defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", { limit: 1, timeoutMs: 20 }), + defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", { limit: 1, timeoutMs: 20 }), ).rejects.toThrow(/timed out/i); }); @@ -62,7 +96,7 @@ describe("listSessionMessages paged history", () => { init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); })); const controller = new AbortController(); - const pending = defaultOpenCodeHttpClient.listSessionMessages(BASE, "ses_1", { + const pending = defaultOpenCodeHttpClient.listSessionMessages(ENDPOINT, "ses_1", { limit: 1, signal: controller.signal, }); @@ -78,13 +112,13 @@ describe("permission and question replies", () => { seen.push({ url, init }); return { ok: true, status: 200, json: async () => true } as Response; }); - await defaultOpenCodeHttpClient.replyPermission(BASE, "ses_1", "per_1", "once", "legacy"); - await defaultOpenCodeHttpClient.replyPermission(BASE, "ses_1", "per_2", "always", "v2"); + await defaultOpenCodeHttpClient.replyPermission(ENDPOINT, "ses_1", "per_1", "once", "legacy"); + await defaultOpenCodeHttpClient.replyPermission(ENDPOINT, "ses_1", "per_2", "always", "v2"); expect(seen).toHaveLength(2); - expect(seen[0]!.url).toBe(`${BASE}/session/ses_1/permissions/per_1`); + expect(seen[0]!.url).toBe(`${BASE}/session/ses_1/permissions/per_1?${DIR}`); expect(seen[0]!.init?.method).toBe("POST"); expect(seen[0]!.init?.body).toBe(JSON.stringify({ response: "once" })); - expect(seen[1]!.url).toBe(`${BASE}/api/session/ses_1/permission/per_2/reply`); + expect(seen[1]!.url).toBe(`${BASE}/api/session/ses_1/permission/per_2/reply?${DIR}`); expect(seen[1]!.init?.body).toBe(JSON.stringify({ reply: "always" })); }); @@ -94,15 +128,15 @@ describe("permission and question replies", () => { seen.push({ url, init }); return { ok: true, status: 200, json: async () => true } as Response; }); - await defaultOpenCodeHttpClient.replyQuestion(BASE, "ses_1", "que_1", [["Yes"]], "legacy"); - await defaultOpenCodeHttpClient.rejectQuestion(BASE, "ses_1", "que_2", "legacy"); - await defaultOpenCodeHttpClient.replyQuestion(BASE, "ses_1", "que_3", [["No"]], "v2"); - await defaultOpenCodeHttpClient.rejectQuestion(BASE, "ses_1", "que_4", "v2"); + await defaultOpenCodeHttpClient.replyQuestion(ENDPOINT, "ses_1", "que_1", [["Yes"]], "legacy"); + await defaultOpenCodeHttpClient.rejectQuestion(ENDPOINT, "ses_1", "que_2", "legacy"); + await defaultOpenCodeHttpClient.replyQuestion(ENDPOINT, "ses_1", "que_3", [["No"]], "v2"); + await defaultOpenCodeHttpClient.rejectQuestion(ENDPOINT, "ses_1", "que_4", "v2"); expect(seen.map((entry) => entry.url)).toEqual([ - `${BASE}/question/que_1/reply`, - `${BASE}/question/que_2/reject`, - `${BASE}/api/session/ses_1/question/que_3/reply`, - `${BASE}/api/session/ses_1/question/que_4/reject`, + `${BASE}/question/que_1/reply?${DIR}`, + `${BASE}/question/que_2/reject?${DIR}`, + `${BASE}/api/session/ses_1/question/que_3/reply?${DIR}`, + `${BASE}/api/session/ses_1/question/que_4/reject?${DIR}`, ]); expect(seen[0]!.init?.body).toBe(JSON.stringify({ answers: [["Yes"]] })); expect(seen[2]!.init?.body).toBe(JSON.stringify({ answers: [["No"]] })); @@ -114,9 +148,9 @@ describe("permission and question replies", () => { status: 404, json: async () => ({ _tag: url.includes("permission") ? "PermissionNotFoundError" : "QuestionNotFoundError" }), }) as Response); - await defaultOpenCodeHttpClient.replyPermission(BASE, "ses_1", "per_gone", "reject", "v2"); - await defaultOpenCodeHttpClient.replyQuestion(BASE, "ses_1", "que_gone", [["No"]], "v2"); - await defaultOpenCodeHttpClient.rejectQuestion(BASE, "ses_1", "que_gone", "v2"); + await defaultOpenCodeHttpClient.replyPermission(ENDPOINT, "ses_1", "per_gone", "reject", "v2"); + await defaultOpenCodeHttpClient.replyQuestion(ENDPOINT, "ses_1", "que_gone", [["No"]], "v2"); + await defaultOpenCodeHttpClient.rejectQuestion(ENDPOINT, "ses_1", "que_gone", "v2"); }); it("surfaces a typed missing reply session instead of resolving the card", async () => { @@ -125,13 +159,13 @@ describe("permission and question replies", () => { status: 404, json: async () => ({ _tag: "SessionNotFoundError", sessionID: "ses_gone", message: "not found" }), }) as Response); - await expect(defaultOpenCodeHttpClient.replyPermission(BASE, "ses_gone", "per_1", "once", "v2")) + await expect(defaultOpenCodeHttpClient.replyPermission(ENDPOINT, "ses_gone", "per_1", "once", "v2")) .rejects.toBeInstanceOf(OpenCodeReplySessionNotFoundError); }); it("surfaces non-404 reply failures", async () => { stubFetch(() => ({ ok: false, status: 500, json: async () => ({}) }) as Response); - await expect(defaultOpenCodeHttpClient.replyPermission(BASE, "ses_1", "per_1", "once", "legacy")) + await expect(defaultOpenCodeHttpClient.replyPermission(ENDPOINT, "ses_1", "per_1", "once", "legacy")) .rejects.toThrow(/reply permission/i); }); }); @@ -139,19 +173,19 @@ describe("permission and question replies", () => { describe("session status polling", () => { it("returns per-session status types for idle confirmation", async () => { stubFetch(() => okJson({ ses_1: { type: "busy" }, ses_2: { type: "idle" } })); - const statuses = await defaultOpenCodeHttpClient.getSessionStatus(BASE); + const statuses = await defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT); expect(statuses).toEqual({ ses_1: { type: "busy" }, ses_2: { type: "idle" } }); }); it("drops malformed entries and rejects broken payloads", async () => { stubFetch(() => okJson({ ses_1: { type: "idle" }, ses_2: "busy" })); - expect(await defaultOpenCodeHttpClient.getSessionStatus(BASE)).toEqual({ ses_1: { type: "idle" } }); + expect(await defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT)).toEqual({ ses_1: { type: "idle" } }); stubFetch(() => okJson(["idle"])); - await expect(defaultOpenCodeHttpClient.getSessionStatus(BASE)).rejects.toThrow(/malformed/i); + await expect(defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT)).rejects.toThrow(/malformed/i); stubFetch(() => ({ ok: false, status: 500, json: async () => ({}) }) as Response); - await expect(defaultOpenCodeHttpClient.getSessionStatus(BASE)).rejects.toThrow(/session status/i); + await expect(defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT)).rejects.toThrow(/session status/i); }); it("bounds status entries and ignores oversized status keys", async () => { @@ -160,7 +194,7 @@ describe("session status polling", () => { ...Array.from({ length: 300 }, (_, index) => [`ses_${index}`, { type: "idle" }]), ]); stubFetch(() => okJson(statuses)); - const result = await defaultOpenCodeHttpClient.getSessionStatus(BASE); + const result = await defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT); expect(Object.keys(result)).toHaveLength(255); expect(result["s".repeat(513)]).toBeUndefined(); }); @@ -169,7 +203,7 @@ describe("session status polling", () => { stubFetch((_url, init) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); })); - await expect(defaultOpenCodeHttpClient.getSessionStatus(BASE, { timeoutMs: 20 })).rejects.toThrow(/timed out/i); + await expect(defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT, { timeoutMs: 20 })).rejects.toThrow(/timed out/i); }); it("forwards caller cancellation to a status read", async () => { @@ -177,7 +211,7 @@ describe("session status polling", () => { init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); })); const controller = new AbortController(); - const pending = defaultOpenCodeHttpClient.getSessionStatus(BASE, { signal: controller.signal }); + const pending = defaultOpenCodeHttpClient.getSessionStatus(ENDPOINT, { signal: controller.signal }); controller.abort(); await expect(pending).rejects.toThrow(); }); @@ -200,7 +234,7 @@ describe("SSE boundary", () => { stubFetch(() => ({ ok: true, status: 200, body: stream }) as Response); const received: number[] = []; - await defaultOpenCodeHttpClient.subscribeEvents(BASE, new AbortController().signal, (event) => { + await defaultOpenCodeHttpClient.subscribeEvents(ENDPOINT, new AbortController().signal, (event) => { if (!event || typeof event !== "object" || Array.isArray(event) || !("properties" in event)) return; const properties = event.properties; if (!properties || typeof properties !== "object" || Array.isArray(properties) || !("index" in properties)) return; @@ -221,7 +255,7 @@ describe("SSE boundary", () => { }); stubFetch(() => ({ ok: true, status: 200, body: stream }) as Response); const received: unknown[] = []; - await defaultOpenCodeHttpClient.subscribeEvents(BASE, new AbortController().signal, (event) => received.push(event)); + await defaultOpenCodeHttpClient.subscribeEvents(ENDPOINT, new AbortController().signal, (event) => received.push(event)); expect(received).toEqual([{ type: "mcode.adapter.oversized-sse-frame", properties: {} }]); }); }); diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts index ae8dbecd8..c654a3144 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts @@ -61,7 +61,7 @@ function fakeHttp(envelopes: unknown[]): FakeHttp { } function testProvider(http: FakeHttp) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const host = { events: { submit: async () => ({ commit: {}, delivery: { ingress: "queued" } }) }, @@ -171,7 +171,7 @@ describe("OpenCodeProvider full-access permission bypass", () => { expect(cards).toHaveLength(0); expect(http.replyPermission).toHaveBeenCalledTimes(1); expect(http.replyPermission).toHaveBeenCalledWith( - "http://127.0.0.1:4096", "ses_1", "per_1", "always", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "per_1", "always", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(resolved).toEqual([{ requestId: "per_1", decision: "allow-session" }]); expect(provider.listPendingPermissions("thread-1")).toHaveLength(0); @@ -210,7 +210,7 @@ describe("OpenCodeProvider full-access permission bypass", () => { await sending; expect(http.replyPermission).toHaveBeenCalledTimes(1); expect(http.replyPermission).toHaveBeenCalledWith( - "http://127.0.0.1:4096", "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), ); await provider.shutdown(); }); diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-attach.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-attach.test.ts new file mode 100644 index 000000000..8d964c407 --- /dev/null +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-attach.test.ts @@ -0,0 +1,135 @@ +import "reflect-metadata"; +import { describe, expect, it, vi } from "vitest"; +import { AgentEventType, type ProviderRuntimeEvent, type TurnRequest } from "@mcode/contracts"; +import { OpenCodeProvider } from "../opencode-provider.js"; +import { OpenCodeServerPool } from "../opencode-server-pool.js"; +import type { OpenCodeEndpoint } from "../opencode-http-client.js"; + +const ATTACHED_URL = "http://10.0.0.5:4096"; +const TURN_CWD = "/w/attached repo"; + +function fakeHttp(onSubscribe: (signal: AbortSignal, onEnvelope: (e: unknown) => void) => Promise) { + return { + createSession: vi.fn(async (_endpoint: OpenCodeEndpoint) => ({ id: "ses_a" })), + promptAsync: vi.fn(async (_endpoint: OpenCodeEndpoint) => {}), + abortSession: vi.fn(async (_endpoint: OpenCodeEndpoint, _sessionId: string) => {}), + listModels: vi.fn(async (_endpoint: OpenCodeEndpoint) => [{ id: "anthropic/claude-sonnet-4-6", name: "Sonnet", group: "Anthropic" }]), + listSessionMessages: vi.fn(async () => []), + getSessionStatus: vi.fn(async () => ({ ses_a: { type: "idle" } })), + replyPermission: vi.fn(async () => {}), + replyQuestion: vi.fn(async () => {}), + rejectQuestion: vi.fn(async () => {}), + subscribeEvents: vi.fn(async (_endpoint: OpenCodeEndpoint, signal: AbortSignal, onEnvelope: (e: unknown) => void) => + onSubscribe(signal, onEnvelope)), + }; +} + +function completingStream(signal: AbortSignal, onEnvelope: (e: unknown) => void): Promise { + onEnvelope({ type: "session.idle", properties: { sessionID: "ses_a" } }); + return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })); +} + +function hangingStream(signal: AbortSignal): Promise { + return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })); +} + +function setup(serveUrl: string, http: ReturnType) { + const terminateTree = vi.fn(async () => {}); + const spawn = vi.fn(() => ({ pid: 77, on: () => {}, off: () => {}, kill: () => true })); + const pool = new OpenCodeServerPool({ + spawn, waitForHealth: async () => {}, terminateTree, + findFreePort: async () => 4100, now: () => Date.now(), env: () => ({}), + }); + const acquire = vi.spyOn(pool, "acquire"); + const probeCli = vi.fn(async () => ({ binaryPath: "opencode", version: "test" })); + const settingsService = { get: () => ({ provider: { cli: { opencode: "" }, opencode: { serveUrl } } }) }; + const host = { + processes: { attach: () => {}, terminateTree }, + runtime: { platform: "linux" }, + }; + const provider = new OpenCodeProvider(settingsService as never, { getEnv: () => ({}) } as never, host as never); + provider.configureTestSeams({ + pool, http, probeCli, + idleConfirm: { intervalMs: 1, requiredPolls: 1, timeoutMs: 500, maxPollErrors: 2 }, + }); + const events: string[] = []; + provider.on("event", (runtimeEvent: ProviderRuntimeEvent) => events.push(runtimeEvent.event.type)); + return { provider, acquire, probeCli, spawn, terminateTree, events }; +} + +function turnRequest(): TurnRequest<"opencode"> { + return { + turnId: "turn-attach", + turnExecutionId: "22222222-2222-4222-8222-222222222222", + sessionId: "mcode-thread-attach", + workspaceId: "ws-attach", + threadId: "thread-attach", + message: "hello", + cwd: TURN_CWD, + model: "anthropic/claude-sonnet-4-6", + permissionMode: "full", + interactionMode: "build", + providerOptions: {}, + } as TurnRequest<"opencode">; +} + +describe("OpenCodeProvider attached to an external serve URL", () => { + it("runs the turn on the attached server in the turn's directory without spawning, and shutdown leaves it alone", async () => { + const http = fakeHttp(completingStream); + const { provider, acquire, probeCli, spawn, terminateTree, events } = setup(ATTACHED_URL, http); + + await provider.sendTurn(turnRequest()); + + const attached = { baseUrl: ATTACHED_URL, directory: TURN_CWD }; + expect(http.createSession.mock.calls[0]?.[0]).toEqual(attached); + expect(http.subscribeEvents.mock.calls[0]?.[0]).toEqual(attached); + expect(http.promptAsync.mock.calls[0]?.[0]).toEqual(attached); + expect(events).toEqual([AgentEventType.TurnStarted, AgentEventType.System, AgentEventType.TurnComplete, AgentEventType.Ended]); + expect(acquire).not.toHaveBeenCalled(); + expect(probeCli).not.toHaveBeenCalled(); + + await provider.shutdown(); + expect(http.abortSession).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(terminateTree).not.toHaveBeenCalled(); + }); + + it("stop aborts the upstream session on the attached endpoint", async () => { + const http = fakeHttp(hangingStream); + const { provider, events } = setup(ATTACHED_URL, http); + + const sending = provider.sendTurn(turnRequest()); + await vi.waitFor(() => expect(http.promptAsync).toHaveBeenCalled()); + await provider.stopSession("mcode-thread-attach"); + await sending; + + expect(http.abortSession.mock.calls).toEqual([[{ baseUrl: ATTACHED_URL, directory: TURN_CWD }, "ses_a"]]); + expect(events.at(-1)).toBe(AgentEventType.Ended); + await provider.shutdown(); + }); + + it("lists models from the attached server without touching the pool", async () => { + const http = fakeHttp(hangingStream); + const { provider, acquire, probeCli } = setup(ATTACHED_URL, http); + + const models = await provider.listModels(); + + expect(models.map((model) => model.id)).toEqual(["anthropic/claude-sonnet-4-6"]); + expect(http.listModels.mock.calls).toEqual([[{ baseUrl: ATTACHED_URL, directory: process.cwd() }]]); + expect(acquire).not.toHaveBeenCalled(); + expect(probeCli).not.toHaveBeenCalled(); + await provider.shutdown(); + }); + + it("an empty serve URL keeps the pooled server for the same directory", async () => { + const http = fakeHttp(completingStream); + const { provider, acquire, spawn } = setup("", http); + + await provider.sendTurn(turnRequest()); + + expect(acquire.mock.calls).toEqual([[{ binaryPath: "opencode", cwd: TURN_CWD, hostname: "127.0.0.1" }]]); + expect(spawn).toHaveBeenCalledTimes(1); + expect(http.promptAsync.mock.calls[0]?.[0]).toEqual({ baseUrl: "http://127.0.0.1:4100", directory: TURN_CWD }); + await provider.shutdown(); + }); +}); diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts index 9c9702590..ba44dfecb 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts @@ -26,7 +26,7 @@ function testPool(): OpenCodeServerPool { } function testProvider(http: never) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const submitted: unknown[] = []; const host = { diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-permissions.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-permissions.test.ts index b8e4fe887..379c36c9b 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-permissions.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-permissions.test.ts @@ -66,7 +66,7 @@ function fakeHttp(envelopes: unknown[], hooks?: { onPrompt?: () => void }): Fake } function testProvider(http: FakeHttp) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const submitted: unknown[] = []; const host = { @@ -163,7 +163,7 @@ describe("OpenCodeProvider permission flow", () => { await sending; expect(http.replyPermission).toHaveBeenCalledTimes(1); expect(http.replyPermission).toHaveBeenCalledWith( - "http://127.0.0.1:4096", "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(provider.listPendingPermissions("thread-1")).toHaveLength(0); @@ -187,7 +187,7 @@ describe("OpenCodeProvider permission flow", () => { expect(provider.resolvePermission("per_1", "deny")).toBe(true); await sending; expect(http.replyPermission).toHaveBeenCalledWith( - "http://127.0.0.1:4096", "ses_1", "per_1", "reject", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "per_1", "reject", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(resolved).toEqual([{ requestId: "per_1", decision: "deny" }]); expect(provider.resolvePermission("nope", "allow")).toBe(false); @@ -229,7 +229,7 @@ describe("OpenCodeProvider permission flow", () => { expect(provider.resolvePermission("que_1", "allow", [["Yes"], ["East"]])).toBe(true); await vi.waitFor(() => expect(http.replyQuestion).toHaveBeenCalledTimes(1)); expect(http.replyQuestion).toHaveBeenCalledWith( - "http://127.0.0.1:4096", + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "que_1", [["Yes"], ["East"]], @@ -242,7 +242,7 @@ describe("OpenCodeProvider permission flow", () => { expect(provider.resolvePermission("que_2", "deny")).toBe(false); await sending; expect(http.rejectQuestion).toHaveBeenCalledWith( - "http://127.0.0.1:4096", "ses_1", "que_2", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + { baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", "que_2", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(http.rejectQuestion).toHaveBeenCalledTimes(1); await provider.shutdown(); diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-resume.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-resume.test.ts index ffe33f46f..d7180c81f 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-resume.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-resume.test.ts @@ -16,7 +16,7 @@ function testPool(): OpenCodeServerPool { } function testProvider(http: never, pool: OpenCodeServerPool) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const submitted: unknown[] = []; const host = { @@ -103,7 +103,7 @@ describe("OpenCodeProvider resume cursor", () => { const { provider, submitted } = testProvider(http as never, testPool()); await provider.sendTurn(turnRequest({ resumeFrom: "ses_kept" })); expect(http.createSession).not.toHaveBeenCalled(); - expect(http.promptAsync).toHaveBeenCalledWith("http://127.0.0.1:4096", "ses_kept", expect.anything()); + expect(http.promptAsync).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_kept", expect.anything()); const events = submittedEvents(submitted); expect(events).toContainEqual(expect.objectContaining({ subtype: "sdk_session_id:ses_kept" })); await provider.shutdown(); @@ -116,7 +116,7 @@ describe("OpenCodeProvider resume cursor", () => { resumeFrom: JSON.stringify({ schemaVersion: 99, sessionId: "ses_kept" }), })); expect(http.createSession).toHaveBeenCalledTimes(1); - expect(http.promptAsync).toHaveBeenCalledWith("http://127.0.0.1:4096", "ses_brand_new", expect.anything()); + expect(http.promptAsync).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_brand_new", expect.anything()); await provider.shutdown(); }); @@ -131,7 +131,7 @@ describe("OpenCodeProvider resume cursor", () => { const { provider, submitted } = testProvider(http as never, testPool()); await provider.sendTurn(turnRequest({ resumeFrom: "ses_gone" })); expect(http.createSession).toHaveBeenCalledTimes(1); - expect(http.promptAsync).toHaveBeenCalledWith("http://127.0.0.1:4096", "ses_fresh_1", expect.anything()); + expect(http.promptAsync).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_fresh_1", expect.anything()); const events = submittedEvents(submitted); expect(events).toContainEqual(expect.objectContaining({ type: "system", diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts index 8ee9d0aa2..2e271c061 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-turn-diff.test.ts @@ -5,7 +5,7 @@ import { OpenCodeServerPool } from "../opencode-server-pool.js"; import type { ProviderTurnDiffUpdate, TurnRequest } from "@mcode/contracts"; function testProvider(http: never, pool: OpenCodeServerPool) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const host = { events: { submit: async () => ({ commit: {}, delivery: { ingress: "queued" } }) }, diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider.test.ts index 350fdf51b..96df5a8c5 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider.test.ts @@ -6,7 +6,7 @@ import type { OpenCodeHttpClient } from "../opencode-http-client.js"; import type { TurnRequest } from "@mcode/contracts"; function testProvider(http: OpenCodeHttpClient | undefined, pool: OpenCodeServerPool) { - const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" }, opencode: { serveUrl: "" } } }) }; const envService = { getEnv: () => ({}) }; const submitted: unknown[] = []; const host = { @@ -116,7 +116,7 @@ describe("OpenCodeProvider minimal turn", () => { await provider.sendTurn(turnRequest()); expect(http.createSession).toHaveBeenCalledTimes(1); expect(http.promptAsync).toHaveBeenCalledTimes(1); - expect(http.promptAsync).toHaveBeenCalledWith("http://127.0.0.1:4096", "ses_1", { + expect(http.promptAsync).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:4096", directory: "/w/a" }, "ses_1", { model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" }, parts: [{ type: "text", text: "hello" }], }); diff --git a/apps/server/src/features/providers/adapters/opencode/opencode-http-client.ts b/apps/server/src/features/providers/adapters/opencode/opencode-http-client.ts index ed6f76980..1fa96a5c1 100644 --- a/apps/server/src/features/providers/adapters/opencode/opencode-http-client.ts +++ b/apps/server/src/features/providers/adapters/opencode/opencode-http-client.ts @@ -21,11 +21,18 @@ export class OpenCodeReplySessionNotFoundError extends Error { } } -/** Narrow HTTP surface the OpenCode provider needs from one `serve` instance. */ +/** + * One `serve` instance plus the project directory a request acts in. A single + * serve process hosts many directories and routes each request by its + * `directory` query; without it, sessions land in the server's own cwd. + */ +export type OpenCodeEndpoint = Readonly<{ baseUrl: string; directory: string }>; + +/** Narrow HTTP surface the OpenCode provider needs from one `serve` directory. */ export interface OpenCodeHttpClient { - createSession(baseUrl: string, title?: string): Promise<{ id: string }>; - promptAsync(baseUrl: string, sessionId: string, body: Record): Promise; - abortSession(baseUrl: string, sessionId: string): Promise; + createSession(endpoint: OpenCodeEndpoint, title?: string): Promise<{ id: string }>; + promptAsync(endpoint: OpenCodeEndpoint, sessionId: string, body: Record): Promise; + abortSession(endpoint: OpenCodeEndpoint, sessionId: string): Promise; /** * Relay one user decision to a pending permission request. * `once` approves a single run, `always` approves for the session, @@ -33,7 +40,7 @@ export interface OpenCodeHttpClient { * already consumed the request; a missing session remains visible. */ replyPermission( - baseUrl: string, + endpoint: OpenCodeEndpoint, sessionId: string, permissionId: string, response: "once" | "always" | "reject", @@ -45,7 +52,7 @@ export interface OpenCodeHttpClient { * Only a typed QuestionNotFound 404 means upstream already consumed it. */ replyQuestion( - baseUrl: string, + endpoint: OpenCodeEndpoint, sessionId: string, requestId: string, answers: string[][], @@ -54,7 +61,7 @@ export interface OpenCodeHttpClient { ): Promise; /** Dismiss a pending question request. Only QuestionNotFound is idempotent. */ rejectQuestion( - baseUrl: string, + endpoint: OpenCodeEndpoint, sessionId: string, requestId: string, version: OpenCodeRequestVersion, @@ -66,15 +73,15 @@ export interface OpenCodeHttpClient { * `session.idle` between steps of a multi-step turn, so the first idle is * never terminal proof on its own. */ - getSessionStatus(baseUrl: string, options?: OpenCodeStatusRequestOptions): Promise>; - listModels(baseUrl: string): Promise; - subscribeEvents(baseUrl: string, signal: AbortSignal, onEnvelope: (envelope: unknown) => void): Promise; + getSessionStatus(endpoint: OpenCodeEndpoint, options?: OpenCodeStatusRequestOptions): Promise>; + listModels(endpoint: OpenCodeEndpoint): Promise; + subscribeEvents(endpoint: OpenCodeEndpoint, signal: AbortSignal, onEnvelope: (envelope: unknown) => void): Promise; /** * Read one bounded page of upstream session history. The limit is always * sent and clamped; there is no unbounded full-history fetch. */ listSessionMessages( - baseUrl: string, + endpoint: OpenCodeEndpoint, sessionId: string, options?: { limit?: number; timeoutMs?: number; signal?: AbortSignal }, ): Promise; @@ -96,6 +103,18 @@ const MAX_STATUS_TYPE_CHARS = 64; const OVERSIZED_SSE_ENVELOPE = { type: "mcode.adapter.oversized-sse-frame", properties: {} }; const MALFORMED_SSE_ENVELOPE = { type: "mcode.adapter.malformed-sse-frame", properties: {} }; +/** + * Build one request URL. Every request carries the endpoint directory as a + * query parameter: the `x-opencode-directory` header cannot carry non-Latin-1 + * paths, and percent-encoding keeps spaces and non-ASCII unambiguous. + */ +function endpointUrl(endpoint: OpenCodeEndpoint, path: string, query: Readonly> = {}): string { + const params = [...Object.entries(query), ["directory", endpoint.directory]] + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); + return `${endpoint.baseUrl}${path}?${params}`; +} + function checkStatus(res: Response, what: string): void { if (res.ok) return; throw new Error(`OpenCode ${what} failed with HTTP ${res.status}`); @@ -304,8 +323,8 @@ async function drainSseStream( /** Default fetch-based client over the `opencode serve` REST + SSE surface. */ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { - async createSession(baseUrl, title) { - const res = await fetch(`${baseUrl}/session`, { + async createSession(endpoint, title) { + const res = await fetch(endpointUrl(endpoint, "/session"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(title ? { title } : {}), @@ -316,8 +335,8 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { return { id: data.id }; }, - async promptAsync(baseUrl, sessionId, body) { - const res = await fetch(`${baseUrl}/session/${encodeURIComponent(sessionId)}/prompt_async`, { + async promptAsync(endpoint, sessionId, body) { + const res = await fetch(endpointUrl(endpoint, `/session/${encodeURIComponent(sessionId)}/prompt_async`), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -325,17 +344,17 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { if (res.status !== 204 && !res.ok) throw new Error(`OpenCode prompt_async failed with HTTP ${res.status}`); }, - async abortSession(baseUrl, sessionId) { - const res = await fetch(`${baseUrl}/session/${encodeURIComponent(sessionId)}/abort`, { method: "POST" }); + async abortSession(endpoint, sessionId) { + const res = await fetch(endpointUrl(endpoint, `/session/${encodeURIComponent(sessionId)}/abort`), { method: "POST" }); if (!res.ok && res.status !== 404) throw new Error(`OpenCode abort failed with HTTP ${res.status}`); }, - async replyPermission(baseUrl, sessionId, permissionId, response, version, options) { + async replyPermission(endpoint, sessionId, permissionId, response, version, options) { const path = version === "v2" ? `/api/session/${encodeURIComponent(sessionId)}/permission/${encodeURIComponent(permissionId)}/reply` : `/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permissionId)}`; const res = await fetch( - `${baseUrl}${path}`, + endpointUrl(endpoint, path), { method: "POST", headers: { "content-type": "application/json" }, @@ -346,11 +365,11 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { await consumeReplyNotFound(res, "PermissionNotFoundError", "reply permission"); }, - async replyQuestion(baseUrl, sessionId, requestId, answers, version, options) { + async replyQuestion(endpoint, sessionId, requestId, answers, version, options) { const path = version === "v2" ? `/api/session/${encodeURIComponent(sessionId)}/question/${encodeURIComponent(requestId)}/reply` : `/question/${encodeURIComponent(requestId)}/reply`; - const res = await fetch(`${baseUrl}${path}`, { + const res = await fetch(endpointUrl(endpoint, path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers }), @@ -359,25 +378,25 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { await consumeReplyNotFound(res, "QuestionNotFoundError", "reply question"); }, - async rejectQuestion(baseUrl, sessionId, requestId, version, options) { + async rejectQuestion(endpoint, sessionId, requestId, version, options) { const path = version === "v2" ? `/api/session/${encodeURIComponent(sessionId)}/question/${encodeURIComponent(requestId)}/reject` : `/question/${encodeURIComponent(requestId)}/reject`; - const res = await fetch(`${baseUrl}${path}`, { method: "POST", signal: options?.signal }); + const res = await fetch(endpointUrl(endpoint, path), { method: "POST", signal: options?.signal }); await consumeReplyNotFound(res, "QuestionNotFoundError", "reject question"); }, - async getSessionStatus(baseUrl, options) { + async getSessionStatus(endpoint, options) { const data = await fetchSessionStatus( - `${baseUrl}/session/status`, + endpointUrl(endpoint, "/session/status"), boundedStatusTimeout(options?.timeoutMs), options?.signal, ); return boundedSessionStatuses(data); }, - async listModels(baseUrl) { - const res = await fetch(`${baseUrl}/config/providers`); + async listModels(endpoint) { + const res = await fetch(endpointUrl(endpoint, "/config/providers")); checkStatus(res, "list providers"); const data = (await res.json()) as { providers?: Array<{ id: string; name: string; models?: Record }>; @@ -397,8 +416,8 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { return out; }, - async subscribeEvents(baseUrl, signal, onEnvelope) { - const res = await fetch(`${baseUrl}/event`, { + async subscribeEvents(endpoint, signal, onEnvelope) { + const res = await fetch(endpointUrl(endpoint, "/event"), { headers: { accept: "text/event-stream" }, signal, }); @@ -408,13 +427,13 @@ export const defaultOpenCodeHttpClient: OpenCodeHttpClient = { await drainSseStream(reader, onEnvelope); }, - async listSessionMessages(baseUrl, sessionId, options) { + async listSessionMessages(endpoint, sessionId, options) { const limit = clampHistoryLimit(options?.limit); const timeoutMs = options?.timeoutMs ?? OPENCODE_HISTORY_TIMEOUT_MS; // Race the fetch against the timeout so a stalled upstream fails visibly // instead of hanging the turn behind an endless spinner. Listeners come // off in the finally so repeated resumes never accumulate them. - const url = `${baseUrl}/session/${encodeURIComponent(sessionId)}/message?limit=${limit}`; + const url = endpointUrl(endpoint, `/session/${encodeURIComponent(sessionId)}/message`, { limit: String(limit) }); const res = await fetchSessionHistory(url, timeoutMs, options?.signal); if (res.status === 404) throw new Error(`OpenCode session history failed with HTTP 404 for ${sessionId}`); if (!res.ok) throw new Error(`OpenCode session history failed with HTTP ${res.status}`); diff --git a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts index 2276966f7..8d0cef388 100644 --- a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts +++ b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts @@ -32,6 +32,7 @@ import { OpenCodeServerPool, type OpenCodePoolKey } from "./opencode-server-pool import { defaultOpenCodeHttpClient, OpenCodeReplySessionNotFoundError, + type OpenCodeEndpoint, type OpenCodeHttpClient, type OpenCodeRequestVersion, } from "./opencode-http-client.js"; @@ -92,7 +93,7 @@ interface OpenCodePendingAsk { request: PermissionRequest; sessionId: string; upstreamSessionId: string; - baseUrl: string; + endpoint: OpenCodeEndpoint; kind: "permission" | "question"; version: OpenCodeRequestVersion; signal: AbortSignal; @@ -100,9 +101,19 @@ interface OpenCodePendingAsk { replying: boolean; } +/** + * Where a turn's requests go. A pooled lease borrows a reference on an + * app-owned serve child; an attached lease points at an external server the + * app never spawns, releases into idle close, or terminates. + */ +type OpenCodeServeLease = + | { kind: "pooled"; key: OpenCodePoolKey; endpoint: OpenCodeEndpoint } + | { kind: "attached"; endpoint: OpenCodeEndpoint }; + interface OpenCodeTurnState { upstreamSessionId: string; - poolKey: OpenCodePoolKey; + /** Last lease this session ran on; stop aborts through it. */ + lease: OpenCodeServeLease | null; abortController: AbortController; active: boolean; aborted: boolean; @@ -328,19 +339,18 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP } async listModels(): Promise { - const cliPath = this.cliPath(); - const entry = await this.pool.acquire({ binaryPath: cliPath, cwd: process.cwd(), hostname: OPENCODE_SERVE_HOSTNAME }).catch((error: unknown) => { - logger.debug("OpenCode listModels pool acquire failed", { error: error instanceof Error ? error.message : String(error) }); + const lease = await this.acquireServeLease(process.cwd()).catch((error: unknown) => { + logger.debug("OpenCode listModels serve acquire failed", { error: error instanceof Error ? error.message : String(error) }); return null; }); - // Model discovery must not leak pooled servers; release the listing lease. - if (entry) this.pool.release(entry.key); - if (!entry) return []; + if (!lease) return []; try { - return await this.http.listModels(entry.baseUrl); + return await this.http.listModels(lease.endpoint); } catch (error) { logger.warn("OpenCode listModels failed", { error: error instanceof Error ? error.message : String(error) }); return []; + } finally { + this.releaseServeLease(lease); } } @@ -364,13 +374,10 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP state.aborted = true; this.drainPendingForSession(sessionId); state.abortController.abort(); - if (state.upstreamSessionId) { - const entry = this.pool.entryFor(state.poolKey); - if (entry) { - await this.http.abortSession(entry.baseUrl, state.upstreamSessionId).catch((error: unknown) => { - logger.debug("OpenCode abort failed", { sessionId, error: error instanceof Error ? error.message : String(error) }); - }); - } + if (state.upstreamSessionId && state.lease) { + await this.http.abortSession(state.lease.endpoint, state.upstreamSessionId).catch((error: unknown) => { + logger.debug("OpenCode abort failed", { sessionId, error: error instanceof Error ? error.message : String(error) }); + }); } } @@ -441,7 +448,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP if (!this.ownsPendingAsk(entry)) return; if (entry.kind === "permission") { await this.http.replyPermission( - entry.baseUrl, + entry.endpoint, entry.upstreamSessionId, entry.request.requestId, mapPermissionDecisionToReply(decision), @@ -454,7 +461,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP return; } await this.http.replyQuestion( - entry.baseUrl, + entry.endpoint, entry.upstreamSessionId, entry.request.requestId, answers, @@ -463,7 +470,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP ); } else { await this.http.rejectQuestion( - entry.baseUrl, + entry.endpoint, entry.upstreamSessionId, entry.request.requestId, entry.version, @@ -560,6 +567,25 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP return this.settingsService.get().provider.cli.opencode?.trim() || "opencode"; } + /** + * Resolve where requests for one working directory go. A configured serve + * URL attaches without probing the CLI or touching the pool; otherwise the + * pool spawns or reuses the serve child keyed by this directory. + */ + private async acquireServeLease(cwd: string): Promise { + const serveUrl = this.settingsService.get().provider.opencode.serveUrl; + if (serveUrl) return { kind: "attached", endpoint: { baseUrl: serveUrl, directory: cwd } }; + const probe = await this.probeCli(this.cliPath(), this.host.runtime.platform); + const key: OpenCodePoolKey = { binaryPath: probe.binaryPath, cwd, hostname: OPENCODE_SERVE_HOSTNAME }; + const entry = await this.pool.acquire(key); + return { kind: "pooled", key, endpoint: { baseUrl: entry.baseUrl, directory: cwd } }; + } + + /** Return a pooled reference; an attached server has no app-owned lifetime to release. */ + private releaseServeLease(lease: OpenCodeServeLease): void { + if (lease.kind === "pooled") this.pool.release(lease.key); + } + private threadIdFor(sessionId: string): string { return sessionId.startsWith("mcode-") ? sessionId.slice(6) : sessionId; } @@ -569,7 +595,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP if (existing) return existing; const created: OpenCodeTurnState = { upstreamSessionId: "", - poolKey: { binaryPath: "", cwd: "", hostname: OPENCODE_SERVE_HOSTNAME }, + lease: null, abortController: new AbortController(), active: false, aborted: false, @@ -631,35 +657,34 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP state.nativeDiff = new OpenCodeNativeTurnDiff(); state.nativeDiffRevision = 0; emit({ type: AgentEventType.TurnStarted, threadId } satisfies AgentEvent); - const entry = await this.acquireTurnEntry(req, routing, state, emit); - if (!entry) { + const lease = await this.acquireTurnLease(req, routing, state, emit); + if (!lease) { this.drainPendingForSession(req.sessionId); return; } const settler = new OpenCodeTurnSettler(emit, threadId, req.turnExecutionId, state); state.abortController.signal.addEventListener("abort", settler.onAbort, { once: true }); try { - await this.streamTurn(req, state, entry, settler); + await this.streamTurn(req, state, lease.endpoint, settler); } finally { this.drainPendingForSession(req.sessionId); state.abortController.signal.removeEventListener("abort", settler.onAbort); state.active = false; - this.pool.release(state.poolKey); + this.releaseServeLease(lease); await this.waitForCanonicalExecution(routing.executionId); } } - private async acquireTurnEntry( + private async acquireTurnLease( req: TurnRequest<"opencode">, routing: CanonicalLiveEventRouting, state: OpenCodeTurnState, emit: (event: AgentEvent) => void, - ): Promise<{ baseUrl: string } | null> { + ): Promise { const threadId = this.threadIdFor(req.sessionId); try { - const probe = await this.probeCli(this.cliPath(), this.host.runtime.platform); - state.poolKey = { binaryPath: probe.binaryPath, cwd: req.cwd, hostname: OPENCODE_SERVE_HOSTNAME }; - return await this.pool.acquire(state.poolKey); + state.lease = await this.acquireServeLease(req.cwd); + return state.lease; } catch (error) { const message = error instanceof Error ? error.message : String(error); emit({ type: AgentEventType.Error, threadId, error: message } satisfies AgentEvent); @@ -672,12 +697,12 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private async streamTurn( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - entry: { baseUrl: string }, + endpoint: OpenCodeEndpoint, settler: OpenCodeTurnSettler, ): Promise { state.active = true; try { - await this.promptUpstream(req, state, entry, settler, false); + await this.promptUpstream(req, state, endpoint, settler, false); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error("OpenCodeProvider turn error", { sessionId: req.sessionId, error: message }); @@ -694,7 +719,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private async promptUpstream( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - entry: { baseUrl: string }, + endpoint: OpenCodeEndpoint, settler: OpenCodeTurnSettler, retried: boolean, ): Promise { @@ -704,10 +729,10 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP req.sessionId, event, ); - await this.ensureUpstreamSession(req, state, entry, emit, retried); + await this.ensureUpstreamSession(req, state, endpoint, emit, retried); const upstreamId = state.upstreamSessionId; - const subscribe = this.http.subscribeEvents(entry.baseUrl, state.abortController.signal, (envelope) => { - this.handleTurnEnvelope(envelope, req, state, entry.baseUrl, upstreamId, settler); + const subscribe = this.http.subscribeEvents(endpoint, state.abortController.signal, (envelope) => { + this.handleTurnEnvelope(envelope, req, state, endpoint, upstreamId, settler); }).catch((error: unknown) => { logger.debug("OpenCode event subscription ended", { sessionId: req.sessionId, @@ -715,7 +740,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP }); }); try { - await this.http.promptAsync(entry.baseUrl, upstreamId, { + await this.http.promptAsync(endpoint, upstreamId, { model: toOpenCodeModelRef(req.model), parts: [{ type: "text", text: req.message }], }); @@ -733,7 +758,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP logger.info("OpenCode upstream session missing; starting fresh", { sessionId: req.sessionId }); this.emitSessionInvalidatedNotice(emit, threadId, upstreamId); state.upstreamSessionId = ""; - await this.promptUpstream(req, state, entry, settler, true); + await this.promptUpstream(req, state, endpoint, settler, true); return; } throw error; @@ -750,7 +775,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private async ensureUpstreamSession( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - entry: { baseUrl: string }, + endpoint: OpenCodeEndpoint, emit: (event: AgentEvent) => void, skipResume: boolean, ): Promise { @@ -761,7 +786,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP const resumed = skipResume ? undefined : parseOpenCodeResumeCursor(req.resumeFrom); if (resumed) { try { - await this.http.listSessionMessages(entry.baseUrl, resumed, { + await this.http.listSessionMessages(endpoint, resumed, { limit: 1, signal: state.abortController.signal, }); @@ -774,7 +799,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP this.emitSessionInvalidatedNotice(emit, threadId, resumed); } } - const created = await this.http.createSession(entry.baseUrl, req.threadId); + const created = await this.http.createSession(endpoint, req.threadId); // Validate the upstream id before it becomes the durable resume cursor. state.upstreamSessionId = formatOpenCodeResumeCursor(created.id); emit({ type: AgentEventType.System, threadId, subtype: `sdk_session_id:${created.id}` } satisfies AgentEvent); @@ -815,7 +840,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP envelope: unknown, req: TurnRequest<"opencode">, state: OpenCodeTurnState, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, settler: OpenCodeTurnSettler, ): void { @@ -837,14 +862,14 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP partRole: normalized ? partRoleOf(state.messageRoles, normalized) : undefined, forwardedText: state.forwardedText, }); - this.dispatchMappedEnvelope(req, state, baseUrl, upstreamId, threadId, mapped, normalized, settler); + this.dispatchMappedEnvelope(req, state, endpoint, upstreamId, threadId, mapped, normalized, settler); } /** Route one mapped envelope to cards, canonical events, and settlement. */ private dispatchMappedEnvelope( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, threadId: string, mapped: OpenCodeMappedOutput, @@ -852,13 +877,13 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP settler: OpenCodeTurnSettler, ): void { if (mapped.reason === "permission-request" || mapped.reason === "question-request") { - this.maybeEmitAsk(req, baseUrl, upstreamId, normalized, mapped.reason); + this.maybeEmitAsk(req, endpoint, upstreamId, normalized, mapped.reason); } this.forwardTurnEvents(mapped.events, req, threadId, settler); if (mapped.events.some((e) => e.type === AgentEventType.Error)) { settler.settle("errored"); } else if (mapped.events.some((e) => e.type === AgentEventType.TurnComplete)) { - void this.confirmIdleCompleted(req, state, baseUrl, upstreamId, settler); + void this.confirmIdleCompleted(req, state, endpoint, upstreamId, settler); } else if (mapped.disposition === "mapped") { settler.noteActivity(); } @@ -876,7 +901,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private async confirmIdleCompleted( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, settler: OpenCodeTurnSettler, ): Promise { @@ -885,7 +910,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP return; } state.idleConfirming = true; - const confirmation = this.pollIdleConfirmation(req, state, baseUrl, upstreamId, settler); + const confirmation = this.pollIdleConfirmation(req, state, endpoint, upstreamId, settler); state.idleConfirmPromise = confirmation; try { await confirmation; @@ -898,7 +923,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP private async pollIdleConfirmation( req: TurnRequest<"opencode">, state: OpenCodeTurnState, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, settler: OpenCodeTurnSettler, ): Promise { @@ -914,7 +939,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP if (gate === "restart") continue; await waitForAbortableDelay(this.idleConfirm.intervalMs, state.abortController.signal); const poll = await this.pollIdleStep( - baseUrl, + endpoint, upstreamId, state.abortController.signal, Math.max(1, confirm.deadline - Date.now()), @@ -986,13 +1011,13 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP /** One live status read: quiet (idle or drained), active (busy work), or error. */ private async pollIdleStep( - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, signal: AbortSignal, timeoutMs: number, ): Promise<{ kind: "error" } | { kind: "quiet" } | { kind: "active" }> { try { - const type = (await this.http.getSessionStatus(baseUrl, { signal, timeoutMs }))[upstreamId]?.type; + const type = (await this.http.getSessionStatus(endpoint, { signal, timeoutMs }))[upstreamId]?.type; // Drained sessions leave the status map: after an idle event, a missing // entry means the session stayed quiet, not that polling broke. if (type === undefined) return { kind: "quiet" }; @@ -1018,7 +1043,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP */ private maybeEmitAsk( req: TurnRequest<"opencode">, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, normalized: { type: string; properties: Record } | null, reason: "permission-request" | "question-request", @@ -1034,14 +1059,14 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP deliveryAttempt: req.deliveryAttempt ?? 1, }; if (reason === "permission-request") { - this.maybeEmitPermissionAsk(req, baseUrl, upstreamId, normalized, state, routing); + this.maybeEmitPermissionAsk(req, endpoint, upstreamId, normalized, state, routing); return; } const synthesized = synthesizeOpenCodeQuestionRequest({ threadId, properties: normalized.properties }); if (!synthesized) return this.emitAskDiagnostic(req, threadId); if (this.pendingPermissions.has(synthesized.requestId)) return; this.pendingPermissions.set(synthesized.requestId, { - request: synthesized, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl, + request: synthesized, sessionId: req.sessionId, upstreamSessionId: upstreamId, endpoint, kind: "question", version: askVersion(normalized.type), signal: state.abortController.signal, routing, replying: false, }); @@ -1057,7 +1082,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP */ private maybeEmitPermissionAsk( req: TurnRequest<"opencode">, - baseUrl: string, + endpoint: OpenCodeEndpoint, upstreamId: string, normalized: { type: string; properties: Record }, state: OpenCodeTurnState, @@ -1068,7 +1093,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP if (!request) return this.emitAskDiagnostic(req, threadId); if (this.pendingPermissions.has(request.requestId)) return; const entry: OpenCodePendingAsk = { - request, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl, + request, sessionId: req.sessionId, upstreamSessionId: upstreamId, endpoint, kind: "permission", version: askVersion(normalized.type), signal: state.abortController.signal, routing, replying: false, }; diff --git a/apps/server/src/features/providers/transport/__tests__/providers-availability-rpc.test.ts b/apps/server/src/features/providers/transport/__tests__/providers-availability-rpc.test.ts index b47c0dc4e..92716267b 100644 --- a/apps/server/src/features/providers/transport/__tests__/providers-availability-rpc.test.ts +++ b/apps/server/src/features/providers/transport/__tests__/providers-availability-rpc.test.ts @@ -102,15 +102,16 @@ describe("providers.listAvailability RPC", () => { await new Promise((resolve) => server.close(() => resolve())); }); - it("returns six entries in catalog order", async () => { + it("returns every entry in catalog order", async () => { /** Canned response that mirrors PROVIDER_CATALOG order. */ const fakeAvailability: ProviderAvailability[] = [ { id: "claude", enabled: true, hasAdapter: true, beta: false, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, { id: "codex", enabled: true, hasAdapter: true, beta: false, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, { id: "copilot", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, { id: "gemini", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, - { id: "cursor", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, - { id: "opencode", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "cursor", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "opencode", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "devin", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, ]; const deps = makeMinimalDeps({ @@ -133,6 +134,7 @@ describe("providers.listAvailability RPC", () => { "gemini", "cursor", "opencode", + "devin", ]); }); diff --git a/apps/web/src/components/settings/sections/ProviderSection.tsx b/apps/web/src/components/settings/sections/ProviderSection.tsx index d65136f87..5557a6c09 100644 --- a/apps/web/src/components/settings/sections/ProviderSection.tsx +++ b/apps/web/src/components/settings/sections/ProviderSection.tsx @@ -11,12 +11,12 @@ import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import type { ProviderAvailability, ProviderId } from "@mcode/contracts"; +import { getCatalogEntry, PartialSettingsSchema, type ProviderAvailability, type ProviderId } from "@mcode/contracts"; import { ConfirmDisableDialog } from "./ConfirmDisableDialog"; /** Provider IDs that expose a CLI path input field. */ -type CliProvider = "claude" | "codex" | "copilot" | "cursor" | "devin"; -const HAS_CLI_INPUT: readonly CliProvider[] = ["claude", "codex", "copilot", "cursor", "devin"]; +type CliProvider = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "devin"; +const HAS_CLI_INPUT: readonly CliProvider[] = ["claude", "codex", "copilot", "cursor", "opencode", "devin"]; /** Narrows a ProviderId to those that have an editable CLI path setting. */ function hasCliInput(id: ProviderId): id is CliProvider { @@ -75,6 +75,7 @@ export function ProviderSection() { ? (val: string) => void update({ provider: { cli: { [p.id]: val } } }) : undefined } + extraConfig={p.id === "opencode" ? : undefined} /> ))} {comingSoonProviders.length > 0 && ( @@ -114,6 +115,8 @@ interface ProviderRowProps { cliPath: string | undefined; /** Called when the user edits the CLI path; undefined when no CLI path config exists. */ onCliPathChange?: (v: string) => void; + /** Provider-specific settings rendered under the CLI path. */ + extraConfig?: ReactNode; } function ProviderControls({ row, switchDisabled, onToggle }: Pick & { switchDisabled: boolean }) { @@ -124,18 +127,18 @@ function ProviderControls({ row, switchDisabled, onToggle }: Pick; } -function ProviderConfig({ row, label, hint, cliPath, onCliPathChange, controls }: { row: ProviderAvailability; label: string; hint: string; cliPath: string | undefined; onCliPathChange: (value: string) => void; controls: ReactNode }) { +function ProviderConfig({ row, label, hint, cliPath, onCliPathChange, controls, extraConfig }: { row: ProviderAvailability; label: string; hint: string; cliPath: string | undefined; onCliPathChange: (value: string) => void; controls: ReactNode; extraConfig?: ReactNode }) { const [isConfigOpen, setIsConfigOpen] = useState(row.beta); return
{controls} -
onCliPathChange(event.target.value)} placeholder={row.id} className="h-7 w-56 text-xs" />
; +
onCliPathChange(event.target.value)} placeholder={row.id} className="h-7 w-56 text-xs" />
{extraConfig}
; } /** * Single provider row with a disclosure for editable CLI path configuration. */ -function ProviderRow({ row, isLastEnabled, onToggle, cliPath, onCliPathChange }: ProviderRowProps) { +function ProviderRow({ row, isLastEnabled, onToggle, cliPath, onCliPathChange, extraConfig }: ProviderRowProps) { // Adapter-less providers cannot be toggled; the last enabled provider also // blocks toggling to prevent an unusable state. const switchDisabled = !row.hasAdapter || isLastEnabled; @@ -152,7 +155,63 @@ function ProviderRow({ row, isLastEnabled, onToggle, cliPath, onCliPathChange }: ); } - return ; + return ; +} + +/** Parse a serve URL draft through the settings contract; null when it would be rejected. */ +function parseServeUrlDraft(draft: string): string | null { + const parsed = PartialSettingsSchema().safeParse({ provider: { opencode: { serveUrl: draft } } }); + return parsed.success ? (parsed.data.provider?.opencode?.serveUrl ?? "") : null; +} + +/** + * OpenCode serve URL editor. Commits on blur or Enter rather than per + * keystroke, because a half-typed URL fails validation and would be dropped. + */ +function OpenCodeServeUrlField() { + const saved = useSettingsStore((s) => s.settings.provider.opencode.serveUrl); + const update = useSettingsStore((s) => s.update); + const [draft, setDraft] = useState(saved); + const [invalid, setInvalid] = useState(false); + + const commit = () => { + const normalized = parseServeUrlDraft(draft); + if (normalized === null) { + setInvalid(true); + return; + } + setInvalid(false); + setDraft(normalized); + if (normalized !== saved) void update({ provider: { opencode: { serveUrl: normalized } } }); + }; + + return
+
+ +

+ {invalid + ? "Enter an http or https URL, or leave it empty." + : "Empty spawns a local server per worktree. A URL attaches to a shared server the app never closes."} +

+
+ { + setDraft(event.target.value); + setInvalid(false); + }} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + placeholder="http://127.0.0.1:4096" + className="h-7 w-56 text-xs" + /> +
; } /** Displays a non-interactive provider that is not available yet. */ @@ -174,8 +233,7 @@ function ComingSoonProviderRow({ row }: { row: ProviderAvailability }) { /** Returns the human-readable display name for a provider ID. */ function labelFor(id: ProviderId): string { - if (id === "copilot") return "GitHub Copilot"; - return id.charAt(0).toUpperCase() + id.slice(1); + return getCatalogEntry(id).name; } /** Returns the hint text shown below the provider label. */ diff --git a/apps/web/src/components/settings/sections/__tests__/ProviderSection.test.tsx b/apps/web/src/components/settings/sections/__tests__/ProviderSection.test.tsx index 0d0998e70..428954125 100644 --- a/apps/web/src/components/settings/sections/__tests__/ProviderSection.test.tsx +++ b/apps/web/src/components/settings/sections/__tests__/ProviderSection.test.tsx @@ -66,8 +66,9 @@ beforeEach(() => { { id: "codex", enabled: true, hasAdapter: true, beta: false, comingSoon: false, capabilities: [], cli: { status: "not_found", resolvedPath: null, configuredPath: "" } }, { id: "copilot", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, { id: "gemini", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, - { id: "cursor", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, - { id: "opencode", enabled: false, hasAdapter: false, beta: false, comingSoon: true, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "cursor", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "opencode", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, + { id: "devin", enabled: false, hasAdapter: true, beta: true, comingSoon: false, capabilities: [], cli: { status: "unchecked", resolvedPath: null, configuredPath: "" } }, ], }); }); @@ -76,25 +77,26 @@ describe("ProviderSection", () => { it("renders switches only for available providers", () => { render(); const switches = screen.getAllByRole("switch"); - expect(switches).toHaveLength(3); + expect(switches.map((element) => element.dataset.testid)).toEqual([ + "provider-switch-claude", + "provider-switch-codex", + "provider-switch-copilot", + "provider-switch-cursor", + "provider-switch-opencode", + "provider-switch-devin", + ]); expect(screen.queryByTestId("provider-switch-gemini")).not.toBeInTheDocument(); }); - it("renders the Beta badge for copilot and Coming soon badges for planned providers", () => { + it("renders Beta badges for beta providers and a Coming soon badge for planned providers", () => { render(); expect(screen.getByTestId("provider-badge-copilot-beta")).toBeInTheDocument(); + expect(screen.getByTestId("provider-badge-opencode-beta")).toBeInTheDocument(); expect(screen.getByTestId("provider-badge-gemini-comingsoon")).toHaveAttribute( "variant", "secondary", ); - expect(screen.getByTestId("provider-badge-cursor-comingsoon")).toHaveAttribute( - "variant", - "secondary", - ); - expect(screen.getByTestId("provider-badge-opencode-comingsoon")).toHaveAttribute( - "variant", - "secondary", - ); + expect(screen.queryByTestId("provider-badge-opencode-comingsoon")).not.toBeInTheDocument(); }); it("groups planned providers after available providers without adapter copy", () => { @@ -102,8 +104,7 @@ describe("ProviderSection", () => { const comingSoon = screen.getByTestId("coming-soon-providers"); expect(within(comingSoon).getByText("Gemini")).toBeInTheDocument(); - expect(within(comingSoon).getByText("Cursor")).toBeInTheDocument(); - expect(within(comingSoon).getByText("Opencode")).toBeInTheDocument(); + expect(within(comingSoon).queryByText("OpenCode")).not.toBeInTheDocument(); expect(screen.queryByText("Adapter not available yet.")).not.toBeInTheDocument(); expect( screen @@ -167,4 +168,39 @@ describe("ProviderSection", () => { expect(screen.queryByTestId("provider-config-trigger-gemini")).not.toBeInTheDocument(); }); + + it("commits a normalized OpenCode serve URL on blur", async () => { + const user = userEvent.setup(); + const update = vi.fn().mockResolvedValue(undefined); + useSettingsStore.setState({ settings: getDefaultSettings(), update }); + render(); + + const input = screen.getByTestId("provider-serve-url-opencode"); + await user.type(input, "http://127.0.0.1:4096/"); + expect(update).not.toHaveBeenCalled(); + await user.tab(); + + expect(update).toHaveBeenCalledWith({ provider: { opencode: { serveUrl: "http://127.0.0.1:4096" } } }); + expect(input).toHaveValue("http://127.0.0.1:4096"); + }); + + it("rejects a non-http OpenCode serve URL without saving it", async () => { + const user = userEvent.setup(); + const update = vi.fn().mockResolvedValue(undefined); + useSettingsStore.setState({ settings: getDefaultSettings(), update }); + render(); + + const input = screen.getByTestId("provider-serve-url-opencode"); + await user.type(input, "file:///tmp/serve{Enter}"); + + expect(update).not.toHaveBeenCalled(); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByText("Enter an http or https URL, or leave it empty.")).toBeInTheDocument(); + }); + + it("shows the OpenCode CLI path alongside its serve URL", () => { + render(); + expect(screen.getByTestId("provider-cli-path-opencode")).toBeEnabled(); + expect(screen.getByTestId("provider-serve-url-opencode")).toHaveAttribute("placeholder", "http://127.0.0.1:4096"); + }); }); diff --git a/docs/internals/providers/provider-architecture.md b/docs/internals/providers/provider-architecture.md index 59f71a083..81af1ee8c 100644 --- a/docs/internals/providers/provider-architecture.md +++ b/docs/internals/providers/provider-architecture.md @@ -38,6 +38,11 @@ instead. Its pool key includes the executable, working directory, and hostname. Sessions in one worktree share a server, while another worktree gets a separate server. Reference counting and idle eviction belong to that pool, so it does not also create a `SessionRuntime` for each session. +When `provider.opencode.serveUrl` is set, the provider attaches to that +external server and bypasses the pool, because the app must never close or +idle-evict a server it did not start. One serve process hosts many +directories, so every request carries a `directory` query parameter; without +it, sessions run in the server's own working directory. Both the Claude and Codex providers were originally built with per-turn process spawning (via their respective SDKs). Both suffered the same reliability issues: stdin pipe timing diff --git a/packages/contracts/src/models/__tests__/settings.test.ts b/packages/contracts/src/models/__tests__/settings.test.ts index 2eb900785..d3f67b156 100644 --- a/packages/contracts/src/models/__tests__/settings.test.ts +++ b/packages/contracts/src/models/__tests__/settings.test.ts @@ -171,3 +171,42 @@ describe("settings.externalApps.defaultEditor", () => { expect(p.externalApps?.defaultEditor).toBe("cursor"); }); }); + +describe("provider.opencode.serveUrl", () => { + const parseServeUrl = (serveUrl: string) => + SettingsSchema().parse({ provider: { opencode: { serveUrl } } }).provider.opencode.serveUrl; + + it("defaults to empty so the adapter spawns a pooled local server", () => { + expect(getDefaultSettings().provider.opencode.serveUrl).toBe(""); + }); + + it.each([ + ["", ""], + [" ", ""], + ["http://127.0.0.1:4096", "http://127.0.0.1:4096"], + ["http://127.0.0.1:4096/", "http://127.0.0.1:4096"], + ["https://OpenCode.example.com/serve/", "https://opencode.example.com/serve"], + ])("accepts %j as %j", (input, expected) => { + expect(parseServeUrl(input)).toBe(expected); + }); + + it.each([ + "file:///tmp/opencode.sock", + "javascript:alert(1)", + "ws://127.0.0.1:4096", + "not a url", + "127.0.0.1:4096", + "http://user:secret@127.0.0.1:4096", + "http://127.0.0.1:4096/?directory=/etc", + ])("rejects %j", (serveUrl) => { + expect(SettingsSchema().safeParse({ provider: { opencode: { serveUrl } } }).success).toBe(false); + expect(PartialSettingsSchema().safeParse({ provider: { opencode: { serveUrl } } }).success).toBe(false); + }); + + it("normalizes a partial update the same way", () => { + expect( + PartialSettingsSchema().parse({ provider: { opencode: { serveUrl: "http://localhost:4096/" } } }) + .provider?.opencode?.serveUrl, + ).toBe("http://localhost:4096"); + }); +}); diff --git a/packages/contracts/src/models/settings.ts b/packages/contracts/src/models/settings.ts index 3938e2ad5..c955ebda3 100644 --- a/packages/contracts/src/models/settings.ts +++ b/packages/contracts/src/models/settings.ts @@ -111,6 +111,43 @@ export const SERVER_HEAP_MAX_MB = 8192; /** Shipped default from older installs, migrated to {@link SERVER_HEAP_DEFAULT_MB}. */ export const SERVER_HEAP_LEGACY_DEFAULT_MB = 96; +/** + * Normalize an OpenCode serve URL, or return null when it is not attachable. + * Empty means "spawn a local server". Otherwise only an absolute http(s) + * origin with an optional path prefix is accepted, without a trailing slash. + */ +function normalizeOpenCodeServeUrl(raw: string): string | null { + if (raw === "") return ""; + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + // The adapter appends its own `?directory=` query to every request. + if (url.search || url.hash) return null; + return url.href.replace(/\/+$/, ""); +} + +/** + * External `opencode serve` URL. Empty spawns a pooled local server per + * worktree; an http(s) URL attaches to a shared server the app never owns. + */ +const OpenCodeServeUrlSchema = z + .string() + .trim() + .transform((raw, ctx) => { + const normalized = normalizeOpenCodeServeUrl(raw); + if (normalized !== null) return normalized; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "OpenCode serve URL must be empty or an absolute http(s) URL without credentials, query, or fragment", + }); + return z.NEVER; + }); + // --------------------------------------------------------------------------- // Settings schema // --------------------------------------------------------------------------- @@ -319,6 +356,13 @@ export const SettingsSchema = lazySchema(() => : o.priorityProcessing === true, })) .default({ fastMode: false }), + /** OpenCode (`opencode serve`) backend selection. */ + opencode: z + .object({ + /** External serve URL to attach to. Empty spawns a pooled local server. */ + serveUrl: OpenCodeServeUrlSchema.default(""), + }) + .default({}), /** Cursor ACP-only tuning (`provider` + `cursor` keeps nesting depth ≤ 3). */ cursor: z .object({ @@ -615,6 +659,11 @@ export const PartialSettingsSchema = lazySchema(() => priorityProcessing: z.boolean().optional(), }) .optional(), + opencode: z + .object({ + serveUrl: OpenCodeServeUrlSchema.optional(), + }) + .optional(), }) .optional(), chat: z