Skip to content
Closed
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
46 changes: 35 additions & 11 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. `\^[`) 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 @@ -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 {
/**
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 All @@ -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
Expand All @@ -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 Expand Up @@ -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,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI failure output never reaches users

Medium Severity

Non-zero opencode models now stores stdout and stderr on OpenCodeRuntimeError.cause and only puts character counts in detail. openCodeRuntimeErrorDetail and the health-check path read detail only, so Failed to execute OpenCode CLI health check still omits the CLI output needed to diagnose the failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e6af7a. Configure here.

});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

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