Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/server/src/provider/opencodeRuntime.cliParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,136 @@ describe("parseAgentListCliOutput", () => {
});
});

describe("terminal escape stripping", () => {
// opencode <= 1.18 emits `ESC ]0;<cwd>: 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(
Expand Down
35 changes: 26 additions & 9 deletions apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "{}";

Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string> = [];

Expand All @@ -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);
Expand Down Expand Up @@ -269,7 +278,7 @@ export function parseModelsCliOutput(stdout: string): {
/** @internal */
export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
const agents: Array<Agent> = [];
const lines = stdout.split("\n");
const lines = stripTerminalEscapes(stdout).split("\n");
let currentHeader: { name: string; mode: string } | null = null;
const blockLines: Array<string> = [];

Expand All @@ -278,7 +287,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
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"],
Expand Down Expand Up @@ -311,7 +320,15 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {

/** @internal */
export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
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 : [];
}

Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@
"./usageFormat": {
"types": "./src/usageFormat.ts",
"import": "./src/usageFormat.ts"
},
"./stripTerminalEscapes": {
"types": "./src/stripTerminalEscapes.ts",
"import": "./src/stripTerminalEscapes.ts"
}
},
"scripts": {
Expand Down
44 changes: 44 additions & 0 deletions packages/shared/src/stripTerminalEscapes.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
18 changes: 18 additions & 0 deletions packages/shared/src/stripTerminalEscapes.ts
Original file line number Diff line number Diff line change
@@ -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;<cwd>: 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, "");
}
Loading