diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..1f6ae20f9c49 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -257,6 +257,136 @@ describe("parseAgentListCliOutput", () => { }); }); +describe("terminal escape stripping", () => { + // opencode <= 1.18 emits `ESC ]0;: ready BEL` on stdout for every + // command, even when stdout is a pipe. + const OSC_TITLE = "\u001b]0;tmp: ready\u0007"; + + it("parseModelsCliOutput ignores an OSC title before the first slug", () => { + const stdout = [ + `${OSC_TITLE}openai/gpt-4o`, + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.deepEqual([...result.connected], ["openai"]); + const model = result.providers.get("openai")!.models["gpt-4o"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "gpt-4o"); + }); + + it("parseAgentListCliOutput strips an OSC title from the agent name", () => { + const stdout = [ + `${OSC_TITLE}build (primary)`, + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + }); + + it("parseSkillsCliOutput strips an OSC title before the JSON payload", () => { + const stdout = `${OSC_TITLE}${JSON.stringify([{ name: "review-pr" }])}`; + NodeAssert.deepEqual(parseSkillsCliOutput(stdout), [{ name: "review-pr" }]); + }); + + it("strips ST-terminated OSC sequences and ANSI color codes", () => { + const stdout = `\u001b]0;tmp: ready\u001b\\openai/gpt-4o\n${JSON.stringify({ + id: "\u001b[1mgpt-4o\u001b[0m", + providerID: "openai", + })}`; + + const result = parseModelsCliOutput(stdout); + NodeAssert.deepEqual([...result.connected], ["openai"]); + NodeAssert.equal(result.providers.get("openai")!.models["gpt-4o"]!.id, "gpt-4o"); + }); + + it("strips escapes embedded inside decoded model string fields at any depth", () => { + // JSON.stringify encodes control bytes textually (e.g. `\u001b`), so these + // survive the byte-level pass over raw stdout and must be stripped after + // JSON decoding. + const stdout = [ + "openai/gpt-4o", + JSON.stringify({ + id: "\u001b]0;pwned\u0007gpt-4o", + providerID: "openai", + name: "\u001b[31mGPT-4o\u001b[0m", + api: { + id: "\u001b[1mgpt-4o\u001b[0m", + url: "\u001b]8;;https://x\u001b\\https://x\u001b]8;;\u001b\\", + npm: "@ai-sdk/openai", + }, + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("openai")!.models["gpt-4o"]!; + NodeAssert.equal(model.id, "gpt-4o"); + NodeAssert.equal(model.name, "GPT-4o"); + NodeAssert.equal(model.api.id, "gpt-4o"); + NodeAssert.equal(model.api.url, "https://x"); + NodeAssert.equal(model.providerID, "openai"); + }); + + it("keeps unterminated escape prefixes inside decoded model values as literal text", () => { + // No BEL/ST terminator: stripping must not swallow trailing value text. + const stdout = [ + "openai/gpt-4o", + JSON.stringify({ + id: "\u001b]0;partial gpt-4o", + providerID: "openai", + name: "GPT-4o", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("openai")!.models["gpt-4o"]!; + NodeAssert.equal(model.id, "\u001b]0;partial gpt-4o"); + NodeAssert.equal(model.name, "GPT-4o"); + }); + + it("strips escapes embedded inside decoded agent permission fields", () => { + const permissions = [ + { permission: "\u001b[31m*\u001b[0m", action: "allow", pattern: "*" }, + { + permission: "read", + action: "ask", + pattern: "\u001b]0;tmp\u0007*.env", + }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + const agentPermission = result[0]!.permission; + NodeAssert.deepEqual(agentPermission[0], { permission: "*", action: "allow", pattern: "*" }); + NodeAssert.equal(agentPermission[1]!.pattern, "*.env"); + }); + + it("strips escapes embedded inside decoded skill string fields", () => { + const result = parseSkillsCliOutput( + JSON.stringify([ + { + name: "\u001b[1mreview-pr\u001b[0m", + description: "\u001b]8;;https://git.host/pr/1\u001b\\Review a PR.\u001b]8;;\u001b\\", + location: "/tmp/review-pr/SKILL.md", + content: "---\nname: \u001b[32mreview-pr\u001b[0m\n---\n", + }, + ]), + ); + + NodeAssert.deepEqual(result, [ + { + name: "review-pr", + description: "Review a PR.", + location: "/tmp/review-pr/SKILL.md", + content: "---\nname: review-pr\n---\n", + }, + ]); + }); +}); + describe("parseSkillsCliOutput", () => { it("parses skill metadata from the CLI JSON output", () => { const result = parseSkillsCliOutput( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..35b654cd1c15 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. `\u001b`) 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 = "{}"; @@ -133,9 +142,7 @@ const OpenCodeSkillSchema = Schema.Struct({ location: Schema.optionalKey(Schema.NullOr(Schema.String)), content: 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; } @@ -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 : []; } diff --git a/packages/shared/package.json b/packages/shared/package.json index a797e97b6625..e7a8c0e2cad1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -226,6 +226,10 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./stripTerminalEscapes": { + "types": "./src/stripTerminalEscapes.ts", + "import": "./src/stripTerminalEscapes.ts" } }, "scripts": { diff --git a/packages/shared/src/stripTerminalEscapes.test.ts b/packages/shared/src/stripTerminalEscapes.test.ts new file mode 100644 index 000000000000..34692fe038c5 --- /dev/null +++ b/packages/shared/src/stripTerminalEscapes.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { stripTerminalEscapes } from "./stripTerminalEscapes.ts"; + +describe("stripTerminalEscapes", () => { + it("strips BEL-terminated OSC sequences", () => { + expect(stripTerminalEscapes("\u001b]0;/repo: ready\u0007openai/gpt-4o")).toBe("openai/gpt-4o"); + }); + + it("strips ST-terminated OSC sequences", () => { + expect(stripTerminalEscapes("\u001b]0;/repo: ready\u001b\\build (primary)")).toBe( + "build (primary)", + ); + }); + + it("strips multiple OSC sequences in one payload", () => { + expect(stripTerminalEscapes("a\u001b]2;t\u0007b\u001b]8;;http://x\u001b\\c")).toBe("abc"); + }); + + it("strips CSI color and cursor sequences", () => { + expect(stripTerminalEscapes("\u001b[1mgpt-4o\u001b[0m")).toBe("gpt-4o"); + expect(stripTerminalEscapes("\u001b[38;5;208mhi\u001b[39m")).toBe("hi"); + expect(stripTerminalEscapes("\u001b[38:2::1:2:3mhi\u001b[m")).toBe("hi"); + expect(stripTerminalEscapes("\u001b[2J\u001b[Hready")).toBe("ready"); + }); + + it("leaves ordinary text untouched", () => { + const text = "plain text 123 [not an escape] \\slash"; + expect(stripTerminalEscapes(text)).toBe(text); + }); + + it("keeps unterminated escape prefixes as literal text", () => { + // No BEL/ST terminator: the OSC match must not swallow the trailing text. + expect(stripTerminalEscapes("\u001b]0;partial openai/gpt-4o")).toBe( + "\u001b]0;partial openai/gpt-4o", + ); + }); + + it("strips mixed OSC, CSI, and text", () => { + expect(stripTerminalEscapes("\u001b]0;tmp: ready\u0007\u001b[32mok\u001b[39m done")).toBe( + "ok done", + ); + }); +}); diff --git a/packages/shared/src/stripTerminalEscapes.ts b/packages/shared/src/stripTerminalEscapes.ts new file mode 100644 index 000000000000..879d49ae7bcb --- /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, ""); +}