diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 80329a6794d5..e6919fdabfb7 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -34,6 +34,15 @@ import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { stripTerminalEscapes } from "@t3tools/shared/stripTerminalEscapes"; + +// Control bytes are escaped as text (e.g. `\^[`) inside JSON.stringify +// output, so escapes embedded in CLI-printed values only become visible after +// JSON decoding. This reviver strips them from every decoded string, at any +// depth, while leaving non-string values and object/array structure untouched. +const stripTerminalEscapesJsonReviver = (_key: string, value: unknown): unknown => + typeof value === "string" ? stripTerminalEscapes(value) : value; + const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -132,9 +141,7 @@ const OpenCodeSkillSchema = Schema.Struct({ description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), }); -const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( - Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), -); +const decodeOpenCodeSkillsArrayExit = Schema.decodeUnknownExit(Schema.Array(OpenCodeSkillSchema)); export interface OpenCodeRuntimeShape { /** @@ -186,7 +193,9 @@ export interface OpenCodeRuntimeShape { } function parseServerUrlFromOutput(output: string): string | null { - for (const line of output.split("\n")) { + // The opencode CLI can prepend an OSC title sequence to its output; strip it + // so the ready line still matches. + for (const line of stripTerminalEscapes(output).split("\n")) { if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { continue; } @@ -196,7 +205,7 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } -const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const SLUG_LINE_RE = /^(\S+\/.*\S)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command @@ -216,7 +225,7 @@ export function parseModelsCliOutput(stdout: string): { string, { id: string; name: string; models: { [key: string]: Model } } >(); - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentSlug: string | null = null; const jsonLines: Array = []; @@ -225,7 +234,7 @@ export function parseModelsCliOutput(stdout: string): { const jsonStr = jsonLines.join("\n").trim(); if (jsonStr.length > 0) { try { - const model = JSON.parse(jsonStr) as Model; + const model = JSON.parse(jsonStr, stripTerminalEscapesJsonReviver) as Model; const separator = currentSlug.indexOf("/"); if (separator > 0) { const providerID = currentSlug.slice(0, separator); @@ -269,7 +278,7 @@ export function parseModelsCliOutput(stdout: string): { /** @internal */ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const agents: Array = []; - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array = []; @@ -278,7 +287,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const jsonStr = blockLines.join("\n").trim(); if (jsonStr.length > 0) { try { - const permission = JSON.parse(jsonStr); + const permission = JSON.parse(jsonStr, stripTerminalEscapesJsonReviver); agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], @@ -311,7 +320,15 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { /** @internal */ export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); + let parsed: unknown; + try { + // The reviver strips escapes embedded in skill strings before validation, + // so schema-decoded values can never carry raw control bytes. + parsed = JSON.parse(stripTerminalEscapes(stdout), stripTerminalEscapesJsonReviver); + } catch { + return []; + } + const result = decodeOpenCodeSkillsArrayExit(parsed); return Exit.isSuccess(result) ? result.value : []; } @@ -791,9 +808,16 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); } if (modelsResult.value.code !== 0) { + const stderrDetail = modelsResult.value.stderr.trim(); + const stdoutDetail = modelsResult.value.stdout.trim(); return yield* new OpenCodeRuntimeError({ operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + detail: `OpenCode models command exited with code ${modelsResult.value.code} (stderr ${stderrDetail.length} chars, stdout ${stdoutDetail.length} chars).`, + cause: { + exitCode: modelsResult.value.code, + stdout: modelsResult.value.stdout, + stderr: modelsResult.value.stderr, + }, }); } diff --git a/packages/shared/package.json b/packages/shared/package.json index eeaa5f59e087..79f6c805501d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -230,6 +230,10 @@ "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./stripTerminalEscapes": { + "types": "./src/stripTerminalEscapes.ts", + "import": "./src/stripTerminalEscapes.ts" } }, "scripts": { diff --git a/packages/shared/src/stripTerminalEscapes.ts b/packages/shared/src/stripTerminalEscapes.ts new file mode 100644 index 000000000000..430b9d7d10b0 --- /dev/null +++ b/packages/shared/src/stripTerminalEscapes.ts @@ -0,0 +1,18 @@ +// Some CLIs (e.g. opencode <= 1.18) emit terminal escape sequences on stdout +// even when stdout is a pipe - most notably OSC title sets like +// `ESC ]0;: ready BEL`. Anything that parses such output must strip them +// first, or the escapes leak into stored identifiers and slugs. + +// OSC: `ESC ]` payload terminated by BEL or by ST (`ESC \`). +// eslint-disable-next-line no-control-regex -- matching control bytes is the point of this helper +const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; +// CSI: `ESC [` parameter bytes (including the ITU T.416 colon subparameter +// separator) and optional intermediate bytes, followed by a final byte in +// @-~ - ANSI colors, cursor movement, mode set/reset, ... +// eslint-disable-next-line no-control-regex -- matching control bytes is the point of this helper +const CSI_SEQUENCE = /\u001b\[[0-9:;?<=>]*[ -/]*[@-~]/g; + +/** Removes OSC and CSI escape sequences from terminal output. */ +export function stripTerminalEscapes(text: string): string { + return text.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, ""); +}