From 763421069fc356fbe2f3ffc472f14b6388c9090c Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 21:33:34 +0800 Subject: [PATCH 01/15] feat: add OpenCode runtime provider --- .../agent/config/set-reasoning-effort.ts | 2 +- apps/cli/src/commands/agent/create.ts | 2 +- apps/cli/src/core/client-switch.ts | 5 +- apps/cli/src/core/doctor.ts | 2 +- .../client/src/handlers/auth-error-hint.ts | 35 +- packages/client/src/handlers/index.ts | 6 + .../client/src/handlers/opencode/index.ts | 973 ++++++++++++++++++ .../client/src/handlers/opencode/parser.ts | 176 ++++ packages/client/src/index.ts | 23 + .../runtime/capabilities/discover-models.ts | 6 + .../client/src/runtime/capabilities/index.ts | 4 +- .../src/runtime/capabilities/opencode.ts | 25 + packages/client/src/runtime/managed-skills.ts | 1 + packages/client/src/runtime/managed-state.ts | 1 + .../client/src/runtime/opencode-binary.ts | 132 +++ .../runtime/provider-process-supervisor.ts | 62 ++ packages/client/src/runtime/runtime-notice.ts | 2 + .../server/src/services/context-tree-io.ts | 14 + packages/shared/src/index.ts | 1 + .../src/schemas/agent-runtime-config.ts | 21 + packages/shared/src/schemas/context-tree.ts | 2 + .../shared/src/schemas/runtime-provider.ts | 10 +- .../web/src/components/new-agent-dialog.tsx | 5 +- .../src/pages/agent-detail/model-section.tsx | 11 +- .../agent-detail/reasoning-effort-section.tsx | 2 + .../pages/agent-detail/runtime-section.tsx | 1 + .../src/pages/agent-detail/runtime-tab.tsx | 4 +- .../pages/clients/cards/shared/providers.ts | 7 + 28 files changed, 1519 insertions(+), 16 deletions(-) create mode 100644 packages/client/src/handlers/opencode/index.ts create mode 100644 packages/client/src/handlers/opencode/parser.ts create mode 100644 packages/client/src/runtime/capabilities/opencode.ts create mode 100644 packages/client/src/runtime/opencode-binary.ts create mode 100644 packages/client/src/runtime/provider-process-supervisor.ts diff --git a/apps/cli/src/commands/agent/config/set-reasoning-effort.ts b/apps/cli/src/commands/agent/config/set-reasoning-effort.ts index c56fe0a34..1a45a8b40 100644 --- a/apps/cli/src/commands/agent/config/set-reasoning-effort.ts +++ b/apps/cli/src/commands/agent/config/set-reasoning-effort.ts @@ -7,7 +7,7 @@ export function registerAgentConfigSetReasoningEffortCommand(config: Command): v config .command("set-reasoning-effort ") .description( - 'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor and Kimi Code have no separate effort field.', + 'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor, Kimi Code, and OpenCode have no separate effort field.', ) .action(async (agentName: string, level: string) => { const serverUrl = resolveServerUrl(process.env.FIRST_TREE_SERVER_URL); diff --git a/apps/cli/src/commands/agent/create.ts b/apps/cli/src/commands/agent/create.ts index d76ea3227..627b83cae 100644 --- a/apps/cli/src/commands/agent/create.ts +++ b/apps/cli/src/commands/agent/create.ts @@ -16,7 +16,7 @@ export function registerAgentCreateCommand(agent: Command): void { ) .option( "--runtime ", - "Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code (default: claude-code)", + "Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code, opencode (default: claude-code)", "claude-code", ) .option("--display-name ", "Display name") diff --git a/apps/cli/src/core/client-switch.ts b/apps/cli/src/core/client-switch.ts index a80e00625..6c84fe62b 100644 --- a/apps/cli/src/core/client-switch.ts +++ b/apps/cli/src/core/client-switch.ts @@ -856,7 +856,10 @@ export function parseSwitchProcessEnvValue(envText: string, key: string): string } function isKnownProviderCommand(command: string): boolean { - if (/(^|[/\s])(claude|codex|cursor-agent)(\s|$)/i.test(command) || /@openai\/codex|claude-code/i.test(command)) { + if ( + /(^|[/\s])(claude|codex|cursor-agent|opencode)(\s|$)/i.test(command) || + /@openai\/codex|claude-code|opencode-ai/i.test(command) + ) { return true; } // Cursor's official main command is the generic name `agent`. Match it ONLY diff --git a/apps/cli/src/core/doctor.ts b/apps/cli/src/core/doctor.ts index 513aea082..50ca86c49 100644 --- a/apps/cli/src/core/doctor.ts +++ b/apps/cli/src/core/doctor.ts @@ -299,7 +299,7 @@ export async function checkWebSocket(): Promise { // `daemon probe`) // --------------------------------------------------------------------------- -const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]; +const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code", "opencode"]; function formatCapabilityDetail(entry: CapabilityEntry): string { if (entry.state === "ok") { diff --git a/packages/client/src/handlers/auth-error-hint.ts b/packages/client/src/handlers/auth-error-hint.ts index 82bb0387b..6bd92bc55 100644 --- a/packages/client/src/handlers/auth-error-hint.ts +++ b/packages/client/src/handlers/auth-error-hint.ts @@ -13,7 +13,7 @@ * CLI. The hint reframes the message so the next step is obvious. */ -type Runtime = "codex" | "claude-code" | "cursor" | "kimi-code"; +type Runtime = "codex" | "claude-code" | "cursor" | "kimi-code" | "opencode"; /** * Substring keywords used to detect codex's auth-refresh failures. Codex's @@ -80,6 +80,21 @@ export function isKimiCodeAuthError(codeOrMessage: string): boolean { ); } +const OPENCODE_AUTH_KEYWORDS: readonly string[] = [ + "authentication required", + "not authenticated", + "unauthorized", + "invalid api key", + "missing api key", + "auth login", + "provider.auth", +]; + +export function isOpenCodeAuthError(message: string): boolean { + const lower = message.toLowerCase(); + return OPENCODE_AUTH_KEYWORDS.some((keyword) => lower.includes(keyword)); +} + /** * The single auth-failure code claude-code's SDK reports (out of the * `SDKAssistantMessageError` union). Centralised here so both the assistant- @@ -107,11 +122,21 @@ export function formatAuthHint(runtime: Runtime, originalMessage: string): strin ? "`codex login`" : runtime === "cursor" ? "`cursor-agent login`" - : runtime === "kimi-code" - ? "`kimi` and then `/login`" - : "`claude auth login`"; + : runtime === "opencode" + ? "`opencode auth login`" + : runtime === "kimi-code" + ? "`kimi` and then `/login`" + : "`claude auth login`"; const provider = - runtime === "codex" ? "OpenAI" : runtime === "cursor" ? "Cursor" : runtime === "kimi-code" ? "Kimi" : "Anthropic"; + runtime === "codex" + ? "OpenAI" + : runtime === "cursor" + ? "Cursor" + : runtime === "opencode" + ? "OpenCode's selected provider" + : runtime === "kimi-code" + ? "Kimi" + : "Anthropic"; // Cap the appended raw message so an upstream stack-trace envelope (codex // wraps its `event.error.message` in surprising ways) doesn't bloat the // hint into a wall of text on the chat timeline. diff --git a/packages/client/src/handlers/index.ts b/packages/client/src/handlers/index.ts index e621483d3..e85815927 100644 --- a/packages/client/src/handlers/index.ts +++ b/packages/client/src/handlers/index.ts @@ -6,6 +6,7 @@ import { type ClaudeExecutableResolution, resolveClaudeCodeExecutable } from "./ import { createCodexHandler } from "./codex/index.js"; import { createCursorHandler } from "./cursor/index.js"; import { createKimiCodeHandler } from "./kimi-code.js"; +import { createOpenCodeHandler } from "./opencode/index.js"; /** Injectable seam so tests can force a Claude-executable resolution (no real PATH / shell spawn). */ export type RegisterBuiltinHandlersDeps = { @@ -53,4 +54,9 @@ export function registerBuiltinHandlers(deps: RegisterBuiltinHandlersDeps = {}): // Kimi Code is driven through the bundled Node SDK. It reuses the host's // ~/.kimi-code credential/config and does not add a First Tree login flow. registerHandler("kimi-code", (config) => createKimiCodeHandler(config)); + // OpenCode is an external, host-authenticated, per-turn CLI runtime. The + // handler owns JSONL/session semantics; Windows stays fail-closed until a + // pre-admission Job Object supervisor is supplied and accepted as drain + // authority. + registerHandler("opencode", (config) => createOpenCodeHandler(config)); } diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts new file mode 100644 index 000000000..8634891a3 --- /dev/null +++ b/packages/client/src/handlers/opencode/index.ts @@ -0,0 +1,973 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute, join, resolve } from "node:path"; +import { + type AgentRuntimeConfig, + type AgentRuntimeConfigPayload, + isLandingCampaignTrialAgentMetadata, + runtimeProviderSchema, + type ToolFileRef, +} from "@first-tree/shared"; +import { ensureAgentBootstrap } from "../../runtime/agent-bootstrap.js"; +import { buildAgentBriefing } from "../../runtime/agent-briefing.js"; +import type { AgentConfigCache } from "../../runtime/agent-config-cache.js"; +import { type ChatContext, fetchChatContext } from "../../runtime/chat-context.js"; +import { renderChatContextPrompt, renderRuntimeOutputContract } from "../../runtime/chat-context-section.js"; +import { resolveContextTreeRelativePath, toolFileRefsFromShellCommand } from "../../runtime/context-tree-file-refs.js"; +import type { + AgentHandler, + DeliveryToken, + HandlerFactory, + SessionContext, + SessionMessage, +} from "../../runtime/handler.js"; +import { deliveryTokenFromSessionContext } from "../../runtime/handler.js"; +import { type ReconciledTeamSkill, reconcileManagedSkillsForConfig } from "../../runtime/managed-skills.js"; +import { + OPENCODE_SUPPORTED_VERSION, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "../../runtime/opencode-binary.js"; +import { + createDefaultProviderProcessSupervisor, + type ProviderProcessSupervisor, +} from "../../runtime/provider-process-supervisor.js"; +import { redactErrorPreview } from "../../runtime/redact-error-preview.js"; +import { + buildBriefingUpdateNotice, + computeBriefingFingerprint, + readSessionBriefingFingerprint, + writeSessionBriefingFingerprint, +} from "../../runtime/session-briefing-fingerprint.js"; +import { currentSourceRepoNamesFromPayload, declaredSourceRepos } from "../../runtime/source-repos.js"; +import { acquireAgentHome, markWorkspaceInitComplete } from "../../runtime/workspace.js"; +import { chunkAssistantText } from "../assistant-text.js"; +import { formatAuthHint, isOpenCodeAuthError } from "../auth-error-hint.js"; +import { type OpenCodeStreamEvent, OpenCodeStreamParser, type OpenCodeUsage } from "./parser.js"; + +export const OPENCODE_MANAGED_AGENT = "first-tree"; +export const OPENCODE_PENDING_SESSION_PREFIX = "opencode-pending-"; + +const STDERR_TAIL_LIMIT = 8_000; +const DEFAULT_TURN_TIMEOUT_MS = 20 * 60_000; +const KILL_GRACE_MS = 5_000; +const FINAL_CLOSE_WAIT_MS = 2_000; +const DB_GATE_TIMEOUT_MS = 30_000; + +export function isOpenCodePendingSessionId(sessionId: string): boolean { + return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); +} + +type OpenCodeMcpConfig = + | { type: "local"; command: string[]; enabled: true } + | { type: "remote"; url: string; headers?: Record; enabled: true }; + +export function mapOpenCodeMcpServers(payload: AgentRuntimeConfigPayload): Record { + const out: Record = {}; + for (const server of payload.mcpServers) { + if (server.transport === "stdio") { + out[server.name] = { + type: "local", + command: [server.command, ...(server.args ?? [])], + enabled: true, + }; + } else { + out[server.name] = { + type: "remote", + url: server.url, + ...(server.headers ? { headers: server.headers } : {}), + enabled: true, + }; + } + } + return out; +} + +export function buildOpenCodeConfigContent(input: { + payload: AgentRuntimeConfigPayload; + standingPrompt: string; +}): string { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + share: "disabled", + snapshot: false, + agent: { + [OPENCODE_MANAGED_AGENT]: { + description: "First Tree managed agent", + mode: "primary", + prompt: input.standingPrompt, + ...(input.payload.model ? { model: input.payload.model } : {}), + permission: { + edit: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + task: "allow", + }, + }, + }, + permission: { + edit: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + task: "allow", + }, + mcp: mapOpenCodeMcpServers(input.payload), + }); +} + +export function buildOpenCodeTurnArgs(input: { cwd: string; model: string; resumeSessionId: string | null }): string[] { + const args = [ + "run", + "--format", + "json", + "--auto", + "--agent", + OPENCODE_MANAGED_AGENT, + "--title", + "First Tree managed turn", + "--dir", + input.cwd, + "--print-logs", + "--log-level", + "ERROR", + ]; + if (input.model) args.push("--model", input.model); + if (input.resumeSessionId) args.push("--session", input.resumeSessionId); + return args; +} + +type ProcessOutcome = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdoutTail: string; + stderrTail: string; + spawnError?: Error; +}; + +type TurnState = { + parser: OpenCodeStreamParser; + sessionIds: Set; + terminalReasons: string[]; + errors: string[]; + text: string[]; + usage: OpenCodeUsage | null; + sawProviderActivity: boolean; + sawUnsafeTool: boolean; + unknownCount: number; +}; + +const dbGatePromises = new Map>(); + +export function clearOpenCodeDbGateCacheForTests(): void { + dbGatePromises.clear(); +} + +export const createOpenCodeHandler: HandlerFactory = (config) => { + const workspaceRoot = config.workspaceRoot as string; + const runtimeProvider = runtimeProviderSchema.parse(config.runtimeProvider ?? "opencode"); + const agentConfigCache = (config.agentConfigCache as AgentConfigCache | undefined) ?? null; + const contextTreePath = (config.contextTreePath as string | undefined) ?? null; + const contextTreeRepoUrl = (config.contextTreeRepoUrl as string | undefined) ?? null; + const contextTreeBranch = (config.contextTreeBranch as string | undefined) ?? null; + const resolveBinary = + (config.opencodeBinaryResolver as typeof resolveOpenCodeRuntimeBinary | undefined) ?? resolveOpenCodeRuntimeBinary; + const processSupervisor = + (config.providerProcessSupervisor as ProviderProcessSupervisor | undefined) ?? + createDefaultProviderProcessSupervisor(); + const turnTimeoutMs = + typeof config.opencodeTurnTimeoutMs === "number" && config.opencodeTurnTimeoutMs > 0 + ? config.opencodeTurnTimeoutMs + : DEFAULT_TURN_TIMEOUT_MS; + + let cwd: string | null = null; + let ctx: SessionContext | null = null; + let activeConfig: AgentRuntimeConfig | null = null; + let teamSkills: readonly ReconciledTeamSkill[] = []; + let binary: string | null = null; + let providerSessionId: string | null = null; + let pendingSyntheticId: string | null = null; + let sessionActive = false; + let initialTurnPreparing = false; + let currentAbort: AbortController | null = null; + let currentTurnPromise: Promise | null = null; + let versionReady = false; + let generation = 0; + let drainScheduled = false; + let drainInProgress = false; + const queue: Array<{ message: SessionMessage; token: DeliveryToken }> = []; + + function buildEnv( + sessionCtx: SessionContext, + payload: AgentRuntimeConfigPayload, + standingPrompt: string, + ): Record { + const base: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") base[key] = value; + } + for (const entry of payload.env) base[entry.key] = entry.value; + const merged = sessionCtx.buildAgentEnv(base); + const env: Record = {}; + for (const [key, value] of Object.entries(merged)) { + if (typeof value === "string") env[key] = value; + } + env.OPENCODE_CONFIG_CONTENT = buildOpenCodeConfigContent({ payload, standingPrompt }); + return env; + } + + async function fetchChatContextOrLog(sessionCtx: SessionContext): Promise { + try { + return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent); + } catch (error) { + sessionCtx.log(`OpenCode chat-context fetch failed: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + function buildBriefing(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload, workspaceCwd: string): string { + return buildAgentBriefing({ + identity: sessionCtx.agent, + payload, + workspacePath: workspaceCwd, + sourceRepos: declaredSourceRepos(workspaceCwd, payload), + contextTreePath, + contextTreeRepoUrl, + contextTreeBranch, + teamSkills, + }); + } + + async function refreshProjection(sessionCtx: SessionContext): Promise<{ + payload: AgentRuntimeConfigPayload; + briefing: string; + standingPrompt: string; + }> { + if (!cwd) throw new Error("OpenCode workspace is not prepared"); + let runtimeConfig = activeConfig; + if (agentConfigCache) { + runtimeConfig = await agentConfigCache.refresh(sessionCtx.agent.agentId); + } + const payload: AgentRuntimeConfigPayload = + runtimeConfig?.payload ?? + ({ + kind: "opencode", + prompt: { append: "" }, + model: "", + mcpServers: [], + env: [], + gitRepos: [], + resourceSkills: [], + } satisfies AgentRuntimeConfigPayload); + if (payload.kind !== "opencode") { + throw new Error(`OpenCode handler received ${payload.kind} runtime config`); + } + teamSkills = (await reconcileManagedSkillsForConfig(cwd, runtimeProvider, runtimeConfig, sessionCtx.log)) + .teamSkills; + const briefing = buildBriefing(sessionCtx, payload, cwd); + ensureAgentBootstrap({ + workspace: cwd, + sessionCtx, + contextTreePath, + briefing, + currentSourceRepoNames: currentSourceRepoNamesFromPayload(payload, runtimeConfig !== null), + }); + markWorkspaceInitComplete(cwd); + const chatContext = await fetchChatContextOrLog(sessionCtx); + const chatPrompt = renderChatContextPrompt(chatContext); + const standingPrompt = [renderRuntimeOutputContract(), chatPrompt].filter(Boolean).join("\n\n"); + activeConfig = runtimeConfig; + return { payload, briefing, standingPrompt }; + } + + function runProcess(input: { + command: string; + args: string[]; + prompt?: string; + env: Record; + workspaceCwd: string; + state?: TurnState; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + timeoutMs: number; + turnGeneration: number; + label: string; + }): Promise { + return new Promise((resolveOutcome) => { + let supervised: ReturnType; + try { + supervised = processSupervisor.spawn({ + command: input.command, + args: input.args, + label: input.label, + timeoutMs: input.timeoutMs, + options: { + cwd: input.workspaceCwd, + env: input.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + ...(process.platform === "win32" ? {} : { detached: true }), + }, + }); + } catch (error) { + resolveOutcome({ + exitCode: null, + signal: null, + stdoutTail: "", + stderrTail: "", + spawnError: error instanceof Error ? error : new Error(String(error)), + }); + return; + } + const child = supervised.child; + let stdoutTail = ""; + let stderrTail = ""; + let closed: { exitCode: number | null; signal: NodeJS.Signals | null } | null = null; + let stdoutEnded = false; + let settled = false; + let spawnError: Error | undefined; + + const finish = (): void => { + if (settled || !closed || !stdoutEnded) return; + settled = true; + resolveOutcome({ ...closed, stdoutTail, stderrTail, spawnError }); + }; + const handleEvents = (events: OpenCodeStreamEvent[]): void => { + if (!input.state) return; + for (const event of events) { + try { + handleEvent(event, input.state, input.sessionCtx); + } catch (error) { + input.sessionCtx.log( + `OpenCode event handling failed (${event.kind}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + }; + const terminate = (): void => { + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); + else child.kill("SIGTERM"); + } catch { + // The process may already be gone. + } + const hardKill = setTimeout(() => { + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { + // Ignore a completed process. + } + }, KILL_GRACE_MS); + hardKill.unref?.(); + const finalWait = setTimeout(() => { + stdoutEnded = true; + closed ??= { exitCode: null, signal: "SIGKILL" }; + finish(); + }, KILL_GRACE_MS + FINAL_CLOSE_WAIT_MS); + finalWait.unref?.(); + }; + input.abortSignal.addEventListener("abort", terminate, { once: true }); + + child.on("error", (error) => { + spawnError = error; + closed ??= { exitCode: null, signal: null }; + stdoutEnded = true; + finish(); + }); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdoutTail = (stdoutTail + chunk).slice(-STDERR_TAIL_LIMIT); + if (input.state && !input.abortSignal.aborted && generation === input.turnGeneration) { + handleEvents(input.state.parser.push(chunk)); + } + }); + child.stdout?.on("end", () => { + if (input.state && !input.abortSignal.aborted && generation === input.turnGeneration) { + handleEvents(input.state.parser.flush()); + } + stdoutEnded = true; + finish(); + }); + child.stdout?.on("error", () => { + stdoutEnded = true; + finish(); + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT); + }); + child.on("close", (exitCode, signal) => { + input.abortSignal.removeEventListener("abort", terminate); + closed = { exitCode, signal }; + finish(); + }); + child.stdin?.on("error", () => { + // EPIPE is classified from close + stderr. + }); + if (input.prompt !== undefined) child.stdin?.write(input.prompt); + child.stdin?.end(); + }); + } + + function handleEvent(event: OpenCodeStreamEvent, state: TurnState, sessionCtx: SessionContext): void { + sessionCtx.recordProviderActivity(); + state.sawProviderActivity = true; + switch (event.kind) { + case "session": + state.sessionIds.add(event.sessionId); + break; + case "text": + state.text.push(event.text); + break; + case "tool": + if (event.status === "pending" && !isReadOnlyTool(event.name)) state.sawUnsafeTool = true; + { + const toolFileRefs = event.status === "pending" ? undefined : fileRefsForTool(event.name, event.args); + sessionCtx.emitEvent({ + kind: "tool_call", + payload: { + toolUseId: event.toolUseId, + name: event.name, + args: event.args, + status: event.status, + ...(event.resultPreview ? { resultPreview: event.resultPreview } : {}), + ...(toolFileRefs && toolFileRefs.length > 0 ? { toolFileRefs } : {}), + }, + }); + } + break; + case "usage": + state.usage = event.usage; + break; + case "terminal": + state.terminalReasons.push(event.reason); + break; + case "error": + state.errors.push(event.message); + break; + case "unknown": + if (state.unknownCount < 5) { + sessionCtx.log(`OpenCode tolerant-parse diagnostic: ${event.note}: ${event.raw}`); + } + state.unknownCount++; + break; + } + } + + function fileRefsForTool(name: string, args: unknown): ToolFileRef[] | undefined { + if (!cwd) return undefined; + const values = asRecord(args); + if (name.toLowerCase() === "bash") { + const command = typeof values?.command === "string" ? values.command : null; + if (!command) return undefined; + return toolFileRefsFromShellCommand({ + command, + cwd, + contextTreePath, + contextTreeRepoUrl, + contextTreeBranch, + }); + } + const rawPath = + typeof values?.path === "string" + ? values.path + : typeof values?.filePath === "string" + ? values.filePath + : typeof values?.file_path === "string" + ? values.file_path + : null; + if (!rawPath) return undefined; + const absolutePath = isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath); + const repoRelativePath = resolveContextTreeRelativePath(absolutePath, { + contextTreePath, + contextTreeRepoUrl, + }); + const write = /^(edit|write|patch)$/i.test(name); + return [ + { + origin: write ? "file_change" : "tool_arg", + localPath: rawPath, + pathKind: "file", + ...(contextTreeRepoUrl && repoRelativePath && repoRelativePath !== "/" + ? { + repoUrl: contextTreeRepoUrl, + ...(contextTreeBranch ? { repoBranch: contextTreeBranch } : {}), + repoRelativePath, + } + : {}), + }, + ]; + } + + async function ensureDbReady(input: { + activeBinary: string; + env: Record; + workspaceCwd: string; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + turnGeneration: number; + }): Promise { + const dataHome = input.env.XDG_DATA_HOME ?? input.env.APPDATA ?? input.env.HOME ?? ""; + const key = `${input.activeBinary}\0${dataHome}`; + let gate = dbGatePromises.get(key); + if (!gate) { + gate = (async () => { + const outcome = await runProcess({ + command: input.activeBinary, + args: ["db", "SELECT 1 AS ready", "--format", "json"], + env: input.env, + workspaceCwd: input.workspaceCwd, + sessionCtx: input.sessionCtx, + abortSignal: input.abortSignal, + timeoutMs: DB_GATE_TIMEOUT_MS, + turnGeneration: input.turnGeneration, + label: "opencode database readiness", + }); + if (outcome.spawnError || outcome.exitCode !== 0) { + throw ( + outcome.spawnError ?? + new Error(`OpenCode database readiness failed (${outcome.exitCode}): ${outcome.stderrTail}`) + ); + } + })().catch((error) => { + dbGatePromises.delete(key); + throw error; + }); + dbGatePromises.set(key, gate); + } + await gate; + } + + async function ensureSupportedVersion(input: { + activeBinary: string; + env: Record; + workspaceCwd: string; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + turnGeneration: number; + }): Promise { + if (versionReady) return; + const outcome = await runProcess({ + command: input.activeBinary, + args: ["--version"], + env: input.env, + workspaceCwd: input.workspaceCwd, + sessionCtx: input.sessionCtx, + abortSignal: input.abortSignal, + timeoutMs: DB_GATE_TIMEOUT_MS, + turnGeneration: input.turnGeneration, + label: "opencode exact-version gate", + }); + const version = parseOpenCodeVersionOutput(`${outcome.stdoutTail}\n${outcome.stderrTail}`); + if (outcome.spawnError || outcome.exitCode !== 0 || version !== OPENCODE_SUPPORTED_VERSION) { + const detail = redactErrorPreview( + outcome.spawnError?.message || outcome.stderrTail || outcome.stdoutTail || `exit ${outcome.exitCode}`, + 800, + ); + throw new Error( + `Unsupported OpenCode runtime. First Tree requires opencode-ai@${OPENCODE_SUPPORTED_VERSION}; ` + + `observed ${version ?? "no parseable version"}. ${detail}`, + ); + } + versionReady = true; + } + + function adoptSessionId(sessionCtx: SessionContext, id: string): void { + if (providerSessionId === id) return; + const synthetic = pendingSyntheticId; + providerSessionId = id; + if (synthetic) { + pendingSyntheticId = null; + sessionCtx.replaceSessionId?.(id, "opencode_session_id_confirmed"); + if (cwd) { + const baseline = readSessionBriefingFingerprint(cwd, synthetic); + if (baseline) writeSessionBriefingFingerprint(cwd, id, baseline); + } + } + } + + async function runTurn( + prompt: string, + sessionCtx: SessionContext, + messages: readonly SessionMessage[], + token: DeliveryToken, + ): Promise { + const workspaceCwd = cwd; + const activeBinary = binary; + if (!workspaceCwd || !activeBinary || !sessionActive) { + token.retry(messages, sessionActive ? "opencode_not_prepared" : "opencode_session_inactive"); + return false; + } + const turnGeneration = ++generation; + const abort = new AbortController(); + currentAbort = abort; + const promise = (async () => { + const { payload, standingPrompt } = await refreshProjection(sessionCtx); + const env = buildEnv(sessionCtx, payload, standingPrompt); + await ensureSupportedVersion({ + activeBinary, + env, + workspaceCwd, + sessionCtx, + abortSignal: abort.signal, + turnGeneration, + }); + await ensureDbReady({ + activeBinary, + env, + workspaceCwd, + sessionCtx, + abortSignal: abort.signal, + turnGeneration, + }); + if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) return false; + + const expectedSessionId = providerSessionId; + const state: TurnState = { + parser: new OpenCodeStreamParser(), + sessionIds: new Set(), + terminalReasons: [], + errors: [], + text: [], + usage: null, + sawProviderActivity: false, + sawUnsafeTool: false, + unknownCount: 0, + }; + token.processingStarted(messages); + const timeout = setTimeout(() => abort.abort(), turnTimeoutMs); + timeout.unref?.(); + const outcome = await runProcess({ + command: activeBinary, + args: buildOpenCodeTurnArgs({ + cwd: workspaceCwd, + model: payload.model, + resumeSessionId: expectedSessionId, + }), + prompt: `${prompt}\n`, + env, + workspaceCwd, + state, + sessionCtx, + abortSignal: abort.signal, + timeoutMs: turnTimeoutMs, + turnGeneration, + label: `opencode turn ${sessionCtx.chatId}`, + }); + clearTimeout(timeout); + + if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) { + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + token.retry(messages, "opencode_turn_aborted_or_timed_out"); + return false; + } + + const ids = [...state.sessionIds]; + const protocolErrors: string[] = []; + if (ids.length !== 1) protocolErrors.push(`expected one session ID, observed ${ids.length}`); + if (expectedSessionId && ids[0] !== expectedSessionId) { + protocolErrors.push(`resume session mismatch: expected ${expectedSessionId}, observed ${ids[0] ?? "none"}`); + } + if (state.terminalReasons.length === 0) protocolErrors.push("missing terminal step_finish event"); + if (state.errors.length > 0) protocolErrors.push(...state.errors); + + const success = !outcome.spawnError && outcome.exitCode === 0 && protocolErrors.length === 0; + if (success) { + const id = ids[0]; + if (!id) throw new Error("OpenCode success without session ID"); + adoptSessionId(sessionCtx, id); + const finalText = state.text.join(""); + for (const chunk of chunkAssistantText(finalText)) { + sessionCtx.emitEvent({ kind: "assistant_text", payload: { text: chunk } }); + } + if (state.usage) { + sessionCtx.emitEvent({ + kind: "token_usage", + payload: { + provider: "opencode", + model: payload.model || "opencode-default", + inputTokens: state.usage.inputTokens, + cachedInputTokens: state.usage.cachedInputTokens, + outputTokens: state.usage.outputTokens, + }, + }); + } + try { + await sessionCtx.forwardResult(finalText); + } catch (error) { + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: `forwardResult failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2000), + }, + }); + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + await token.complete(messages, { status: "error", completion: "consumed", reason: "forward_failed" }); + return true; + } + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "success" } }); + await token.complete(messages, { status: "success" }); + return true; + } + + const rawFailure = [ + ...protocolErrors, + outcome.spawnError?.message, + outcome.stderrTail, + outcome.exitCode === null ? `signal ${outcome.signal ?? "unknown"}` : `exit ${outcome.exitCode}`, + ] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n") + .slice(0, 2000); + const failure = redactErrorPreview(rawFailure, 2000); + const message = isOpenCodeAuthError(failure) ? formatAuthHint("opencode", failure) : failure; + sessionCtx.emitEvent({ kind: "error", payload: { source: "sdk", message } }); + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + + const deterministic = + isOpenCodeAuthError(failure) || + /invalid model|unknown model|model .* not found|permission denied|configuration/i.test(failure); + if (deterministic || state.sawUnsafeTool || state.text.length > 0) { + await token.complete(messages, { + status: "error", + completion: "consumed", + reason: deterministic ? "provider_clean_error" : "unsafe_provider_failure_notice_posted", + }); + return true; + } + token.retry( + messages, + state.sawProviderActivity ? "opencode_unknown_pre_effect_failure" : "opencode_pre_provider_failure", + ); + sessionCtx.failSessionForRecovery?.("opencode_turn_unknown_custody", providerSessionId ?? undefined); + return false; + })(); + currentTurnPromise = promise.then( + () => {}, + () => {}, + ); + try { + return await promise; + } finally { + if (generation === turnGeneration) { + currentAbort = null; + currentTurnPromise = null; + scheduleDrain(); + } + } + } + + async function prepareSession(sessionCtx: SessionContext): Promise<{ + briefing: string; + workspaceCwd: string; + }> { + if (isLandingCampaignTrialAgentMetadata(sessionCtx.agent.metadata)) { + throw new Error("landing campaign trial agents require the codex app-server runtime"); + } + ctx = sessionCtx; + cwd = acquireAgentHome(workspaceRoot); + const resolution = resolveBinary(process.env); + if (!resolution.ok) { + throw new Error(resolution.error); + } + binary = resolution.binary; + sessionCtx.log(`OpenCode binary: ${resolution.binary}`); + const { briefing } = await refreshProjection(sessionCtx); + sessionActive = true; + return { briefing, workspaceCwd: cwd }; + } + + async function runQueued( + drained: Array<{ message: SessionMessage; token: DeliveryToken }>, + sessionCtx: SessionContext, + ): Promise { + const token = drained[0]?.token; + if (!token) return; + const messages = drained.map((entry) => entry.message); + const parts: string[] = []; + try { + for (const message of messages) parts.push(await sessionCtx.formatInboundContent(message)); + } catch (error) { + sessionCtx.log(`OpenCode queued formatting failed: ${error instanceof Error ? error.message : String(error)}`); + for (const entry of drained) entry.token.retry(entry.message, "opencode_queued_format_failed"); + return; + } + const sessionKey = providerSessionId ?? pendingSyntheticId; + let fingerprint: string | null = null; + if (cwd && sessionKey) { + const projection = await refreshProjection(sessionCtx); + fingerprint = computeBriefingFingerprint(projection.briefing); + if (readSessionBriefingFingerprint(cwd, sessionKey) !== fingerprint) { + parts.unshift(buildBriefingUpdateNotice(join(cwd, "AGENTS.md"))); + } + } + const delivered = await runTurn(parts.join("\n\n"), sessionCtx, messages, token); + if (delivered && fingerprint && cwd && sessionKey) { + writeSessionBriefingFingerprint(cwd, providerSessionId ?? sessionKey, fingerprint); + } + } + + function scheduleDrain(): void { + if ( + drainScheduled || + drainInProgress || + queue.length === 0 || + !ctx || + !sessionActive || + currentTurnPromise || + initialTurnPreparing + ) { + return; + } + drainScheduled = true; + setImmediate(() => { + drainScheduled = false; + if ( + drainInProgress || + queue.length === 0 || + !ctx || + !sessionActive || + currentTurnPromise || + initialTurnPreparing + ) { + scheduleDrain(); + return; + } + const drained = queue.splice(0); + const sessionCtx = ctx; + drainInProgress = true; + void runQueued(drained, sessionCtx) + .catch((error) => { + sessionCtx.log(`OpenCode queued turn failed: ${error instanceof Error ? error.message : String(error)}`); + for (const entry of drained) entry.token.retry(entry.message, "opencode_queued_turn_failed"); + }) + .finally(() => { + drainInProgress = false; + scheduleDrain(); + }); + }); + } + + function retryQueue(reason: string): void { + for (const entry of queue.splice(0)) entry.token.retry(entry.message, reason); + } + + return { + async start(message, sessionCtx, token) { + const explicit = token !== undefined; + const deliveryToken = token ?? deliveryTokenFromSessionContext(sessionCtx); + initialTurnPreparing = true; + let completed = false; + let briefing: string; + let workspaceCwd: string; + try { + ({ briefing, workspaceCwd } = await prepareSession(sessionCtx)); + const prompt = await sessionCtx.formatInboundContent(message); + await runTurn(prompt, sessionCtx, [message], deliveryToken); + completed = true; + } finally { + initialTurnPreparing = false; + if (completed) scheduleDrain(); + } + if (!providerSessionId) pendingSyntheticId = `${OPENCODE_PENDING_SESSION_PREFIX}${randomUUID()}`; + const sessionId = providerSessionId ?? pendingSyntheticId; + if (!sessionId) throw new Error("OpenCode session id unresolved"); + writeSessionBriefingFingerprint(workspaceCwd, sessionId, computeBriefingFingerprint(briefing)); + return explicit ? { sessionId, route: { kind: "owned", mode: "processing" } } : sessionId; + }, + + async resume(message, sessionId, sessionCtx, token) { + const explicit = token !== undefined; + const deliveryToken = token ?? deliveryTokenFromSessionContext(sessionCtx); + initialTurnPreparing = true; + let briefing: string; + let workspaceCwd: string; + try { + ({ briefing, workspaceCwd } = await prepareSession(sessionCtx)); + } catch (error) { + initialTurnPreparing = false; + throw error; + } + if (isOpenCodePendingSessionId(sessionId)) { + pendingSyntheticId = sessionId; + providerSessionId = null; + } else { + providerSessionId = sessionId; + pendingSyntheticId = null; + } + const fingerprint = computeBriefingFingerprint(briefing); + if (message) { + let prompt = await sessionCtx.formatInboundContent(message); + if (readSessionBriefingFingerprint(workspaceCwd, sessionId) !== fingerprint) { + prompt = `${buildBriefingUpdateNotice(join(workspaceCwd, "AGENTS.md"))}\n\n${prompt}`; + } + try { + const delivered = await runTurn(prompt, sessionCtx, [message], deliveryToken); + if (delivered) { + writeSessionBriefingFingerprint(workspaceCwd, providerSessionId ?? sessionId, fingerprint); + } + } finally { + initialTurnPreparing = false; + scheduleDrain(); + } + } else { + initialTurnPreparing = false; + scheduleDrain(); + } + const effectiveId = providerSessionId ?? pendingSyntheticId ?? sessionId; + return explicit + ? { sessionId: effectiveId, route: message ? { kind: "owned", mode: "processing" } : null } + : effectiveId; + }, + + inject(message, token) { + if (!ctx) return { kind: "rejected", reason: "no_active_context", retryable: true }; + queue.push({ message, token: token ?? deliveryTokenFromSessionContext(ctx) }); + scheduleDrain(); + return { kind: "owned", mode: "queued" }; + }, + + async suspend(reason) { + sessionActive = false; + retryQueue(reason ?? "opencode_suspend_before_terminal"); + generation++; + currentAbort?.abort(); + await currentTurnPromise; + currentAbort = null; + currentTurnPromise = null; + initialTurnPreparing = false; + }, + + async shutdown(reason) { + sessionActive = false; + retryQueue(reason ?? "opencode_shutdown_before_terminal"); + generation++; + currentAbort?.abort(); + await currentTurnPromise; + currentAbort = null; + currentTurnPromise = null; + cwd = null; + ctx = null; + activeConfig = null; + teamSkills = []; + binary = null; + providerSessionId = null; + pendingSyntheticId = null; + versionReady = false; + initialTurnPreparing = false; + queue.length = 0; + }, + } satisfies AgentHandler; +}; + +function isReadOnlyTool(name: string): boolean { + return /^(read|glob|grep|list|ls|webfetch|websearch)$/i.test(name); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} diff --git a/packages/client/src/handlers/opencode/parser.ts b/packages/client/src/handlers/opencode/parser.ts new file mode 100644 index 000000000..b8844f933 --- /dev/null +++ b/packages/client/src/handlers/opencode/parser.ts @@ -0,0 +1,176 @@ +export type OpenCodeUsage = { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}; + +export type OpenCodeStreamEvent = + | { kind: "session"; sessionId: string } + | { kind: "text"; text: string } + | { + kind: "tool"; + toolUseId: string; + name: string; + status: "pending" | "ok" | "error"; + args: unknown; + resultPreview?: string; + } + | { kind: "usage"; usage: OpenCodeUsage } + | { kind: "terminal"; reason: string } + | { kind: "error"; message: string } + | { kind: "unknown"; note: string; raw: string }; + +const PREVIEW_LIMIT = 400; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function string(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function number(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function preview(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value.slice(0, PREVIEW_LIMIT); + if (value === undefined || value === null) return undefined; + try { + return JSON.stringify(value).slice(0, PREVIEW_LIMIT); + } catch { + return String(value).slice(0, PREVIEW_LIMIT); + } +} + +function usage(value: unknown): OpenCodeUsage | null { + const row = record(value); + if (!row) return null; + const cache = record(row.cache); + const input = number(row.input) ?? number(row.inputTokens) ?? number(row.input_tokens); + const cached = + number(row.cacheRead) ?? + number(row.cache_read) ?? + number(row.cachedInputTokens) ?? + number(row.cached_input_tokens) ?? + number(cache?.read); + const output = number(row.output) ?? number(row.outputTokens) ?? number(row.output_tokens); + if (input === null && cached === null && output === null) return null; + return { + inputTokens: Math.max(0, input ?? 0), + cachedInputTokens: Math.max(0, cached ?? 0), + outputTokens: Math.max(0, output ?? 0), + }; +} + +function sessionId(row: Record, part: Record | null): string | null { + return string(row.sessionID) ?? string(row.sessionId) ?? string(part?.sessionID) ?? string(part?.sessionId); +} + +/** Parse one OpenCode `run --format json` line without throwing. */ +export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { + const raw = line.trim(); + if (!raw) return []; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return [{ kind: "unknown", note: "unparsable JSONL line", raw: raw.slice(0, PREVIEW_LIMIT) }]; + } + const row = record(value); + if (!row) return [{ kind: "unknown", note: "non-object JSONL value", raw: raw.slice(0, PREVIEW_LIMIT) }]; + const part = record(row.part); + const events: OpenCodeStreamEvent[] = []; + const id = sessionId(row, part); + if (id) events.push({ kind: "session", sessionId: id }); + + switch (string(row.type)) { + case "text": { + const text = string(part?.text) ?? string(row.text); + if (text) events.push({ kind: "text", text }); + break; + } + case "tool_use": { + const state = record(part?.state); + const metadata = record(state?.metadata); + const statusValue = string(state?.status) ?? "pending"; + const status = + statusValue === "completed" || statusValue === "ok" + ? (number(metadata?.exit) ?? 0) === 0 + ? "ok" + : "error" + : statusValue === "error" || statusValue === "failed" + ? "error" + : "pending"; + events.push({ + kind: "tool", + toolUseId: + string(part?.id) ?? + string(part?.callID) ?? + string(part?.callId) ?? + `${string(part?.tool) ?? "tool"}:${string(part?.messageID) ?? "unknown"}`, + name: string(part?.tool) ?? "unknown", + status, + args: state?.input ?? part?.input ?? {}, + ...(status === "pending" ? {} : { resultPreview: preview(state?.output ?? state?.error) }), + }); + break; + } + case "step_finish": { + const tokenUsage = usage(part?.tokens ?? row.tokens); + if (tokenUsage) events.push({ kind: "usage", usage: tokenUsage }); + const reason = string(part?.reason); + if (reason && reason !== "tool-calls") events.push({ kind: "terminal", reason }); + break; + } + case "error": { + const error = record(part?.error ?? row.error); + events.push({ + kind: "error", + message: + string(error?.message) ?? + string(part?.message) ?? + string(row.message) ?? + preview(part?.error ?? row.error) ?? + "OpenCode emitted an error event", + }); + break; + } + case "step_start": + break; + default: + if (!id) { + events.push({ + kind: "unknown", + note: `unknown event type ${String(row.type)}`, + raw: raw.slice(0, PREVIEW_LIMIT), + }); + } + } + return events; +} + +export class OpenCodeStreamParser { + private buffer = ""; + + push(chunk: string): OpenCodeStreamEvent[] { + this.buffer += chunk; + const events: OpenCodeStreamEvent[] = []; + let start = 0; + for (;;) { + const newline = this.buffer.indexOf("\n", start); + if (newline < 0) break; + events.push(...parseOpenCodeStreamLine(this.buffer.slice(start, newline))); + start = newline + 1; + } + if (start > 0) this.buffer = this.buffer.slice(start); + return events; + } + + flush(): OpenCodeStreamEvent[] { + const tail = this.buffer; + this.buffer = ""; + return tail.trim() ? parseOpenCodeStreamLine(tail) : []; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d347f9c0d..eb34cbfa7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,12 @@ export { export { detectStreamApiError, StreamApiTransientError } from "./handlers/claude-code.js"; export { registerBuiltinHandlers } from "./handlers/index.js"; export { createKimiCodeHandler, formatKimiCodeError, kimiToolIsReadOnly } from "./handlers/kimi-code.js"; +export { + buildOpenCodeConfigContent, + buildOpenCodeTurnArgs, + createOpenCodeHandler, + mapOpenCodeMcpServers, +} from "./handlers/opencode/index.js"; export { applyClientLoggerConfig, captureClientException, @@ -61,6 +67,7 @@ export { revalidateCapabilities, shouldFullReprobe, } from "./runtime/capabilities/index.js"; +export { probeOpenCodeCapability } from "./runtime/capabilities/opencode.js"; export type { AdoptOptions, @@ -107,6 +114,22 @@ export { getHandlerFactory, hasHandler, registerHandler } from "./runtime/handle export type { BuildImageAttachmentsOptions, BuildMessageImageSnapshotsResult } from "./runtime/image-snapshots.js"; export { buildMessageImageSnapshots } from "./runtime/image-snapshots.js"; export { InputController } from "./runtime/input-controller.js"; +export { + findOpenCodeExecutableOnPath, + formatOpenCodeBinaryMissingMessage, + OPENCODE_INSTALL_COMMAND, + OPENCODE_LOGIN_COMMAND, + OPENCODE_SUPPORTED_VERSION, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "./runtime/opencode-binary.js"; +export { + createDefaultProviderProcessSupervisor, + type ProviderProcessSpec, + ProviderProcessSupervisionUnsupportedError, + type ProviderProcessSupervisor, + type SupervisedProviderProcess, +} from "./runtime/provider-process-supervisor.js"; export type { AgentRuntimeOptions } from "./runtime/runtime.js"; export { AgentRuntime } from "./runtime/runtime.js"; // Runtime-auth (browser OAuth) diff --git a/packages/client/src/runtime/capabilities/discover-models.ts b/packages/client/src/runtime/capabilities/discover-models.ts index 199b9407a..341425640 100644 --- a/packages/client/src/runtime/capabilities/discover-models.ts +++ b/packages/client/src/runtime/capabilities/discover-models.ts @@ -204,6 +204,12 @@ export async function discoverProviderModels( return discoverCursorModels(deps); case "kimi-code": return discoverKimiModels(deps); + case "opencode": + return unavailable( + provider, + "OpenCode model discovery is not enabled in V1; enter the provider-native provider/model id", + deps, + ); case "claude-code": case "claude-code-tui": case "codex": diff --git a/packages/client/src/runtime/capabilities/index.ts b/packages/client/src/runtime/capabilities/index.ts index f1e84807e..93123e4dc 100644 --- a/packages/client/src/runtime/capabilities/index.ts +++ b/packages/client/src/runtime/capabilities/index.ts @@ -9,6 +9,7 @@ import { probeClaudeCodeTuiCapability } from "./claude-code-tui.js"; import { probeCodexCapability } from "./codex.js"; import { probeCursorCapability } from "./cursor.js"; import { probeKimiCodeCapability } from "./kimi-code.js"; +import { probeOpenCodeCapability } from "./opencode.js"; /** Periodic full re-probe ceiling: re-detect at most this often on reconnect to * catch silent drift (a provider uninstalled while connected). Detection is @@ -19,7 +20,7 @@ export const REPROBE_MAX_AGE_MS = 24 * 60 * 60 * 1000; * temporarily disabled. Drives whether a daemon's advertised snapshot still has * a provider worth re-probing (see {@link hasNonOkProvider}). */ export const PROBED_RUNTIME_PROVIDERS: readonly RuntimeProvider[] = ( - ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"] as const + ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code", "opencode"] as const ).filter((p) => isRuntimeProviderEnabled(p)); /** First delay before the daemon-side degraded-capability re-probe fires. Short @@ -92,6 +93,7 @@ export async function probeCapabilities(): Promise { if (isRuntimeProviderEnabled("codex")) probes.push(["codex", probeCodexCapability()]); if (isRuntimeProviderEnabled("cursor")) probes.push(["cursor", probeCursorCapability()]); if (isRuntimeProviderEnabled("kimi-code")) probes.push(["kimi-code", probeKimiCodeCapability()]); + if (isRuntimeProviderEnabled("opencode")) probes.push(["opencode", probeOpenCodeCapability()]); return aggregate(probes); } diff --git a/packages/client/src/runtime/capabilities/opencode.ts b/packages/client/src/runtime/capabilities/opencode.ts new file mode 100644 index 000000000..754b70575 --- /dev/null +++ b/packages/client/src/runtime/capabilities/opencode.ts @@ -0,0 +1,25 @@ +import type { CapabilityEntry } from "@first-tree/shared"; +import { findOpenCodeExecutableOnPath, formatOpenCodeBinaryMissingMessage } from "../opencode-binary.js"; +import { type DetectOutcome, runDetect } from "./detect.js"; + +export type OpenCodeProbeDeps = { + findOnPath?: (env?: Record) => string | null; + env?: NodeJS.ProcessEnv; +}; + +/** + * Install-only probe. It deliberately does not launch OpenCode, inspect its + * config, or infer provider authentication. + */ +export async function probeOpenCodeCapability(deps: OpenCodeProbeDeps = {}): Promise { + const env = deps.env ?? process.env; + const findOnPath = deps.findOnPath ?? findOpenCodeExecutableOnPath; + return runDetect(async (): Promise => { + const runtimePath = findOnPath(env); + if (runtimePath) return { installed: true, runtimeSource: "path", runtimePath }; + return { + installed: false, + error: formatOpenCodeBinaryMissingMessage("no opencode binary resolved on this host"), + }; + }); +} diff --git a/packages/client/src/runtime/managed-skills.ts b/packages/client/src/runtime/managed-skills.ts index 9b67bcb96..6ca71fb4f 100644 --- a/packages/client/src/runtime/managed-skills.ts +++ b/packages/client/src/runtime/managed-skills.ts @@ -48,6 +48,7 @@ const PROVIDER_SKILL_ROOTS: Readonly> = { codex: ".agents/skills", cursor: ".cursor/skills", "kimi-code": ".kimi-code/skills", + opencode: ".opencode/skills", }; const ALLOWED_TARGET_ROOTS = new Set([...Object.values(PROVIDER_SKILL_ROOTS), LEGACY_RESOURCE_SKILLS_ROOT]); diff --git a/packages/client/src/runtime/managed-state.ts b/packages/client/src/runtime/managed-state.ts index 3355bff8d..5652bd840 100644 --- a/packages/client/src/runtime/managed-state.ts +++ b/packages/client/src/runtime/managed-state.ts @@ -375,6 +375,7 @@ function isSafePersistedTarget(target: string): boolean { root === ".claude/skills" || root === ".cursor/skills" || root === ".kimi-code/skills" || + root === ".opencode/skills" || root === ".first-tree/resources/skills"; return allowedRoot && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(parts.at(-1) ?? ""); } diff --git a/packages/client/src/runtime/opencode-binary.ts b/packages/client/src/runtime/opencode-binary.ts new file mode 100644 index 000000000..7f1fa71a4 --- /dev/null +++ b/packages/client/src/runtime/opencode-binary.ts @@ -0,0 +1,132 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { wellKnownBinDirs } from "./install-locations.js"; +import { getLoginShellPathDirs } from "./login-shell-path.js"; + +/** Exact CLI contract validated by the cross-platform harness. */ +export const OPENCODE_SUPPORTED_VERSION = "1.18.7"; +/** Host-local OpenCode installation surfaced in setup and error copy. */ +export const OPENCODE_INSTALL_COMMAND = `npm install -g opencode-ai@${OPENCODE_SUPPORTED_VERSION}`; +export const OPENCODE_LOGIN_COMMAND = "opencode auth login"; + +export function formatOpenCodeBinaryMissingMessage(input: unknown): string { + const original = errorText(input).trim(); + const suffix = original ? ` Original error: ${original}` : ""; + return ( + "OpenCode CLI is missing on this machine. " + + "First Tree does not bundle or install OpenCode and never reads its provider credentials. " + + `Install it with \`${OPENCODE_INSTALL_COMMAND}\`, then complete provider-owned setup with ` + + `\`${OPENCODE_LOGIN_COMMAND}\` and retry.` + + suffix + ); +} + +export function isOpenCodeBinaryMissingError(input: unknown): boolean { + const text = errorText(input); + return /opencode cli is missing|opencode.*not (?:found|installed)/i.test(text); +} + +export type FindOpenCodeExecutableDeps = { + loginShellPathDirs?: () => string[]; + wellKnownDirs?: () => string[]; + platform?: NodeJS.Platform; + pathDelimiter?: string; +}; + +/** Existence-only resolver shared by capability detection and the handler. */ +export function findOpenCodeExecutableOnPath( + env: Record = process.env, + deps: FindOpenCodeExecutableDeps = {}, +): string | null { + const platform = deps.platform ?? process.platform; + const pathDelimiter = deps.pathDelimiter ?? (platform === "win32" ? ";" : delimiter); + const loginShellPathDirs = deps.loginShellPathDirs ?? getLoginShellPathDirs; + const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); + const wellKnownDirs = deps.wellKnownDirs ?? (() => wellKnownBinDirs(home)); + const seen = new Set(); + + const search = (dirs: readonly string[]): string | null => { + for (const dir of dirs) { + if (!dir) continue; + const base = isAbsolute(dir) ? dir : resolve(dir); + if (seen.has(base)) continue; + seen.add(base); + for (const candidate of openCodeExecutableCandidates(base, platform)) { + if (isExecutableFile(candidate, platform)) return candidate; + } + } + return null; + }; + + const pathValue = env.PATH ?? env.Path ?? env.path ?? ""; + const pathDirs = pathValue ? pathValue.split(pathDelimiter) : []; + return search(pathDirs) ?? search(wellKnownDirs()) ?? search(loginShellPathDirs()); +} + +export type OpenCodeRuntimeBinaryResolution = + | { ok: true; binary: string } + | { ok: false; error: string; transient: false }; + +export type OpenCodeRuntimeResolveDeps = { + findOnPath?: (env?: Record) => string | null; +}; + +/** + * Resolve only. Every OpenCode invocation, including the exact-version gate, + * is launched later through the provider process supervisor so Windows never + * executes an unadmitted runtime process. + */ +export function resolveOpenCodeRuntimeBinary( + env: NodeJS.ProcessEnv = process.env, + deps: OpenCodeRuntimeResolveDeps = {}, +): OpenCodeRuntimeBinaryResolution { + const findOnPath = deps.findOnPath ?? findOpenCodeExecutableOnPath; + const binary = findOnPath(env); + if (!binary) { + return { + ok: false, + error: formatOpenCodeBinaryMissingMessage("no opencode binary resolved"), + transient: false, + }; + } + return { ok: true, binary }; +} + +/** + * npm exposes global Windows CLIs through `.cmd` shims, which cannot be + * launched with `shell: false` and cannot be pre-admitted as the OpenCode root + * process. Resolve the package's native executable beside the shim instead. + */ +function openCodeExecutableCandidates(base: string, platform: NodeJS.Platform): string[] { + if (platform !== "win32") return [join(base, "opencode")]; + const candidates = [join(base, "opencode.exe"), join(base, "node_modules", "opencode-ai", "bin", "opencode.exe")]; + if (basename(base).toLowerCase() === ".bin") { + candidates.push(join(dirname(base), "opencode-ai", "bin", "opencode.exe")); + } + return candidates; +} + +export function parseOpenCodeVersionOutput(output: string): string | null { + return output.match(/\d+\.\d+(?:\.\d+)?/)?.[0] ?? null; +} + +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(filePath).isFile()) return false; + accessSync(filePath, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +function errorText(input: unknown): string { + if (input instanceof Error) return `${input.name} ${input.message}`; + if (typeof input === "string") return input; + if (input && typeof input === "object" && "message" in input) { + const message = (input as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return String(input); +} diff --git a/packages/client/src/runtime/provider-process-supervisor.ts b/packages/client/src/runtime/provider-process-supervisor.ts new file mode 100644 index 000000000..1ae032cc3 --- /dev/null +++ b/packages/client/src/runtime/provider-process-supervisor.ts @@ -0,0 +1,62 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { getChildProcessRegistry } from "./child-process-registry.js"; + +export type ProviderProcessSpec = { + command: string; + args: readonly string[]; + options: SpawnOptions; + label: string; + timeoutMs?: number; +}; + +export type SupervisedProviderProcess = { + child: ChildProcess; + /** + * Runtime-local cleanup observation. This is diagnostic only and is never a + * substitute for client-switch drain authority. + */ + exited: Promise; +}; + +export interface ProviderProcessSupervisor { + spawn(spec: ProviderProcessSpec): SupervisedProviderProcess; +} + +/** + * The existing POSIX path: environment-attributed process-tree observation is + * still the client-switch authority; the registry only improves local abort + * and daemon shutdown cleanup. + * + * Windows deliberately fails closed here. OpenCode descendants must be + * pre-admitted to a non-breakaway kill-on-close Job by a platform supervisor + * before this factory can be enabled. Supplying that supervisor is an + * explicit product seam, not a child-registry fallback. + */ +export function createDefaultProviderProcessSupervisor( + platform: NodeJS.Platform = process.platform, +): ProviderProcessSupervisor { + return { + spawn(spec) { + if (platform === "win32") { + throw new ProviderProcessSupervisionUnsupportedError( + "OpenCode on Windows requires a pre-admission Job Object supervisor; " + + "the current client-switch drain authority remains unsupported", + ); + } + const { child, record } = getChildProcessRegistry().spawn(spec.command, spec.args, { + ...spec.options, + category: "other", + label: spec.label, + ...(spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {}), + }); + return { child, exited: record.exited }; + }, + }; +} + +export class ProviderProcessSupervisionUnsupportedError extends Error { + constructor(message: string) { + super(message); + this.name = "ProviderProcessSupervisionUnsupportedError"; + } +} diff --git a/packages/client/src/runtime/runtime-notice.ts b/packages/client/src/runtime/runtime-notice.ts index 282cb5472..663f986e2 100644 --- a/packages/client/src/runtime/runtime-notice.ts +++ b/packages/client/src/runtime/runtime-notice.ts @@ -43,6 +43,8 @@ function providerLabel(provider: RuntimeProvider): string { return "Cursor"; case "kimi-code": return "Kimi Code"; + case "opencode": + return "OpenCode"; default: return provider; } diff --git a/packages/server/src/services/context-tree-io.ts b/packages/server/src/services/context-tree-io.ts index 8d0924eab..0499f946e 100644 --- a/packages/server/src/services/context-tree-io.ts +++ b/packages/server/src/services/context-tree-io.ts @@ -42,6 +42,8 @@ const CURSOR_READ_TOOLS = new Set(["read"]); const CURSOR_WRITE_TOOLS = new Set(["edit", "write"]); const KIMI_READ_TOOLS = new Set(["Read", "Grep", "Glob"]); const KIMI_WRITE_TOOLS = new Set(["Write", "Edit"]); +const OPENCODE_READ_TOOLS = new Set(["read", "grep", "glob"]); +const OPENCODE_WRITE_TOOLS = new Set(["edit", "write", "patch"]); const CONTEXT_TREE_IO_TOOL_NAMES = [ "Bash", "Edit", @@ -58,6 +60,8 @@ const CONTEXT_TREE_IO_TOOL_NAMES = [ "read", "shell", "write", + "bash", + "patch", ]; const log = createLogger("ContextTreeIo"); const GIT_STATUS_DELTA_REF_ORIGIN = "git_status_delta"; @@ -198,6 +202,7 @@ function isShellTool(runtimeProvider: string, toolName: string): boolean { (runtimeProvider === "codex" && toolName === "command") || (runtimeProvider === "cursor" && toolName === "shell") || (runtimeProvider === "kimi-code" && toolName === "Bash") || + (runtimeProvider === "opencode" && toolName === "bash") || (isClaudeRuntime(runtimeProvider) && toolName === "Bash") ); } @@ -236,6 +241,9 @@ function skippedDecisionFastPathForNoRefs( if (runtimeProvider === "kimi-code" && (KIMI_READ_TOOLS.has(toolName) || KIMI_WRITE_TOOLS.has(toolName))) { return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; } + if (runtimeProvider === "opencode" && (OPENCODE_READ_TOOLS.has(toolName) || OPENCODE_WRITE_TOOLS.has(toolName))) { + return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; + } if (isClaudeRuntime(runtimeProvider) && (CLAUDE_READ_TOOLS.has(toolName) || CLAUDE_WRITE_TOOLS.has(toolName))) { return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; } @@ -272,6 +280,12 @@ function deriveEventIo(event: SessionEvent, runtimeProvider: string): EventIoDer if (runtimeProvider === "kimi-code" && KIMI_WRITE_TOOLS.has(toolName)) { return { action: "write", source: "kimi_write_tool" }; } + if (runtimeProvider === "opencode" && OPENCODE_READ_TOOLS.has(toolName)) { + return { action: "read", source: "opencode_read_tool" }; + } + if (runtimeProvider === "opencode" && OPENCODE_WRITE_TOOLS.has(toolName)) { + return { action: "write", source: "opencode_write_tool" }; + } if (isClaudeRuntime(runtimeProvider) && CLAUDE_READ_TOOLS.has(toolName)) { return { action: "read", source: "claude_read_tool" }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c71fcaae8..4b207a06a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -137,6 +137,7 @@ export { DEFAULT_CODEX_RUNTIME_CONFIG_PAYLOAD, DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD, DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD, + DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD, type DryRunAgentRuntimeConfig, defaultRuntimeConfigPayload, deriveRepoLocalPath, diff --git a/packages/shared/src/schemas/agent-runtime-config.ts b/packages/shared/src/schemas/agent-runtime-config.ts index 86fb8153d..668d48628 100644 --- a/packages/shared/src/schemas/agent-runtime-config.ts +++ b/packages/shared/src/schemas/agent-runtime-config.ts @@ -293,12 +293,20 @@ const kimiCodeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend( // configuration while a non-empty exact id is passed to the SDK. }); +const opencodeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ + kind: z.literal("opencode"), + // OpenCode model identifiers are provider-native `provider/model` values. + // An empty value delegates selection to the operator's local OpenCode + // configuration; non-empty values are forwarded as one argv entry. +}); + const taggedPayloadUnion = z.discriminatedUnion("kind", [ claudeRuntimeConfigPayloadShape, claudeCodeTuiRuntimeConfigPayloadShape, codexRuntimeConfigPayloadShape, cursorRuntimeConfigPayloadShape, kimiCodeRuntimeConfigPayloadShape, + opencodeRuntimeConfigPayloadShape, ]); type TaggedPayload = z.infer; @@ -446,6 +454,17 @@ export const DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD: AgentRuntimeConfigPayload resourceSkills: [], }; +/** Default payload for OpenCode. Empty model inherits the host-local config. */ +export const DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD: AgentRuntimeConfigPayload = { + kind: "opencode", + prompt: { append: "" }, + model: "", + mcpServers: [], + env: [], + gitRepos: [], + resourceSkills: [], +}; + /** * Default payload selector by runtime provider. */ @@ -459,6 +478,8 @@ export function defaultRuntimeConfigPayload( return { ...DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD }; case "kimi-code": return { ...DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD }; + case "opencode": + return { ...DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD }; case "claude-code-tui": return { ...DEFAULT_CLAUDE_CODE_TUI_RUNTIME_CONFIG_PAYLOAD }; case "claude-code": diff --git a/packages/shared/src/schemas/context-tree.ts b/packages/shared/src/schemas/context-tree.ts index c67b67a42..448e3512d 100644 --- a/packages/shared/src/schemas/context-tree.ts +++ b/packages/shared/src/schemas/context-tree.ts @@ -189,6 +189,8 @@ export const contextTreeIoSourceSchema = z.enum([ "cursor_write_tool", "kimi_read_tool", "kimi_write_tool", + "opencode_read_tool", + "opencode_write_tool", "shell_command", "git_status_delta", ]); diff --git a/packages/shared/src/schemas/runtime-provider.ts b/packages/shared/src/schemas/runtime-provider.ts index 8ed2ee3fd..e742cd00f 100644 --- a/packages/shared/src/schemas/runtime-provider.ts +++ b/packages/shared/src/schemas/runtime-provider.ts @@ -11,9 +11,17 @@ export const RUNTIME_PROVIDERS = { CODEX: "codex", CURSOR: "cursor", KIMI_CODE: "kimi-code", + OPENCODE: "opencode", } as const; -export const runtimeProviderSchema = z.enum(["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]); +export const runtimeProviderSchema = z.enum([ + "claude-code", + "claude-code-tui", + "codex", + "cursor", + "kimi-code", + "opencode", +]); export type RuntimeProvider = z.infer; export const DEFAULT_RUNTIME_PROVIDER: RuntimeProvider = "claude-code"; diff --git a/packages/web/src/components/new-agent-dialog.tsx b/packages/web/src/components/new-agent-dialog.tsx index 1c99f4a4e..8767bb654 100644 --- a/packages/web/src/components/new-agent-dialog.tsx +++ b/packages/web/src/components/new-agent-dialog.tsx @@ -146,7 +146,8 @@ function asRuntimeProvider(provider: string): RuntimeProvider | null { provider === "claude-code-tui" || provider === "codex" || provider === "cursor" || - provider === "kimi-code" + provider === "kimi-code" || + provider === "opencode" ) { return provider; } @@ -170,6 +171,7 @@ function pickPreferredRuntime(caps: ClientCapabilities): RuntimeProvider | null // Same central-switch guard as the TUI line: a stale `ok` snapshot from a // daemon must not auto-pick a provider that has since been disabled. if (isRuntimeProviderEnabled("cursor") && caps.cursor?.state === "ok") return "cursor"; + if (isRuntimeProviderEnabled("opencode") && caps.opencode?.state === "ok") return "opencode"; // Any other provider (incl. one still disabled in a stale snapshot) is only // auto-picked when enabled. for (const [provider, entry] of Object.entries(caps)) { @@ -187,6 +189,7 @@ function prettyRuntimeLabel(provider: RuntimeProvider): string { if (provider === "codex") return "Codex"; if (provider === "cursor") return "Cursor"; if (provider === "kimi-code") return "Kimi Code"; + if (provider === "opencode") return "OpenCode"; return provider; } diff --git a/packages/web/src/pages/agent-detail/model-section.tsx b/packages/web/src/pages/agent-detail/model-section.tsx index 3ae01ea3c..1a90d83d8 100644 --- a/packages/web/src/pages/agent-detail/model-section.tsx +++ b/packages/web/src/pages/agent-detail/model-section.tsx @@ -82,6 +82,7 @@ const MODEL_OPTIONS_BY_PROVIDER: Record = { codex: CODEX_MODEL_OPTIONS, cursor: [], "kimi-code": [], + opencode: [], }; const MODEL_HELP_BY_PROVIDER: Record = { @@ -92,6 +93,8 @@ const MODEL_HELP_BY_PROVIDER: Record = { "Options come from this computer's Cursor CLI when reachable. The id is passed through verbatim on the next turn — one your account can't use fails visibly, no silent fallback. Unset uses the Cursor default (auto).", "kimi-code": "Options come from this computer's ~/.kimi-code config when reachable. Passed to new sessions. Unset uses the model configured in ~/.kimi-code.", + opencode: + "Enter an exact OpenCode provider/model id. It is passed through verbatim on the next turn; unset inherits the host-local OpenCode configuration.", }; /** Extra note when discovery is unsupported / offline / timed out (`null` from the API helper). */ @@ -454,9 +457,11 @@ function FreeFormModelInput({ placeholder={ provider === "kimi-code" ? "local Kimi default" - : provider === "cursor" - ? "auto (Cursor default)" - : "provider default" + : provider === "opencode" + ? "local OpenCode default" + : provider === "cursor" + ? "auto (Cursor default)" + : "provider default" } className="font-mono" aria-label="Model" diff --git a/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx b/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx index 31f6c23fb..3c82c2274 100644 --- a/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx +++ b/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx @@ -41,6 +41,7 @@ const EFFORT_OPTIONS_BY_PROVIDER: Record = { // cursor agents. The empty entry keeps the Record exhaustive. cursor: [], "kimi-code": [], + opencode: [], }; const EFFORT_HELP_BY_PROVIDER: Record = { @@ -49,6 +50,7 @@ const EFFORT_HELP_BY_PROVIDER: Record = { codex: "Applies to new sessions. Higher means more reasoning per turn; max and ultra require a compatible model.", cursor: "Cursor encodes effort in the model id; there is no separate control.", "kimi-code": "Kimi thinking configuration is inherited from the local Kimi configuration.", + opencode: "OpenCode model variants are provider-native; there is no separate First Tree effort control.", }; export type ReasoningEffortSectionProps = { diff --git a/packages/web/src/pages/agent-detail/runtime-section.tsx b/packages/web/src/pages/agent-detail/runtime-section.tsx index 52d0e6a10..7ddedeca2 100644 --- a/packages/web/src/pages/agent-detail/runtime-section.tsx +++ b/packages/web/src/pages/agent-detail/runtime-section.tsx @@ -32,6 +32,7 @@ const RUNTIME_NAME: Record = { codex: "Codex", cursor: "Cursor", "kimi-code": "Kimi Code", + opencode: "OpenCode", }; export function RuntimeSection(props: RuntimeSectionProps) { diff --git a/packages/web/src/pages/agent-detail/runtime-tab.tsx b/packages/web/src/pages/agent-detail/runtime-tab.tsx index c7a5b0592..226b61d37 100644 --- a/packages/web/src/pages/agent-detail/runtime-tab.tsx +++ b/packages/web/src/pages/agent-detail/runtime-tab.tsx @@ -87,7 +87,9 @@ export function RuntimeTab() { {/* Cursor has no separate reasoning-effort channel — effort/fast variants live in the provider-native model id, so the control is hidden rather than rendered empty. */} - {ctx.setupRuntimeProvider !== "cursor" && ctx.setupRuntimeProvider !== "kimi-code" ? ( + {ctx.setupRuntimeProvider !== "cursor" && + ctx.setupRuntimeProvider !== "kimi-code" && + ctx.setupRuntimeProvider !== "opencode" ? ( configSave.save({ reasoningEffort: v }, { field: "effort" })} diff --git a/packages/web/src/pages/clients/cards/shared/providers.ts b/packages/web/src/pages/clients/cards/shared/providers.ts index ac651c3ef..90799c012 100644 --- a/packages/web/src/pages/clients/cards/shared/providers.ts +++ b/packages/web/src/pages/clients/cards/shared/providers.ts @@ -21,6 +21,7 @@ export const PROVIDER_ORDER: RuntimeProvider[] = [ RUNTIME_PROVIDERS.CODEX, RUNTIME_PROVIDERS.CURSOR, RUNTIME_PROVIDERS.KIMI_CODE, + RUNTIME_PROVIDERS.OPENCODE, ].filter((p) => isRuntimeProviderEnabled(p)); export const PROVIDER_LABEL: Record = { @@ -29,6 +30,7 @@ export const PROVIDER_LABEL: Record = { codex: "Codex", cursor: "Cursor", "kimi-code": "Kimi Code", + opencode: "OpenCode", }; const KNOWN_RUNTIME_PROVIDERS: readonly string[] = Object.values(RUNTIME_PROVIDERS); @@ -71,6 +73,7 @@ export const PROVIDER_NPM_PACKAGE: Record = { // Runtime execution is bundled, but the official CLI remains the supported // operator login/recovery surface for the shared ~/.kimi-code credential. "kimi-code": "@moonshot-ai/kimi-code", + opencode: "opencode-ai@1.18.7", }; /** @@ -95,6 +98,7 @@ export const PROVIDER_LOGIN_COMMAND: Record = { codex: "codex login", cursor: "cursor-agent login", "kimi-code": "kimi # then run /login", + opencode: "opencode auth login", }; /** @@ -217,5 +221,8 @@ export function providerInstallHint( if (provider === "kimi-code") { return `Install the official Kimi CLI with \`npm install -g @moonshot-ai/kimi-code\` on this ${device}, run \`kimi\`, then \`/login\`. First Tree still executes through its bundled Kimi SDK.`; } + if (provider === "opencode") { + return `Run \`npm install -g opencode-ai@1.18.7\` on this ${device}, then complete provider-owned setup with \`opencode auth login\`.`; + } return `Install the OpenAI Codex CLI on this ${device}.`; } From ec1c1060c344c0048b7186354b0eafbef6c8f1b7 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 21:33:45 +0800 Subject: [PATCH 02/15] test: cover OpenCode runtime provider --- .../src/__tests__/capability-refresh.test.ts | 4 + .../src/__tests__/auth-error-hint.test.ts | 24 +- .../src/__tests__/builtin-handlers.test.ts | 13 + .../src/__tests__/capability-probes.test.ts | 12 +- .../src/__tests__/capability-reprobe.test.ts | 1 + .../src/__tests__/managed-skills.test.ts | 10 +- .../src/__tests__/opencode-binary.test.ts | 69 +++++ .../src/__tests__/opencode-capability.test.ts | 22 ++ .../src/__tests__/opencode-handler.test.ts | 283 ++++++++++++++++++ .../src/__tests__/opencode-parser.test.ts | 48 +++ .../provider-process-supervisor.test.ts | 20 ++ .../qa/cases/runtime/opencode-provider.md | 90 ++++++ .../src/__tests__/admin-agent-config.test.ts | 27 ++ .../src/__tests__/context-tree-io.test.ts | 48 +++ .../__tests__/agent-runtime-config.test.ts | 19 ++ .../runtime-provider-schemas.test.ts | 3 + .../opencode-provider-surfaces.test.ts | 22 ++ 17 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/__tests__/opencode-binary.test.ts create mode 100644 packages/client/src/__tests__/opencode-capability.test.ts create mode 100644 packages/client/src/__tests__/opencode-handler.test.ts create mode 100644 packages/client/src/__tests__/opencode-parser.test.ts create mode 100644 packages/client/src/__tests__/provider-process-supervisor.test.ts create mode 100644 packages/qa/cases/runtime/opencode-provider.md create mode 100644 packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts diff --git a/apps/cli/src/__tests__/capability-refresh.test.ts b/apps/cli/src/__tests__/capability-refresh.test.ts index 74b6fa06e..8da0b3c28 100644 --- a/apps/cli/src/__tests__/capability-refresh.test.ts +++ b/apps/cli/src/__tests__/capability-refresh.test.ts @@ -37,6 +37,7 @@ const allOk = (): ClientCapabilities => ({ codex: ok(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); const codexMissing = (): ClientCapabilities => ({ @@ -45,6 +46,7 @@ const codexMissing = (): ClientCapabilities => ({ codex: missing(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); // Detection is install-only, so a provider mid-login is one whose binary is not @@ -73,6 +75,7 @@ const codexPendingSnapshot = (): ClientCapabilities => ({ codex: codexPending(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); /** What a re-probe sees while the login is still in flight: still not installed. */ @@ -82,6 +85,7 @@ const codexUnauthSnapshot = (): ClientCapabilities => ({ codex: codexUnauth(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); const BASE = 100; diff --git a/packages/client/src/__tests__/auth-error-hint.test.ts b/packages/client/src/__tests__/auth-error-hint.test.ts index 6cd23291c..ce285af76 100644 --- a/packages/client/src/__tests__/auth-error-hint.test.ts +++ b/packages/client/src/__tests__/auth-error-hint.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatAuthHint, isClaudeAuthError, isCodexAuthError } from "../handlers/auth-error-hint.js"; +import { + formatAuthHint, + isClaudeAuthError, + isCodexAuthError, + isOpenCodeAuthError, +} from "../handlers/auth-error-hint.js"; /** * Locks the behavioural contract of the auth-error hint module that @@ -93,6 +98,15 @@ describe("isClaudeAuthError", () => { }); }); +describe("isOpenCodeAuthError", () => { + it("matches provider-owned credential failures without treating capacity failures as auth", () => { + expect(isOpenCodeAuthError("Provider returned 401 Unauthorized: invalid API key")).toBe(true); + expect(isOpenCodeAuthError("Run opencode auth login before using this provider")).toBe(true); + expect(isOpenCodeAuthError("rate limit exceeded")).toBe(false); + expect(isOpenCodeAuthError("")).toBe(false); + }); +}); + describe("formatAuthHint", () => { it("targets `codex login` for the codex runtime and quotes the original SDK message", () => { const hint = formatAuthHint( @@ -115,6 +129,14 @@ describe("formatAuthHint", () => { expect(hint).toContain("authentication_failed"); }); + it("keeps OpenCode authentication host-local and points at the provider-owned login", () => { + const hint = formatAuthHint("opencode", "Provider returned 401 Unauthorized"); + expect(hint).toContain("opencode"); + expect(hint).toContain("`opencode auth login`"); + expect(hint).toContain("OpenCode's selected provider"); + expect(hint).toContain("not First Tree's"); + }); + it("falls back to a placeholder when the SDK gives no message", () => { const hint = formatAuthHint("codex", ""); expect(hint).toContain("(no message from SDK)"); diff --git a/packages/client/src/__tests__/builtin-handlers.test.ts b/packages/client/src/__tests__/builtin-handlers.test.ts index 46818d332..41351b7e6 100644 --- a/packages/client/src/__tests__/builtin-handlers.test.ts +++ b/packages/client/src/__tests__/builtin-handlers.test.ts @@ -97,6 +97,19 @@ describe("Built-in Handlers", () => { expect(typeof handler.shutdown).toBe("function"); }); + it("registers opencode handler with a valid session-oriented shape", () => { + registerBuiltinHandlers(); + + const factory = getHandlerFactory("opencode"); + expect(typeof factory).toBe("function"); + const handler = factory({ workspaceRoot: "/tmp/test", runtimeProvider: "opencode" }); + expect(typeof handler.start).toBe("function"); + expect(typeof handler.resume).toBe("function"); + expect(typeof handler.inject).toBe("function"); + expect(typeof handler.suspend).toBe("function"); + expect(typeof handler.shutdown).toBe("function"); + }); + it("codex factory returns a valid session-oriented handler", () => { registerBuiltinHandlers(); diff --git a/packages/client/src/__tests__/capability-probes.test.ts b/packages/client/src/__tests__/capability-probes.test.ts index b4be4672a..b7a0fe025 100644 --- a/packages/client/src/__tests__/capability-probes.test.ts +++ b/packages/client/src/__tests__/capability-probes.test.ts @@ -739,18 +739,22 @@ describe("probeCapabilities (aggregator)", () => { vi.doMock("../runtime/capabilities/kimi-code.js", () => ({ probeKimiCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")), })); + vi.doMock("../runtime/capabilities/opencode.js", () => ({ + probeOpenCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")), + })); const mod = await import("../runtime/capabilities/index.js"); const caps = await mod.probeCapabilities(); // claude-code-tui is in DISABLED_RUNTIME_PROVIDERS — it is skipped, so it // gets no capability entry AND its probe is never called (no binary spawn). - expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code"]); + expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code", "opencode"]); expect(caps["claude-code"]?.state).toBe("ok"); expect(caps["claude-code-tui"]).toBeUndefined(); expect(caps.codex?.state).toBe("ok"); expect(caps.cursor?.state).toBe("ok"); expect(caps["kimi-code"]?.state).toBe("ok"); + expect(caps.opencode?.state).toBe("ok"); expect(tuiProbe).not.toHaveBeenCalled(); vi.doUnmock("../runtime/capabilities/claude-code.js"); @@ -758,6 +762,7 @@ describe("probeCapabilities (aggregator)", () => { vi.doUnmock("../runtime/capabilities/codex.js"); vi.doUnmock("../runtime/capabilities/cursor.js"); vi.doUnmock("../runtime/capabilities/kimi-code.js"); + vi.doUnmock("../runtime/capabilities/opencode.js"); vi.resetModules(); }); @@ -778,6 +783,9 @@ describe("probeCapabilities (aggregator)", () => { vi.doMock("../runtime/capabilities/kimi-code.js", () => ({ probeKimiCodeCapability: vi.fn().mockRejectedValue("kimi probe failed"), })); + vi.doMock("../runtime/capabilities/opencode.js", () => ({ + probeOpenCodeCapability: vi.fn().mockRejectedValue("opencode probe failed"), + })); const mod = await import("../runtime/capabilities/index.js"); const caps = await mod.probeCapabilities(); @@ -790,6 +798,7 @@ describe("probeCapabilities (aggregator)", () => { expect(caps.codex).toMatchObject({ state: "error", error: "codex probe failed" }); expect(caps.cursor).toMatchObject({ state: "error", error: "cursor probe failed" }); expect(caps["kimi-code"]).toMatchObject({ state: "error", error: "kimi probe failed" }); + expect(caps.opencode).toMatchObject({ state: "error", error: "opencode probe failed" }); // Disabled provider is never probed, so no entry (not even an error one). expect(caps["claude-code-tui"]).toBeUndefined(); @@ -798,6 +807,7 @@ describe("probeCapabilities (aggregator)", () => { vi.doUnmock("../runtime/capabilities/claude-code-tui.js"); vi.doUnmock("../runtime/capabilities/cursor.js"); vi.doUnmock("../runtime/capabilities/kimi-code.js"); + vi.doUnmock("../runtime/capabilities/opencode.js"); vi.resetModules(); }); }); diff --git a/packages/client/src/__tests__/capability-reprobe.test.ts b/packages/client/src/__tests__/capability-reprobe.test.ts index 875cfffb5..7ec83d1bb 100644 --- a/packages/client/src/__tests__/capability-reprobe.test.ts +++ b/packages/client/src/__tests__/capability-reprobe.test.ts @@ -80,6 +80,7 @@ describe("hasNonOkProvider", () => { codex: okEntry(), cursor: okEntry(), "kimi-code": okEntry(), + opencode: okEntry(), }), ).toBe(false); }); diff --git a/packages/client/src/__tests__/managed-skills.test.ts b/packages/client/src/__tests__/managed-skills.test.ts index 5e1d1a8b5..29d9d032c 100644 --- a/packages/client/src/__tests__/managed-skills.test.ts +++ b/packages/client/src/__tests__/managed-skills.test.ts @@ -29,7 +29,14 @@ import { readManagedStateResult, } from "../runtime/managed-state.js"; -const PROVIDERS: readonly RuntimeProvider[] = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]; +const PROVIDERS: readonly RuntimeProvider[] = [ + "claude-code", + "claude-code-tui", + "codex", + "cursor", + "kimi-code", + "opencode", +]; function teamSkill(overrides: Partial = {}): RuntimeResourceSkill { return { @@ -97,6 +104,7 @@ describe("managed Skill reconciler", () => { ["codex", ".agents/skills"], ["cursor", ".cursor/skills"], ["kimi-code", ".kimi-code/skills"], + ["opencode", ".opencode/skills"], ]); }); diff --git a/packages/client/src/__tests__/opencode-binary.test.ts b/packages/client/src/__tests__/opencode-binary.test.ts new file mode 100644 index 000000000..55443ff29 --- /dev/null +++ b/packages/client/src/__tests__/opencode-binary.test.ts @@ -0,0 +1,69 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + findOpenCodeExecutableOnPath, + formatOpenCodeBinaryMissingMessage, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "../runtime/opencode-binary.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OpenCode binary resolution", () => { + it("finds the operator-installed binary without launching it", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-bin-")); + roots.push(root); + const binary = join(root, "opencode"); + writeFileSync(binary, "#!/bin/sh\nexit 0\n"); + chmodSync(binary, 0o755); + + expect( + findOpenCodeExecutableOnPath( + { PATH: root }, + { platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] }, + ), + ).toBe(binary); + }); + + it("resolves without launching; the handler performs its gate through the process supervisor", () => { + const result = resolveOpenCodeRuntimeBinary({}, { findOnPath: () => "/opt/bin/opencode" }); + expect(result).toEqual({ ok: true, binary: "/opt/bin/opencode" }); + }); + + it("resolves npm's native Windows executable instead of the opencode.cmd shim", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-bin-")); + roots.push(root); + const native = join(root, "node_modules", "opencode-ai", "bin", "opencode.exe"); + mkdirSync(join(root, "node_modules", "opencode-ai", "bin"), { recursive: true }); + writeFileSync(join(root, "opencode.cmd"), "@echo off\r\n"); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { PATH: root }, + { + platform: "win32", + pathDelimiter: ";", + wellKnownDirs: () => [], + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + + it("surfaces external install and provider-owned auth instructions", () => { + expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("npm install -g opencode-ai@1.18.7"); + expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("opencode auth login"); + }); + + it("parses the exact version gate output without executing a binary", () => { + expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7"); + expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull(); + }); +}); diff --git a/packages/client/src/__tests__/opencode-capability.test.ts b/packages/client/src/__tests__/opencode-capability.test.ts new file mode 100644 index 000000000..996c3b9e2 --- /dev/null +++ b/packages/client/src/__tests__/opencode-capability.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeOpenCodeCapability } from "../runtime/capabilities/opencode.js"; + +describe("OpenCode install-only capability", () => { + it("reports the exact path without launching or inspecting auth", async () => { + const findOnPath = vi.fn(() => "/usr/local/bin/opencode"); + await expect(probeOpenCodeCapability({ findOnPath, env: { PATH: "/usr/local/bin" } })).resolves.toMatchObject({ + state: "ok", + available: true, + runtimeSource: "path", + runtimePath: "/usr/local/bin/opencode", + }); + expect(findOnPath).toHaveBeenCalledTimes(1); + }); + + it("reports a missing external runtime with actionable setup copy", async () => { + const result = await probeOpenCodeCapability({ findOnPath: () => null, env: {} }); + expect(result).toMatchObject({ state: "missing", available: false }); + expect(result.error).toContain("npm install -g opencode-ai@1.18.7"); + expect(result.error).toContain("opencode auth login"); + }); +}); diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts new file mode 100644 index 000000000..d2ad9b26e --- /dev/null +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -0,0 +1,283 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentRuntimeConfig } from "@first-tree/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildOpenCodeConfigContent, + buildOpenCodeTurnArgs, + clearOpenCodeDbGateCacheForTests, + createOpenCodeHandler, + mapOpenCodeMcpServers, +} from "../handlers/opencode/index.js"; +import type { AgentConfigCache } from "../runtime/agent-config-cache.js"; +import type { DeliveryToken, SessionContext, SessionMessage } from "../runtime/handler.js"; +import type { ProviderProcessSpec, ProviderProcessSupervisor } from "../runtime/provider-process-supervisor.js"; + +const roots: string[] = []; + +afterEach(() => { + clearOpenCodeDbGateCacheForTests(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function runtimeConfig(): AgentRuntimeConfig { + return { + agentId: "agent-1", + version: 1, + payload: { + kind: "opencode", + prompt: { append: "managed prompt" }, + model: "openai/gpt-test", + mcpServers: [{ name: "repo", transport: "stdio", command: "mcp-bin", args: ["--stdio"] }], + env: [{ key: "PROVIDER_ENV", value: "local", sensitive: true }], + gitRepos: [], + resourceSkills: [], + }, + updatedAt: new Date(0).toISOString(), + updatedBy: "test", + }; +} + +function cache(config: AgentRuntimeConfig): AgentConfigCache { + return { + get: () => config, + refresh: async () => config, + refreshIfNewer: async () => config, + updateSdk: () => {}, + updateUrls: () => {}, + allReferencedUrls: () => new Set(), + forget: () => {}, + }; +} + +function message(id: string, content: string): SessionMessage { + return { + inboxEntryId: 1, + id, + chatId: "chat-1", + senderId: "human-1", + format: "text", + content, + metadata: null, + }; +} + +function deliveryToken() { + return { + processingStarted: vi.fn(), + complete: vi.fn(async () => {}), + retry: vi.fn(), + terminalRejected: vi.fn(async () => {}), + } satisfies DeliveryToken; +} + +function createSyntheticSupervisor( + specs: ProviderProcessSpec[], + options: { version?: string; turnDelayMs?: number } = {}, +): ProviderProcessSupervisor { + return { + spawn(spec) { + specs.push(spec); + const isDb = spec.args[0] === "db"; + const isVersion = spec.args[0] === "--version"; + const resumed = spec.args.includes("--session") ? spec.args[spec.args.indexOf("--session") + 1] : "ses_new"; + const script = isVersion + ? `process.stdout.write(${JSON.stringify(`${options.version ?? "1.18.7"}\n`)})` + : isDb + ? "process.stdout.write('[{\"ready\":1}]\\n')" + : ` +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => input += chunk); +process.stdin.on("end", () => { + setTimeout(() => { + const sid = ${JSON.stringify(resumed)}; + process.stdout.write(JSON.stringify({type:"step_start",sessionID:sid,part:{sessionID:sid}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"text",sessionID:sid,part:{text:input.trim()}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"step_finish",sessionID:sid,part:{reason:"stop",tokens:{input:3,output:2}}}) + "\\n"); + }, ${JSON.stringify(options.turnDelayMs ?? 0)}); +}); +`; + const child = spawn(process.execPath, ["-e", script], { + ...spec.options, + detached: false, + }); + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + return { child, exited }; + }, + }; +} + +function context(events: unknown[], forwarded: string[]): SessionContext { + return { + agent: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk: { + serverUrl: "https://example.test", + getChatDetail: async () => ({ + id: "chat-1", + title: "OpenCode test", + topic: "OpenCode", + description: null, + }), + listChatParticipants: async () => [ + { + agentId: "human-1", + name: "human", + displayName: "Human", + type: "human", + role: "member", + mode: "default", + accessMode: "speaker", + }, + ], + } as unknown as SessionContext["sdk"], + log: vi.fn(), + chatId: "chat-1", + recordProviderActivity: vi.fn(), + emitEvent: (event) => events.push(event), + forwardResult: async (text) => { + forwarded.push(text); + }, + markMessagesConsumed: vi.fn(), + finishTurn: vi.fn(async () => {}), + retryTurn: vi.fn(), + buildAgentEnv: (env) => ({ + ...env, + FIRST_TREE_AGENT_ID: "agent-1", + FIRST_TREE_CHAT_ID: "chat-1", + FIRST_TREE_PROVIDER: "opencode", + FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", + }), + formatInboundContent: async (entry) => `[From: human]\n${String(entry.content)}`, + resolveSenderLabel: async () => "human", + formatFromHeader: async () => "[From: human]", + }; +} + +describe("OpenCode V1 handler", () => { + it("builds private MCP/agent config and provider-native argv", () => { + const config = runtimeConfig().payload; + expect(mapOpenCodeMcpServers(config)).toEqual({ + repo: { type: "local", command: ["mcp-bin", "--stdio"], enabled: true }, + }); + const projected = JSON.parse(buildOpenCodeConfigContent({ payload: config, standingPrompt: "standing" })); + expect(projected.agent["first-tree"]).toMatchObject({ + mode: "primary", + prompt: "standing", + model: "openai/gpt-test", + }); + expect(buildOpenCodeTurnArgs({ cwd: "/work", model: "openai/gpt-test", resumeSessionId: "ses_1" })).toEqual( + expect.arrayContaining([ + "run", + "--format", + "json", + "--auto", + "--agent", + "first-tree", + "--model", + "openai/gpt-test", + "--session", + "ses_1", + ]), + ); + }); + + it("serializes DB readiness, sends prompt only on stdin, and resumes the confirmed session", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-handler-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const events: unknown[] = []; + const forwarded: string[] = []; + const sessionCtx = context(events, forwarded); + const cfg = runtimeConfig(); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(cfg), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + + const firstToken = deliveryToken(); + const started = await handler.start(message("m1", "first prompt"), sessionCtx, firstToken); + expect(started).toMatchObject({ sessionId: "ses_new", route: { kind: "owned", mode: "processing" } }); + expect(specs.map((spec) => spec.args[0])).toEqual(["--version", "db", "run"]); + const firstRun = specs[2]; + expect(firstRun?.args).not.toContain("first prompt"); + expect(firstRun?.options.env).toMatchObject({ + FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", + PROVIDER_ENV: "local", + }); + expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).toContain('"first-tree"'); + expect(forwarded).toContain("[From: human]\nfirst prompt"); + expect(firstToken.complete).toHaveBeenCalledWith([expect.objectContaining({ id: "m1" })], { + status: "success", + }); + + await handler.suspend(); + const secondToken = deliveryToken(); + await handler.resume(message("m2", "second prompt"), "ses_new", sessionCtx, secondToken); + const secondRun = specs.at(-1); + expect(secondRun?.args).toEqual(expect.arrayContaining(["--session", "ses_new"])); + expect(specs.filter((spec) => spec.args[0] === "db")).toHaveLength(1); + expect(events).toContainEqual(expect.objectContaining({ kind: "token_usage" })); + expect(events).toContainEqual({ kind: "turn_end", payload: { status: "success" } }); + await handler.shutdown(); + }); + + it("queues active injects instead of steering the current process", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-queue-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { turnDelayMs: 100 }), + }); + const sessionCtx = context([], []); + const startPromise = handler.start(message("m1", "first"), sessionCtx, deliveryToken()); + await vi.waitFor(() => { + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + }); + const receipt = handler.inject(message("m2", "queued"), deliveryToken()); + expect(receipt).toEqual({ kind: "owned", mode: "queued" }); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + await startPromise; + await vi.waitFor(() => { + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(2); + }); + await handler.shutdown(); + }); + + it("fails closed through the supervisor before DB or turn launch on a version mismatch", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-version-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "1.18.8" }), + }); + + await expect(handler.start(message("m1", "never submitted"), context([], []), deliveryToken())).rejects.toThrow( + /requires opencode-ai@1\.18\.7.*observed 1\.18\.8/i, + ); + expect(specs.map((spec) => spec.args)).toEqual([["--version"]]); + await handler.shutdown(); + }); +}); diff --git a/packages/client/src/__tests__/opencode-parser.test.ts b/packages/client/src/__tests__/opencode-parser.test.ts new file mode 100644 index 000000000..bd4b74ad7 --- /dev/null +++ b/packages/client/src/__tests__/opencode-parser.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { OpenCodeStreamParser, parseOpenCodeStreamLine } from "../handlers/opencode/parser.js"; + +describe("OpenCode JSONL parser", () => { + it("normalizes session, text, tool, usage, and terminal events", () => { + const lines = [ + JSON.stringify({ type: "step_start", sessionID: "ses_1", part: { sessionID: "ses_1" } }), + JSON.stringify({ type: "text", sessionID: "ses_1", part: { text: "hello" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_1", + part: { id: "tool_1", tool: "bash", state: { status: "completed", input: { command: "pwd" }, output: "/w" } }, + }), + JSON.stringify({ + type: "step_finish", + sessionID: "ses_1", + part: { reason: "stop", tokens: { input: 10, cache: { read: 3 }, output: 4 } }, + }), + ]; + const parser = new OpenCodeStreamParser(); + const events = parser.push(`${lines.join("\n")}\n`); + expect(events).toContainEqual({ kind: "text", text: "hello" }); + expect(events).toContainEqual({ + kind: "tool", + toolUseId: "tool_1", + name: "bash", + status: "ok", + args: { command: "pwd" }, + resultPreview: "/w", + }); + expect(events).toContainEqual({ + kind: "usage", + usage: { inputTokens: 10, cachedInputTokens: 3, outputTokens: 4 }, + }); + expect(events).toContainEqual({ kind: "terminal", reason: "stop" }); + }); + + it("does not treat tool-calls as terminal", () => { + expect( + parseOpenCodeStreamLine(JSON.stringify({ type: "step_finish", part: { reason: "tool-calls" } })), + ).not.toContainEqual(expect.objectContaining({ kind: "terminal" })); + }); + + it("tolerates malformed and unknown lines", () => { + expect(parseOpenCodeStreamLine("not-json")[0]).toMatchObject({ kind: "unknown" }); + expect(parseOpenCodeStreamLine(JSON.stringify({ type: "future" }))[0]).toMatchObject({ kind: "unknown" }); + }); +}); diff --git a/packages/client/src/__tests__/provider-process-supervisor.test.ts b/packages/client/src/__tests__/provider-process-supervisor.test.ts new file mode 100644 index 000000000..ce3a0de94 --- /dev/null +++ b/packages/client/src/__tests__/provider-process-supervisor.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + createDefaultProviderProcessSupervisor, + ProviderProcessSupervisionUnsupportedError, +} from "../runtime/provider-process-supervisor.js"; + +describe("provider process supervisor", () => { + it("fails closed on Windows until a pre-admission Job Object supervisor is supplied", () => { + const supervisor = createDefaultProviderProcessSupervisor("win32"); + + expect(() => + supervisor.spawn({ + command: "opencode.exe", + args: ["run"], + label: "test", + options: { stdio: "ignore" }, + }), + ).toThrow(ProviderProcessSupervisionUnsupportedError); + }); +}); diff --git a/packages/qa/cases/runtime/opencode-provider.md b/packages/qa/cases/runtime/opencode-provider.md new file mode 100644 index 000000000..463f3c47c --- /dev/null +++ b/packages/qa/cases/runtime/opencode-provider.md @@ -0,0 +1,90 @@ +--- +id: opencode-provider +description: Validate the external OpenCode CLI provider end to end — private config projection, per-turn JSONL, session resume, queueing, auth, and process drain. +areas: [runtime] +surfaces: [web, cli, server, client] +--- + +# OpenCode Runtime Provider + +## Goal + +Confirm that an agent bound to `opencode` runs through the exact supported external CLI, reuses provider-owned +host-local authentication without giving First Tree token custody, and preserves First Tree's delivery, session, +configuration, Context Tree I/O, and process-drain contracts. + +Use this case when the OpenCode handler, binary resolver, capability probe, private config projection, parser, model +surface, or provider supervisor changes. + +## Preconditions + +- Run in the isolated QA cell selected by the plan: Docker plus a temporary source worktree, with an explicit native + bridge only where the OS process authority cannot live inside Docker. Never modify the operator checkout. +- Install the exact OpenCode version surfaced by the product on the client host and complete provider-owned setup with + `opencode auth login`. The test may prove the login by completing a real turn, but must not read, copy, print, or + archive provider credential files. +- Use disposable source, MCP, and Context Tree fixtures. Provider tool calls must not modify the product checkout. +- Windows acceptance requires the separately owner-reviewed drain-authority decision and a product Job Object + supervisor. Until both exist, the Windows branch must fail closed before any OpenCode invocation and cannot PASS. + +## Checklist + +- Capability: the connected client reports `opencode` as `missing` or `ok` solely from the same binary resolver used by + the handler. Re-probing must not launch OpenCode, inspect its config, infer auth, or contact a model provider. +- Provider selection: Web and CLI expose OpenCode only on a client advertising the capability. The config defaults to + an empty model, accepts an exact provider-native `provider/model` string, and exposes no separate reasoning-effort + control. +- Runtime gates: the first active use launches `opencode --version` through the provider supervisor and requires the + exact supported version. It then serially runs `opencode db "SELECT 1 AS ready" --format json` before concurrent + per-turn processes may use the same client data home. +- Private projection: each turn supplies a caller-scoped `OPENCODE_CONFIG_CONTENT` containing the First Tree primary + agent, output/chat-context standing prompt, declared MCP servers, explicit model when configured, and managed + permissions. It must not rewrite the operator's global OpenCode config. Projected Skills live only under + `.opencode/skills` and retain the shared ownership, lock, journal, rollback, and fail-closed reconciliation behavior. +- Child boundary: observe the First Tree identity/drain envelope and runtime-session token-file path in the child + environment. The token contents and provider credentials must not enter argv, logs, Server data, or retained + evidence. Prompt text appears only on stdin followed by EOF. +- Real turn: observe + `opencode run --format json --auto --agent first-tree --dir ` plus `--model` only when configured and + `--session` only for a confirmed resume. Verify normalized assistant, tool, token-usage, and successful terminal + events, and a deterministic disposable file tool effect. +- Session and queue: persist the unique session ID observed in JSONL. Suspend and resume the same chat with an explicit + `--session`; reject a mismatch or missing terminal event. Inject a message during an active turn and prove it is + queued for a subsequent process rather than sent to the current stdin. +- Managed MCP: project disposable stdio and remote servers through the private config, complete a real tool call, and + confirm secret headers are absent from logs/evidence. A config change must apply on the next turn without modifying + global OpenCode state. +- Auth and failure custody: a logged-out real turn produces a durable error notice directing the operator to + `opencode auth login`; First Tree offers no in-product OAuth. Deterministic provider/config failures and failures + after assistant or unsafe tool output are consumed only after the notice. Unknown pre-effect failures remain + retryable and preserve recovery custody. +- Context Tree I/O: OpenCode `read`/`glob`/`grep` records `opencode_read_tool`; `edit`/`write`/`patch` records + `opencode_write_tool`; qualifying `bash` commands produce the provider-neutral shell evidence with repo/path + qualification. +- Process safety: on POSIX, prove the existing environment-attributed OS process-tree drain observes and clears the + OpenCode root and descendants. On Windows, prove pre-admission to a non-breakaway kill-on-close Job, root exit while + a detached child remains a Job member, `TerminateJobObject`, PID/start-time identity, and two empty scans at least + 500 ms apart. Child registry evidence is diagnostic only and must never authorize a client switch. + +## Expected Result + +`PASS` requires a real authenticated two-turn First Tree/OpenCode flow with session continuity, a deterministic tool +effect, private config/MCP/Skills evidence, correct delivery and failure custody, normalized events, Context Tree I/O, +and platform-accepted process-drain proof. + +`FAIL` includes prompt text in argv, credentials or secret headers retained by First Tree, an unadmitted runtime +process, global OpenCode config mutation, silent model fallback, synthetic or mismatched resume, missing terminal-event +validation, active-turn steering, unsafe side-effect replay, terminal failure consumed before its durable notice, or a +client switch authorized by child registry alone. + +`BLOCKED` means the exact CLI, provider login/entitlement/network, isolated platform bridge, owner-reviewed Windows +drain authority, or product Job supervisor is absent. Unit tests and the one-time protocol harness do not turn a +blocked First Tree product branch into PASS. `INCONCLUSIVE` means a live turn ran but retained evidence cannot +distinguish the claimed behavior. + +## Evidence + +Keep sanitized capability snapshots, exact binary/version and argv/cwd observations, private config shape with secrets +removed, session ID continuity, event-kind sequence and token totals, disposable file hashes, MCP call and Context Tree +rows, delivery/retry/notice transitions, and authoritative drain receipts. Never retain provider request bodies, +credential files, auth headers, runtime-session token contents, private prompts, or raw stderr before redaction. diff --git a/packages/server/src/__tests__/admin-agent-config.test.ts b/packages/server/src/__tests__/admin-agent-config.test.ts index c05bd48ff..572ce3c5f 100644 --- a/packages/server/src/__tests__/admin-agent-config.test.ts +++ b/packages/server/src/__tests__/admin-agent-config.test.ts @@ -226,6 +226,33 @@ describe("Admin agent-config API (Step 2)", () => { expect("reasoningEffort" in model.json().payload).toBe(false); }); + it("persists an exact OpenCode provider/model id and rejects reasoning effort", async () => { + const app = getApp(); + const req = await authedRequest(app); + const agent = await (await seedAgentFactory(app))({ + name: `cfg-opencode-${crypto.randomUUID().slice(0, 8)}`, + type: "agent", + runtimeProvider: "opencode", + }); + + const model = await req("PATCH", `/api/v1/agents/${agent.uuid}/config`, { + expectedVersion: 1, + payload: { model: "anthropic/claude-opus-4-6" }, + }); + expect(model.statusCode).toBe(200); + expect(model.json().payload).toMatchObject({ + kind: "opencode", + model: "anthropic/claude-opus-4-6", + }); + + const effort = await req("PATCH", `/api/v1/agents/${agent.uuid}/config`, { + expectedVersion: 2, + payload: { reasoningEffort: "high" }, + }); + expect(effort.statusCode).toBe(400); + expect(effort.json<{ error: string }>().error).toContain("not supported"); + }); + it("persists model-dependent max and ultra values for codex agents", async () => { const app = getApp(); const req = await authedRequest(app); diff --git a/packages/server/src/__tests__/context-tree-io.test.ts b/packages/server/src/__tests__/context-tree-io.test.ts index 0fe41c3ea..eb7c86eee 100644 --- a/packages/server/src/__tests__/context-tree-io.test.ts +++ b/packages/server/src/__tests__/context-tree-io.test.ts @@ -503,6 +503,54 @@ describe("context-tree IO service", () => { ).toEqual({ recordable: false, reason: "unsupported_tool" }); }); + it("derives OpenCode lower-case read/write and bash IO only for opencode", () => { + const read = { + kind: "tool_call", + payload: { + toolUseId: "oc-read", + name: "read", + args: { path: "NODE.md" }, + status: "ok", + toolFileRefs: [ + { + origin: "tool_arg", + repoUrl: TREE_REPO, + repoBranch: "main", + repoRelativePath: "NODE.md", + pathKind: "file", + }, + ], + }, + }; + const write = { + kind: "tool_call", + payload: { + toolUseId: "oc-write", + name: "patch", + args: { path: "system/NODE.md" }, + status: "ok", + toolFileRefs: [ + { + origin: "file_change", + repoUrl: TREE_REPO, + repoBranch: "main", + repoRelativePath: "system/NODE.md", + pathKind: "file", + }, + ], + }, + }; + expect( + explainContextTreeIoDecision({ runtimeProvider: "opencode", sessionEvent: read, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: true }); + expect( + explainContextTreeIoDecision({ runtimeProvider: "opencode", sessionEvent: write, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: true }); + expect( + explainContextTreeIoDecision({ runtimeProvider: "cursor", sessionEvent: write, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: false, reason: "unsupported_tool" }); + }); + it("end-to-end regression: a real-shaped completed cursor shell event lands as a repo-qualified read", async () => { // The exact event shape the cursor handler emits after client enrichment // for a tree read via shell (`cat /NODE.md`) — the path the old diff --git a/packages/shared/src/__tests__/agent-runtime-config.test.ts b/packages/shared/src/__tests__/agent-runtime-config.test.ts index 7c5845e8e..dbf99adf9 100644 --- a/packages/shared/src/__tests__/agent-runtime-config.test.ts +++ b/packages/shared/src/__tests__/agent-runtime-config.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_CODEX_RUNTIME_CONFIG_PAYLOAD, DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD, DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD, + DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD, defaultRuntimeConfigPayload, deriveRepoLocalPath, deriveRepoShortLabel, @@ -214,6 +215,24 @@ describe("agent runtime config — kimi-code variant", () => { }); }); +describe("agent runtime config — opencode variant", () => { + it("defaults to host-local model selection with no reasoning-effort field", () => { + expect(DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD).toMatchObject({ kind: "opencode", model: "" }); + expect("reasoningEffort" in DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD).toBe(false); + expect(defaultRuntimeConfigPayload("opencode")).toMatchObject({ kind: "opencode", model: "" }); + }); + + it("passes a provider-native provider/model id through unchanged", () => { + const parsed = agentRuntimeConfigPayloadSchema.parse({ + kind: "opencode", + model: "openai/gpt-5.5", + reasoningEffort: "high", + }); + expect(parsed.model).toBe("openai/gpt-5.5"); + expect("reasoningEffort" in parsed).toBe(false); + }); +}); + describe("agent runtime config — reasoning effort", () => { it("claude default is '' (inherit local effortLevel); codex default is 'high'", () => { expect(effortOf(DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD)).toBe(""); diff --git a/packages/shared/src/__tests__/runtime-provider-schemas.test.ts b/packages/shared/src/__tests__/runtime-provider-schemas.test.ts index 4237e1f98..81fca5969 100644 --- a/packages/shared/src/__tests__/runtime-provider-schemas.test.ts +++ b/packages/shared/src/__tests__/runtime-provider-schemas.test.ts @@ -26,6 +26,7 @@ describe("runtimeProviderSchema", () => { expect(runtimeProviderSchema.parse("codex")).toBe("codex"); expect(runtimeProviderSchema.parse("cursor")).toBe("cursor"); expect(runtimeProviderSchema.parse("kimi-code")).toBe("kimi-code"); + expect(runtimeProviderSchema.parse("opencode")).toBe("opencode"); }); it("rejects unknown providers", () => { @@ -39,6 +40,7 @@ describe("runtimeProviderSchema", () => { expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.CODEX)).toBe("codex"); expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.CURSOR)).toBe("cursor"); expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.KIMI_CODE)).toBe("kimi-code"); + expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.OPENCODE)).toBe("opencode"); }); it("DEFAULT_RUNTIME_PROVIDER is claude-code (existing rows pre-0026 have no kind)", () => { @@ -50,6 +52,7 @@ describe("runtimeProviderSchema", () => { expect(isRuntimeProviderEnabled("codex")).toBe(true); expect(isRuntimeProviderEnabled("cursor")).toBe(true); expect(isRuntimeProviderEnabled("kimi-code")).toBe(true); + expect(isRuntimeProviderEnabled("opencode")).toBe(true); expect(isRuntimeProviderEnabled("claude-code-tui")).toBe(false); expect(isRuntimeProviderEnabled("future-provider")).toBe(true); }); diff --git a/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts new file mode 100644 index 000000000..d195d6e29 --- /dev/null +++ b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + buildInstallCommand, + PROVIDER_LABEL, + providerInstallHint, + runtimeProviderLabel, +} from "../cards/shared/providers.js"; +import { providerSupportsInProductAuth } from "../cards/shared/runtime-auth-view.js"; + +describe("OpenCode provider surfaces", () => { + it("labels OpenCode and keeps auth host-local", () => { + expect(PROVIDER_LABEL.opencode).toBe("OpenCode"); + expect(runtimeProviderLabel("opencode")).toBe("OpenCode"); + expect(providerSupportsInProductAuth("opencode")).toBe(false); + }); + + it("shows the pinned package family and provider-owned login command", () => { + expect(buildInstallCommand("opencode")).toBe("npm install -g opencode-ai@1.18.7\nopencode auth login"); + expect(providerInstallHint("opencode", "win32")).toContain("opencode auth login"); + expect(providerInstallHint("opencode", "win32")).toContain("Windows PC"); + }); +}); From db3dee193bb0c91a267722e068bc974532f6ddb6 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 21:38:27 +0800 Subject: [PATCH 03/15] fix: harden OpenCode version parsing --- .../src/__tests__/opencode-binary.test.ts | 1 + .../client/src/runtime/opencode-binary.ts | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/client/src/__tests__/opencode-binary.test.ts b/packages/client/src/__tests__/opencode-binary.test.ts index 55443ff29..8b39efdcb 100644 --- a/packages/client/src/__tests__/opencode-binary.test.ts +++ b/packages/client/src/__tests__/opencode-binary.test.ts @@ -65,5 +65,6 @@ describe("OpenCode binary resolution", () => { it("parses the exact version gate output without executing a binary", () => { expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7"); expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull(); + expect(parseOpenCodeVersionOutput(`${"0".repeat(100_000)}.x opencode 1.18.7`)).toBe("1.18.7"); }); }); diff --git a/packages/client/src/runtime/opencode-binary.ts b/packages/client/src/runtime/opencode-binary.ts index 7f1fa71a4..ec5a149ac 100644 --- a/packages/client/src/runtime/opencode-binary.ts +++ b/packages/client/src/runtime/opencode-binary.ts @@ -108,7 +108,34 @@ function openCodeExecutableCandidates(base: string, platform: NodeJS.Platform): } export function parseOpenCodeVersionOutput(output: string): string | null { - return output.match(/\d+\.\d+(?:\.\d+)?/)?.[0] ?? null; + for (let start = 0; start < output.length; start++) { + if (!isAsciiDigit(output.charCodeAt(start))) continue; + let end = start; + while (end < output.length) { + const code = output.charCodeAt(end); + if (!isAsciiDigit(code) && code !== 46) break; + end++; + } + const candidate = output.slice(start, end); + const parts = candidate.split("."); + if ((parts.length === 2 || parts.length === 3) && parts.every(isBoundedNumericVersionPart)) { + return candidate; + } + start = end - 1; + } + return null; +} + +function isAsciiDigit(code: number): boolean { + return code >= 48 && code <= 57; +} + +function isBoundedNumericVersionPart(part: string): boolean { + if (part.length < 1 || part.length > 6) return false; + for (let index = 0; index < part.length; index++) { + if (!isAsciiDigit(part.charCodeAt(index))) return false; + } + return true; } function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { From df55229594e9f1415658891ad123ff5a1359be3f Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:00:52 +0800 Subject: [PATCH 04/15] fix: enforce OpenCode runtime contracts --- .../client/src/handlers/opencode/index.ts | 351 ++++++++++++++---- .../client/src/handlers/opencode/parser.ts | 40 +- packages/client/src/index.ts | 4 +- .../client/src/runtime/opencode-binary.ts | 70 +++- .../pages/clients/cards/shared/providers.ts | 4 +- 5 files changed, 361 insertions(+), 108 deletions(-) diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index 8634891a3..7332364cc 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -1,8 +1,11 @@ import { randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { type AgentRuntimeConfig, type AgentRuntimeConfigPayload, + encodeProviderRetryEventMessage, isLandingCampaignTrialAgentMetadata, runtimeProviderSchema, type ToolFileRef, @@ -19,14 +22,17 @@ import type { HandlerFactory, SessionContext, SessionMessage, + TurnConsumedErrorReason, } from "../../runtime/handler.js"; import { deliveryTokenFromSessionContext } from "../../runtime/handler.js"; import { type ReconciledTeamSkill, reconcileManagedSkillsForConfig } from "../../runtime/managed-skills.js"; import { - OPENCODE_SUPPORTED_VERSION, + isSupportedOpenCodeVersion, + OPENCODE_SUPPORTED_VERSION_RANGE, parseOpenCodeVersionOutput, resolveOpenCodeRuntimeBinary, } from "../../runtime/opencode-binary.js"; +import { ProviderAttempt, type ProviderAttemptSettlement } from "../../runtime/provider-attempt.js"; import { createDefaultProviderProcessSupervisor, type ProviderProcessSupervisor, @@ -42,9 +48,9 @@ import { currentSourceRepoNamesFromPayload, declaredSourceRepos } from "../../ru import { acquireAgentHome, markWorkspaceInitComplete } from "../../runtime/workspace.js"; import { chunkAssistantText } from "../assistant-text.js"; import { formatAuthHint, isOpenCodeAuthError } from "../auth-error-hint.js"; +import { consumedErrorOutcome } from "../turn-settlement.js"; import { type OpenCodeStreamEvent, OpenCodeStreamParser, type OpenCodeUsage } from "./parser.js"; -export const OPENCODE_MANAGED_AGENT = "first-tree"; export const OPENCODE_PENDING_SESSION_PREFIX = "opencode-pending-"; const STDERR_TAIL_LIMIT = 8_000; @@ -52,6 +58,8 @@ const DEFAULT_TURN_TIMEOUT_MS = 20 * 60_000; const KILL_GRACE_MS = 5_000; const FINAL_CLOSE_WAIT_MS = 2_000; const DB_GATE_TIMEOUT_MS = 30_000; +const CONFIG_CONTENT_ENV_MAX_BYTES = 16 * 1024; +const WINDOWS_ENV_BLOCK_MAX_CHARS = 30_000; export function isOpenCodePendingSessionId(sessionId: string): boolean { return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); @@ -61,17 +69,25 @@ type OpenCodeMcpConfig = | { type: "local"; command: string[]; enabled: true } | { type: "remote"; url: string; headers?: Record; enabled: true }; -export function mapOpenCodeMcpServers(payload: AgentRuntimeConfigPayload): Record { +export type OpenCodeMcpProjection = { + servers: Record; + aliases: Array<{ configuredName: string; managedName: string }>; +}; + +export function mapOpenCodeMcpServers(payload: AgentRuntimeConfigPayload, scope: string): OpenCodeMcpProjection { const out: Record = {}; - for (const server of payload.mcpServers) { + const aliases: OpenCodeMcpProjection["aliases"] = []; + for (const [index, server] of payload.mcpServers.entries()) { + const managedName = `first-tree-${scope}-mcp-${index + 1}`; + aliases.push({ configuredName: server.name, managedName }); if (server.transport === "stdio") { - out[server.name] = { + out[managedName] = { type: "local", command: [server.command, ...(server.args ?? [])], enabled: true, }; } else { - out[server.name] = { + out[managedName] = { type: "remote", url: server.url, ...(server.headers ? { headers: server.headers } : {}), @@ -79,23 +95,34 @@ export function mapOpenCodeMcpServers(payload: AgentRuntimeConfigPayload): Recor }; } } - return out; + return { servers: out, aliases }; } export function buildOpenCodeConfigContent(input: { payload: AgentRuntimeConfigPayload; - standingPrompt: string; + managedAgentName: string; + scope: string; }): string { + const mcp = mapOpenCodeMcpServers(input.payload, input.scope); + const aliasNotice = + mcp.aliases.length === 0 + ? "" + : `\nManaged MCP aliases:\n${mcp.aliases + .map(({ configuredName, managedName }) => `- ${configuredName}: ${managedName}`) + .join("\n")}`; return JSON.stringify({ $schema: "https://opencode.ai/config.json", autoupdate: false, share: "disabled", snapshot: false, agent: { - [OPENCODE_MANAGED_AGENT]: { + [input.managedAgentName]: { description: "First Tree managed agent", mode: "primary", - prompt: input.standingPrompt, + prompt: + "You are running as a First Tree managed OpenCode agent. Read and follow the workspace AGENTS.md. " + + "Use the First Tree runtime CLI for teammate communication when instructed." + + aliasNotice, ...(input.payload.model ? { model: input.payload.model } : {}), permission: { edit: "allow", @@ -113,18 +140,23 @@ export function buildOpenCodeConfigContent(input: { websearch: "allow", task: "allow", }, - mcp: mapOpenCodeMcpServers(input.payload), + mcp: mcp.servers, }); } -export function buildOpenCodeTurnArgs(input: { cwd: string; model: string; resumeSessionId: string | null }): string[] { +export function buildOpenCodeTurnArgs(input: { + cwd: string; + model: string; + resumeSessionId: string | null; + managedAgentName: string; +}): string[] { const args = [ "run", "--format", "json", "--auto", "--agent", - OPENCODE_MANAGED_AGENT, + input.managedAgentName, "--title", "First Tree managed turn", "--dir", @@ -138,6 +170,72 @@ export function buildOpenCodeTurnArgs(input: { cwd: string; model: string; resum return args; } +export type OpenCodeConfigProjection = { + env: Record; + cleanup: () => void; + transport: "env" | "file"; +}; + +export function projectOpenCodeConfig( + env: Record, + configContent: string, + deps: { + maxEnvBytes?: number; + maxWindowsEnvChars?: number; + makeTempDir?: () => string; + platform?: NodeJS.Platform; + } = {}, +): OpenCodeConfigProjection { + const maxEnvBytes = deps.maxEnvBytes ?? CONFIG_CONTENT_ENV_MAX_BYTES; + const platform = deps.platform ?? process.platform; + const maxWindowsEnvChars = deps.maxWindowsEnvChars ?? WINDOWS_ENV_BLOCK_MAX_CHARS; + const privateEnv = { ...env }; + delete privateEnv.OPENCODE_CONFIG; + delete privateEnv.OPENCODE_CONFIG_CONTENT; + const contentEnv = { ...privateEnv, OPENCODE_CONFIG_CONTENT: configContent }; + if ( + Buffer.byteLength(configContent, "utf8") <= maxEnvBytes && + (platform !== "win32" || windowsEnvBlockChars(contentEnv) <= maxWindowsEnvChars) + ) { + return { + env: contentEnv, + cleanup: () => {}, + transport: "env", + }; + } + + const configDir = deps.makeTempDir?.() ?? mkdtempSync(join(tmpdir(), "first-tree-opencode-config-")); + const configPath = join(configDir, "opencode.json"); + try { + writeFileSync(configPath, configContent, { encoding: "utf8", mode: 0o600, flag: "wx" }); + chmodSync(configDir, 0o700); + chmodSync(configPath, 0o600); + } catch (error) { + rmSync(configDir, { recursive: true, force: true }); + throw error; + } + const fileEnv = { + ...privateEnv, + OPENCODE_CONFIG: configPath, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ autoupdate: false, share: "disabled", snapshot: false }), + }; + if (platform === "win32" && windowsEnvBlockChars(fileEnv) > maxWindowsEnvChars) { + rmSync(configDir, { recursive: true, force: true }); + throw new Error("OpenCode runtime provider mismatch: child environment exceeds the safe Windows block limit"); + } + return { + env: fileEnv, + cleanup: () => rmSync(configDir, { recursive: true, force: true }), + transport: "file", + }; +} + +function windowsEnvBlockChars(env: Readonly>): number { + let total = 1; + for (const [key, value] of Object.entries(env)) total += key.length + 1 + value.length + 1; + return total; +} + type ProcessOutcome = { exitCode: number | null; signal: NodeJS.Signals | null; @@ -155,7 +253,7 @@ type TurnState = { usage: OpenCodeUsage | null; sawProviderActivity: boolean; sawUnsafeTool: boolean; - unknownCount: number; + protocolDiagnostics: string[]; }; const dbGatePromises = new Map>(); @@ -180,6 +278,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { typeof config.opencodeTurnTimeoutMs === "number" && config.opencodeTurnTimeoutMs > 0 ? config.opencodeTurnTimeoutMs : DEFAULT_TURN_TIMEOUT_MS; + const projectionScope = randomUUID().replaceAll("-", "").slice(0, 12); + const managedAgentName = `first-tree-${projectionScope}`; let cwd: string | null = null; let ctx: SessionContext | null = null; @@ -196,13 +296,11 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { let generation = 0; let drainScheduled = false; let drainInProgress = false; + let pendingChatContextPrompt: string | null = null; + let providerTurnFailureAttempt = 0; const queue: Array<{ message: SessionMessage; token: DeliveryToken }> = []; - function buildEnv( - sessionCtx: SessionContext, - payload: AgentRuntimeConfigPayload, - standingPrompt: string, - ): Record { + function buildEnv(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload): Record { const base: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === "string") base[key] = value; @@ -213,7 +311,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { for (const [key, value] of Object.entries(merged)) { if (typeof value === "string") env[key] = value; } - env.OPENCODE_CONFIG_CONTENT = buildOpenCodeConfigContent({ payload, standingPrompt }); + delete env.OPENCODE_CONFIG; + delete env.OPENCODE_CONFIG_CONTENT; return env; } @@ -242,7 +341,6 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { async function refreshProjection(sessionCtx: SessionContext): Promise<{ payload: AgentRuntimeConfigPayload; briefing: string; - standingPrompt: string; }> { if (!cwd) throw new Error("OpenCode workspace is not prepared"); let runtimeConfig = activeConfig; @@ -274,11 +372,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { currentSourceRepoNames: currentSourceRepoNamesFromPayload(payload, runtimeConfig !== null), }); markWorkspaceInitComplete(cwd); - const chatContext = await fetchChatContextOrLog(sessionCtx); - const chatPrompt = renderChatContextPrompt(chatContext); - const standingPrompt = [renderRuntimeOutputContract(), chatPrompt].filter(Boolean).join("\n\n"); activeConfig = runtimeConfig; - return { payload, briefing, standingPrompt }; + return { payload, briefing }; } function runProcess(input: { @@ -423,7 +518,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { state.text.push(event.text); break; case "tool": - if (event.status === "pending" && !isReadOnlyTool(event.name)) state.sawUnsafeTool = true; + if (!isReadOnlyTool(event.name)) state.sawUnsafeTool = true; { const toolFileRefs = event.status === "pending" ? undefined : fileRefsForTool(event.name, event.args); sessionCtx.emitEvent({ @@ -448,11 +543,13 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { case "error": state.errors.push(event.message); break; + case "reasoning": + break; case "unknown": - if (state.unknownCount < 5) { - sessionCtx.log(`OpenCode tolerant-parse diagnostic: ${event.note}: ${event.raw}`); + if (state.protocolDiagnostics.length < 5) { + sessionCtx.log(`OpenCode protocol diagnostic: ${event.note}`); } - state.unknownCount++; + state.protocolDiagnostics.push(event.note); break; } } @@ -559,16 +656,16 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { abortSignal: input.abortSignal, timeoutMs: DB_GATE_TIMEOUT_MS, turnGeneration: input.turnGeneration, - label: "opencode exact-version gate", + label: "opencode compatible-version gate", }); const version = parseOpenCodeVersionOutput(`${outcome.stdoutTail}\n${outcome.stderrTail}`); - if (outcome.spawnError || outcome.exitCode !== 0 || version !== OPENCODE_SUPPORTED_VERSION) { + if (outcome.spawnError || outcome.exitCode !== 0 || !isSupportedOpenCodeVersion(version)) { const detail = redactErrorPreview( outcome.spawnError?.message || outcome.stderrTail || outcome.stdoutTail || `exit ${outcome.exitCode}`, 800, ); throw new Error( - `Unsupported OpenCode runtime. First Tree requires opencode-ai@${OPENCODE_SUPPORTED_VERSION}; ` + + `OpenCode runtime provider mismatch: unsupported version. First Tree requires ${OPENCODE_SUPPORTED_VERSION_RANGE}; ` + `observed ${version ?? "no parseable version"}. ${detail}`, ); } @@ -589,6 +686,78 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { } } + function emitProviderTurnSettlementEvent(sessionCtx: SessionContext, settlement: ProviderAttemptSettlement): void { + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: encodeProviderRetryEventMessage(settlement.eventPayload), + }, + }); + } + + function consumedReasonForProviderSettlement(settlement: ProviderAttemptSettlement): TurnConsumedErrorReason { + return settlement.decision.action === "stop" && settlement.decision.terminalKind === "capacity_wait_required" + ? "capacity_wait_required" + : settlement.decision.action === "stop" && settlement.decision.terminalKind === "exhausted" + ? "provider_retry_exhausted" + : settlement.decision.reasonCode; + } + + async function settleFailure(input: { + failure: string; + spawnError?: Error; + state: Pick; + sessionCtx: SessionContext; + messages: readonly SessionMessage[]; + token: DeliveryToken; + }): Promise { + const replaySafety = input.state.sawUnsafeTool + ? "unsafe" + : input.state.text.length > 0 + ? "user_visible" + : input.state.sawProviderActivity + ? "pre_visible" + : "pre_provider"; + const displayMessage = isOpenCodeAuthError(input.failure) + ? formatAuthHint("opencode", input.failure) + : input.failure; + const attempt = new ProviderAttempt({ + provider: runtimeProvider, + scope: "provider_turn", + source: input.spawnError ? "sdk" : "stream", + replaySafety, + }); + attempt.recordSignal({ + kind: input.spawnError ? "local_error" : "provider_error", + error: input.spawnError ?? input.failure, + messagePreview: displayMessage, + }); + const settlement = attempt.settle({ attempt: ++providerTurnFailureAttempt }); + if (!settlement) { + input.token.retry(input.messages, "opencode_unclassified_failure"); + return false; + } + + emitProviderTurnSettlementEvent(input.sessionCtx, settlement); + input.sessionCtx.emitEvent({ + kind: "error", + payload: { source: "sdk", message: displayMessage }, + }); + input.sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + if (settlement.decision.action === "retry") { + input.token.retry(input.messages, settlement.decision.reasonCode); + if (input.state.sawProviderActivity) { + input.sessionCtx.failSessionForRecovery?.("opencode_turn_retryable_failure", providerSessionId ?? undefined); + } + return false; + } + await input.token.complete(input.messages, consumedErrorOutcome(consumedReasonForProviderSettlement(settlement))); + providerTurnFailureAttempt = 0; + pendingChatContextPrompt = null; + return true; + } + async function runTurn( prompt: string, sessionCtx: SessionContext, @@ -605,8 +774,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { const abort = new AbortController(); currentAbort = abort; const promise = (async () => { - const { payload, standingPrompt } = await refreshProjection(sessionCtx); - const env = buildEnv(sessionCtx, payload, standingPrompt); + const { payload } = await refreshProjection(sessionCtx); + const env = buildEnv(sessionCtx, payload); await ensureSupportedVersion({ activeBinary, env, @@ -625,6 +794,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }); if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) return false; + const oneShotPrompt = pendingChatContextPrompt; + const providerPrompt = oneShotPrompt ? `${oneShotPrompt}\n\n${prompt}` : prompt; const expectedSessionId = providerSessionId; const state: TurnState = { parser: new OpenCodeStreamParser(), @@ -635,29 +806,42 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { usage: null, sawProviderActivity: false, sawUnsafeTool: false, - unknownCount: 0, + protocolDiagnostics: [], }; token.processingStarted(messages); const timeout = setTimeout(() => abort.abort(), turnTimeoutMs); timeout.unref?.(); - const outcome = await runProcess({ - command: activeBinary, - args: buildOpenCodeTurnArgs({ - cwd: workspaceCwd, - model: payload.model, - resumeSessionId: expectedSessionId, - }), - prompt: `${prompt}\n`, - env, - workspaceCwd, - state, - sessionCtx, - abortSignal: abort.signal, - timeoutMs: turnTimeoutMs, - turnGeneration, - label: `opencode turn ${sessionCtx.chatId}`, - }); - clearTimeout(timeout); + let outcome: ProcessOutcome; + try { + const configProjection = projectOpenCodeConfig( + env, + buildOpenCodeConfigContent({ payload, managedAgentName, scope: projectionScope }), + ); + try { + outcome = await runProcess({ + command: activeBinary, + args: buildOpenCodeTurnArgs({ + cwd: workspaceCwd, + model: payload.model, + resumeSessionId: expectedSessionId, + managedAgentName, + }), + prompt: `${providerPrompt}\n`, + env: configProjection.env, + workspaceCwd, + state, + sessionCtx, + abortSignal: abort.signal, + timeoutMs: turnTimeoutMs, + turnGeneration, + label: `opencode turn ${sessionCtx.chatId}`, + }); + } finally { + configProjection.cleanup(); + } + } finally { + clearTimeout(timeout); + } if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) { sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); @@ -671,8 +855,20 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { if (expectedSessionId && ids[0] !== expectedSessionId) { protocolErrors.push(`resume session mismatch: expected ${expectedSessionId}, observed ${ids[0] ?? "none"}`); } - if (state.terminalReasons.length === 0) protocolErrors.push("missing terminal step_finish event"); + if (state.terminalReasons.length !== 1) { + protocolErrors.push(`expected one terminal step_finish event, observed ${state.terminalReasons.length}`); + } if (state.errors.length > 0) protocolErrors.push(...state.errors); + if (state.protocolDiagnostics.length > 0) { + protocolErrors.push( + `unsupported or malformed OpenCode JSONL (${state.protocolDiagnostics.length} line${ + state.protocolDiagnostics.length === 1 ? "" : "s" + })`, + ); + } + if (/agent\s+["'][^"']+["']\s+not found.*falling back to default agent/i.test(outcome.stderrTail)) { + protocolErrors.push("managed agent was not selected"); + } const success = !outcome.spawnError && outcome.exitCode === 0 && protocolErrors.length === 0; if (success) { @@ -707,10 +903,14 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }); sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); await token.complete(messages, { status: "error", completion: "consumed", reason: "forward_failed" }); + providerTurnFailureAttempt = 0; + pendingChatContextPrompt = null; return true; } sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "success" } }); await token.complete(messages, { status: "success" }); + providerTurnFailureAttempt = 0; + if (pendingChatContextPrompt === oneShotPrompt) pendingChatContextPrompt = null; return true; } @@ -724,27 +924,14 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { .join("\n") .slice(0, 2000); const failure = redactErrorPreview(rawFailure, 2000); - const message = isOpenCodeAuthError(failure) ? formatAuthHint("opencode", failure) : failure; - sessionCtx.emitEvent({ kind: "error", payload: { source: "sdk", message } }); - sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); - - const deterministic = - isOpenCodeAuthError(failure) || - /invalid model|unknown model|model .* not found|permission denied|configuration/i.test(failure); - if (deterministic || state.sawUnsafeTool || state.text.length > 0) { - await token.complete(messages, { - status: "error", - completion: "consumed", - reason: deterministic ? "provider_clean_error" : "unsafe_provider_failure_notice_posted", - }); - return true; - } - token.retry( + return settleFailure({ + failure, + ...(outcome.spawnError ? { spawnError: outcome.spawnError } : {}), + state, + sessionCtx, messages, - state.sawProviderActivity ? "opencode_unknown_pre_effect_failure" : "opencode_pre_provider_failure", - ); - sessionCtx.failSessionForRecovery?.("opencode_turn_unknown_custody", providerSessionId ?? undefined); - return false; + token, + }); })(); currentTurnPromise = promise.then( () => {}, @@ -752,6 +939,16 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { ); try { return await promise; + } catch (error) { + const failure = redactErrorPreview(error instanceof Error ? error.message : String(error), 2000); + return await settleFailure({ + failure, + spawnError: error instanceof Error ? error : new Error(String(error)), + state: { sawProviderActivity: false, sawUnsafeTool: false, text: [] }, + sessionCtx, + messages, + token, + }); } finally { if (generation === turnGeneration) { currentAbort = null; @@ -777,6 +974,10 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { binary = resolution.binary; sessionCtx.log(`OpenCode binary: ${resolution.binary}`); const { briefing } = await refreshProjection(sessionCtx); + const chatContext = await fetchChatContextOrLog(sessionCtx); + pendingChatContextPrompt = [renderRuntimeOutputContract(), renderChatContextPrompt(chatContext)] + .filter(Boolean) + .join("\n\n"); sessionActive = true; return { briefing, workspaceCwd: cwd }; } @@ -958,7 +1159,9 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { providerSessionId = null; pendingSyntheticId = null; versionReady = false; + providerTurnFailureAttempt = 0; initialTurnPreparing = false; + pendingChatContextPrompt = null; queue.length = 0; }, } satisfies AgentHandler; diff --git a/packages/client/src/handlers/opencode/parser.ts b/packages/client/src/handlers/opencode/parser.ts index b8844f933..66217b1b1 100644 --- a/packages/client/src/handlers/opencode/parser.ts +++ b/packages/client/src/handlers/opencode/parser.ts @@ -18,7 +18,8 @@ export type OpenCodeStreamEvent = | { kind: "usage"; usage: OpenCodeUsage } | { kind: "terminal"; reason: string } | { kind: "error"; message: string } - | { kind: "unknown"; note: string; raw: string }; + | { kind: "reasoning" } + | { kind: "unknown"; note: string }; const PREVIEW_LIMIT = 400; @@ -76,10 +77,10 @@ export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { try { value = JSON.parse(raw); } catch { - return [{ kind: "unknown", note: "unparsable JSONL line", raw: raw.slice(0, PREVIEW_LIMIT) }]; + return [{ kind: "unknown", note: "unparsable JSONL line" }]; } const row = record(value); - if (!row) return [{ kind: "unknown", note: "non-object JSONL value", raw: raw.slice(0, PREVIEW_LIMIT) }]; + if (!row) return [{ kind: "unknown", note: "non-object JSONL value" }]; const part = record(row.part); const events: OpenCodeStreamEvent[] = []; const id = sessionId(row, part); @@ -88,11 +89,17 @@ export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { switch (string(row.type)) { case "text": { const text = string(part?.text) ?? string(row.text); - if (text) events.push({ kind: "text", text }); + if (!text) events.push({ kind: "unknown", note: "text event missing text" }); + else events.push({ kind: "text", text }); break; } case "tool_use": { const state = record(part?.state); + const toolName = string(part?.tool); + if (!part || !state || !toolName) { + events.push({ kind: "unknown", note: "tool_use event missing part, state, or tool name" }); + break; + } const metadata = record(state?.metadata); const statusValue = string(state?.status) ?? "pending"; const status = @@ -109,8 +116,8 @@ export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { string(part?.id) ?? string(part?.callID) ?? string(part?.callId) ?? - `${string(part?.tool) ?? "tool"}:${string(part?.messageID) ?? "unknown"}`, - name: string(part?.tool) ?? "unknown", + `${toolName}:${string(part?.messageID) ?? "unknown"}`, + name: toolName, status, args: state?.input ?? part?.input ?? {}, ...(status === "pending" ? {} : { resultPreview: preview(state?.output ?? state?.error) }), @@ -118,10 +125,15 @@ export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { break; } case "step_finish": { + if (!part) { + events.push({ kind: "unknown", note: "step_finish event missing part" }); + break; + } const tokenUsage = usage(part?.tokens ?? row.tokens); if (tokenUsage) events.push({ kind: "usage", usage: tokenUsage }); const reason = string(part?.reason); - if (reason && reason !== "tool-calls") events.push({ kind: "terminal", reason }); + if (!reason) events.push({ kind: "unknown", note: "step_finish event missing reason" }); + else if (reason !== "tool-calls") events.push({ kind: "terminal", reason }); break; } case "error": { @@ -139,14 +151,14 @@ export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { } case "step_start": break; + case "reasoning": + events.push({ kind: "reasoning" }); + break; default: - if (!id) { - events.push({ - kind: "unknown", - note: `unknown event type ${String(row.type)}`, - raw: raw.slice(0, PREVIEW_LIMIT), - }); - } + events.push({ + kind: "unknown", + note: `unknown event type ${String(row.type)}`, + }); } return events; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index eb34cbfa7..0737f9c22 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -117,9 +117,11 @@ export { InputController } from "./runtime/input-controller.js"; export { findOpenCodeExecutableOnPath, formatOpenCodeBinaryMissingMessage, + isSupportedOpenCodeVersion, OPENCODE_INSTALL_COMMAND, OPENCODE_LOGIN_COMMAND, - OPENCODE_SUPPORTED_VERSION, + OPENCODE_MINIMUM_VERSION, + OPENCODE_SUPPORTED_VERSION_RANGE, parseOpenCodeVersionOutput, resolveOpenCodeRuntimeBinary, } from "./runtime/opencode-binary.js"; diff --git a/packages/client/src/runtime/opencode-binary.ts b/packages/client/src/runtime/opencode-binary.ts index ec5a149ac..1ec3daf94 100644 --- a/packages/client/src/runtime/opencode-binary.ts +++ b/packages/client/src/runtime/opencode-binary.ts @@ -4,10 +4,11 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:pa import { wellKnownBinDirs } from "./install-locations.js"; import { getLoginShellPathDirs } from "./login-shell-path.js"; -/** Exact CLI contract validated by the cross-platform harness. */ -export const OPENCODE_SUPPORTED_VERSION = "1.18.7"; +/** Lowest compatible CLI validated by the runtime contract. */ +export const OPENCODE_MINIMUM_VERSION = "1.18.7"; +export const OPENCODE_SUPPORTED_VERSION_RANGE = ">=1.18.7 <2.0.0"; /** Host-local OpenCode installation surfaced in setup and error copy. */ -export const OPENCODE_INSTALL_COMMAND = `npm install -g opencode-ai@${OPENCODE_SUPPORTED_VERSION}`; +export const OPENCODE_INSTALL_COMMAND = `npm install -g opencode-ai@^${OPENCODE_MINIMUM_VERSION}`; export const OPENCODE_LOGIN_COMMAND = "opencode auth login"; export function formatOpenCodeBinaryMissingMessage(input: unknown): string { @@ -42,7 +43,8 @@ export function findOpenCodeExecutableOnPath( const platform = deps.platform ?? process.platform; const pathDelimiter = deps.pathDelimiter ?? (platform === "win32" ? ";" : delimiter); const loginShellPathDirs = deps.loginShellPathDirs ?? getLoginShellPathDirs; - const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); + const configuredHome = env.HOME || env.USERPROFILE; + const home = configuredHome && configuredHome.length > 0 ? configuredHome : homedir(); const wellKnownDirs = deps.wellKnownDirs ?? (() => wellKnownBinDirs(home)); const seen = new Set(); @@ -61,7 +63,12 @@ export function findOpenCodeExecutableOnPath( const pathValue = env.PATH ?? env.Path ?? env.path ?? ""; const pathDirs = pathValue ? pathValue.split(pathDelimiter) : []; - return search(pathDirs) ?? search(wellKnownDirs()) ?? search(loginShellPathDirs()); + return ( + search(pathDirs) ?? + search([join(home, ".opencode", "bin")]) ?? + search(wellKnownDirs()) ?? + search(loginShellPathDirs()) + ); } export type OpenCodeRuntimeBinaryResolution = @@ -73,7 +80,7 @@ export type OpenCodeRuntimeResolveDeps = { }; /** - * Resolve only. Every OpenCode invocation, including the exact-version gate, + * Resolve only. Every OpenCode invocation, including the compatible-version gate, * is launched later through the provider process supervisor so Windows never * executes an unadmitted runtime process. */ @@ -110,26 +117,55 @@ function openCodeExecutableCandidates(base: string, platform: NodeJS.Platform): export function parseOpenCodeVersionOutput(output: string): string | null { for (let start = 0; start < output.length; start++) { if (!isAsciiDigit(output.charCodeAt(start))) continue; - let end = start; - while (end < output.length) { - const code = output.charCodeAt(end); - if (!isAsciiDigit(code) && code !== 46) break; - end++; - } - const candidate = output.slice(start, end); - const parts = candidate.split("."); - if ((parts.length === 2 || parts.length === 3) && parts.every(isBoundedNumericVersionPart)) { - return candidate; + if (start > 0 && isVersionTokenCode(output.charCodeAt(start - 1))) continue; + const firstDot = scanNumericPart(output, start); + if (firstDot < 0 || output.charCodeAt(firstDot) !== 46) continue; + const secondStart = firstDot + 1; + const secondDot = scanNumericPart(output, secondStart); + if (secondDot < 0 || output.charCodeAt(secondDot) !== 46) continue; + const patchStart = secondDot + 1; + const end = scanNumericPart(output, patchStart); + if (end < 0) continue; + if (end < output.length && isVersionTokenCode(output.charCodeAt(end))) { + start = end; + continue; } - start = end - 1; + const parts = [output.slice(start, firstDot), output.slice(secondStart, secondDot), output.slice(patchStart, end)]; + if (parts.every(isBoundedNumericVersionPart)) return parts.join("."); + start = end; } return null; } +export function isSupportedOpenCodeVersion(version: string | null): boolean { + if (!version) return false; + const parts = version.split("."); + if (parts.length !== 3 || !parts.every(isBoundedNumericVersionPart)) return false; + const major = Number(parts[0]); + const minor = Number(parts[1]); + const patch = Number(parts[2]); + if (major !== 1) return false; + return minor > 18 || (minor === 18 && patch >= 7); +} + +function scanNumericPart(value: string, start: number): number { + let end = start; + while (end < value.length && isAsciiDigit(value.charCodeAt(end))) end++; + return end === start ? -1 : end; +} + +function isVersionTokenCode(code: number): boolean { + return isAsciiDigit(code) || code === 46 || code === 45 || code === 43 || isAsciiLetter(code); +} + function isAsciiDigit(code: number): boolean { return code >= 48 && code <= 57; } +function isAsciiLetter(code: number): boolean { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + function isBoundedNumericVersionPart(part: string): boolean { if (part.length < 1 || part.length > 6) return false; for (let index = 0; index < part.length; index++) { diff --git a/packages/web/src/pages/clients/cards/shared/providers.ts b/packages/web/src/pages/clients/cards/shared/providers.ts index 90799c012..26f2f9492 100644 --- a/packages/web/src/pages/clients/cards/shared/providers.ts +++ b/packages/web/src/pages/clients/cards/shared/providers.ts @@ -73,7 +73,7 @@ export const PROVIDER_NPM_PACKAGE: Record = { // Runtime execution is bundled, but the official CLI remains the supported // operator login/recovery surface for the shared ~/.kimi-code credential. "kimi-code": "@moonshot-ai/kimi-code", - opencode: "opencode-ai@1.18.7", + opencode: "opencode-ai@^1.18.7", }; /** @@ -222,7 +222,7 @@ export function providerInstallHint( return `Install the official Kimi CLI with \`npm install -g @moonshot-ai/kimi-code\` on this ${device}, run \`kimi\`, then \`/login\`. First Tree still executes through its bundled Kimi SDK.`; } if (provider === "opencode") { - return `Run \`npm install -g opencode-ai@1.18.7\` on this ${device}, then complete provider-owned setup with \`opencode auth login\`.`; + return `Run \`npm install -g opencode-ai@^1.18.7\` on this ${device}, then complete provider-owned setup with \`opencode auth login\`.`; } return `Install the OpenAI Codex CLI on this ${device}.`; } From fbbb6f4a0e29c9a110d5f2b3b2047eb84bcf16f7 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:01:03 +0800 Subject: [PATCH 05/15] test: harden OpenCode runtime contracts --- .../src/__tests__/opencode-binary.test.ts | 72 +++- .../src/__tests__/opencode-capability.test.ts | 2 +- .../src/__tests__/opencode-handler.test.ts | 324 +++++++++++++++++- .../src/__tests__/opencode-parser.test.ts | 11 +- .../qa/cases/runtime/opencode-provider.md | 25 +- .../opencode-provider-surfaces.test.ts | 2 +- 6 files changed, 404 insertions(+), 32 deletions(-) diff --git a/packages/client/src/__tests__/opencode-binary.test.ts b/packages/client/src/__tests__/opencode-binary.test.ts index 8b39efdcb..c4ee7f3be 100644 --- a/packages/client/src/__tests__/opencode-binary.test.ts +++ b/packages/client/src/__tests__/opencode-binary.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { findOpenCodeExecutableOnPath, formatOpenCodeBinaryMissingMessage, + isSupportedOpenCodeVersion, parseOpenCodeVersionOutput, resolveOpenCodeRuntimeBinary, } from "../runtime/opencode-binary.js"; @@ -31,6 +32,22 @@ describe("OpenCode binary resolution", () => { ).toBe(binary); }); + it("finds the official OpenCode home even when the daemon PATH is empty", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-home-")); + roots.push(root); + const binary = join(root, ".opencode", "bin", "opencode"); + mkdirSync(join(root, ".opencode", "bin"), { recursive: true }); + writeFileSync(binary, "#!/bin/sh\nexit 0\n"); + chmodSync(binary, 0o755); + + expect( + findOpenCodeExecutableOnPath( + { HOME: root, PATH: "" }, + { platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] }, + ), + ).toBe(binary); + }); + it("resolves without launching; the handler performs its gate through the process supervisor", () => { const result = resolveOpenCodeRuntimeBinary({}, { findOnPath: () => "/opt/bin/opencode" }); expect(result).toEqual({ ok: true, binary: "/opt/bin/opencode" }); @@ -57,14 +74,65 @@ describe("OpenCode binary resolution", () => { ).toBe(native); }); + it("resolves a global Windows npm prefix when the daemon PATH is empty", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-prefix-")); + roots.push(root); + const native = join(root, "node_modules", "opencode-ai", "bin", "opencode.exe"); + mkdirSync(join(root, "node_modules", "opencode-ai", "bin"), { recursive: true }); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { USERPROFILE: root, PATH: "" }, + { + platform: "win32", + pathDelimiter: ";", + wellKnownDirs: () => [root], + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + + it("resolves the official Windows home without a daemon PATH or cmd shim", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-home-")); + roots.push(root); + const native = join(root, ".opencode", "bin", "opencode.exe"); + mkdirSync(join(root, ".opencode", "bin"), { recursive: true }); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { USERPROFILE: root, PATH: "" }, + { + platform: "win32", + pathDelimiter: ";", + wellKnownDirs: () => [], + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + it("surfaces external install and provider-owned auth instructions", () => { - expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("npm install -g opencode-ai@1.18.7"); + expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("npm install -g opencode-ai@^1.18.7"); expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("opencode auth login"); }); - it("parses the exact version gate output without executing a binary", () => { + it("parses a stable semver without accepting prerelease or partial versions", () => { expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7"); + expect(parseOpenCodeVersionOutput("opencode 1.18.9-beta.1")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 1.18")).toBeNull(); expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull(); expect(parseOpenCodeVersionOutput(`${"0".repeat(100_000)}.x opencode 1.18.7`)).toBe("1.18.7"); }); + + it("accepts the supported major-one range and fails closed outside it", () => { + expect(isSupportedOpenCodeVersion("1.18.7")).toBe(true); + expect(isSupportedOpenCodeVersion("1.18.9")).toBe(true); + expect(isSupportedOpenCodeVersion("1.19.0")).toBe(true); + expect(isSupportedOpenCodeVersion("1.18.6")).toBe(false); + expect(isSupportedOpenCodeVersion("2.0.0")).toBe(false); + expect(isSupportedOpenCodeVersion(null)).toBe(false); + }); }); diff --git a/packages/client/src/__tests__/opencode-capability.test.ts b/packages/client/src/__tests__/opencode-capability.test.ts index 996c3b9e2..8d3147a63 100644 --- a/packages/client/src/__tests__/opencode-capability.test.ts +++ b/packages/client/src/__tests__/opencode-capability.test.ts @@ -16,7 +16,7 @@ describe("OpenCode install-only capability", () => { it("reports a missing external runtime with actionable setup copy", async () => { const result = await probeOpenCodeCapability({ findOnPath: () => null, env: {} }); expect(result).toMatchObject({ state: "missing", available: false }); - expect(result.error).toContain("npm install -g opencode-ai@1.18.7"); + expect(result.error).toContain("npm install -g opencode-ai@^1.18.7"); expect(result.error).toContain("opencode auth login"); }); }); diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index d2ad9b26e..8bd382abb 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -1,8 +1,9 @@ import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRuntimeConfig } from "@first-tree/shared"; +import { parseProviderRetryEventMessage } from "@first-tree/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenCodeConfigContent, @@ -10,6 +11,7 @@ import { clearOpenCodeDbGateCacheForTests, createOpenCodeHandler, mapOpenCodeMcpServers, + projectOpenCodeConfig, } from "../handlers/opencode/index.js"; import type { AgentConfigCache } from "../runtime/agent-config-cache.js"; import type { DeliveryToken, SessionContext, SessionMessage } from "../runtime/handler.js"; @@ -64,6 +66,14 @@ function message(id: string, content: string): SessionMessage { }; } +function successfulTurn(sessionId = "ses_new", text = "ok"): string { + return [ + JSON.stringify({ type: "step_start", sessionID: sessionId, part: { sessionID: sessionId } }), + JSON.stringify({ type: "text", sessionID: sessionId, part: { text } }), + JSON.stringify({ type: "step_finish", sessionID: sessionId, part: { reason: "stop" } }), + ].join("\n"); +} + function deliveryToken() { return { processingStarted: vi.fn(), @@ -75,7 +85,7 @@ function deliveryToken() { function createSyntheticSupervisor( specs: ProviderProcessSpec[], - options: { version?: string; turnDelayMs?: number } = {}, + options: { version?: string; turnDelayMs?: number; capturedInputs?: string[] } = {}, ): ProviderProcessSupervisor { return { spawn(spec) { @@ -104,12 +114,58 @@ process.stdin.on("end", () => { ...spec.options, detached: false, }); + if (!isDb && !isVersion && child.stdin && options.capturedInputs) { + const stdin = child.stdin; + const write = stdin.write.bind(stdin); + stdin.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { + options.capturedInputs?.push(String(chunk)); + return Reflect.apply(write, stdin, [chunk, ...args]); + }) as typeof stdin.write; + } const exited = new Promise((resolve) => child.once("exit", () => resolve())); return { child, exited }; }, }; } +function createProtocolSupervisor( + specs: ProviderProcessSpec[], + turnOutputs: string[], + capturedInputs: string[] = [], +): ProviderProcessSupervisor { + let turn = 0; + return { + spawn(spec) { + specs.push(spec); + const output = + spec.args[0] === "--version" + ? "1.18.9\n" + : spec.args[0] === "db" + ? '[{"ready":1}]\n' + : (turnOutputs[turn++] ?? ""); + const child = spawn( + process.execPath, + [ + "-e", + `process.stdin.resume(); process.stdin.on("end", () => process.stdout.write(${JSON.stringify(output)}));`, + ], + { + ...spec.options, + detached: false, + }, + ); + if (spec.args[0] === "run" && child.stdin) { + const write = child.stdin.write.bind(child.stdin); + child.stdin.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { + capturedInputs.push(String(chunk)); + return Reflect.apply(write, child.stdin, [chunk, ...args]); + }) as typeof child.stdin.write; + } + return { child, exited: new Promise((resolve) => child.once("exit", () => resolve())) }; + }, + }; +} + function context(events: unknown[], forwarded: string[]): SessionContext { return { agent: { @@ -167,23 +223,40 @@ function context(events: unknown[], forwarded: string[]): SessionContext { describe("OpenCode V1 handler", () => { it("builds private MCP/agent config and provider-native argv", () => { const config = runtimeConfig().payload; - expect(mapOpenCodeMcpServers(config)).toEqual({ - repo: { type: "local", command: ["mcp-bin", "--stdio"], enabled: true }, + expect(mapOpenCodeMcpServers(config, "scope-a")).toEqual({ + servers: { + "first-tree-scope-a-mcp-1": { type: "local", command: ["mcp-bin", "--stdio"], enabled: true }, + }, + aliases: [{ configuredName: "repo", managedName: "first-tree-scope-a-mcp-1" }], }); - const projected = JSON.parse(buildOpenCodeConfigContent({ payload: config, standingPrompt: "standing" })); - expect(projected.agent["first-tree"]).toMatchObject({ + const projected = JSON.parse( + buildOpenCodeConfigContent({ + payload: config, + managedAgentName: "first-tree-scope-a", + scope: "scope-a", + }), + ); + expect(projected.agent["first-tree-scope-a"]).toMatchObject({ mode: "primary", - prompt: "standing", model: "openai/gpt-test", }); - expect(buildOpenCodeTurnArgs({ cwd: "/work", model: "openai/gpt-test", resumeSessionId: "ses_1" })).toEqual( + expect(projected.agent["first-tree-scope-a"].prompt).not.toContain("Current Chat Context"); + expect(projected.mcp).toHaveProperty("first-tree-scope-a-mcp-1"); + expect( + buildOpenCodeTurnArgs({ + cwd: "/work", + model: "openai/gpt-test", + resumeSessionId: "ses_1", + managedAgentName: "first-tree-scope-a", + }), + ).toEqual( expect.arrayContaining([ "run", "--format", "json", "--auto", "--agent", - "first-tree", + "first-tree-scope-a", "--model", "openai/gpt-test", "--session", @@ -192,6 +265,43 @@ describe("OpenCode V1 handler", () => { ); }); + it("moves oversized private config out of the Windows-sensitive environment block and cleans it", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-")); + roots.push(root); + const projection = projectOpenCodeConfig( + { BASE: "1", OPENCODE_CONFIG: "/operator/override", OPENCODE_CONFIG_CONTENT: "stale" }, + '{"secret":"value"}', + { + maxEnvBytes: 1, + makeTempDir: () => root, + }, + ); + expect(projection.transport).toBe("file"); + expect(JSON.parse(String(projection.env.OPENCODE_CONFIG_CONTENT))).toEqual({ + autoupdate: false, + share: "disabled", + snapshot: false, + }); + expect(String(projection.env.OPENCODE_CONFIG_CONTENT)).not.toContain("secret"); + expect(projection.env.OPENCODE_CONFIG).toBe(join(root, "opencode.json")); + expect(readFileSync(join(root, "opencode.json"), "utf8")).toBe('{"secret":"value"}'); + projection.cleanup(); + expect(existsSync(root)).toBe(false); + }); + + it("fails closed when even the file-backed projection cannot fit a Windows environment block", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-overflow-")); + roots.push(root); + expect(() => + projectOpenCodeConfig({ HUGE: "x".repeat(1_000) }, '{"agent":{}}', { + platform: "win32", + maxWindowsEnvChars: 100, + makeTempDir: () => root, + }), + ).toThrow(/exceeds the safe Windows block limit/i); + expect(existsSync(root)).toBe(false); + }); + it("serializes DB readiness, sends prompt only on stdin, and resumes the confirmed session", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-handler-")); roots.push(root); @@ -219,8 +329,10 @@ describe("OpenCode V1 handler", () => { FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", PROVIDER_ENV: "local", }); - expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).toContain('"first-tree"'); - expect(forwarded).toContain("[From: human]\nfirst prompt"); + expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).toContain('"first-tree-'); + expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).not.toContain("Current Chat Context"); + expect(forwarded.some((text) => text.includes("[From: human]\nfirst prompt"))).toBe(true); + expect(forwarded[0]).toContain(" { const root = mkdtempSync(join(tmpdir(), "ft-opencode-queue-")); roots.push(root); const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; const handler = createOpenCodeHandler({ workspaceRoot: root, runtimeProvider: "opencode", agentConfigCache: cache(runtimeConfig()), opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), - providerProcessSupervisor: createSyntheticSupervisor(specs, { turnDelayMs: 100 }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { turnDelayMs: 100, capturedInputs: inputs }), }); const sessionCtx = context([], []); const startPromise = handler.start(message("m1", "first"), sessionCtx, deliveryToken()); @@ -259,10 +372,186 @@ describe("OpenCode V1 handler", () => { await vi.waitFor(() => { expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(2); }); + await vi.waitFor(() => expect(inputs).toHaveLength(2)); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-protocol-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const sessionCtx = context(events, []); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor( + specs, + [`${JSON.stringify({ type: "future", sessionID: "ses_new" })}\n`, `${successfulTurn()}\n`], + inputs, + ), + }); + const firstToken = deliveryToken(); + await handler.start(message("m1", "first"), sessionCtx, firstToken); + expect(firstToken.retry).toHaveBeenCalled(); + expect(firstToken.complete).not.toHaveBeenCalled(); + expect( + events.some((event) => event.payload?.message && parseProviderRetryEventMessage(event.payload.message)), + ).toBe(true); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain('"type":"future"'); + + const secondToken = deliveryToken(); + expect(handler.inject(message("m2", "second"), secondToken)).toEqual({ kind: "owned", mode: "queued" }); + await vi.waitFor(() => expect(secondToken.complete).toHaveBeenCalled()); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-auth-")); + roots.push(root); + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context(events, []), token); + const retryPayloads = events + .map((event) => (event.payload?.message ? parseProviderRetryEventMessage(event.payload.message) : null)) + .filter((value) => value !== null); + expect(retryPayloads).toContainEqual( + expect.objectContaining({ event: "provider_failure_terminal", provider: "opencode", category: "credential" }), + ); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + await handler.shutdown(); + }); + + it("fails closed when OpenCode warns that the managed agent fell back", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-agent-fallback-")); + roots.push(root); + const forwarded: string[] = []; + const output = `agent "first-tree-missing" not found. Falling back to default agent\n${successfulTurn()}\n`; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [output]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context([], forwarded), token); + expect(forwarded).toEqual([]); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + await handler.shutdown(); + }); + + it("treats a completed non-read-only tool event as an unsafe replay fence", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-effect-")); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + "not-json", + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("serializes one shared data-home DB gate across handler instances", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-db-shared-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const supervisor = createSyntheticSupervisor(specs, { turnDelayMs: 25 }); + const create = () => + createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + const left = create(); + const right = create(); + await Promise.all([ + left.start(message("m1", "left"), context([], []), deliveryToken()), + right.start(message("m2", "right"), context([], []), deliveryToken()), + ]); + expect(specs.filter((spec) => spec.args[0] === "db")).toHaveLength(1); + const agentNames = specs + .filter((spec) => spec.args[0] === "run") + .map((spec) => spec.args[spec.args.indexOf("--agent") + 1]); + expect(new Set(agentNames).size).toBe(2); + await left.shutdown(); + await right.shutdown(); + }); + + it("accepts later compatible 1.x releases", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-version-ok-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "1.18.9" }), + }); + + await expect(handler.start(message("m1", "submitted"), context([], []), deliveryToken())).resolves.toMatchObject({ + sessionId: "ses_new", + }); + expect(specs.map((spec) => spec.args[0])).toEqual(["--version", "db", "run"]); await handler.shutdown(); }); - it("fails closed through the supervisor before DB or turn launch on a version mismatch", async () => { + it("fails closed through the supervisor before DB or turn launch outside the supported range", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-version-")); roots.push(root); const specs: ProviderProcessSpec[] = []; @@ -271,11 +560,14 @@ describe("OpenCode V1 handler", () => { runtimeProvider: "opencode", agentConfigCache: cache(runtimeConfig()), opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), - providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "1.18.8" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "2.0.0" }), }); - await expect(handler.start(message("m1", "never submitted"), context([], []), deliveryToken())).rejects.toThrow( - /requires opencode-ai@1\.18\.7.*observed 1\.18\.8/i, + const token = deliveryToken(); + await handler.start(message("m1", "never submitted"), context([], []), token); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), ); expect(specs.map((spec) => spec.args)).toEqual([["--version"]]); await handler.shutdown(); diff --git a/packages/client/src/__tests__/opencode-parser.test.ts b/packages/client/src/__tests__/opencode-parser.test.ts index bd4b74ad7..2227ca30b 100644 --- a/packages/client/src/__tests__/opencode-parser.test.ts +++ b/packages/client/src/__tests__/opencode-parser.test.ts @@ -5,6 +5,7 @@ describe("OpenCode JSONL parser", () => { it("normalizes session, text, tool, usage, and terminal events", () => { const lines = [ JSON.stringify({ type: "step_start", sessionID: "ses_1", part: { sessionID: "ses_1" } }), + JSON.stringify({ type: "reasoning", sessionID: "ses_1", part: { text: "private chain" } }), JSON.stringify({ type: "text", sessionID: "ses_1", part: { text: "hello" } }), JSON.stringify({ type: "tool_use", @@ -33,6 +34,7 @@ describe("OpenCode JSONL parser", () => { usage: { inputTokens: 10, cachedInputTokens: 3, outputTokens: 4 }, }); expect(events).toContainEqual({ kind: "terminal", reason: "stop" }); + expect(events).toContainEqual({ kind: "reasoning" }); }); it("does not treat tool-calls as terminal", () => { @@ -41,8 +43,13 @@ describe("OpenCode JSONL parser", () => { ).not.toContainEqual(expect.objectContaining({ kind: "terminal" })); }); - it("tolerates malformed and unknown lines", () => { + it("classifies malformed, unknown, and malformed-known lines as protocol violations", () => { expect(parseOpenCodeStreamLine("not-json")[0]).toMatchObject({ kind: "unknown" }); - expect(parseOpenCodeStreamLine(JSON.stringify({ type: "future" }))[0]).toMatchObject({ kind: "unknown" }); + expect(parseOpenCodeStreamLine(JSON.stringify({ type: "future", sessionID: "ses_1" }))).toContainEqual( + expect.objectContaining({ kind: "unknown" }), + ); + expect(parseOpenCodeStreamLine(JSON.stringify({ type: "text", sessionID: "ses_1", part: {} }))).toContainEqual( + expect.objectContaining({ kind: "unknown" }), + ); }); }); diff --git a/packages/qa/cases/runtime/opencode-provider.md b/packages/qa/cases/runtime/opencode-provider.md index 463f3c47c..76020b9fa 100644 --- a/packages/qa/cases/runtime/opencode-provider.md +++ b/packages/qa/cases/runtime/opencode-provider.md @@ -9,7 +9,7 @@ surfaces: [web, cli, server, client] ## Goal -Confirm that an agent bound to `opencode` runs through the exact supported external CLI, reuses provider-owned +Confirm that an agent bound to `opencode` runs through a supported external CLI, reuses provider-owned host-local authentication without giving First Tree token custody, and preserves First Tree's delivery, session, configuration, Context Tree I/O, and process-drain contracts. @@ -20,7 +20,7 @@ surface, or provider supervisor changes. - Run in the isolated QA cell selected by the plan: Docker plus a temporary source worktree, with an explicit native bridge only where the OS process authority cannot live inside Docker. Never modify the operator checkout. -- Install the exact OpenCode version surfaced by the product on the client host and complete provider-owned setup with +- Install an OpenCode version in the product's supported `>=1.18.7 <2.0.0` range on the client host and complete provider-owned setup with `opencode auth login`. The test may prove the login by completing a real turn, but must not read, copy, print, or archive provider credential files. - Use disposable source, MCP, and Context Tree fixtures. Provider tool calls must not modify the product checkout. @@ -34,20 +34,25 @@ surface, or provider supervisor changes. - Provider selection: Web and CLI expose OpenCode only on a client advertising the capability. The config defaults to an empty model, accepts an exact provider-native `provider/model` string, and exposes no separate reasoning-effort control. -- Runtime gates: the first active use launches `opencode --version` through the provider supervisor and requires the - exact supported version. It then serially runs `opencode db "SELECT 1 AS ready" --format json` before concurrent +- Runtime gates: the first active use launches `opencode --version` through the provider supervisor and requires a + stable release in `>=1.18.7 <2.0.0`; prerelease, older, major-two, and unparseable output fail closed. It then + serially runs `opencode db "SELECT 1 AS ready" --format json` before concurrent per-turn processes may use the same client data home. -- Private projection: each turn supplies a caller-scoped `OPENCODE_CONFIG_CONTENT` containing the First Tree primary - agent, output/chat-context standing prompt, declared MCP servers, explicit model when configured, and managed - permissions. It must not rewrite the operator's global OpenCode config. Projected Skills live only under +- Private projection: each handler supplies uniquely namespaced First Tree primary-agent and MCP keys so OpenCode's + deep merge cannot retain colliding operator fields. Small projections use caller-scoped `OPENCODE_CONFIG_CONTENT`; + large projections use a private runtime-owned config file that is removed after the turn. Current Chat Context and + the runtime output contract never enter persistent config; they ride stdin as one-shot context and survive + non-delivery. The projection must not rewrite the operator's global OpenCode config. Projected Skills live only under `.opencode/skills` and retain the shared ownership, lock, journal, rollback, and fail-closed reconciliation behavior. - Child boundary: observe the First Tree identity/drain envelope and runtime-session token-file path in the child environment. The token contents and provider credentials must not enter argv, logs, Server data, or retained evidence. Prompt text appears only on stdin followed by EOF. - Real turn: observe - `opencode run --format json --auto --agent first-tree --dir ` plus `--model` only when configured and + `opencode run --format json --auto --agent --dir ` plus `--model` only when configured and `--session` only for a confirmed resume. Verify normalized assistant, tool, token-usage, and successful terminal - events, and a deterministic disposable file tool effect. + events, exactly one non-`tool-calls` terminal event, and a deterministic disposable file tool effect. Every non-empty + stdout line must be a supported JSON object; malformed and unknown lines fail closed while official `reasoning` + events are explicitly ignored. - Session and queue: persist the unique session ID observed in JSONL. Suspend and resume the same chat with an explicit `--session`; reject a mismatch or missing terminal event. Inject a message during an active turn and prove it is queued for a subsequent process rather than sent to the current stdin. @@ -77,7 +82,7 @@ process, global OpenCode config mutation, silent model fallback, synthetic or mi validation, active-turn steering, unsafe side-effect replay, terminal failure consumed before its durable notice, or a client switch authorized by child registry alone. -`BLOCKED` means the exact CLI, provider login/entitlement/network, isolated platform bridge, owner-reviewed Windows +`BLOCKED` means a compatible CLI, provider login/entitlement/network, isolated platform bridge, owner-reviewed Windows drain authority, or product Job supervisor is absent. Unit tests and the one-time protocol harness do not turn a blocked First Tree product branch into PASS. `INCONCLUSIVE` means a live turn ran but retained evidence cannot distinguish the claimed behavior. diff --git a/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts index d195d6e29..db6fc5672 100644 --- a/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts +++ b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts @@ -15,7 +15,7 @@ describe("OpenCode provider surfaces", () => { }); it("shows the pinned package family and provider-owned login command", () => { - expect(buildInstallCommand("opencode")).toBe("npm install -g opencode-ai@1.18.7\nopencode auth login"); + expect(buildInstallCommand("opencode")).toBe("npm install -g opencode-ai@^1.18.7\nopencode auth login"); expect(providerInstallHint("opencode", "win32")).toContain("opencode auth login"); expect(providerInstallHint("opencode", "win32")).toContain("Windows PC"); }); From aac8027ea8691ec4e2bc2c7c736fc7dcdcfdbf54 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:28:06 +0800 Subject: [PATCH 06/15] fix: preserve OpenCode recovery custody --- .../client/src/handlers/opencode/index.ts | 147 +++++++++++++----- packages/client/src/runtime/handler.ts | 15 +- .../client/src/runtime/opencode-binary.ts | 83 ++++------ .../client/src/runtime/session-manager.ts | 18 ++- 4 files changed, 162 insertions(+), 101 deletions(-) diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index 7332364cc..491ce46eb 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -1,6 +1,5 @@ -import { randomUUID } from "node:crypto"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { type AgentRuntimeConfig, @@ -60,6 +59,7 @@ const FINAL_CLOSE_WAIT_MS = 2_000; const DB_GATE_TIMEOUT_MS = 30_000; const CONFIG_CONTENT_ENV_MAX_BYTES = 16 * 1024; const WINDOWS_ENV_BLOCK_MAX_CHARS = 30_000; +const OPENCODE_CONFIG_RUNTIME_DIR = join(".first-tree-workspace", "opencode-config"); export function isOpenCodePendingSessionId(sessionId: string): boolean { return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); @@ -133,13 +133,6 @@ export function buildOpenCodeConfigContent(input: { }, }, }, - permission: { - edit: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - task: "allow", - }, mcp: mcp.servers, }); } @@ -184,13 +177,13 @@ export function projectOpenCodeConfig( maxWindowsEnvChars?: number; makeTempDir?: () => string; platform?: NodeJS.Platform; + runtimeRoot?: string; } = {}, ): OpenCodeConfigProjection { const maxEnvBytes = deps.maxEnvBytes ?? CONFIG_CONTENT_ENV_MAX_BYTES; const platform = deps.platform ?? process.platform; const maxWindowsEnvChars = deps.maxWindowsEnvChars ?? WINDOWS_ENV_BLOCK_MAX_CHARS; const privateEnv = { ...env }; - delete privateEnv.OPENCODE_CONFIG; delete privateEnv.OPENCODE_CONFIG_CONTENT; const contentEnv = { ...privateEnv, OPENCODE_CONFIG_CONTENT: configContent }; if ( @@ -204,7 +197,22 @@ export function projectOpenCodeConfig( }; } - const configDir = deps.makeTempDir?.() ?? mkdtempSync(join(tmpdir(), "first-tree-opencode-config-")); + if (privateEnv.OPENCODE_CONFIG) { + throw new Error( + "OpenCode private projection is too large for the child environment and cannot replace the host OPENCODE_CONFIG", + ); + } + let configDir: string; + if (deps.makeTempDir) { + configDir = deps.makeTempDir(); + } else { + if (!deps.runtimeRoot) { + throw new Error("OpenCode file-backed projection requires a runtime-owned workspace directory"); + } + mkdirSync(deps.runtimeRoot, { recursive: true, mode: 0o700 }); + chmodSync(deps.runtimeRoot, 0o700); + configDir = mkdtempSync(join(deps.runtimeRoot, "turn-")); + } const configPath = join(configDir, "opencode.json"); try { writeFileSync(configPath, configContent, { encoding: "utf8", mode: 0o600, flag: "wx" }); @@ -257,9 +265,11 @@ type TurnState = { }; const dbGatePromises = new Map>(); +const providerTurnFailureAttempts = new Map(); export function clearOpenCodeDbGateCacheForTests(): void { dbGatePromises.clear(); + providerTurnFailureAttempts.clear(); } export const createOpenCodeHandler: HandlerFactory = (config) => { @@ -278,9 +288,11 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { typeof config.opencodeTurnTimeoutMs === "number" && config.opencodeTurnTimeoutMs > 0 ? config.opencodeTurnTimeoutMs : DEFAULT_TURN_TIMEOUT_MS; - const projectionScope = randomUUID().replaceAll("-", "").slice(0, 12); - const managedAgentName = `first-tree-${projectionScope}`; - + const retrySleep = + (config.opencodeRetrySleep as ((delayMs: number) => Promise) | undefined) ?? + ((delayMs: number) => new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs))); + const configProjector = + (config.opencodeConfigProjector as typeof projectOpenCodeConfig | undefined) ?? projectOpenCodeConfig; let cwd: string | null = null; let ctx: SessionContext | null = null; let activeConfig: AgentRuntimeConfig | null = null; @@ -297,9 +309,15 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { let drainScheduled = false; let drainInProgress = false; let pendingChatContextPrompt: string | null = null; - let providerTurnFailureAttempt = 0; + let projectionScope: string | null = null; + let managedAgentName: string | null = null; + let privateConfigRuntimeRoot: string | null = null; const queue: Array<{ message: SessionMessage; token: DeliveryToken }> = []; + function deliveryAttemptKey(sessionCtx: SessionContext, messages: readonly SessionMessage[]): string { + return `${sessionCtx.agent.agentId}\0${sessionCtx.chatId}\0${messages.map((message) => message.id).join("\0")}`; + } + function buildEnv(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload): Record { const base: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { @@ -311,7 +329,6 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { for (const [key, value] of Object.entries(merged)) { if (typeof value === "string") env[key] = value; } - delete env.OPENCODE_CONFIG; delete env.OPENCODE_CONFIG_CONTENT; return env; } @@ -712,6 +729,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { messages: readonly SessionMessage[]; token: DeliveryToken; }): Promise { + const attemptKey = deliveryAttemptKey(input.sessionCtx, input.messages); const replaySafety = input.state.sawUnsafeTool ? "unsafe" : input.state.text.length > 0 @@ -733,7 +751,9 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { error: input.spawnError ?? input.failure, messagePreview: displayMessage, }); - const settlement = attempt.settle({ attempt: ++providerTurnFailureAttempt }); + const attemptNumber = (providerTurnFailureAttempts.get(attemptKey) ?? 0) + 1; + providerTurnFailureAttempts.set(attemptKey, attemptNumber); + const settlement = attempt.settle({ attempt: attemptNumber }); if (!settlement) { input.token.retry(input.messages, "opencode_unclassified_failure"); return false; @@ -746,14 +766,19 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }); input.sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); if (settlement.decision.action === "retry") { + await retrySleep(settlement.decision.delayMs); input.token.retry(input.messages, settlement.decision.reasonCode); if (input.state.sawProviderActivity) { input.sessionCtx.failSessionForRecovery?.("opencode_turn_retryable_failure", providerSessionId ?? undefined); } return false; } - await input.token.complete(input.messages, consumedErrorOutcome(consumedReasonForProviderSettlement(settlement))); - providerTurnFailureAttempt = 0; + const completion = await input.token.complete( + input.messages, + consumedErrorOutcome(consumedReasonForProviderSettlement(settlement)), + ); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(attemptKey); pendingChatContextPrompt = null; return true; } @@ -766,13 +791,24 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { ): Promise { const workspaceCwd = cwd; const activeBinary = binary; - if (!workspaceCwd || !activeBinary || !sessionActive) { + const activeProjectionScope = projectionScope; + const activeManagedAgentName = managedAgentName; + const activePrivateConfigRuntimeRoot = privateConfigRuntimeRoot; + if ( + !workspaceCwd || + !activeBinary || + !activeProjectionScope || + !activeManagedAgentName || + !activePrivateConfigRuntimeRoot || + !sessionActive + ) { token.retry(messages, sessionActive ? "opencode_not_prepared" : "opencode_session_inactive"); return false; } const turnGeneration = ++generation; const abort = new AbortController(); currentAbort = abort; + let observedState: TurnState | null = null; const promise = (async () => { const { payload } = await refreshProjection(sessionCtx); const env = buildEnv(sessionCtx, payload); @@ -808,14 +844,20 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sawUnsafeTool: false, protocolDiagnostics: [], }; + observedState = state; token.processingStarted(messages); const timeout = setTimeout(() => abort.abort(), turnTimeoutMs); timeout.unref?.(); let outcome: ProcessOutcome; try { - const configProjection = projectOpenCodeConfig( + const configProjection = configProjector( env, - buildOpenCodeConfigContent({ payload, managedAgentName, scope: projectionScope }), + buildOpenCodeConfigContent({ + payload, + managedAgentName: activeManagedAgentName, + scope: activeProjectionScope, + }), + { runtimeRoot: activePrivateConfigRuntimeRoot }, ); try { outcome = await runProcess({ @@ -824,7 +866,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { cwd: workspaceCwd, model: payload.model, resumeSessionId: expectedSessionId, - managedAgentName, + managedAgentName: activeManagedAgentName, }), prompt: `${providerPrompt}\n`, env: configProjection.env, @@ -844,9 +886,14 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { } if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) { - sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); - token.retry(messages, "opencode_turn_aborted_or_timed_out"); - return false; + return settleFailure({ + failure: "OpenCode turn aborted or timed out before a safe terminal event", + spawnError: new Error("OpenCode turn aborted or timed out"), + state, + sessionCtx, + messages, + token, + }); } const ids = [...state.sessionIds]; @@ -902,14 +949,20 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }, }); sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); - await token.complete(messages, { status: "error", completion: "consumed", reason: "forward_failed" }); - providerTurnFailureAttempt = 0; + const completion = await token.complete(messages, { + status: "error", + completion: "consumed", + reason: "forward_failed", + }); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(deliveryAttemptKey(sessionCtx, messages)); pendingChatContextPrompt = null; return true; } sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "success" } }); - await token.complete(messages, { status: "success" }); - providerTurnFailureAttempt = 0; + const completion = await token.complete(messages, { status: "success" }); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(deliveryAttemptKey(sessionCtx, messages)); if (pendingChatContextPrompt === oneShotPrompt) pendingChatContextPrompt = null; return true; } @@ -944,7 +997,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { return await settleFailure({ failure, spawnError: error instanceof Error ? error : new Error(String(error)), - state: { sawProviderActivity: false, sawUnsafeTool: false, text: [] }, + state: observedState ?? { sawProviderActivity: false, sawUnsafeTool: false, text: [] }, sessionCtx, messages, token, @@ -967,6 +1020,16 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { } ctx = sessionCtx; cwd = acquireAgentHome(workspaceRoot); + projectionScope = stableOpenCodeScope(sessionCtx.agent.agentId); + managedAgentName = `first-tree-${projectionScope}`; + privateConfigRuntimeRoot = join( + cwd, + OPENCODE_CONFIG_RUNTIME_DIR, + stableOpenCodeScope(`${sessionCtx.agent.agentId}\0${sessionCtx.chatId}`), + ); + rmSync(privateConfigRuntimeRoot, { recursive: true, force: true }); + mkdirSync(privateConfigRuntimeRoot, { recursive: true, mode: 0o700 }); + chmodSync(privateConfigRuntimeRoot, 0o700); const resolution = resolveBinary(process.env); if (!resolution.ok) { throw new Error(resolution.error); @@ -1063,13 +1126,14 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { const deliveryToken = token ?? deliveryTokenFromSessionContext(sessionCtx); initialTurnPreparing = true; let completed = false; + let delivered = false; let briefing: string; let workspaceCwd: string; try { ({ briefing, workspaceCwd } = await prepareSession(sessionCtx)); const prompt = await sessionCtx.formatInboundContent(message); - await runTurn(prompt, sessionCtx, [message], deliveryToken); - completed = true; + delivered = await runTurn(prompt, sessionCtx, [message], deliveryToken); + completed = delivered; } finally { initialTurnPreparing = false; if (completed) scheduleDrain(); @@ -1077,7 +1141,9 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { if (!providerSessionId) pendingSyntheticId = `${OPENCODE_PENDING_SESSION_PREFIX}${randomUUID()}`; const sessionId = providerSessionId ?? pendingSyntheticId; if (!sessionId) throw new Error("OpenCode session id unresolved"); - writeSessionBriefingFingerprint(workspaceCwd, sessionId, computeBriefingFingerprint(briefing)); + if (delivered) { + writeSessionBriefingFingerprint(workspaceCwd, sessionId, computeBriefingFingerprint(briefing)); + } return explicit ? { sessionId, route: { kind: "owned", mode: "processing" } } : sessionId; }, @@ -1159,7 +1225,12 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { providerSessionId = null; pendingSyntheticId = null; versionReady = false; - providerTurnFailureAttempt = 0; + if (privateConfigRuntimeRoot) { + rmSync(privateConfigRuntimeRoot, { recursive: true, force: true }); + } + projectionScope = null; + managedAgentName = null; + privateConfigRuntimeRoot = null; initialTurnPreparing = false; pendingChatContextPrompt = null; queue.length = 0; @@ -1171,6 +1242,10 @@ function isReadOnlyTool(name: string): boolean { return /^(read|glob|grep|list|ls|webfetch|websearch)$/i.test(name); } +export function stableOpenCodeScope(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } diff --git a/packages/client/src/runtime/handler.ts b/packages/client/src/runtime/handler.ts index 4db548868..6f34f5ed8 100644 --- a/packages/client/src/runtime/handler.ts +++ b/packages/client/src/runtime/handler.ts @@ -106,7 +106,7 @@ export type ResumeResult = ResumeReceipt | string; export type DeliveryToken = { processingStarted(messages: SessionMessage | readonly SessionMessage[]): void; - complete(messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome): Promise; + complete(messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome): Promise; retry(messages: SessionMessage | readonly SessionMessage[], reason: string): void; terminalRejected( messages: SessionMessage | readonly SessionMessage[], @@ -115,6 +115,17 @@ export type DeliveryToken = { ): Promise; }; +/** + * Observable completion custody for handlers whose one-shot provider payload + * may only advance after the inbox delivery really settles. `retry` means the + * completion path deliberately retained server custody (for example because a + * required durable runtime-failure notice could not be posted). + * + * `void` remains accepted on DeliveryToken.complete for legacy/test tokens; + * production SessionManager tokens always return an explicit disposition. + */ +export type DeliveryCompletionDisposition = "settled" | "retry"; + export function noopDeliveryToken(): DeliveryToken { return { processingStarted: () => {}, @@ -167,7 +178,7 @@ export type SessionContext = HandlerContext & { * The coordinator sends one ACK-through for the last message's * `inboxEntryId` and settles local ledger only after server confirmation. */ - finishTurn: (messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome) => Promise; + finishTurn: (messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome) => Promise; /** * Mark a concrete message or batch as abandoned by a retryable path diff --git a/packages/client/src/runtime/opencode-binary.ts b/packages/client/src/runtime/opencode-binary.ts index 1ec3daf94..b0f120b2c 100644 --- a/packages/client/src/runtime/opencode-binary.ts +++ b/packages/client/src/runtime/opencode-binary.ts @@ -1,6 +1,7 @@ import { accessSync, constants, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { prerelease, satisfies, valid } from "semver"; import { wellKnownBinDirs } from "./install-locations.js"; import { getLoginShellPathDirs } from "./login-shell-path.js"; @@ -63,12 +64,13 @@ export function findOpenCodeExecutableOnPath( const pathValue = env.PATH ?? env.Path ?? env.path ?? ""; const pathDirs = pathValue ? pathValue.split(pathDelimiter) : []; - return ( - search(pathDirs) ?? - search([join(home, ".opencode", "bin")]) ?? - search(wellKnownDirs()) ?? - search(loginShellPathDirs()) - ); + const providerInstallDirs = [ + join(home, ".opencode", "bin"), + ...(platform === "win32" + ? [...(env.APPDATA ? [join(env.APPDATA, "npm")] : []), join(home, "AppData", "Roaming", "npm")] + : []), + ]; + return search(pathDirs) ?? search(providerInstallDirs) ?? search(wellKnownDirs()) ?? search(loginShellPathDirs()); } export type OpenCodeRuntimeBinaryResolution = @@ -115,63 +117,34 @@ function openCodeExecutableCandidates(base: string, platform: NodeJS.Platform): } export function parseOpenCodeVersionOutput(output: string): string | null { - for (let start = 0; start < output.length; start++) { - if (!isAsciiDigit(output.charCodeAt(start))) continue; - if (start > 0 && isVersionTokenCode(output.charCodeAt(start - 1))) continue; - const firstDot = scanNumericPart(output, start); - if (firstDot < 0 || output.charCodeAt(firstDot) !== 46) continue; - const secondStart = firstDot + 1; - const secondDot = scanNumericPart(output, secondStart); - if (secondDot < 0 || output.charCodeAt(secondDot) !== 46) continue; - const patchStart = secondDot + 1; - const end = scanNumericPart(output, patchStart); - if (end < 0) continue; - if (end < output.length && isVersionTokenCode(output.charCodeAt(end))) { - start = end; - continue; - } - const parts = [output.slice(start, firstDot), output.slice(secondStart, secondDot), output.slice(patchStart, end)]; - if (parts.every(isBoundedNumericVersionPart)) return parts.join("."); - start = end; + for (const token of whitespaceTokens(output)) { + if (token.length > 64) continue; + const normalized = valid(token); + if (!normalized || normalized !== token || prerelease(normalized) !== null) continue; + return normalized; } return null; } export function isSupportedOpenCodeVersion(version: string | null): boolean { - if (!version) return false; - const parts = version.split("."); - if (parts.length !== 3 || !parts.every(isBoundedNumericVersionPart)) return false; - const major = Number(parts[0]); - const minor = Number(parts[1]); - const patch = Number(parts[2]); - if (major !== 1) return false; - return minor > 18 || (minor === 18 && patch >= 7); -} - -function scanNumericPart(value: string, start: number): number { - let end = start; - while (end < value.length && isAsciiDigit(value.charCodeAt(end))) end++; - return end === start ? -1 : end; -} - -function isVersionTokenCode(code: number): boolean { - return isAsciiDigit(code) || code === 46 || code === 45 || code === 43 || isAsciiLetter(code); -} - -function isAsciiDigit(code: number): boolean { - return code >= 48 && code <= 57; + if (!version || valid(version) !== version || prerelease(version) !== null) return false; + return satisfies(version, OPENCODE_SUPPORTED_VERSION_RANGE, { includePrerelease: false }); } -function isAsciiLetter(code: number): boolean { - return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); -} - -function isBoundedNumericVersionPart(part: string): boolean { - if (part.length < 1 || part.length > 6) return false; - for (let index = 0; index < part.length; index++) { - if (!isAsciiDigit(part.charCodeAt(index))) return false; +function whitespaceTokens(value: string): string[] { + const tokens: string[] = []; + let start = -1; + for (let index = 0; index <= value.length; index++) { + const code = index < value.length ? value.charCodeAt(index) : 32; + const whitespace = code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32; + if (!whitespace && start < 0) { + start = index; + } else if (whitespace && start >= 0) { + tokens.push(value.slice(start, index)); + start = -1; + } } - return true; + return tokens; } function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { diff --git a/packages/client/src/runtime/session-manager.ts b/packages/client/src/runtime/session-manager.ts index e38a12565..34a0bb624 100644 --- a/packages/client/src/runtime/session-manager.ts +++ b/packages/client/src/runtime/session-manager.ts @@ -41,6 +41,7 @@ import { clampRetryAttempt } from "./error-taxonomy.js"; import type { AgentHandler, AgentIdentity, + DeliveryCompletionDisposition, DeliveryToken, HandlerConfig, HandlerFactory, @@ -1297,29 +1298,30 @@ export class SessionManager { messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome, deliveryLeaseValid: (() => boolean) | null = null, - ): Promise { - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + ): Promise { + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; const retryReason = this.errorCompletionRetryReason(outcome); if (retryReason) { this.warnRejectedErrorCompletion(chatId, outcome, retryReason); this.retryDeliveryTurn(chatId, messages, retryReason); this.projectSessionRuntime(chatId); - return; + return "retry"; } if (outcome.status === "success") { this.clearPendingRuntimeFailureNotice(chatId); } else if (outcome.completion === "consumed") { const noticePosted = await this.postPendingRuntimeFailureNotice(chatId, deliveryLeaseValid); - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; if (!noticePosted) { this.retryDeliveryTurn(chatId, messages, "runtime_failure_notice_delivery_failed"); this.projectSessionRuntime(chatId); - return; + return "retry"; } } - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; await this.inboxDelivery.finishTurn(chatId, messages, outcome); this.projectSessionRuntime(chatId); + return "settled"; } private createDeliveryToken(chatId: string, routeLeaseValid: (() => boolean) | null = null): DeliveryToken { @@ -1344,8 +1346,8 @@ export class SessionManager { this.projectSessionRuntime(chatId); }, complete: async (messages, outcome) => { - if (!claimTerminal("complete")) return; - await this.completeDeliveryTurn(chatId, messages, outcome, isValid); + if (!claimTerminal("complete")) return "retry"; + return await this.completeDeliveryTurn(chatId, messages, outcome, isValid); }, retry: (messages, reason) => { if (!claimTerminal("retry")) return; From d0eaff2078eb6d72373304bd4ca91fd1d1ef724b Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:28:06 +0800 Subject: [PATCH 07/15] test: cover OpenCode recovery boundaries --- .../src/__tests__/opencode-binary.test.ts | 12 +- .../src/__tests__/opencode-handler.test.ts | 542 ++++++++++++++++-- .../src/__tests__/session-manager.test.ts | 9 +- 3 files changed, 510 insertions(+), 53 deletions(-) diff --git a/packages/client/src/__tests__/opencode-binary.test.ts b/packages/client/src/__tests__/opencode-binary.test.ts index c4ee7f3be..a4cac638c 100644 --- a/packages/client/src/__tests__/opencode-binary.test.ts +++ b/packages/client/src/__tests__/opencode-binary.test.ts @@ -77,17 +77,16 @@ describe("OpenCode binary resolution", () => { it("resolves a global Windows npm prefix when the daemon PATH is empty", () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-prefix-")); roots.push(root); - const native = join(root, "node_modules", "opencode-ai", "bin", "opencode.exe"); - mkdirSync(join(root, "node_modules", "opencode-ai", "bin"), { recursive: true }); + const native = join(root, "npm", "node_modules", "opencode-ai", "bin", "opencode.exe"); + mkdirSync(join(root, "npm", "node_modules", "opencode-ai", "bin"), { recursive: true }); writeFileSync(native, "native"); expect( findOpenCodeExecutableOnPath( - { USERPROFILE: root, PATH: "" }, + { USERPROFILE: join(root, "profile"), APPDATA: root, PATH: "" }, { platform: "win32", pathDelimiter: ";", - wellKnownDirs: () => [root], loginShellPathDirs: () => [], }, ), @@ -122,6 +121,8 @@ describe("OpenCode binary resolution", () => { it("parses a stable semver without accepting prerelease or partial versions", () => { expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7"); expect(parseOpenCodeVersionOutput("opencode 1.18.9-beta.1")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 01.18.7")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 1.18.7_suffix")).toBeNull(); expect(parseOpenCodeVersionOutput("opencode 1.18")).toBeNull(); expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull(); expect(parseOpenCodeVersionOutput(`${"0".repeat(100_000)}.x opencode 1.18.7`)).toBe("1.18.7"); @@ -133,6 +134,9 @@ describe("OpenCode binary resolution", () => { expect(isSupportedOpenCodeVersion("1.19.0")).toBe(true); expect(isSupportedOpenCodeVersion("1.18.6")).toBe(false); expect(isSupportedOpenCodeVersion("2.0.0")).toBe(false); + expect(isSupportedOpenCodeVersion("1.18.9-beta.1")).toBe(false); + expect(isSupportedOpenCodeVersion("01.18.7")).toBe(false); + expect(isSupportedOpenCodeVersion("1.18.7_suffix")).toBe(false); expect(isSupportedOpenCodeVersion(null)).toBe(false); }); }); diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index 8bd382abb..38acc1822 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRuntimeConfig } from "@first-tree/shared"; @@ -12,10 +12,16 @@ import { createOpenCodeHandler, mapOpenCodeMcpServers, projectOpenCodeConfig, + stableOpenCodeScope, } from "../handlers/opencode/index.js"; import type { AgentConfigCache } from "../runtime/agent-config-cache.js"; import type { DeliveryToken, SessionContext, SessionMessage } from "../runtime/handler.js"; import type { ProviderProcessSpec, ProviderProcessSupervisor } from "../runtime/provider-process-supervisor.js"; +import { readSessionBriefingFingerprint } from "../runtime/session-briefing-fingerprint.js"; +import { SessionManager } from "../runtime/session-manager.js"; +import type { FirstTreeHubSDK } from "../sdk.js"; +import { silentLogger } from "./_logger-helpers.js"; +import { mockEntry } from "./test-helpers.js"; const roots: string[] = []; @@ -83,6 +89,40 @@ function deliveryToken() { } satisfies DeliveryToken; } +const SYNTHETIC_PROVIDER_SCRIPT = ` +const kind = process.env.FIRST_TREE_TEST_PROVIDER_KIND; +if (kind === "version") { + process.stdout.write(process.env.FIRST_TREE_TEST_PROVIDER_VERSION ?? ""); +} else if (kind === "db") { + process.stdout.write('[{"ready":1}]\\n'); +} else { + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + input += chunk; + }); + process.stdin.on("end", () => { + setTimeout(() => { + const sid = process.env.FIRST_TREE_TEST_PROVIDER_SESSION_ID ?? "ses_new"; + process.stdout.write(JSON.stringify({type:"step_start",sessionID:sid,part:{sessionID:sid}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"text",sessionID:sid,part:{text:input.trim()}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"step_finish",sessionID:sid,part:{reason:"stop",tokens:{input:3,output:2}}}) + "\\n"); + }, Number(process.env.FIRST_TREE_TEST_PROVIDER_DELAY_MS ?? "0")); + }); +} +`; + +const PROTOCOL_PROVIDER_SCRIPT = ` +process.stdin.resume(); +process.stdin.on("end", () => { + const encoded = process.env.FIRST_TREE_TEST_PROVIDER_OUTPUT_BASE64 ?? ""; + process.stdout.write(Buffer.from(encoded, "base64").toString("utf8")); + if (process.env.FIRST_TREE_TEST_PROVIDER_HOLD_OPEN === "1") { + setInterval(() => {}, 1000); + } +}); +`; + function createSyntheticSupervisor( specs: ProviderProcessSpec[], options: { version?: string; turnDelayMs?: number; capturedInputs?: string[] } = {}, @@ -93,25 +133,15 @@ function createSyntheticSupervisor( const isDb = spec.args[0] === "db"; const isVersion = spec.args[0] === "--version"; const resumed = spec.args.includes("--session") ? spec.args[spec.args.indexOf("--session") + 1] : "ses_new"; - const script = isVersion - ? `process.stdout.write(${JSON.stringify(`${options.version ?? "1.18.7"}\n`)})` - : isDb - ? "process.stdout.write('[{\"ready\":1}]\\n')" - : ` -let input = ""; -process.stdin.setEncoding("utf8"); -process.stdin.on("data", chunk => input += chunk); -process.stdin.on("end", () => { - setTimeout(() => { - const sid = ${JSON.stringify(resumed)}; - process.stdout.write(JSON.stringify({type:"step_start",sessionID:sid,part:{sessionID:sid}}) + "\\n"); - process.stdout.write(JSON.stringify({type:"text",sessionID:sid,part:{text:input.trim()}}) + "\\n"); - process.stdout.write(JSON.stringify({type:"step_finish",sessionID:sid,part:{reason:"stop",tokens:{input:3,output:2}}}) + "\\n"); - }, ${JSON.stringify(options.turnDelayMs ?? 0)}); -}); -`; - const child = spawn(process.execPath, ["-e", script], { + const child = spawn(process.execPath, ["-e", SYNTHETIC_PROVIDER_SCRIPT], { ...spec.options, + env: { + ...spec.options.env, + FIRST_TREE_TEST_PROVIDER_KIND: isVersion ? "version" : isDb ? "db" : "turn", + FIRST_TREE_TEST_PROVIDER_VERSION: `${options.version ?? "1.18.7"}\n`, + FIRST_TREE_TEST_PROVIDER_SESSION_ID: resumed, + FIRST_TREE_TEST_PROVIDER_DELAY_MS: String(options.turnDelayMs ?? 0), + }, detached: false, }); if (!isDb && !isVersion && child.stdin && options.capturedInputs) { @@ -132,6 +162,7 @@ function createProtocolSupervisor( specs: ProviderProcessSpec[], turnOutputs: string[], capturedInputs: string[] = [], + holdOpen = false, ): ProviderProcessSupervisor { let turn = 0; return { @@ -143,17 +174,15 @@ function createProtocolSupervisor( : spec.args[0] === "db" ? '[{"ready":1}]\n' : (turnOutputs[turn++] ?? ""); - const child = spawn( - process.execPath, - [ - "-e", - `process.stdin.resume(); process.stdin.on("end", () => process.stdout.write(${JSON.stringify(output)}));`, - ], - { - ...spec.options, - detached: false, + const child = spawn(process.execPath, ["-e", PROTOCOL_PROVIDER_SCRIPT], { + ...spec.options, + env: { + ...spec.options.env, + FIRST_TREE_TEST_PROVIDER_OUTPUT_BASE64: Buffer.from(output, "utf8").toString("base64"), + FIRST_TREE_TEST_PROVIDER_HOLD_OPEN: holdOpen && spec.args[0] === "run" ? "1" : "0", }, - ); + detached: holdOpen, + }); if (spec.args[0] === "run" && child.stdin) { const write = child.stdin.write.bind(child.stdin); child.stdin.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { @@ -166,10 +195,16 @@ function createProtocolSupervisor( }; } -function context(events: unknown[], forwarded: string[]): SessionContext { +function context( + events: unknown[], + forwarded: string[], + identity: { agentId?: string; chatId?: string } = {}, +): SessionContext { + const agentId = identity.agentId ?? "agent-1"; + const chatId = identity.chatId ?? "chat-1"; return { agent: { - agentId: "agent-1", + agentId, inboxId: "inbox-1", displayName: "Agent", type: "agent", @@ -198,7 +233,7 @@ function context(events: unknown[], forwarded: string[]): SessionContext { ], } as unknown as SessionContext["sdk"], log: vi.fn(), - chatId: "chat-1", + chatId, recordProviderActivity: vi.fn(), emitEvent: (event) => events.push(event), forwardResult: async (text) => { @@ -207,10 +242,11 @@ function context(events: unknown[], forwarded: string[]): SessionContext { markMessagesConsumed: vi.fn(), finishTurn: vi.fn(async () => {}), retryTurn: vi.fn(), + failSessionForRecovery: vi.fn(), buildAgentEnv: (env) => ({ ...env, - FIRST_TREE_AGENT_ID: "agent-1", - FIRST_TREE_CHAT_ID: "chat-1", + FIRST_TREE_AGENT_ID: agentId, + FIRST_TREE_CHAT_ID: chatId, FIRST_TREE_PROVIDER: "opencode", FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", }), @@ -241,6 +277,7 @@ describe("OpenCode V1 handler", () => { model: "openai/gpt-test", }); expect(projected.agent["first-tree-scope-a"].prompt).not.toContain("Current Chat Context"); + expect(projected).not.toHaveProperty("permission"); expect(projected.mcp).toHaveProperty("first-tree-scope-a-mcp-1"); expect( buildOpenCodeTurnArgs({ @@ -268,14 +305,11 @@ describe("OpenCode V1 handler", () => { it("moves oversized private config out of the Windows-sensitive environment block and cleans it", () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-")); roots.push(root); - const projection = projectOpenCodeConfig( - { BASE: "1", OPENCODE_CONFIG: "/operator/override", OPENCODE_CONFIG_CONTENT: "stale" }, - '{"secret":"value"}', - { - maxEnvBytes: 1, - makeTempDir: () => root, - }, - ); + const runtimeRoot = join(root, ".first-tree-workspace", "opencode-config", "scope"); + const projection = projectOpenCodeConfig({ BASE: "1", OPENCODE_CONFIG_CONTENT: "stale" }, '{"secret":"value"}', { + maxEnvBytes: 1, + runtimeRoot, + }); expect(projection.transport).toBe("file"); expect(JSON.parse(String(projection.env.OPENCODE_CONFIG_CONTENT))).toEqual({ autoupdate: false, @@ -283,10 +317,88 @@ describe("OpenCode V1 handler", () => { snapshot: false, }); expect(String(projection.env.OPENCODE_CONFIG_CONTENT)).not.toContain("secret"); - expect(projection.env.OPENCODE_CONFIG).toBe(join(root, "opencode.json")); - expect(readFileSync(join(root, "opencode.json"), "utf8")).toBe('{"secret":"value"}'); + const configPath = String(projection.env.OPENCODE_CONFIG); + expect(configPath.startsWith(runtimeRoot)).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe('{"secret":"value"}'); projection.cleanup(); - expect(existsSync(root)).toBe(false); + expect(existsSync(configPath)).toBe(false); + expect(existsSync(runtimeRoot)).toBe(true); + }); + + it("preserves host custom config for inline projection and fails closed rather than replacing it on overflow", () => { + const hostConfig = "/operator/custom-opencode.json"; + const inline = projectOpenCodeConfig( + { OPENCODE_CONFIG: hostConfig, OPENCODE_CONFIG_CONTENT: "stale" }, + '{"agent":{}}', + ); + expect(inline.transport).toBe("env"); + expect(inline.env.OPENCODE_CONFIG).toBe(hostConfig); + expect(inline.env.OPENCODE_CONFIG_CONTENT).toBe('{"agent":{}}'); + expect(() => + projectOpenCodeConfig({ OPENCODE_CONFIG: hostConfig }, '{"agent":{}}', { + maxEnvBytes: 1, + runtimeRoot: "/private/runtime", + }), + ).toThrow(/cannot replace the host OPENCODE_CONFIG/i); + }); + + it("derives a stable high-entropy caller scope without cross-caller collisions", () => { + const first = stableOpenCodeScope("agent-1"); + expect(first).toHaveLength(64); + expect(stableOpenCodeScope("agent-1")).toBe(first); + expect(stableOpenCodeScope("agent-2")).not.toBe(first); + }); + + it("keeps the managed agent identity stable across fresh handlers and distinct across agents", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-stable-agent-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const supervisor = createSyntheticSupervisor(specs); + const run = async (agentId: string) => { + const handler = createOpenCodeHandler({ + workspaceRoot: join(root, agentId), + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + await handler.start( + message(`m-${agentId}-${specs.length}`, "turn"), + context([], [], { agentId }), + deliveryToken(), + ); + await handler.shutdown(); + }; + await run("agent-1"); + await run("agent-1"); + await run("agent-2"); + const managedNames = specs + .filter((spec) => spec.args[0] === "run") + .map((spec) => spec.args[spec.args.indexOf("--agent") + 1]); + expect(managedNames[0]).toBe(managedNames[1]); + expect(managedNames[2]).not.toBe(managedNames[0]); + }); + + it("sweeps caller-owned stale private config state and removes the scope on shutdown", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-recovery-")); + roots.push(root); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const stalePath = join(scopeRoot, "stale", "opencode.json"); + mkdirSync(join(scopeRoot, "stale"), { recursive: true }); + writeFileSync(stalePath, '{"stale":true}'); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor([]), + }); + + await handler.start(message("m-private", "turn"), context([], []), deliveryToken()); + expect(existsSync(stalePath)).toBe(false); + expect(existsSync(scopeRoot)).toBe(true); + await handler.shutdown(); + expect(existsSync(scopeRoot)).toBe(false); }); it("fails closed when even the file-backed projection cannot fit a Windows environment block", () => { @@ -310,6 +422,7 @@ describe("OpenCode V1 handler", () => { const forwarded: string[] = []; const sessionCtx = context(events, forwarded); const cfg = runtimeConfig(); + cfg.payload.env.push({ key: "OPENCODE_CONFIG", value: "/operator/custom-opencode.json", sensitive: false }); const handler = createOpenCodeHandler({ workspaceRoot: root, runtimeProvider: "opencode", @@ -328,6 +441,7 @@ describe("OpenCode V1 handler", () => { expect(firstRun?.options.env).toMatchObject({ FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", PROVIDER_ENV: "local", + OPENCODE_CONFIG: "/operator/custom-opencode.json", }); expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).toContain('"first-tree-'); expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).not.toContain("Current Chat Context"); @@ -395,6 +509,7 @@ describe("OpenCode V1 handler", () => { [`${JSON.stringify({ type: "future", sessionID: "ses_new" })}\n`, `${successfulTurn()}\n`], inputs, ), + opencodeRetrySleep: async () => {}, }); const firstToken = deliveryToken(); await handler.start(message("m1", "first"), sessionCtx, firstToken); @@ -414,6 +529,188 @@ describe("OpenCode V1 handler", () => { await handler.shutdown(); }); + it("bounds stream-started retries across fresh handlers and observes the retry-policy delays", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-window-")); + roots.push(root); + const sleep = vi.fn<(delayMs: number) => Promise>(async () => {}); + const sessionCtx = context([], []); + const tokens = [deliveryToken(), deliveryToken(), deliveryToken()]; + for (const token of tokens) { + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: sleep, + }); + await handler.start(message("m-retry", "same delivery"), sessionCtx, token); + await handler.shutdown(); + } + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(tokens[0]?.retry).toHaveBeenCalled(); + expect(tokens[1]?.retry).toHaveBeenCalled(); + expect(tokens[2]?.retry).not.toHaveBeenCalled(); + expect(tokens[2]?.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-retry" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + expect(sessionCtx.failSessionForRecovery).toHaveBeenCalledTimes(2); + }); + + it("keeps bounded retry custody across real SessionManager handler replacement", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-session-manager-retry-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sleep = vi.fn<(delayMs: number) => Promise>(async () => {}); + const supervisor = createProtocolSupervisor(specs, ["not-json\n", "not-json\n", "not-json\n"]); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi.fn(async () => ({ id: "runtime-notice" })); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async () => ({ + id: "chat-sm-retry", + title: "Retry chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + opencodeRetrySleep: sleep, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const entry = mockEntry({ + id: 801, + chatId: "chat-sm-retry", + messageId: "msg-sm-retry", + content: "retry this delivery", + }); + + await manager.dispatch(entry); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledTimes(1)); + for (const expectedRuns of [2, 3]) { + await manager.dispatch(entry); + await vi.waitFor(() => expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(expectedRuns)); + } + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(ackEntry).toHaveBeenCalledWith(801); + await manager.shutdown(); + }); + + it("redelivers one-shot context through SessionManager when the durable failure notice post fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-session-manager-notice-")); + roots.push(root); + const inputs: string[] = []; + const credentialOutput = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const supervisor = createProtocolSupervisor([], [`${credentialOutput}\n`, `${successfulTurn()}\n`], inputs); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(new Error("runtime notice write failed")) + .mockResolvedValue({ id: "runtime-notice" }); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async () => ({ + id: "chat-sm-notice", + title: "Notice chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const entry = mockEntry({ + id: 802, + chatId: "chat-sm-notice", + messageId: "msg-sm-notice", + content: "same terminal delivery", + }); + + await manager.dispatch(entry); + expect(ackEntry).not.toHaveBeenCalled(); + expect(existsSync(join(root, ".first-tree-workspace", "session-briefings"))).toBe(false); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledWith("chat-sm-notice")); + await manager.dispatch(entry); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(802)); + + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { const root = mkdtempSync(join(tmpdir(), "ft-opencode-auth-")); roots.push(root); @@ -448,6 +745,46 @@ describe("OpenCode V1 handler", () => { await handler.shutdown(); }); + it("retains one-shot context and briefing custody when terminal completion is returned for redelivery", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-notice-redelivery-")); + roots.push(root); + const inputs: string[] = []; + const credentialOutput = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor( + [], + [`${credentialOutput}\n`, `${successfulTurn()}\n`], + inputs, + ), + }); + const firstToken = { + ...deliveryToken(), + complete: vi.fn(async () => "retry" as const), + } satisfies DeliveryToken; + const started = await handler.start(message("m-notice", "first"), context([], []), firstToken); + const startedSessionId = typeof started === "string" ? started : started.sessionId; + expect(readSessionBriefingFingerprint(root, startedSessionId)).toBeNull(); + + const secondToken = deliveryToken(); + expect(handler.inject(message("m-redelivered", "first"), secondToken)).toEqual({ kind: "owned", mode: "queued" }); + await vi.waitFor(() => expect(secondToken.complete).toHaveBeenCalled()); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { const root = mkdtempSync(join(tmpdir(), "ft-opencode-agent-fallback-")); roots.push(root); @@ -504,6 +841,119 @@ describe("OpenCode V1 handler", () => { await handler.shutdown(); }); + it.each([ + "completed", + "failed", + ] as const)("settles a %s write-tool timeout as unsafe instead of replaying it", async (status) => { + const root = mkdtempSync(join(tmpdir(), `ft-opencode-timeout-${status}-`)); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status, input: { command: "touch effect" }, output: status === "failed" ? "failed" : "done" }, + }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`], [], true), + opencodeTurnTimeoutMs: 250, + }); + const token = deliveryToken(); + await handler.start(message("m-timeout", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-timeout" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("settles an explicit abort after a write tool as unsafe instead of replaying it", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-abort-effect-")); + roots.push(root); + const events: Array<{ kind?: string }> = []; + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`], [], true), + opencodeTurnTimeoutMs: 5_000, + }); + const token = deliveryToken(); + const started = handler.start(message("m-abort", "first"), context(events, []), token); + await vi.waitFor(() => expect(events.some((event) => event.kind === "tool_call")).toBe(true)); + await handler.suspend("test explicit abort"); + await started; + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-abort" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("preserves the unsafe-effect fence when private-config cleanup fails after provider exit", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-cleanup-effect-")); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + JSON.stringify({ type: "step_finish", sessionID: "ses_new", part: { reason: "stop" } }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + opencodeConfigProjector: (env: Record) => ({ + env, + transport: "env" as const, + cleanup: () => { + throw new Error("private config cleanup failed"); + }, + }), + }); + const token = deliveryToken(); + await handler.start(message("m-cleanup", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-cleanup" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + it("serializes one shared data-home DB gate across handler instances", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-db-shared-")); roots.push(root); @@ -527,7 +977,7 @@ describe("OpenCode V1 handler", () => { const agentNames = specs .filter((spec) => spec.args[0] === "run") .map((spec) => spec.args[spec.args.indexOf("--agent") + 1]); - expect(new Set(agentNames).size).toBe(2); + expect(new Set(agentNames).size).toBe(1); await left.shutdown(); await right.shutdown(); }); diff --git a/packages/client/src/__tests__/session-manager.test.ts b/packages/client/src/__tests__/session-manager.test.ts index 2363cdabf..54ddd9123 100644 --- a/packages/client/src/__tests__/session-manager.test.ts +++ b/packages/client/src/__tests__/session-manager.test.ts @@ -1962,13 +1962,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { capturedCtx, "Your access token could not be refreshed because your refresh token was revoked.", ); - await capturedToken.complete(capturedMessage, { + const completionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(completionDisposition).toBe("settled"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( "chat-provider-terminal", @@ -2076,13 +2077,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { reasonCode: "provider_credential_required", messagePreview: "Failed to authenticate. API Error: 403 Request not allowed", }); - await capturedToken.complete(capturedMessage, { + const completionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(completionDisposition).toBe("settled"); expect(sendMessage).toHaveBeenCalledTimes(1); const notice = String(sendMessage.mock.calls[0]?.[1].content); expect(notice).toContain("Claude Code could not run this turn"); @@ -2244,13 +2246,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { if (!capturedCtx || !capturedToken || !capturedMessage) throw new Error("delivery was not captured"); emitCodexTerminalProviderFailure(capturedCtx, "revoked refresh token"); - await capturedToken.complete(capturedMessage, { + const failedNoticeCompletionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(failedNoticeCompletionDisposition).toBe("retry"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(ackEntry).not.toHaveBeenCalled(); expect(recoverChat).toHaveBeenCalledWith("chat-provider-notice-fail"); From af01256e8cb16e90cfc99233dcfc66ca86da34b2 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:51:55 +0800 Subject: [PATCH 08/15] fix: harden OpenCode handler custody --- .../client/src/handlers/opencode/index.ts | 106 +++-- packages/client/src/runtime/handler.ts | 12 +- .../src/runtime/opencode-private-config.ts | 380 ++++++++++++++++++ 3 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 packages/client/src/runtime/opencode-private-config.ts diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index 491ce46eb..94bf449d7 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -31,6 +31,10 @@ import { parseOpenCodeVersionOutput, resolveOpenCodeRuntimeBinary, } from "../../runtime/opencode-binary.js"; +import { + acquireOpenCodePrivateConfigLease, + type OpenCodePrivateConfigLease, +} from "../../runtime/opencode-private-config.js"; import { ProviderAttempt, type ProviderAttemptSettlement } from "../../runtime/provider-attempt.js"; import { createDefaultProviderProcessSupervisor, @@ -59,7 +63,8 @@ const FINAL_CLOSE_WAIT_MS = 2_000; const DB_GATE_TIMEOUT_MS = 30_000; const CONFIG_CONTENT_ENV_MAX_BYTES = 16 * 1024; const WINDOWS_ENV_BLOCK_MAX_CHARS = 30_000; -const OPENCODE_CONFIG_RUNTIME_DIR = join(".first-tree-workspace", "opencode-config"); +const PROVIDER_ATTEMPT_WINDOW_TTL_MS = 30 * 60_000; +const MAX_PROVIDER_ATTEMPT_WINDOWS = 512; export function isOpenCodePendingSessionId(sessionId: string): boolean { return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); @@ -265,13 +270,36 @@ type TurnState = { }; const dbGatePromises = new Map>(); -const providerTurnFailureAttempts = new Map(); +const providerTurnFailureAttempts = new Map(); export function clearOpenCodeDbGateCacheForTests(): void { dbGatePromises.clear(); providerTurnFailureAttempts.clear(); } +export function openCodeProviderAttemptWindowSizeForTests(): number { + return providerTurnFailureAttempts.size; +} + +type OpenCodeRetrySleep = (delayMs: number, signal: AbortSignal) => Promise; + +async function defaultOpenCodeRetrySleep(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return false; + return new Promise((resolveDelay) => { + let settled = false; + const finish = (completed: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolveDelay(completed); + }; + const onAbort = () => finish(false); + const timer = setTimeout(() => finish(true), delayMs); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + export const createOpenCodeHandler: HandlerFactory = (config) => { const workspaceRoot = config.workspaceRoot as string; const runtimeProvider = runtimeProviderSchema.parse(config.runtimeProvider ?? "opencode"); @@ -288,9 +316,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { typeof config.opencodeTurnTimeoutMs === "number" && config.opencodeTurnTimeoutMs > 0 ? config.opencodeTurnTimeoutMs : DEFAULT_TURN_TIMEOUT_MS; - const retrySleep = - (config.opencodeRetrySleep as ((delayMs: number) => Promise) | undefined) ?? - ((delayMs: number) => new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs))); + const retrySleep = (config.opencodeRetrySleep as OpenCodeRetrySleep | undefined) ?? defaultOpenCodeRetrySleep; const configProjector = (config.opencodeConfigProjector as typeof projectOpenCodeConfig | undefined) ?? projectOpenCodeConfig; let cwd: string | null = null; @@ -311,11 +337,34 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { let pendingChatContextPrompt: string | null = null; let projectionScope: string | null = null; let managedAgentName: string | null = null; - let privateConfigRuntimeRoot: string | null = null; + const handlerGenerationId = randomUUID().replaceAll("-", ""); + let privateConfigLease: OpenCodePrivateConfigLease | null = null; const queue: Array<{ message: SessionMessage; token: DeliveryToken }> = []; function deliveryAttemptKey(sessionCtx: SessionContext, messages: readonly SessionMessage[]): string { - return `${sessionCtx.agent.agentId}\0${sessionCtx.chatId}\0${messages.map((message) => message.id).join("\0")}`; + const deliveryHead = messages[0]; + if (!deliveryHead) { + throw new Error("OpenCode provider attempt requires a delivery head"); + } + return `${sessionCtx.agent.agentId}\0${sessionCtx.chatId}\0${deliveryHead.inboxEntryId}\0${deliveryHead.id}`; + } + + function nextProviderAttempt(attemptKey: string): number { + const now = Date.now(); + for (const [key, entry] of providerTurnFailureAttempts) { + if (now - entry.touchedAt >= PROVIDER_ATTEMPT_WINDOW_TTL_MS) { + providerTurnFailureAttempts.delete(key); + } + } + const attempt = (providerTurnFailureAttempts.get(attemptKey)?.attempt ?? 0) + 1; + providerTurnFailureAttempts.delete(attemptKey); + providerTurnFailureAttempts.set(attemptKey, { attempt, touchedAt: now }); + while (providerTurnFailureAttempts.size > MAX_PROVIDER_ATTEMPT_WINDOWS) { + const oldest = providerTurnFailureAttempts.keys().next().value; + if (typeof oldest !== "string") break; + providerTurnFailureAttempts.delete(oldest); + } + return attempt; } function buildEnv(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload): Record { @@ -728,6 +777,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sessionCtx: SessionContext; messages: readonly SessionMessage[]; token: DeliveryToken; + turnGeneration: number; }): Promise { const attemptKey = deliveryAttemptKey(input.sessionCtx, input.messages); const replaySafety = input.state.sawUnsafeTool @@ -751,8 +801,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { error: input.spawnError ?? input.failure, messagePreview: displayMessage, }); - const attemptNumber = (providerTurnFailureAttempts.get(attemptKey) ?? 0) + 1; - providerTurnFailureAttempts.set(attemptKey, attemptNumber); + const attemptNumber = nextProviderAttempt(attemptKey); const settlement = attempt.settle({ attempt: attemptNumber }); if (!settlement) { input.token.retry(input.messages, "opencode_unclassified_failure"); @@ -766,7 +815,20 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }); input.sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); if (settlement.decision.action === "retry") { - await retrySleep(settlement.decision.delayMs); + const delayAbort = new AbortController(); + if (generation === input.turnGeneration && sessionActive) { + currentAbort = delayAbort; + } + const completedDelay = await retrySleep(settlement.decision.delayMs, delayAbort.signal); + if ( + completedDelay === false || + delayAbort.signal.aborted || + generation !== input.turnGeneration || + !sessionActive + ) { + providerTurnFailureAttempts.delete(attemptKey); + return false; + } input.token.retry(input.messages, settlement.decision.reasonCode); if (input.state.sawProviderActivity) { input.sessionCtx.failSessionForRecovery?.("opencode_turn_retryable_failure", providerSessionId ?? undefined); @@ -793,7 +855,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { const activeBinary = binary; const activeProjectionScope = projectionScope; const activeManagedAgentName = managedAgentName; - const activePrivateConfigRuntimeRoot = privateConfigRuntimeRoot; + const activePrivateConfigRuntimeRoot = privateConfigLease?.runtimeRoot ?? null; if ( !workspaceCwd || !activeBinary || @@ -893,6 +955,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sessionCtx, messages, token, + turnGeneration, }); } @@ -984,6 +1047,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sessionCtx, messages, token, + turnGeneration, }); })(); currentTurnPromise = promise.then( @@ -1001,6 +1065,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sessionCtx, messages, token, + turnGeneration, }); } finally { if (generation === turnGeneration) { @@ -1022,14 +1087,6 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { cwd = acquireAgentHome(workspaceRoot); projectionScope = stableOpenCodeScope(sessionCtx.agent.agentId); managedAgentName = `first-tree-${projectionScope}`; - privateConfigRuntimeRoot = join( - cwd, - OPENCODE_CONFIG_RUNTIME_DIR, - stableOpenCodeScope(`${sessionCtx.agent.agentId}\0${sessionCtx.chatId}`), - ); - rmSync(privateConfigRuntimeRoot, { recursive: true, force: true }); - mkdirSync(privateConfigRuntimeRoot, { recursive: true, mode: 0o700 }); - chmodSync(privateConfigRuntimeRoot, 0o700); const resolution = resolveBinary(process.env); if (!resolution.ok) { throw new Error(resolution.error); @@ -1041,6 +1098,11 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { pendingChatContextPrompt = [renderRuntimeOutputContract(), renderChatContextPrompt(chatContext)] .filter(Boolean) .join("\n\n"); + privateConfigLease ??= await acquireOpenCodePrivateConfigLease({ + workspace: cwd, + callerScope: stableOpenCodeScope(`${sessionCtx.agent.agentId}\0${sessionCtx.chatId}`), + handlerId: handlerGenerationId, + }); sessionActive = true; return { briefing, workspaceCwd: cwd }; } @@ -1225,12 +1287,10 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { providerSessionId = null; pendingSyntheticId = null; versionReady = false; - if (privateConfigRuntimeRoot) { - rmSync(privateConfigRuntimeRoot, { recursive: true, force: true }); - } + await privateConfigLease?.close(); projectionScope = null; managedAgentName = null; - privateConfigRuntimeRoot = null; + privateConfigLease = null; initialTurnPreparing = false; pendingChatContextPrompt = null; queue.length = 0; diff --git a/packages/client/src/runtime/handler.ts b/packages/client/src/runtime/handler.ts index 6f34f5ed8..46c6eb346 100644 --- a/packages/client/src/runtime/handler.ts +++ b/packages/client/src/runtime/handler.ts @@ -106,7 +106,10 @@ export type ResumeResult = ResumeReceipt | string; export type DeliveryToken = { processingStarted(messages: SessionMessage | readonly SessionMessage[]): void; - complete(messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome): Promise; + complete( + messages: SessionMessage | readonly SessionMessage[], + outcome: TurnOutcome, + ): Promise; retry(messages: SessionMessage | readonly SessionMessage[], reason: string): void; terminalRejected( messages: SessionMessage | readonly SessionMessage[], @@ -125,6 +128,8 @@ export type DeliveryToken = { * production SessionManager tokens always return an explicit disposition. */ export type DeliveryCompletionDisposition = "settled" | "retry"; +// biome-ignore lint/suspicious/noConfusingVoidType: legacy/test tokens intentionally resolve void. +export type DeliveryCompletionResult = DeliveryCompletionDisposition | void; export function noopDeliveryToken(): DeliveryToken { return { @@ -178,7 +183,10 @@ export type SessionContext = HandlerContext & { * The coordinator sends one ACK-through for the last message's * `inboxEntryId` and settles local ledger only after server confirmation. */ - finishTurn: (messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome) => Promise; + finishTurn: ( + messages: SessionMessage | readonly SessionMessage[], + outcome: TurnOutcome, + ) => Promise; /** * Mark a concrete message or batch as abandoned by a retryable path diff --git a/packages/client/src/runtime/opencode-private-config.ts b/packages/client/src/runtime/opencode-private-config.ts new file mode 100644 index 000000000..0e6b2e9e4 --- /dev/null +++ b/packages/client/src/runtime/opencode-private-config.ts @@ -0,0 +1,380 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { acquireWorkspaceFileLock } from "./workspace-file-lock.js"; + +const PRIVATE_CONFIG_RELATIVE_ROOT = [".first-tree-workspace", "opencode-config"] as const; +const JOURNAL_FILENAME = "handler-generations.json"; +const LOCK_FILENAME = "handler-generations.lock"; +const MAX_JOURNAL_ENTRIES = 256; +const PROCESS_INSTANCE_ID = randomUUID(); + +type HandlerGenerationEntry = Readonly<{ + handlerId: string; + pid: number; + processInstanceId: string; + createdAt: string; +}>; + +type HandlerGenerationJournal = Readonly<{ + schemaVersion: 1; + entries: readonly HandlerGenerationEntry[]; +}>; + +export type OpenCodePrivateConfigLease = Readonly<{ + runtimeRoot: string; + close: () => Promise; +}>; + +/** + * Allocate one handler-owned private-config directory below a stable caller + * parent. Every filesystem mutation revalidates canonical workspace + * containment and rejects symlinked/non-directory ancestors before it acts. + * + * The persistent lock/journal let a later daemon generation clean directories + * left by a crashed process. Live sibling handlers keep distinct children, so + * an old handler's delayed shutdown can never delete a newer handler's root. + */ +export async function acquireOpenCodePrivateConfigLease(input: { + workspace: string; + callerScope: string; + handlerId: string; +}): Promise { + assertScope(input.callerScope, "caller scope"); + assertScope(input.handlerId, "handler id"); + const workspaceRoot = canonicalWorkspaceRoot(input.workspace); + const callerParent = resolveContained(workspaceRoot, [...PRIVATE_CONFIG_RELATIVE_ROOT, input.callerScope]); + ensureSafeDirectoryChain(workspaceRoot, callerParent); + const lockPath = join(callerParent, LOCK_FILENAME); + const lock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); + const runtimeRoot = join(callerParent, `handler-${input.handlerId}`); + + try { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const journalPath = join(callerParent, JOURNAL_FILENAME); + const journal = readJournalNoFollow(journalPath); + const entries: HandlerGenerationEntry[] = []; + for (const entry of journal.entries) { + if (isStaleProcessEntry(entry)) { + removeHandlerChild(workspaceRoot, callerParent, entry.handlerId); + } else { + entries.push(entry); + } + } + sweepUnjournaledHandlerChildren(workspaceRoot, callerParent, entries); + if (entries.some((entry) => entry.handlerId === input.handlerId)) { + throw new Error("OpenCode private-config handler generation already exists"); + } + if (entries.length >= MAX_JOURNAL_ENTRIES) { + throw new Error("OpenCode private-config generation journal exceeded its safety bound"); + } + createHandlerChild(workspaceRoot, callerParent, input.handlerId); + entries.push({ + handlerId: input.handlerId, + pid: process.pid, + processInstanceId: PROCESS_INSTANCE_ID, + createdAt: new Date().toISOString(), + }); + writeJournalAtomic(journalPath, { schemaVersion: 1, entries }); + } finally { + await lock.release(); + } + + let closed = false; + return { + runtimeRoot, + close: async () => { + if (closed) return; + assertSafeDirectoryChain(workspaceRoot, callerParent); + const closeLock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); + try { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const journalPath = join(callerParent, JOURNAL_FILENAME); + const journal = readJournalNoFollow(journalPath); + const owned = journal.entries.some( + (entry) => + entry.handlerId === input.handlerId && + entry.pid === process.pid && + entry.processInstanceId === PROCESS_INSTANCE_ID, + ); + if (!owned) { + throw new Error("OpenCode private-config generation ownership changed before shutdown"); + } + removeHandlerChild(workspaceRoot, callerParent, input.handlerId); + writeJournalAtomic(journalPath, { + schemaVersion: 1, + entries: journal.entries.filter((entry) => entry.handlerId !== input.handlerId), + }); + closed = true; + } finally { + await closeLock.release(); + } + }, + }; +} + +function canonicalWorkspaceRoot(workspace: string): string { + const root = realpathSync(resolve(workspace)); + const stats = lstatSync(root); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error("OpenCode private-config workspace is not a canonical directory"); + } + return root; +} + +function resolveContained(workspaceRoot: string, segments: readonly string[]): string { + const target = resolve(workspaceRoot, ...segments); + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..") || target === workspaceRoot) { + throw new Error("OpenCode private-config path escapes the workspace"); + } + return target; +} + +function ensureSafeDirectoryChain(workspaceRoot: string, target: string): void { + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..")) throw new Error("OpenCode private-config directory escapes the workspace"); + let current = workspaceRoot; + for (const [index, segment] of rel.split(sep).filter(Boolean).entries()) { + current = join(current, segment); + try { + const stats = lstatSync(current); + if (stats.isSymbolicLink()) { + throw new Error(`OpenCode private-config ancestor is a symlink: ${current}`); + } + if (!stats.isDirectory()) { + throw new Error(`OpenCode private-config ancestor is not a directory: ${current}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + mkdirSync(current, { mode: 0o700 }); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; + } + const created = lstatSync(current); + if (created.isSymbolicLink() || !created.isDirectory()) { + throw new Error(`OpenCode private-config directory changed during creation: ${current}`); + } + } + if (index > 0) chmodSync(current, 0o700); + } +} + +function assertSafeDirectoryChain(workspaceRoot: string, target: string): void { + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..")) throw new Error("OpenCode private-config directory escapes the workspace"); + let current = workspaceRoot; + for (const segment of rel.split(sep).filter(Boolean)) { + current = join(current, segment); + const stats = lstatSync(current); + if (stats.isSymbolicLink()) { + throw new Error(`OpenCode private-config ancestor is a symlink: ${current}`); + } + if (!stats.isDirectory()) { + throw new Error(`OpenCode private-config ancestor is not a directory: ${current}`); + } + } +} + +function createHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): void { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const child = join(callerParent, `handler-${handlerId}`); + try { + mkdirSync(child, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error("OpenCode private-config handler directory already exists"); + } + throw error; + } + const stats = lstatSync(child); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OpenCode private-config handler directory is not a real directory"); + } +} + +function removeHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): void { + assertScope(handlerId, "journal handler id"); + assertSafeDirectoryChain(workspaceRoot, callerParent); + const child = join(callerParent, `handler-${handlerId}`); + let stats: ReturnType; + try { + stats = lstatSync(child); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OpenCode private-config owned handler path is not a real directory"); + } + removeDirectoryTreeNoFollow(child); +} + +function sweepUnjournaledHandlerChildren( + workspaceRoot: string, + callerParent: string, + entries: readonly HandlerGenerationEntry[], +): void { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const registered = new Set(entries.map((entry) => entry.handlerId)); + for (const name of readdirSync(callerParent)) { + if (name === JOURNAL_FILENAME || name === LOCK_FILENAME) continue; + if (!name.startsWith("handler-")) continue; + const handlerId = name.slice("handler-".length); + assertScope(handlerId, "orphan handler id"); + if (!registered.has(handlerId)) { + removeHandlerChild(workspaceRoot, callerParent, handlerId); + } + } +} + +function removeDirectoryTreeNoFollow(directory: string): void { + for (const name of readdirSync(directory)) { + const child = join(directory, name); + const stats = lstatSync(child); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + removeDirectoryTreeNoFollow(child); + } else { + unlinkSync(child); + } + } + rmdirSync(directory); +} + +function readJournalNoFollow(path: string): HandlerGenerationJournal { + let current: ReturnType; + try { + current = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { schemaVersion: 1, entries: [] }; + throw error; + } + if (current.isSymbolicLink() || !current.isFile()) { + throw new Error("OpenCode private-config generation journal is not a regular file"); + } + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_RDONLY | noFollow); + try { + const opened = fstatSync(fd); + const afterOpen = lstatSync(path); + if ( + !opened.isFile() || + !afterOpen.isFile() || + opened.nlink === 0 || + opened.dev !== afterOpen.dev || + opened.ino !== afterOpen.ino + ) { + throw new Error("OpenCode private-config generation journal changed while opening"); + } + return parseJournal(JSON.parse(readFileSync(fd, "utf8")) as unknown); + } finally { + closeSync(fd); + } +} + +function parseJournal(value: unknown): HandlerGenerationJournal { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OpenCode private-config generation journal is invalid"); + } + const record = value as Record; + if (record.schemaVersion !== 1 || !Array.isArray(record.entries)) { + throw new Error("OpenCode private-config generation journal has an unsupported schema"); + } + const entries = record.entries.map((value): HandlerGenerationEntry => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OpenCode private-config generation journal entry is invalid"); + } + const entry = value as Record; + if ( + typeof entry.handlerId !== "string" || + !isScope(entry.handlerId) || + typeof entry.pid !== "number" || + !Number.isSafeInteger(entry.pid) || + entry.pid <= 0 || + typeof entry.processInstanceId !== "string" || + entry.processInstanceId.length < 16 || + typeof entry.createdAt !== "string" || + Number.isNaN(Date.parse(entry.createdAt)) + ) { + throw new Error("OpenCode private-config generation journal entry has invalid fields"); + } + return { + handlerId: entry.handlerId, + pid: entry.pid, + processInstanceId: entry.processInstanceId, + createdAt: entry.createdAt, + }; + }); + if (entries.length > MAX_JOURNAL_ENTRIES) { + throw new Error("OpenCode private-config generation journal exceeded its safety bound"); + } + if (new Set(entries.map((entry) => entry.handlerId)).size !== entries.length) { + throw new Error("OpenCode private-config generation journal has duplicate handlers"); + } + return { schemaVersion: 1, entries }; +} + +function writeJournalAtomic(path: string, value: HandlerGenerationJournal): void { + const tempPath = `${path}.${randomBytes(8).toString("hex")}.tmp`; + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + let fd: number | null = null; + try { + fd = openSync(tempPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, 0o600); + writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fsyncSync(fd); + closeSync(fd); + fd = null; + renameSync(tempPath, path); + } catch (error) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // Preserve the original error. + } + } + try { + unlinkSync(tempPath); + } catch { + // Preserve the original error. + } + throw error; + } +} + +function isStaleProcessEntry(entry: HandlerGenerationEntry): boolean { + if (entry.processInstanceId === PROCESS_INSTANCE_ID) return false; + if (entry.pid === process.pid) return true; + try { + process.kill(entry.pid, 0); + return false; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "ESRCH"; + } +} + +function assertScope(value: string, label: string): void { + if (!isScope(value)) throw new Error(`invalid OpenCode private-config ${label}`); +} + +function isScope(value: string): boolean { + return /^[a-f0-9]{32,64}$/.test(value); +} From 6c1d87ae00deebed2295096dbfb001c224bd7df5 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 22:51:58 +0800 Subject: [PATCH 09/15] test: cover OpenCode handler custody --- .../src/__tests__/opencode-handler.test.ts | 142 ++++++++++++++++-- .../__tests__/opencode-private-config.test.ts | 91 +++++++++++ 2 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 packages/client/src/__tests__/opencode-private-config.test.ts diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index 38acc1822..db41e7c0a 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRuntimeConfig } from "@first-tree/shared"; @@ -11,6 +11,7 @@ import { clearOpenCodeDbGateCacheForTests, createOpenCodeHandler, mapOpenCodeMcpServers, + openCodeProviderAttemptWindowSizeForTests, projectOpenCodeConfig, stableOpenCodeScope, } from "../handlers/opencode/index.js"; @@ -26,6 +27,7 @@ import { mockEntry } from "./test-helpers.js"; const roots: string[] = []; afterEach(() => { + vi.restoreAllMocks(); clearOpenCodeDbGateCacheForTests(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -379,13 +381,10 @@ describe("OpenCode V1 handler", () => { expect(managedNames[2]).not.toBe(managedNames[0]); }); - it("sweeps caller-owned stale private config state and removes the scope on shutdown", async () => { + it("uses a handler-owned private config generation and removes that generation on shutdown", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-recovery-")); roots.push(root); const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); - const stalePath = join(scopeRoot, "stale", "opencode.json"); - mkdirSync(join(scopeRoot, "stale"), { recursive: true }); - writeFileSync(stalePath, '{"stale":true}'); const handler = createOpenCodeHandler({ workspaceRoot: root, runtimeProvider: "opencode", @@ -395,10 +394,42 @@ describe("OpenCode V1 handler", () => { }); await handler.start(message("m-private", "turn"), context([], []), deliveryToken()); - expect(existsSync(stalePath)).toBe(false); expect(existsSync(scopeRoot)).toBe(true); + expect(readFileSync(join(scopeRoot, "handler-generations.json"), "utf8")).toContain('"handlerId"'); await handler.shutdown(); - expect(existsSync(scopeRoot)).toBe(false); + expect(existsSync(scopeRoot)).toBe(true); + expect(readFileSync(join(scopeRoot, "handler-generations.json"), "utf8")).toContain('"entries": []'); + }); + + it("keeps a replacement handler usable after the older generation shuts down late", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-replacement-")); + roots.push(root); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const supervisor = createSyntheticSupervisor([]); + const makeHandler = () => + createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + const older = makeHandler(); + const replacement = makeHandler(); + + await older.start(message("m-private-old", "old"), context([], []), deliveryToken()); + await replacement.start(message("m-private-new", "new"), context([], []), deliveryToken()); + const generationNames = () => readdirSync(scopeRoot).filter((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationNames()).toHaveLength(2); + + await older.shutdown(); + expect(generationNames()).toHaveLength(1); + const replacementToken = deliveryToken(); + replacement.inject(message("m-private-replacement", "still alive"), replacementToken); + await vi.waitFor(() => expect(replacementToken.complete).toHaveBeenCalled()); + + await replacement.shutdown(); + expect(generationNames()).toEqual([]); }); it("fails closed when even the file-backed projection cannot fit a Windows environment block", () => { @@ -564,7 +595,12 @@ describe("OpenCode V1 handler", () => { roots.push(root); const specs: ProviderProcessSpec[] = []; const sleep = vi.fn<(delayMs: number) => Promise>(async () => {}); - const supervisor = createProtocolSupervisor(specs, ["not-json\n", "not-json\n", "not-json\n"]); + const inputs: string[] = []; + const supervisor = createProtocolSupervisor( + specs, + [`${successfulTurn()}\n`, "not-json\n", "not-json\n", "not-json\n"], + inputs, + ); const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); const sendMessage = vi.fn(async () => ({ id: "runtime-notice" })); @@ -610,26 +646,102 @@ describe("OpenCode V1 handler", () => { recoverChat, agentConfigCache: cache(runtimeConfig()), }); - const entry = mockEntry({ + const seed = mockEntry({ id: 801, chatId: "chat-sm-retry", - messageId: "msg-sm-retry", - content: "retry this delivery", + messageId: "msg-sm-seed", + content: "seed the provider session", + }); + const deliveryHead = mockEntry({ + id: 802, + chatId: "chat-sm-retry", + messageId: "msg-sm-head", + content: "retry delivery head", + }); + const fusedTail = mockEntry({ + id: 803, + chatId: "chat-sm-retry", + messageId: "msg-sm-tail", + content: "fused delivery tail", }); - await manager.dispatch(entry); + await manager.dispatch(seed); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(801)); + await Promise.all([manager.dispatch(deliveryHead), manager.dispatch(fusedTail)]); await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledTimes(1)); - for (const expectedRuns of [2, 3]) { - await manager.dispatch(entry); + expect(inputs[1]).toContain("retry delivery head"); + expect(inputs[1]).toContain("fused delivery tail"); + for (const expectedRuns of [3, 4]) { + await manager.dispatch(deliveryHead); await vi.waitFor(() => expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(expectedRuns)); } expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(ackEntry).toHaveBeenCalledWith(801); + expect(ackEntry).toHaveBeenCalledWith(802); await manager.shutdown(); }); + it("abandons no custody mutation when suspend interrupts a provider retry delay", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-suspend-")); + roots.push(root); + let sleepStarted!: () => void; + const started = new Promise((resolveStarted) => { + sleepStarted = resolveStarted; + }); + const sleep = vi.fn( + async (_delayMs: number, signal: AbortSignal) => + new Promise((resolveDelay) => { + sleepStarted(); + signal.addEventListener("abort", () => resolveDelay(false), { once: true }); + }), + ); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: sleep, + }); + const token = deliveryToken(); + const startPromise = handler.start(message("m-delay", "first"), context([], []), token); + await started; + + await handler.suspend("test suspend during provider delay"); + await startPromise; + + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).not.toHaveBeenCalled(); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(0); + await handler.shutdown(); + }); + + it("expires abandoned provider-attempt windows before admitting a new delivery head", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-expiry-")); + roots.push(root); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + const runFailure = async (id: string) => { + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: async () => {}, + }); + await handler.start(message(id, "delivery"), context([], []), deliveryToken()); + await handler.shutdown(); + }; + + await runFailure("m-old-window"); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + clock.mockReturnValue(now + 31 * 60_000); + await runFailure("m-new-window"); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + }); + it("redelivers one-shot context through SessionManager when the durable failure notice post fails", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-session-manager-notice-")); roots.push(root); diff --git a/packages/client/src/__tests__/opencode-private-config.test.ts b/packages/client/src/__tests__/opencode-private-config.test.ts new file mode 100644 index 000000000..dacbf24a7 --- /dev/null +++ b/packages/client/src/__tests__/opencode-private-config.test.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquireOpenCodePrivateConfigLease } from "../runtime/opencode-private-config.js"; + +const roots: string[] = []; +const callerScope = "a".repeat(64); + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OpenCode private config lease", () => { + it("rejects a symlinked managed ancestor without touching its external target", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-workspace-")); + const external = mkdtempSync(join(tmpdir(), "ft-opencode-private-external-")); + roots.push(workspace, external); + const managedRoot = join(workspace, ".first-tree-workspace"); + mkdirSync(managedRoot); + writeFileSync(join(external, "sentinel.txt"), "outside"); + symlinkSync(external, join(managedRoot, "opencode-config"), "dir"); + + await expect( + acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "1".repeat(32), + }), + ).rejects.toThrow(/symlink/i); + expect(readFileSync(join(external, "sentinel.txt"), "utf8")).toBe("outside"); + }); + + it("keeps a newer handler generation alive when an older handler shuts down late", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-generations-")); + roots.push(workspace); + const older = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "2".repeat(32), + }); + const newer = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "3".repeat(32), + }); + writeFileSync(join(newer.runtimeRoot, "sentinel.txt"), "newer"); + + await older.close(); + + expect(existsSync(older.runtimeRoot)).toBe(false); + expect(readFileSync(join(newer.runtimeRoot, "sentinel.txt"), "utf8")).toBe("newer"); + await newer.close(); + expect(existsSync(newer.runtimeRoot)).toBe(false); + }); + + it("sweeps a journaled crashed generation and an unjournaled orphan under the caller lock", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-stale-")); + roots.push(workspace); + const callerParent = join(workspace, ".first-tree-workspace", "opencode-config", callerScope); + const staleId = "4".repeat(32); + const orphanId = "5".repeat(32); + mkdirSync(join(callerParent, `handler-${staleId}`), { recursive: true }); + mkdirSync(join(callerParent, `handler-${orphanId}`), { recursive: true }); + writeFileSync( + join(callerParent, "handler-generations.json"), + `${JSON.stringify({ + schemaVersion: 1, + entries: [ + { + handlerId: staleId, + pid: process.pid, + processInstanceId: "stale-process-instance", + createdAt: new Date(0).toISOString(), + }, + ], + })}\n`, + ); + + const current = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "6".repeat(32), + }); + + expect(existsSync(join(callerParent, `handler-${staleId}`))).toBe(false); + expect(existsSync(join(callerParent, `handler-${orphanId}`))).toBe(false); + expect(existsSync(current.runtimeRoot)).toBe(true); + await current.close(); + }); +}); From d26718db218756e5bfd86456230fbb2dbd4b3403 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 23:14:23 +0800 Subject: [PATCH 10/15] fix: preserve OpenCode runtime custody --- .../client/src/handlers/opencode/index.ts | 90 +++++++----- packages/client/src/runtime/handler.ts | 8 + .../src/runtime/opencode-private-config.ts | 138 +++++++++++++++++- .../client/src/runtime/session-manager.ts | 12 ++ 4 files changed, 204 insertions(+), 44 deletions(-) diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index 94bf449d7..3f60b88b8 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -1,5 +1,4 @@ import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { type AgentRuntimeConfig, @@ -178,11 +177,10 @@ export function projectOpenCodeConfig( env: Record, configContent: string, deps: { + fileStore?: Pick; maxEnvBytes?: number; maxWindowsEnvChars?: number; - makeTempDir?: () => string; platform?: NodeJS.Platform; - runtimeRoot?: string; } = {}, ): OpenCodeConfigProjection { const maxEnvBytes = deps.maxEnvBytes ?? CONFIG_CONTENT_ENV_MAX_BYTES; @@ -207,38 +205,22 @@ export function projectOpenCodeConfig( "OpenCode private projection is too large for the child environment and cannot replace the host OPENCODE_CONFIG", ); } - let configDir: string; - if (deps.makeTempDir) { - configDir = deps.makeTempDir(); - } else { - if (!deps.runtimeRoot) { - throw new Error("OpenCode file-backed projection requires a runtime-owned workspace directory"); - } - mkdirSync(deps.runtimeRoot, { recursive: true, mode: 0o700 }); - chmodSync(deps.runtimeRoot, 0o700); - configDir = mkdtempSync(join(deps.runtimeRoot, "turn-")); - } - const configPath = join(configDir, "opencode.json"); - try { - writeFileSync(configPath, configContent, { encoding: "utf8", mode: 0o600, flag: "wx" }); - chmodSync(configDir, 0o700); - chmodSync(configPath, 0o600); - } catch (error) { - rmSync(configDir, { recursive: true, force: true }); - throw error; + if (!deps.fileStore) { + throw new Error("OpenCode file-backed projection requires a runtime-owned workspace lease"); } + const materialization = deps.fileStore.materialize(configContent); const fileEnv = { ...privateEnv, - OPENCODE_CONFIG: configPath, + OPENCODE_CONFIG: materialization.configPath, OPENCODE_CONFIG_CONTENT: JSON.stringify({ autoupdate: false, share: "disabled", snapshot: false }), }; if (platform === "win32" && windowsEnvBlockChars(fileEnv) > maxWindowsEnvChars) { - rmSync(configDir, { recursive: true, force: true }); + materialization.cleanup(); throw new Error("OpenCode runtime provider mismatch: child environment exceeds the safe Windows block limit"); } return { env: fileEnv, - cleanup: () => rmSync(configDir, { recursive: true, force: true }), + cleanup: materialization.cleanup, transport: "file", }; } @@ -270,7 +252,13 @@ type TurnState = { }; const dbGatePromises = new Map>(); -const providerTurnFailureAttempts = new Map(); +type ProviderTurnFailureWindow = { + attempt: number; + touchedAt: number; + hasPendingDelivery: () => boolean; +}; + +const providerTurnFailureAttempts = new Map(); export function clearOpenCodeDbGateCacheForTests(): void { dbGatePromises.clear(); @@ -349,21 +337,41 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { return `${sessionCtx.agent.agentId}\0${sessionCtx.chatId}\0${deliveryHead.inboxEntryId}\0${deliveryHead.id}`; } - function nextProviderAttempt(attemptKey: string): number { + function nextProviderAttempt( + attemptKey: string, + hasPendingDelivery: ProviderTurnFailureWindow["hasPendingDelivery"], + ): number { const now = Date.now(); for (const [key, entry] of providerTurnFailureAttempts) { - if (now - entry.touchedAt >= PROVIDER_ATTEMPT_WINDOW_TTL_MS) { + let pending = true; + try { + pending = entry.hasPendingDelivery(); + } catch { + // Observer failure is not authority to forget an unacked delivery. + } + if (!pending && now - entry.touchedAt >= PROVIDER_ATTEMPT_WINDOW_TTL_MS) { providerTurnFailureAttempts.delete(key); } } - const attempt = (providerTurnFailureAttempts.get(attemptKey)?.attempt ?? 0) + 1; - providerTurnFailureAttempts.delete(attemptKey); - providerTurnFailureAttempts.set(attemptKey, { attempt, touchedAt: now }); - while (providerTurnFailureAttempts.size > MAX_PROVIDER_ATTEMPT_WINDOWS) { - const oldest = providerTurnFailureAttempts.keys().next().value; - if (typeof oldest !== "string") break; - providerTurnFailureAttempts.delete(oldest); + const existing = providerTurnFailureAttempts.get(attemptKey); + const attempt = (existing?.attempt ?? 0) + 1; + while (!existing && providerTurnFailureAttempts.size >= MAX_PROVIDER_ATTEMPT_WINDOWS) { + const abandoned = [...providerTurnFailureAttempts] + .sort((left, right) => left[1].touchedAt - right[1].touchedAt) + .find(([, entry]) => { + try { + return !entry.hasPendingDelivery(); + } catch { + return false; + } + }); + if (!abandoned) { + throw new Error("OpenCode provider attempt ledger is full of pending deliveries"); + } + providerTurnFailureAttempts.delete(abandoned[0]); } + providerTurnFailureAttempts.delete(attemptKey); + providerTurnFailureAttempts.set(attemptKey, { attempt, touchedAt: now, hasPendingDelivery }); return attempt; } @@ -801,7 +809,10 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { error: input.spawnError ?? input.failure, messagePreview: displayMessage, }); - const attemptNumber = nextProviderAttempt(attemptKey); + const attemptNumber = nextProviderAttempt( + attemptKey, + () => input.sessionCtx.hasPendingDelivery?.(input.messages) ?? true, + ); const settlement = attempt.settle({ attempt: attemptNumber }); if (!settlement) { input.token.retry(input.messages, "opencode_unclassified_failure"); @@ -826,7 +837,6 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { generation !== input.turnGeneration || !sessionActive ) { - providerTurnFailureAttempts.delete(attemptKey); return false; } input.token.retry(input.messages, settlement.decision.reasonCode); @@ -855,13 +865,13 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { const activeBinary = binary; const activeProjectionScope = projectionScope; const activeManagedAgentName = managedAgentName; - const activePrivateConfigRuntimeRoot = privateConfigLease?.runtimeRoot ?? null; + const activePrivateConfigLease = privateConfigLease; if ( !workspaceCwd || !activeBinary || !activeProjectionScope || !activeManagedAgentName || - !activePrivateConfigRuntimeRoot || + !activePrivateConfigLease || !sessionActive ) { token.retry(messages, sessionActive ? "opencode_not_prepared" : "opencode_session_inactive"); @@ -919,7 +929,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { managedAgentName: activeManagedAgentName, scope: activeProjectionScope, }), - { runtimeRoot: activePrivateConfigRuntimeRoot }, + { fileStore: activePrivateConfigLease }, ); try { outcome = await runProcess({ diff --git a/packages/client/src/runtime/handler.ts b/packages/client/src/runtime/handler.ts index 46c6eb346..d89cedee1 100644 --- a/packages/client/src/runtime/handler.ts +++ b/packages/client/src/runtime/handler.ts @@ -195,6 +195,14 @@ export type SessionContext = HandlerContext & { */ retryTurn: (messages: SessionMessage | readonly SessionMessage[], reason: string) => void; + /** + * True while the delivery coordinator still owns at least one concrete + * inbox row in this message/fused batch. Retry windows use this as their + * durable cleanup authority; route-generation invalidation alone does not + * mean the unacked server delivery was abandoned. + */ + hasPendingDelivery?: (messages: SessionMessage | readonly SessionMessage[]) => boolean; + /** * Drop the current live handler after it has fenced an unknown-custody * provider failure and marked the affected inbox work for recovery. The diff --git a/packages/client/src/runtime/opencode-private-config.ts b/packages/client/src/runtime/opencode-private-config.ts index 0e6b2e9e4..fa926565a 100644 --- a/packages/client/src/runtime/opencode-private-config.ts +++ b/packages/client/src/runtime/opencode-private-config.ts @@ -3,10 +3,12 @@ import { chmodSync, closeSync, constants, + fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, + mkdtempSync, openSync, readdirSync, readFileSync, @@ -38,10 +40,20 @@ type HandlerGenerationJournal = Readonly<{ }>; export type OpenCodePrivateConfigLease = Readonly<{ - runtimeRoot: string; + materialize: (configContent: string) => OpenCodePrivateConfigMaterialization; close: () => Promise; }>; +export type OpenCodePrivateConfigMaterialization = Readonly<{ + configPath: string; + cleanup: () => void; +}>; + +type FileIdentity = Readonly<{ + dev: number; + ino: number; +}>; + /** * Allocate one handler-owned private-config directory below a stable caller * parent. Every filesystem mutation revalidates canonical workspace @@ -64,6 +76,7 @@ export async function acquireOpenCodePrivateConfigLease(input: { const lockPath = join(callerParent, LOCK_FILENAME); const lock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); const runtimeRoot = join(callerParent, `handler-${input.handlerId}`); + let handlerIdentity: FileIdentity | null = null; try { assertSafeDirectoryChain(workspaceRoot, callerParent); @@ -84,7 +97,7 @@ export async function acquireOpenCodePrivateConfigLease(input: { if (entries.length >= MAX_JOURNAL_ENTRIES) { throw new Error("OpenCode private-config generation journal exceeded its safety bound"); } - createHandlerChild(workspaceRoot, callerParent, input.handlerId); + handlerIdentity = createHandlerChild(workspaceRoot, callerParent, input.handlerId); entries.push({ handlerId: input.handlerId, pid: process.pid, @@ -96,11 +109,62 @@ export async function acquireOpenCodePrivateConfigLease(input: { await lock.release(); } + if (!handlerIdentity) { + throw new Error("OpenCode private-config handler generation was not created"); + } + const ownedHandlerIdentity = handlerIdentity; + const activeProjectionDirectories = new Map(); let closed = false; return { - runtimeRoot, + materialize: (configContent) => { + if (closed) throw new Error("OpenCode private-config lease is closed"); + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + const configDirectory = mkdtempSync(join(runtimeRoot, "turn-")); + const directoryIdentity = directoryIdentityAt(configDirectory, "projection directory"); + let retained = false; + try { + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + chmodSync(configDirectory, 0o700); + const configPath = join(configDirectory, "opencode.json"); + writeConfigFileNoFollow(configPath, configContent, () => { + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + }); + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + activeProjectionDirectories.set(configDirectory, directoryIdentity); + retained = true; + let cleaned = false; + return { + configPath, + cleanup: () => { + if (cleaned) return; + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + removeDirectoryTreeNoFollow(configDirectory); + activeProjectionDirectories.delete(configDirectory); + cleaned = true; + }, + }; + } finally { + if (!retained) { + removeProjectionDirectoryIfOwned( + workspaceRoot, + callerParent, + input.handlerId, + ownedHandlerIdentity, + configDirectory, + directoryIdentity, + ); + } + } + }, close: async () => { if (closed) return; + if (activeProjectionDirectories.size > 0) { + throw new Error("OpenCode private-config lease still has active projections"); + } assertSafeDirectoryChain(workspaceRoot, callerParent); const closeLock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); try { @@ -116,6 +180,7 @@ export async function acquireOpenCodePrivateConfigLease(input: { if (!owned) { throw new Error("OpenCode private-config generation ownership changed before shutdown"); } + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); removeHandlerChild(workspaceRoot, callerParent, input.handlerId); writeJournalAtomic(journalPath, { schemaVersion: 1, @@ -193,7 +258,7 @@ function assertSafeDirectoryChain(workspaceRoot: string, target: string): void { } } -function createHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): void { +function createHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): FileIdentity { assertSafeDirectoryChain(workspaceRoot, callerParent); const child = join(callerParent, `handler-${handlerId}`); try { @@ -208,6 +273,71 @@ function createHandlerChild(workspaceRoot: string, callerParent: string, handler if (stats.isSymbolicLink() || !stats.isDirectory()) { throw new Error("OpenCode private-config handler directory is not a real directory"); } + return { dev: stats.dev, ino: stats.ino }; +} + +function assertHandlerChildIdentity( + workspaceRoot: string, + callerParent: string, + handlerId: string, + expected: FileIdentity, +): void { + assertSafeDirectoryChain(workspaceRoot, callerParent); + assertDirectoryIdentity(join(callerParent, `handler-${handlerId}`), expected, "handler generation"); +} + +function directoryIdentityAt(path: string, label: string): FileIdentity { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`OpenCode private-config ${label} is not a real directory`); + } + return { dev: stats.dev, ino: stats.ino }; +} + +function assertDirectoryIdentity(path: string, expected: FileIdentity, label: string): void { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isDirectory() || stats.dev !== expected.dev || stats.ino !== expected.ino) { + throw new Error(`OpenCode private-config ${label} identity changed`); + } +} + +function writeConfigFileNoFollow(path: string, configContent: string, validateParent: () => void): void { + validateParent(); + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, 0o600); + try { + const opened = fstatSync(fd); + if (!opened.isFile() || opened.nlink === 0) { + throw new Error("OpenCode private-config file is not a live regular file"); + } + writeFileSync(fd, configContent, "utf8"); + fsyncSync(fd); + fchmodSync(fd, 0o600); + const current = lstatSync(path); + if (current.isSymbolicLink() || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino) { + throw new Error("OpenCode private-config file identity changed while writing"); + } + } finally { + closeSync(fd); + } +} + +function removeProjectionDirectoryIfOwned( + workspaceRoot: string, + callerParent: string, + handlerId: string, + handlerIdentity: FileIdentity, + configDirectory: string, + directoryIdentity: FileIdentity, +): void { + try { + assertHandlerChildIdentity(workspaceRoot, callerParent, handlerId, handlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + removeDirectoryTreeNoFollow(configDirectory); + } catch { + // Fail closed: never follow or remove a path whose containment/identity + // cannot still be proven. + } } function removeHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): void { diff --git a/packages/client/src/runtime/session-manager.ts b/packages/client/src/runtime/session-manager.ts index 34a0bb624..72dcb03c4 100644 --- a/packages/client/src/runtime/session-manager.ts +++ b/packages/client/src/runtime/session-manager.ts @@ -2819,6 +2819,18 @@ export class SessionManager { this.retryDeliveryTurn(chatId, messages, reason); this.projectSessionRuntime(chatId); }, + hasPendingDelivery: (messages) => { + const batch = Array.isArray(messages) ? messages : [messages]; + return batch.some( + (message) => + message.inboxEntryId !== undefined && + this.inboxDelivery.hasEntry({ + chatId, + entryId: message.inboxEntryId, + messageId: message.id, + }), + ); + }, failSessionForRecovery: (reason, sessionId) => { if (routeLeaseValid && !routeLeaseValid()) return; this.failSessionForRecovery(chatId, reason, sessionId); From ccf1cf074e7876094c54749dffe5b087754acb6e Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Wed, 29 Jul 2026 23:14:23 +0800 Subject: [PATCH 11/15] test: cover OpenCode runtime custody races --- .../src/__tests__/opencode-handler.test.ts | 181 ++++++++++++++++-- .../__tests__/opencode-private-config.test.ts | 25 ++- 2 files changed, 185 insertions(+), 21 deletions(-) diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index db41e7c0a..e7ab0b758 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRuntimeConfig } from "@first-tree/shared"; @@ -17,6 +17,7 @@ import { } from "../handlers/opencode/index.js"; import type { AgentConfigCache } from "../runtime/agent-config-cache.js"; import type { DeliveryToken, SessionContext, SessionMessage } from "../runtime/handler.js"; +import { acquireOpenCodePrivateConfigLease } from "../runtime/opencode-private-config.js"; import type { ProviderProcessSpec, ProviderProcessSupervisor } from "../runtime/provider-process-supervisor.js"; import { readSessionBriefingFingerprint } from "../runtime/session-briefing-fingerprint.js"; import { SessionManager } from "../runtime/session-manager.js"; @@ -304,13 +305,17 @@ describe("OpenCode V1 handler", () => { ); }); - it("moves oversized private config out of the Windows-sensitive environment block and cleans it", () => { + it("moves oversized private config out of the Windows-sensitive environment block and cleans it", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-")); roots.push(root); - const runtimeRoot = join(root, ".first-tree-workspace", "opencode-config", "scope"); + const lease = await acquireOpenCodePrivateConfigLease({ + workspace: root, + callerScope: "a".repeat(64), + handlerId: "1".repeat(32), + }); const projection = projectOpenCodeConfig({ BASE: "1", OPENCODE_CONFIG_CONTENT: "stale" }, '{"secret":"value"}', { maxEnvBytes: 1, - runtimeRoot, + fileStore: lease, }); expect(projection.transport).toBe("file"); expect(JSON.parse(String(projection.env.OPENCODE_CONFIG_CONTENT))).toEqual({ @@ -320,11 +325,11 @@ describe("OpenCode V1 handler", () => { }); expect(String(projection.env.OPENCODE_CONFIG_CONTENT)).not.toContain("secret"); const configPath = String(projection.env.OPENCODE_CONFIG); - expect(configPath.startsWith(runtimeRoot)).toBe(true); + expect(configPath).toContain(join(".first-tree-workspace", "opencode-config")); expect(readFileSync(configPath, "utf8")).toBe('{"secret":"value"}'); projection.cleanup(); expect(existsSync(configPath)).toBe(false); - expect(existsSync(runtimeRoot)).toBe(true); + await lease.close(); }); it("preserves host custom config for inline projection and fails closed rather than replacing it on overflow", () => { @@ -339,7 +344,6 @@ describe("OpenCode V1 handler", () => { expect(() => projectOpenCodeConfig({ OPENCODE_CONFIG: hostConfig }, '{"agent":{}}', { maxEnvBytes: 1, - runtimeRoot: "/private/runtime", }), ).toThrow(/cannot replace the host OPENCODE_CONFIG/i); }); @@ -432,17 +436,59 @@ describe("OpenCode V1 handler", () => { expect(generationNames()).toEqual([]); }); - it("fails closed when even the file-backed projection cannot fit a Windows environment block", () => { + it("fails closed before a file-backed write when a live generation leaf becomes a symlink", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-swap-")); + const external = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-outside-")); + roots.push(root, external); + const cfg = runtimeConfig(); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(cfg), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor([]), + opencodeRetrySleep: async () => {}, + }); + await handler.start(message("m-private-swap-first", "small config"), context([], []), deliveryToken()); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const generationName = readdirSync(scopeRoot).find((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationName).toBeTruthy(); + const generationPath = join(scopeRoot, String(generationName)); + rmSync(generationPath, { recursive: true, force: true }); + writeFileSync(join(external, "sentinel.txt"), "outside"); + symlinkSync(external, generationPath, "dir"); + cfg.payload.mcpServers[0] = { + name: "large", + transport: "stdio", + command: "mcp-bin", + args: ["x".repeat(20_000)], + }; + + const token = deliveryToken(); + handler.inject(message("m-private-swap-second", "force file projection"), token); + await vi.waitFor(() => expect(token.retry).toHaveBeenCalled()); + + expect(readdirSync(external)).toEqual(["sentinel.txt"]); + expect(readFileSync(join(external, "sentinel.txt"), "utf8")).toBe("outside"); + await expect(handler.shutdown()).rejects.toThrow(/symlink|identity/i); + }); + + it("fails closed when even the file-backed projection cannot fit a Windows environment block", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-overflow-")); roots.push(root); + const lease = await acquireOpenCodePrivateConfigLease({ + workspace: root, + callerScope: "b".repeat(64), + handlerId: "2".repeat(32), + }); expect(() => projectOpenCodeConfig({ HUGE: "x".repeat(1_000) }, '{"agent":{}}', { platform: "win32", maxWindowsEnvChars: 100, - makeTempDir: () => root, + fileStore: lease, }), ).toThrow(/exceeds the safe Windows block limit/i); - expect(existsSync(root)).toBe(false); + await lease.close(); }); it("serializes DB readiness, sends prompt only on stdin, and resumes the confirmed session", async () => { @@ -713,16 +759,115 @@ describe("OpenCode V1 handler", () => { expect(token.retry).not.toHaveBeenCalled(); expect(token.complete).not.toHaveBeenCalled(); - expect(openCodeProviderAttemptWindowSizeForTests()).toBe(0); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); await handler.shutdown(); }); + it("continues the same retry window after SessionManager preempts an unacked delivery delay", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-preempt-")); + roots.push(root); + let firstDelayStarted!: () => void; + const delayStarted = new Promise((resolveStarted) => { + firstDelayStarted = resolveStarted; + }); + let delayCall = 0; + const sleep = vi.fn(async (_delayMs: number, signal: AbortSignal) => { + delayCall += 1; + if (delayCall > 1) return true; + firstDelayStarted(); + return new Promise((resolveDelay) => { + signal.addEventListener("abort", () => resolveDelay(false), { once: true }); + }); + }); + const specs: ProviderProcessSpec[] = []; + const supervisor = createProtocolSupervisor(specs, [ + "not-json\n", + `${successfulTurn("ses_other")}\n`, + "not-json\n", + "not-json\n", + ]); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi.fn(async () => ({ id: "runtime-notice" })); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async (chatId: string) => ({ + id: chatId, + title: "Retry preemption chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + opencodeRetrySleep: sleep, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const delivery = mockEntry({ + id: 811, + chatId: "chat-retry-preempt", + messageId: "msg-retry-preempt", + content: "same delivery after preemption", + }); + const competing = mockEntry({ + id: 812, + chatId: "chat-competing", + messageId: "msg-competing", + content: "take the only runtime slot", + }); + + const firstDispatch = manager.dispatch(delivery); + await delayStarted; + await manager.dispatch(competing); + await firstDispatch; + expect(ackEntry).not.toHaveBeenCalledWith(811); + expect(ackEntry).toHaveBeenCalledWith(812); + + await manager.dispatch(delivery); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledWith("chat-retry-preempt")); + await manager.dispatch(delivery); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(811)); + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(sendMessage).toHaveBeenCalledTimes(1); + await manager.shutdown(); + }); + it("expires abandoned provider-attempt windows before admitting a new delivery head", async () => { const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-expiry-")); roots.push(root); const now = Date.now(); const clock = vi.spyOn(Date, "now").mockReturnValue(now); - const runFailure = async (id: string) => { + let oldDeliveryPending = true; + const runFailure = async (id: string, hasPendingDelivery: () => boolean) => { const handler = createOpenCodeHandler({ workspaceRoot: root, runtimeProvider: "opencode", @@ -731,14 +876,20 @@ describe("OpenCode V1 handler", () => { providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), opencodeRetrySleep: async () => {}, }); - await handler.start(message(id, "delivery"), context([], []), deliveryToken()); + const sessionCtx = context([], []); + sessionCtx.hasPendingDelivery = hasPendingDelivery; + await handler.start(message(id, "delivery"), sessionCtx, deliveryToken()); await handler.shutdown(); }; - await runFailure("m-old-window"); + await runFailure("m-old-window", () => oldDeliveryPending); expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); clock.mockReturnValue(now + 31 * 60_000); - await runFailure("m-new-window"); + await runFailure("m-old-window", () => oldDeliveryPending); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + oldDeliveryPending = false; + clock.mockReturnValue(now + 62 * 60_000); + await runFailure("m-new-window", () => true); expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); }); diff --git a/packages/client/src/__tests__/opencode-private-config.test.ts b/packages/client/src/__tests__/opencode-private-config.test.ts index dacbf24a7..8598ad1b8 100644 --- a/packages/client/src/__tests__/opencode-private-config.test.ts +++ b/packages/client/src/__tests__/opencode-private-config.test.ts @@ -1,4 +1,13 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -44,14 +53,18 @@ describe("OpenCode private config lease", () => { callerScope, handlerId: "3".repeat(32), }); - writeFileSync(join(newer.runtimeRoot, "sentinel.txt"), "newer"); + const materialization = newer.materialize("newer"); + const callerParent = join(workspace, ".first-tree-workspace", "opencode-config", callerScope); + const generationNames = () => readdirSync(callerParent).filter((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationNames()).toHaveLength(2); await older.close(); - expect(existsSync(older.runtimeRoot)).toBe(false); - expect(readFileSync(join(newer.runtimeRoot, "sentinel.txt"), "utf8")).toBe("newer"); + expect(generationNames()).toHaveLength(1); + expect(readFileSync(materialization.configPath, "utf8")).toBe("newer"); + materialization.cleanup(); await newer.close(); - expect(existsSync(newer.runtimeRoot)).toBe(false); + expect(generationNames()).toEqual([]); }); it("sweeps a journaled crashed generation and an unjournaled orphan under the caller lock", async () => { @@ -85,7 +98,7 @@ describe("OpenCode private config lease", () => { expect(existsSync(join(callerParent, `handler-${staleId}`))).toBe(false); expect(existsSync(join(callerParent, `handler-${orphanId}`))).toBe(false); - expect(existsSync(current.runtimeRoot)).toBe(true); + expect(readdirSync(callerParent).some((name) => /^handler-[a-f0-9]{32}$/.test(name))).toBe(true); await current.close(); }); }); From 56cfb565bdd9110df6d7715ad454593281824ee1 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Thu, 30 Jul 2026 12:42:42 +0800 Subject: [PATCH 12/15] fix(client): bound OpenCode unsafe discovery retries --- .../src/__tests__/opencode-handler.test.ts | 211 ++++++++++++++++++ .../client/src/handlers/opencode/index.ts | 124 ++++++++-- 2 files changed, 312 insertions(+), 23 deletions(-) diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index 08394431e..e7dc25a8a 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -30,6 +30,7 @@ const roots: string[] = []; afterEach(() => { vi.restoreAllMocks(); + resetManagedSkillsReconcileMock(); clearOpenCodeDbGateCacheForTests(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -106,6 +107,37 @@ function reconciledSkillsResult(resourceConfigVersion = 1) { }; } +function resetManagedSkillsReconcileMock() { + const reconcile = vi.mocked(reconcileManagedSkillsForConfig); + reconcile.mockReset(); + reconcile.mockImplementation(async (_workspace, _provider, config) => reconciledSkillsResult(config?.version ?? 0)); + return reconcile; +} + +function controlledOpenCodeSleep() { + const waits: Array<{ + delayMs: number; + signal: AbortSignal; + complete: () => void; + }> = []; + const sleep = vi.fn( + (delayMs: number, signal: AbortSignal) => + new Promise((resolveDelay) => { + let settled = false; + const finish = (completed: boolean) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + resolveDelay(completed); + }; + const onAbort = () => finish(false); + signal.addEventListener("abort", onAbort, { once: true }); + waits.push({ delayMs, signal, complete: () => finish(true) }); + }), + ); + return { sleep, waits }; +} + const SYNTHETIC_PROVIDER_SCRIPT = ` const kind = process.env.FIRST_TREE_TEST_PROVIDER_KIND; if (kind === "version") { @@ -348,6 +380,185 @@ describe("OpenCode V1 handler", () => { await handler.shutdown(); }); + it.each([ + ["queued preflight", 2], + ["turn preflight", 4], + ] as const)("parks persistent unsafe discovery at the %s and resumes the ordered batch once", async (failurePoint, expectedUnsafeRefreshes) => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-managed-skills-parked-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; + const sessionCtx = context([], []); + const reconcile = resetManagedSkillsReconcileMock(); + const { sleep, waits } = controlledOpenCodeSleep(); + let queuedPhase = false; + let unsafe = true; + let queuedRefreshes = 0; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + queuedRefreshes += 1; + const shouldFail = unsafe && (failurePoint === "queued preflight" || queuedRefreshes % 2 === 0); + if (shouldFail) throw new ManagedSkillsUnsafeDiscoveryError(`${failurePoint} unsafe`); + return reconciledSkillsResult(); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { capturedInputs: inputs }), + opencodeUnsafeDiscoverySleep: sleep, + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const heldToken = deliveryToken(); + const tailToken = deliveryToken(); + + expect(handler.inject(message("m-held", "held delivery"), heldToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1)); + expect(handler.inject(message("m-tail", "later delivery"), tailToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + waits[0]?.complete(); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(2)); + + expect(sleep.mock.calls.map(([delayMs]) => delayMs)).toEqual([1_000, 2_000]); + expect(queuedRefreshes).toBe(expectedUnsafeRefreshes); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(heldToken.processingStarted).not.toHaveBeenCalled(); + expect(heldToken.complete).not.toHaveBeenCalled(); + expect(heldToken.retry).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).not.toHaveBeenCalled(); + expect(tailToken.complete).not.toHaveBeenCalled(); + expect(tailToken.retry).not.toHaveBeenCalled(); + const blockedLogs = vi + .mocked(sessionCtx.log) + .mock.calls.flat() + .filter((line) => String(line).includes("queued turn blocked by unsafe managed-skill discovery")); + expect(blockedLogs).toHaveLength(2); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain("OpenCode queued turn failed"); + + unsafe = false; + waits[1]?.complete(); + await vi.waitFor(() => expect(heldToken.complete).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(tailToken.complete).toHaveBeenCalledTimes(1)); + + expect(heldToken.processingStarted).toHaveBeenCalledTimes(1); + expect(heldToken.retry).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).toHaveBeenCalledTimes(1); + expect(tailToken.retry).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(3); + expect(inputs).toHaveLength(3); + expect(inputs[1]).toContain("held delivery"); + expect(inputs[1]).not.toContain("later delivery"); + expect(inputs[2]).toContain("later delivery"); + for (const spec of specs.filter((entry) => entry.args[0] === "run").slice(1)) { + expect(spec.args).toEqual(expect.arrayContaining(["--session", "ses_new"])); + } + await handler.shutdown(); + }); + + it.each(["suspend", "shutdown"] as const)("releases a parked unsafe batch exactly once on %s", async (lifecycle) => { + const root = mkdtempSync(join(tmpdir(), `ft-opencode-managed-skills-${lifecycle}-`)); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sessionCtx = context([], []); + const reconcile = resetManagedSkillsReconcileMock(); + const { sleep, waits } = controlledOpenCodeSleep(); + let queuedPhase = false; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + throw new ManagedSkillsUnsafeDiscoveryError("persistent unsafe discovery"); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeUnsafeDiscoverySleep: sleep, + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const heldToken = deliveryToken(); + const tailToken = deliveryToken(); + expect(handler.inject(message("m-held", "held"), heldToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1)); + expect(handler.inject(message("m-tail", "tail"), tailToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + const reason = `test parked ${lifecycle}`; + + if (lifecycle === "suspend") await handler.suspend(reason); + else await handler.shutdown(reason); + + expect(waits[0]?.signal.aborted).toBe(true); + expect(sleep).toHaveBeenCalledTimes(1); + expect(heldToken.retry).toHaveBeenCalledTimes(1); + expect(heldToken.retry).toHaveBeenCalledWith(expect.objectContaining({ id: "m-held" }), reason); + expect(tailToken.retry).toHaveBeenCalledTimes(1); + expect(tailToken.retry).toHaveBeenCalledWith(expect.objectContaining({ id: "m-tail" }), reason); + expect(heldToken.processingStarted).not.toHaveBeenCalled(); + expect(heldToken.complete).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).not.toHaveBeenCalled(); + expect(tailToken.complete).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain("OpenCode queued turn failed"); + if (lifecycle === "suspend") await handler.shutdown(); + }); + + it("keeps ordinary queued projection failures on the generic recovery path", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-queued-generic-failure-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sessionCtx = context([], []); + const reconcile = resetManagedSkillsReconcileMock(); + let queuedPhase = false; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + throw new Error("ordinary queued projection failure"); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const token = deliveryToken(); + + expect(handler.inject(message("m-generic", "generic failure"), token)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(token.retry).toHaveBeenCalledTimes(1)); + + expect(token.retry).toHaveBeenCalledWith( + expect.objectContaining({ id: "m-generic" }), + "opencode_queued_turn_failed", + ); + expect(token.processingStarted).not.toHaveBeenCalled(); + expect(token.complete).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).toContain( + "OpenCode queued turn failed: ordinary queued projection failure", + ); + await handler.shutdown(); + }); + it("builds private MCP/agent config and provider-native argv", () => { const config = runtimeConfig().payload; expect(mapOpenCodeMcpServers(config, "scope-a")).toEqual({ diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index c8dff4120..ff4139be4 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -69,6 +69,8 @@ const CONFIG_CONTENT_ENV_MAX_BYTES = 16 * 1024; const WINDOWS_ENV_BLOCK_MAX_CHARS = 30_000; const PROVIDER_ATTEMPT_WINDOW_TTL_MS = 30 * 60_000; const MAX_PROVIDER_ATTEMPT_WINDOWS = 512; +const QUEUED_UNSAFE_DISCOVERY_RETRY_BASE_MS = 1_000; +const QUEUED_UNSAFE_DISCOVERY_RETRY_MAX_MS = 30_000; export function isOpenCodePendingSessionId(sessionId: string): boolean { return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); @@ -275,6 +277,7 @@ export function openCodeProviderAttemptWindowSizeForTests(): number { } type OpenCodeRetrySleep = (delayMs: number, signal: AbortSignal) => Promise; +type QueuedDelivery = { message: SessionMessage; token: DeliveryToken }; async function defaultOpenCodeRetrySleep(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return false; @@ -293,6 +296,11 @@ async function defaultOpenCodeRetrySleep(delayMs: number, signal: AbortSignal): }); } +function queuedUnsafeDiscoveryRetryDelayMs(attempt: number): number { + const exponent = Math.min(Math.max(attempt - 1, 0), 30); + return Math.min(QUEUED_UNSAFE_DISCOVERY_RETRY_BASE_MS * 2 ** exponent, QUEUED_UNSAFE_DISCOVERY_RETRY_MAX_MS); +} + export const createOpenCodeHandler: HandlerFactory = (config) => { const workspaceRoot = config.workspaceRoot as string; const runtimeProvider = runtimeProviderSchema.parse(config.runtimeProvider ?? "opencode"); @@ -310,6 +318,8 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { ? config.opencodeTurnTimeoutMs : DEFAULT_TURN_TIMEOUT_MS; const retrySleep = (config.opencodeRetrySleep as OpenCodeRetrySleep | undefined) ?? defaultOpenCodeRetrySleep; + const unsafeDiscoverySleep = + (config.opencodeUnsafeDiscoverySleep as OpenCodeRetrySleep | undefined) ?? defaultOpenCodeRetrySleep; const configProjector = (config.opencodeConfigProjector as typeof projectOpenCodeConfig | undefined) ?? projectOpenCodeConfig; let cwd: string | null = null; @@ -327,12 +337,17 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { let generation = 0; let drainScheduled = false; let drainInProgress = false; + let currentDrainPromise: Promise | null = null; + let drainingBatch: QueuedDelivery[] | null = null; + let unsafeDiscoveryParkedBatch: QueuedDelivery[] | null = null; + let unsafeDiscoveryWaitAbort: AbortController | null = null; + let drainCancellationReason: string | null = null; let pendingChatContextPrompt: string | null = null; let projectionScope: string | null = null; let managedAgentName: string | null = null; const handlerGenerationId = randomUUID().replaceAll("-", ""); let privateConfigLease: OpenCodePrivateConfigLease | null = null; - const queue: Array<{ message: SessionMessage; token: DeliveryToken }> = []; + const queue: QueuedDelivery[] = []; function deliveryAttemptKey(sessionCtx: SessionContext, messages: readonly SessionMessage[]): string { const deliveryHead = messages[0]; @@ -872,6 +887,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { sessionCtx: SessionContext, messages: readonly SessionMessage[], token: DeliveryToken, + unsafeDiscoveryAction: "retry" | "throw" = "retry", ): Promise { const workspaceCwd = cwd; const activeBinary = binary; @@ -1080,6 +1096,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { return await promise; } catch (error) { if (isManagedSkillsUnsafeDiscoveryError(error)) { + if (unsafeDiscoveryAction === "throw") throw error; token.retry(messages, "opencode_managed_skills_unsafe"); sessionCtx.log(`blocked provider turn: ${error.message}`); return false; @@ -1134,10 +1151,18 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { return { briefing, workspaceCwd: cwd }; } - async function runQueued( - drained: Array<{ message: SessionMessage; token: DeliveryToken }>, - sessionCtx: SessionContext, - ): Promise { + function finishDrainingBatch(batch: QueuedDelivery[]): void { + if (unsafeDiscoveryParkedBatch === batch) unsafeDiscoveryParkedBatch = null; + if (drainingBatch === batch) drainingBatch = null; + } + + function retryDrainingBatch(batch: QueuedDelivery[], reason: string): void { + if (drainingBatch !== batch && unsafeDiscoveryParkedBatch !== batch) return; + finishDrainingBatch(batch); + for (const entry of batch) entry.token.retry(entry.message, reason); + } + + async function runQueued(drained: QueuedDelivery[], sessionCtx: SessionContext): Promise { const token = drained[0]?.token; if (!token) return; const messages = drained.map((entry) => entry.message); @@ -1146,28 +1171,58 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { for (const message of messages) parts.push(await sessionCtx.formatInboundContent(message)); } catch (error) { sessionCtx.log(`OpenCode queued formatting failed: ${error instanceof Error ? error.message : String(error)}`); - for (const entry of drained) entry.token.retry(entry.message, "opencode_queued_format_failed"); + retryDrainingBatch(drained, "opencode_queued_format_failed"); return; } - const sessionKey = providerSessionId ?? pendingSyntheticId; - let fingerprint: string | null = null; - if (cwd && sessionKey) { - const projection = await refreshProjection(sessionCtx); - fingerprint = computeBriefingFingerprint(projection.briefing); - if (readSessionBriefingFingerprint(cwd, sessionKey) !== fingerprint) { - parts.unshift(buildBriefingUpdateNotice(join(cwd, "AGENTS.md"))); + + let unsafeAttempt = 0; + while (sessionActive && drainingBatch === drained) { + const sessionKey = providerSessionId ?? pendingSyntheticId; + let fingerprint: string | null = null; + try { + const turnParts = [...parts]; + if (cwd && sessionKey) { + const projection = await refreshProjection(sessionCtx); + fingerprint = computeBriefingFingerprint(projection.briefing); + if (readSessionBriefingFingerprint(cwd, sessionKey) !== fingerprint) { + turnParts.unshift(buildBriefingUpdateNotice(join(cwd, "AGENTS.md"))); + } + } + const delivered = await runTurn(turnParts.join("\n\n"), sessionCtx, messages, token, "throw"); + if (delivered && fingerprint && cwd && sessionKey) { + writeSessionBriefingFingerprint(cwd, providerSessionId ?? sessionKey, fingerprint); + } + finishDrainingBatch(drained); + return; + } catch (error) { + if (!isManagedSkillsUnsafeDiscoveryError(error)) throw error; + if (!sessionActive || drainingBatch !== drained) return; + + unsafeDiscoveryParkedBatch = drained; + unsafeAttempt += 1; + const delayMs = queuedUnsafeDiscoveryRetryDelayMs(unsafeAttempt); + sessionCtx.log( + `OpenCode queued turn blocked by unsafe managed-skill discovery; retrying in ${delayMs}ms: ${error.message}`, + ); + const waitAbort = new AbortController(); + unsafeDiscoveryWaitAbort = waitAbort; + const completedDelay = await unsafeDiscoverySleep(delayMs, waitAbort.signal); + if (unsafeDiscoveryWaitAbort === waitAbort) unsafeDiscoveryWaitAbort = null; + if (!completedDelay) { + if (!drainCancellationReason && sessionActive && drainingBatch === drained) { + throw new Error("OpenCode queued unsafe-discovery wait ended without lifecycle cancellation"); + } + return; + } } } - const delivered = await runTurn(parts.join("\n\n"), sessionCtx, messages, token); - if (delivered && fingerprint && cwd && sessionKey) { - writeSessionBriefingFingerprint(cwd, providerSessionId ?? sessionKey, fingerprint); - } } function scheduleDrain(): void { if ( drainScheduled || drainInProgress || + drainingBatch || queue.length === 0 || !ctx || !sessionActive || @@ -1181,6 +1236,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { drainScheduled = false; if ( drainInProgress || + drainingBatch || queue.length === 0 || !ctx || !sessionActive || @@ -1192,16 +1248,26 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { } const drained = queue.splice(0); const sessionCtx = ctx; + drainingBatch = drained; drainInProgress = true; - void runQueued(drained, sessionCtx) + const drainPromise = runQueued(drained, sessionCtx) .catch((error) => { + const cancellationReason = drainCancellationReason; + if (cancellationReason) { + retryDrainingBatch(drained, cancellationReason); + return; + } sessionCtx.log(`OpenCode queued turn failed: ${error instanceof Error ? error.message : String(error)}`); - for (const entry of drained) entry.token.retry(entry.message, "opencode_queued_turn_failed"); + retryDrainingBatch(drained, "opencode_queued_turn_failed"); }) .finally(() => { + if (!drainCancellationReason && drainingBatch === drained) finishDrainingBatch(drained); drainInProgress = false; + if (currentDrainPromise === drainPromise) currentDrainPromise = null; scheduleDrain(); }); + currentDrainPromise = drainPromise; + void drainPromise; }); } @@ -1288,22 +1354,34 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }, async suspend(reason) { + const recoveryReason = reason ?? "opencode_suspend_before_terminal"; sessionActive = false; - retryQueue(reason ?? "opencode_suspend_before_terminal"); + drainCancellationReason = recoveryReason; generation++; currentAbort?.abort(); - await currentTurnPromise; + unsafeDiscoveryWaitAbort?.abort(); + await Promise.all([currentTurnPromise, currentDrainPromise]); + if (drainingBatch) retryDrainingBatch(drainingBatch, recoveryReason); + retryQueue(recoveryReason); + drainCancellationReason = null; + unsafeDiscoveryWaitAbort = null; currentAbort = null; currentTurnPromise = null; initialTurnPreparing = false; }, async shutdown(reason) { + const recoveryReason = reason ?? "opencode_shutdown_before_terminal"; sessionActive = false; - retryQueue(reason ?? "opencode_shutdown_before_terminal"); + drainCancellationReason = recoveryReason; generation++; currentAbort?.abort(); - await currentTurnPromise; + unsafeDiscoveryWaitAbort?.abort(); + await Promise.all([currentTurnPromise, currentDrainPromise]); + if (drainingBatch) retryDrainingBatch(drainingBatch, recoveryReason); + retryQueue(recoveryReason); + drainCancellationReason = null; + unsafeDiscoveryWaitAbort = null; currentAbort = null; currentTurnPromise = null; cwd = null; From ed2f62f8efd067930c37e015484d7ede92dd97b6 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Thu, 30 Jul 2026 14:14:33 +0800 Subject: [PATCH 13/15] fix(client): preserve unsafe discovery retry custody --- .../__tests__/provider-retry-policy.test.ts | 52 +++++ .../__tests__/session-manager-retry.test.ts | 188 ++++++++++++++++++ .../src/runtime/provider-retry-policy.ts | 14 +- .../client/src/runtime/session-manager.ts | 6 + 4 files changed, 259 insertions(+), 1 deletion(-) diff --git a/packages/client/src/__tests__/provider-retry-policy.test.ts b/packages/client/src/__tests__/provider-retry-policy.test.ts index 25d9ba835..8e263b31b 100644 --- a/packages/client/src/__tests__/provider-retry-policy.test.ts +++ b/packages/client/src/__tests__/provider-retry-policy.test.ts @@ -1,9 +1,11 @@ import type { ProviderRetryScope, ReplaySafety, RuntimeProvider } from "@first-tree/shared"; import { describe, expect, it } from "vitest"; +import { ManagedSkillsUnsafeDiscoveryError } from "../runtime/managed-skills.js"; import { buildProviderRetryEvent, classifyProviderFailure, decideProviderRetry, + MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, type ProviderFailureClassification, } from "../runtime/provider-retry-policy.js"; @@ -201,6 +203,56 @@ describe("classifyProviderFailure", () => { ).toMatchObject({ action: "retry" }); }); + it.each([ + "session_start", + "session_resume", + ] as const)("classifies managed-Skill unsafe discovery by stable identity and retries %s indefinitely with bounded backoff", (scope) => { + const classified = classifyProviderFailure(new ManagedSkillsUnsafeDiscoveryError("opaque safety detail"), { + provider: "opencode", + scope, + source: "session", + }); + expect(classified).toMatchObject({ + category: "transient_transport", + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + }); + + for (const [attempt, delayMs, retryMode] of [ + [1, 1_000, "foreground"], + [2, 2_000, "foreground"], + [3, 4_000, "foreground"], + [20, 60_000, "background"], + ] as const) { + expect( + decideProviderRetry({ + classification: classified, + scope, + attempt, + replaySafety: "pre_provider", + }), + ).toMatchObject({ + action: "retry", + attempt, + delayMs, + retryMode, + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + }); + } + + expect( + decideProviderRetry({ + classification: classified, + scope: "provider_turn", + attempt: 1, + replaySafety: "user_visible", + }), + ).toMatchObject({ + action: "stop", + reasonCode: "unsafe_replay", + terminalKind: "unsafe_replay", + }); + }); + it("a genuinely missing codex binary stays terminal needs_operator (no false retry)", () => { const err = new Error( "Codex runtime binary is missing on this machine. First Tree does not bundle the native Codex engine by default.", diff --git a/packages/client/src/__tests__/session-manager-retry.test.ts b/packages/client/src/__tests__/session-manager-retry.test.ts index e97080441..aeda8ea2f 100644 --- a/packages/client/src/__tests__/session-manager-retry.test.ts +++ b/packages/client/src/__tests__/session-manager-retry.test.ts @@ -1,6 +1,7 @@ import { parseProviderRetryEventMessage, type SessionEvent, type SessionState } from "@first-tree/shared"; import { describe, expect, it, vi } from "vitest"; import type { AgentHandler, HandlerFactory, SessionContext, SessionMessage } from "../runtime/handler.js"; +import { ManagedSkillsUnsafeDiscoveryError } from "../runtime/managed-skills.js"; import { SessionManager } from "../runtime/session-manager.js"; import type { FirstTreeHubSDK } from "../sdk.js"; import { silentLogger } from "./_logger-helpers.js"; @@ -248,6 +249,193 @@ describe("SessionManager: transient session retry", () => { await sm.shutdown(); }); + it("parks managed-Skill unsafe resume retries past the old exhaustion boundary and drains head then tail once", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const recoverChat = vi.fn<(chatId: string) => Promise>().mockResolvedValue(undefined); + const events: SessionEvent[] = []; + let initialCtx: SessionContext | undefined; + let initialMessage: SessionMessage | undefined; + let recoveredCtx: SessionContext | undefined; + let recoveredHead: SessionMessage | undefined; + const recoveredTail: SessionMessage[] = []; + const established: AgentHandler = { + start: vi.fn(async (message, ctx) => { + initialMessage = message; + initialCtx = ctx; + return "existing-opencode-session"; + }), + resume: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe discovery on resume")), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const unsafeRetries = Array.from( + { length: 5 }, + (_, index): AgentHandler => ({ + start: vi.fn(), + resume: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError(`opaque unsafe retry ${index + 1}`)), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }), + ); + const recovered: AgentHandler = { + start: vi.fn(), + resume: vi.fn(async (message, sessionId, ctx) => { + recoveredHead = message; + recoveredCtx = ctx; + return sessionId; + }), + inject: vi.fn((message) => { + recoveredTail.push(message); + return { kind: "owned", mode: "queued" } as const; + }), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ + handlers: [established, ...unsafeRetries, recovered], + ackEntry, + recoverChat, + onSessionEvent: (_chatId, event) => events.push(event), + }); + + await sm.dispatch(mockEntry({ id: 1, chatId: "chat-unsafe-resume", messageId: "msg-seed" })); + if (!initialCtx || !initialMessage) throw new Error("initial OpenCode session was not captured"); + await initialCtx.finishTurn(initialMessage, { status: "success", terminal: true }); + ackEntry.mockClear(); + await sm.handleCommand("chat-unsafe-resume", "session:suspend"); + + await sm.dispatch(mockEntry({ id: 2, chatId: "chat-unsafe-resume", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 3, chatId: "chat-unsafe-resume", messageId: "msg-tail" })); + + // New input must remain behind the retry head instead of forcing an + // immediate safety retry. + expect(unsafeRetries[0]?.resume).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + + for (const delayMs of [1_000, 2_000, 4_000, 8_000, 16_000]) { + await vi.advanceTimersByTimeAsync(delayMs); + } + + // 31 virtual seconds crosses the former 5s + 15s unknown-exhaustion + // boundary. The same head and tail remain locally owned and unacked. + expect(unsafeRetries.every((handler) => vi.mocked(handler.resume).mock.calls.length === 1)).toBe(true); + expect(recovered.resume).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + expect(recoverChat).not.toHaveBeenCalled(); + const resilienceEvents = events + .filter((event): event is Extract => event.kind === "error") + .map((event) => parseProviderRetryEventMessage(event.payload.message)) + .filter((event) => event !== null); + expect(resilienceEvents.some((event) => event.event === "provider_retry_exhausted")).toBe(false); + expect(resilienceEvents.some((event) => event.event === "provider_failure_terminal")).toBe(false); + expect( + resilienceEvents.filter((event) => event.event === "provider_retry_scheduled").map((event) => event.delayMs), + ).toEqual([1_000, 2_000, 4_000, 8_000, 16_000, 32_000]); + + await vi.advanceTimersByTimeAsync(32_000); + expect(recovered.resume).toHaveBeenCalledTimes(1); + expect(recovered.resume).toHaveBeenCalledWith( + expect.objectContaining({ id: "msg-head" }), + "existing-opencode-session", + expect.anything(), + expect.anything(), + ); + expect(recovered.inject).toHaveBeenCalledTimes(1); + expect(recoveredTail.map((message) => message.id)).toEqual(["msg-tail"]); + expect(ackEntry).not.toHaveBeenCalled(); + if (!recoveredCtx || !recoveredHead || !recoveredTail[0]) { + throw new Error("recovered FIFO delivery was not captured"); + } + + await recoveredCtx.finishTurn(recoveredHead, { status: "success", terminal: true }); + await recoveredCtx.finishTurn(recoveredTail[0], { status: "success", terminal: true }); + + expect(ackEntry).toHaveBeenNthCalledWith(1, 2); + expect(ackEntry).toHaveBeenNthCalledWith(2, 3); + expect(recoverChat).not.toHaveBeenCalled(); + await sm.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps fresh-start unsafe discovery unacked and cancels its bounded retry on suspend", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const recoverChat = vi.fn<(chatId: string) => Promise>().mockResolvedValue(undefined); + const retryStart = vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe fresh start")); + const first: AgentHandler = { + start: retryStart, + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const shouldStayUnused: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("late unsafe retry")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ handlers: [first, shouldStayUnused], ackEntry, recoverChat }); + + await sm.dispatch(mockEntry({ id: 10, chatId: "chat-unsafe-start-suspend", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 11, chatId: "chat-unsafe-start-suspend", messageId: "msg-tail" })); + expect(retryStart).toHaveBeenCalledTimes(1); + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + + await sm.handleCommand("chat-unsafe-start-suspend", "session:suspend"); + await sm.handleCommand("chat-unsafe-start-suspend", "session:resume"); + await vi.advanceTimersByTimeAsync(60_000); + + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + expect(recoverChat).toHaveBeenCalledTimes(1); + expect(recoverChat).toHaveBeenCalledWith("chat-unsafe-start-suspend"); + await sm.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a fresh-start unsafe retry on shutdown without terminally settling its FIFO work", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const first: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe fresh start")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const shouldStayUnused: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("late unsafe retry")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ handlers: [first, shouldStayUnused], ackEntry }); + + await sm.dispatch(mockEntry({ id: 20, chatId: "chat-unsafe-start-shutdown", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 21, chatId: "chat-unsafe-start-shutdown", messageId: "msg-tail" })); + await sm.shutdown(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("manual suspend during retry backoff cancels the retry and leaves work for recovery", async () => { vi.useFakeTimers(); try { diff --git a/packages/client/src/runtime/provider-retry-policy.ts b/packages/client/src/runtime/provider-retry-policy.ts index 5e36980fe..aef1e786f 100644 --- a/packages/client/src/runtime/provider-retry-policy.ts +++ b/packages/client/src/runtime/provider-retry-policy.ts @@ -7,6 +7,7 @@ import type { RuntimeProvider, } from "@first-tree/shared"; import { type Classification, classify, ERROR_KINDS } from "./error-taxonomy.js"; +import { isManagedSkillsUnsafeDiscoveryError } from "./managed-skills.js"; import { redactErrorPreview } from "./redact-error-preview.js"; export type ProviderFailureClassification = { @@ -49,6 +50,8 @@ const SESSION_CAPACITY_CAP_MS = 5 * 60_000; const AUTH_HTTP_CODE_RE = /\b(401|403)\b/; const TRANSIENT_HTTP_CODE_RE = /\b(500|502|503|504)\b/; +export const MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE = "managed_skills_unsafe_discovery"; + export function classifyProviderFailure( err: unknown, context: { @@ -64,6 +67,14 @@ export function classifyProviderFailure( const retryAfterMs = readRetryAfterMs(shape); const status = shape.status ?? shape.statusCode; + if (isManagedSkillsUnsafeDiscoveryError(err)) { + return { + category: "transient_transport", + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + message: base.message, + sourceKind: base.kind, + }; + } if (isBillingLimit(text)) { return { category: "provider_capacity", @@ -153,7 +164,8 @@ export function decideProviderRetry(input: { if ( input.scope === "provider_turn" && isUnsafeReplay(input.replaySafety) && - !isRetryableUserVisibleFailure(input.classification.category, input.replaySafety) + (input.classification.reasonCode === MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE || + !isRetryableUserVisibleFailure(input.classification.category, input.replaySafety)) ) { return stop("unsafe_replay", "unsafe_replay", input.replaySafety, "warning"); } diff --git a/packages/client/src/runtime/session-manager.ts b/packages/client/src/runtime/session-manager.ts index 72dcb03c4..b69435a86 100644 --- a/packages/client/src/runtime/session-manager.ts +++ b/packages/client/src/runtime/session-manager.ts @@ -59,6 +59,7 @@ import { buildProviderRetryEvent, classifyProviderFailure, decideProviderRetry, + MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, type ProviderFailureClassification, } from "./provider-retry-policy.js"; import { redactErrorPreview } from "./redact-error-preview.js"; @@ -2206,6 +2207,11 @@ export class SessionManager { private triggerImmediateRetry(chatId: string): void { const entry = this.sessions.get(chatId); if (!entry || entry.retryAttempt === 0) return; + // Managed-Skill discovery safety is a local provider-preflight gate, not + // an availability hint that newer input can bypass. Keep the original + // head and FIFO tail behind the bounded timer so repeated deliveries or + // resume commands cannot turn the safety wait into a hot retry loop. + if (entry.lastRetryReason === MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE) return; if (entry.retryTimer) { clearTimeout(entry.retryTimer); entry.retryTimer = null; From ddc12e911fa2d1954c11b33cb0c3f381d2e81460 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Fri, 31 Jul 2026 11:12:06 +0800 Subject: [PATCH 14/15] fix: gate OpenCode by execution support --- .../src/__tests__/opencode-capability.test.ts | 18 +++++++ .../provider-process-supervisor.test.ts | 4 ++ .../src/runtime/capabilities/opencode.ts | 24 +++++++-- .../runtime/provider-process-supervisor.ts | 11 +++- .../__tests__/agent-capability-gate.test.ts | 16 ++++++ packages/web/src/pages/agent-detail.tsx | 2 +- .../runtime-switch-capabilities.test.ts | 50 +++++++++++++++++++ 7 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts diff --git a/packages/client/src/__tests__/opencode-capability.test.ts b/packages/client/src/__tests__/opencode-capability.test.ts index 8d3147a63..d180e8665 100644 --- a/packages/client/src/__tests__/opencode-capability.test.ts +++ b/packages/client/src/__tests__/opencode-capability.test.ts @@ -19,4 +19,22 @@ describe("OpenCode install-only capability", () => { expect(result.error).toContain("npm install -g opencode-ai@^1.18.7"); expect(result.error).toContain("opencode auth login"); }); + + it("does not advertise a resolved Windows binary that the default supervisor rejects", async () => { + const findOnPath = vi.fn(() => "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe"); + const result = await probeOpenCodeCapability({ + findOnPath, + env: { PATH: "C:\\npm" }, + platform: "win32", + }); + + expect(result).toMatchObject({ + state: "error", + available: false, + runtimeSource: "path", + runtimePath: "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe", + }); + expect(result.error).toContain("cannot run it on Windows"); + expect(findOnPath).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/client/src/__tests__/provider-process-supervisor.test.ts b/packages/client/src/__tests__/provider-process-supervisor.test.ts index ce3a0de94..eabab93ff 100644 --- a/packages/client/src/__tests__/provider-process-supervisor.test.ts +++ b/packages/client/src/__tests__/provider-process-supervisor.test.ts @@ -2,10 +2,14 @@ import { describe, expect, it } from "vitest"; import { createDefaultProviderProcessSupervisor, ProviderProcessSupervisionUnsupportedError, + supportsDefaultProviderProcessSupervision, } from "../runtime/provider-process-supervisor.js"; describe("provider process supervisor", () => { it("fails closed on Windows until a pre-admission Job Object supervisor is supplied", () => { + expect(supportsDefaultProviderProcessSupervision("win32")).toBe(false); + expect(supportsDefaultProviderProcessSupervision("darwin")).toBe(true); + expect(supportsDefaultProviderProcessSupervision("linux")).toBe(true); const supervisor = createDefaultProviderProcessSupervisor("win32"); expect(() => diff --git a/packages/client/src/runtime/capabilities/opencode.ts b/packages/client/src/runtime/capabilities/opencode.ts index 754b70575..9ab2fad7f 100644 --- a/packages/client/src/runtime/capabilities/opencode.ts +++ b/packages/client/src/runtime/capabilities/opencode.ts @@ -1,20 +1,26 @@ import type { CapabilityEntry } from "@first-tree/shared"; import { findOpenCodeExecutableOnPath, formatOpenCodeBinaryMissingMessage } from "../opencode-binary.js"; +import { supportsDefaultProviderProcessSupervision } from "../provider-process-supervisor.js"; import { type DetectOutcome, runDetect } from "./detect.js"; export type OpenCodeProbeDeps = { findOnPath?: (env?: Record) => string | null; env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; }; /** - * Install-only probe. It deliberately does not launch OpenCode, inspect its - * config, or infer provider authentication. + * Resolve-only probe plus an execution-support gate. It deliberately does not + * launch OpenCode, inspect its config, or infer provider authentication. + * + * A resolved Windows binary stays visible in diagnostics, but is not + * advertised as available while the built-in supervisor would reject it + * before spawn. */ export async function probeOpenCodeCapability(deps: OpenCodeProbeDeps = {}): Promise { const env = deps.env ?? process.env; const findOnPath = deps.findOnPath ?? findOpenCodeExecutableOnPath; - return runDetect(async (): Promise => { + const detected = await runDetect(async (): Promise => { const runtimePath = findOnPath(env); if (runtimePath) return { installed: true, runtimeSource: "path", runtimePath }; return { @@ -22,4 +28,16 @@ export async function probeOpenCodeCapability(deps: OpenCodeProbeDeps = {}): Pro error: formatOpenCodeBinaryMissingMessage("no opencode binary resolved on this host"), }; }); + if (detected.state !== "ok" || supportsDefaultProviderProcessSupervision(deps.platform)) { + return detected; + } + return { + ...detected, + state: "error", + available: false, + authenticated: false, + error: + "OpenCode is installed, but First Tree cannot run it on Windows until the client-wide " + + "pre-admission Job Object supervisor is available.", + }; } diff --git a/packages/client/src/runtime/provider-process-supervisor.ts b/packages/client/src/runtime/provider-process-supervisor.ts index 1ae032cc3..ac98a0201 100644 --- a/packages/client/src/runtime/provider-process-supervisor.ts +++ b/packages/client/src/runtime/provider-process-supervisor.ts @@ -22,6 +22,15 @@ export interface ProviderProcessSupervisor { spawn(spec: ProviderProcessSpec): SupervisedProviderProcess; } +/** + * Whether the built-in supervisor can safely execute an external provider on + * this platform. Capability advertising uses the same gate as spawn so a + * Windows client cannot offer a runtime that this supervisor will reject. + */ +export function supportsDefaultProviderProcessSupervision(platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32"; +} + /** * The existing POSIX path: environment-attributed process-tree observation is * still the client-switch authority; the registry only improves local abort @@ -37,7 +46,7 @@ export function createDefaultProviderProcessSupervisor( ): ProviderProcessSupervisor { return { spawn(spec) { - if (platform === "win32") { + if (!supportsDefaultProviderProcessSupervision(platform)) { throw new ProviderProcessSupervisionUnsupportedError( "OpenCode on Windows requires a pre-admission Job Object supervisor; " + "the current client-switch drain authority remains unsupported", diff --git a/packages/server/src/__tests__/agent-capability-gate.test.ts b/packages/server/src/__tests__/agent-capability-gate.test.ts index cf1b60d75..b5663fada 100644 --- a/packages/server/src/__tests__/agent-capability-gate.test.ts +++ b/packages/server/src/__tests__/agent-capability-gate.test.ts @@ -127,6 +127,22 @@ describe("Agent capability gate (services/agent.ts)", () => { ).rejects.toThrow(/does not have runtime provider "claude-code" available/i); }); + it("blocks OpenCode selection when the client reports installed-but-unsupported Windows execution", async () => { + const app = getApp(); + const ctx = await createAdminContext(app); + await setCapabilities(app, ctx.clientId, { opencode: entry("error") }); + + await expect( + createAgent(app.db, { + name: `cap-gate-opencode-win-${crypto.randomUUID().slice(0, 6)}`, + type: "agent", + managerId: ctx.memberId, + clientId: ctx.clientId, + runtimeProvider: "opencode", + }), + ).rejects.toThrow(/does not have runtime provider "opencode" available/i); + }); + it("`force: true` bypasses the gate even when the SDK is reported missing", async () => { const app = getApp(); const ctx = await createAdminContext(app); diff --git a/packages/web/src/pages/agent-detail.tsx b/packages/web/src/pages/agent-detail.tsx index 371d90a48..053dc6c88 100644 --- a/packages/web/src/pages/agent-detail.tsx +++ b/packages/web/src/pages/agent-detail.tsx @@ -994,7 +994,7 @@ function capabilitiesReported(client: HubClient): boolean { return Object.keys(client.capabilities ?? {}).length > 0; } -function runtimeSwitchAvailableProviders(client: HubClient): RuntimeProvider[] { +export function runtimeSwitchAvailableProviders(client: HubClient): RuntimeProvider[] { if (!capabilitiesReported(client)) return [...PROVIDER_ORDER]; return PROVIDER_ORDER.filter((provider) => client.capabilities[provider]?.available === true); } diff --git a/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts b/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts new file mode 100644 index 000000000..0208c15a7 --- /dev/null +++ b/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts @@ -0,0 +1,50 @@ +import type { CapabilityEntry } from "@first-tree/shared"; +import { describe, expect, it } from "vitest"; +import type { HubClient } from "../../../api/activity.js"; +import { runtimeSwitchAvailableProviders } from "../../agent-detail.js"; + +function capability(state: CapabilityEntry["state"]): CapabilityEntry { + return { + state, + available: state === "ok", + authenticated: state === "ok", + authMethod: "none", + sdkVersion: null, + detectedAt: new Date(0).toISOString(), + }; +} + +function client(capabilities: HubClient["capabilities"]): HubClient { + return { + id: "client-win", + userId: "user-1", + status: "connected", + authState: "ok", + binName: "first-tree", + sdkVersion: "0.5.18", + hostname: "windows-host", + os: "win32", + agentCount: 0, + connectedAt: new Date(0).toISOString(), + lastSeenAt: new Date(0).toISOString(), + capabilities, + }; +} + +describe("runtime switch capability filtering", () => { + it("does not offer OpenCode when its installed Windows runtime is unavailable", () => { + const available = runtimeSwitchAvailableProviders( + client({ + codex: capability("ok"), + opencode: { + ...capability("error"), + runtimePath: "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe", + error: "installed but Windows execution is unsupported", + }, + }), + ); + + expect(available).toContain("codex"); + expect(available).not.toContain("opencode"); + }); +}); From 456c54c768caa46f620de4ea817bef4c019cf741 Mon Sep 17 00:00:00 2001 From: YueZengwu Date: Fri, 31 Jul 2026 11:12:06 +0800 Subject: [PATCH 15/15] fix: surface queued OpenCode skill blocks --- .../src/__tests__/opencode-handler.test.ts | 28 ++++++++++++- .../client/src/handlers/opencode/index.ts | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts index e7dc25a8a..2f0beb553 100644 --- a/packages/client/src/__tests__/opencode-handler.test.ts +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -388,7 +388,8 @@ describe("OpenCode V1 handler", () => { roots.push(root); const specs: ProviderProcessSpec[] = []; const inputs: string[] = []; - const sessionCtx = context([], []); + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const sessionCtx = context(events, []); const reconcile = resetManagedSkillsReconcileMock(); const { sleep, waits } = controlledOpenCodeSleep(); let queuedPhase = false; @@ -429,6 +430,31 @@ describe("OpenCode V1 handler", () => { expect(sleep.mock.calls.map(([delayMs]) => delayMs)).toEqual([1_000, 2_000]); expect(queuedRefreshes).toBe(expectedUnsafeRefreshes); + const blockedEvents = events + .map((event) => (event.payload?.message ? parseProviderRetryEventMessage(event.payload.message) : null)) + .filter((payload) => payload?.reasonCode === "managed_skills_unsafe_discovery"); + expect(blockedEvents).toEqual([ + expect.objectContaining({ + event: "provider_retry_scheduled", + provider: "opencode", + scope: "provider_turn", + attempt: 1, + retryMode: "background", + delayMs: 1_000, + userSeverity: "warning", + messagePreview: expect.stringContaining("remains unacknowledged"), + }), + expect.objectContaining({ + event: "provider_retry_scheduled", + provider: "opencode", + scope: "provider_turn", + attempt: 2, + retryMode: "background", + delayMs: 2_000, + userSeverity: "warning", + messagePreview: expect.stringContaining("remains unacknowledged"), + }), + ]); expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); expect(heldToken.processingStarted).not.toHaveBeenCalled(); expect(heldToken.complete).not.toHaveBeenCalled(); diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts index ff4139be4..3eccee642 100644 --- a/packages/client/src/handlers/opencode/index.ts +++ b/packages/client/src/handlers/opencode/index.ts @@ -43,6 +43,7 @@ import { createDefaultProviderProcessSupervisor, type ProviderProcessSupervisor, } from "../../runtime/provider-process-supervisor.js"; +import { buildProviderRetryEvent, classifyProviderFailure } from "../../runtime/provider-retry-policy.js"; import { redactErrorPreview } from "../../runtime/redact-error-preview.js"; import { buildBriefingUpdateNotice, @@ -797,6 +798,44 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { }); } + function emitQueuedUnsafeDiscoveryBlocked( + sessionCtx: SessionContext, + error: Error, + attempt: number, + delayMs: number, + ): void { + const classification = classifyProviderFailure(error, { + provider: runtimeProvider, + scope: "provider_turn", + source: "bind", + }); + const payload = buildProviderRetryEvent({ + event: "provider_retry_scheduled", + provider: runtimeProvider, + scope: "provider_turn", + classification, + decision: { + action: "retry", + delayMs, + reasonCode: classification.reasonCode, + attempt, + retryMode: "background", + replaySafety: "pre_provider", + userSeverity: "warning", + }, + messagePreview: + "Queued delivery remains unacknowledged because First Tree cannot safely reconcile managed OpenCode Skills. " + + `${error.message}`, + }); + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: encodeProviderRetryEventMessage(payload), + }, + }); + } + function consumedReasonForProviderSettlement(settlement: ProviderAttemptSettlement): TurnConsumedErrorReason { return settlement.decision.action === "stop" && settlement.decision.terminalKind === "capacity_wait_required" ? "capacity_wait_required" @@ -1201,6 +1240,7 @@ export const createOpenCodeHandler: HandlerFactory = (config) => { unsafeDiscoveryParkedBatch = drained; unsafeAttempt += 1; const delayMs = queuedUnsafeDiscoveryRetryDelayMs(unsafeAttempt); + emitQueuedUnsafeDiscoveryBlocked(sessionCtx, error, unsafeAttempt, delayMs); sessionCtx.log( `OpenCode queued turn blocked by unsafe managed-skill discovery; retrying in ${delayMs}ms: ${error.message}`, );