From 842a0dd4b0d5c31a138e8c77024915d0cffc6664 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 23 Aug 2026 10:49:41 +0100 Subject: [PATCH] fix(server): strip terminal escape sequences from opencode CLI output opencode <= 1.18 emits an OSC title set (ESC ]0;: ready BEL) on stdout even when piped, which leaked into the server-ready line match, model slugs, agent names, and skill payloads parsed from its output. Add a shared stripTerminalEscapes helper that removes OSC (BEL- and ST-terminated) and CSI sequences while leaving ordinary text untouched, apply it to parseServerUrlFromOutput and the models/agents/skills CLI parsers, and cover both the helper and the parsers with tests. Escapes embedded inside JSON string values only surface after JSON decoding, so all three parsers also strip decoded strings via a JSON.parse reviver; unterminated escape prefixes stay literal text. --- .../opencodeRuntime.cliParsers.test.ts | 130 ++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 35 +++-- packages/shared/package.json | 4 + .../shared/src/stripTerminalEscapes.test.ts | 44 ++++++ packages/shared/src/stripTerminalEscapes.ts | 18 +++ 5 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/stripTerminalEscapes.test.ts create mode 100644 packages/shared/src/stripTerminalEscapes.ts 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, ""); +}