From c1e089ea0081b72e062d456dc4ae87eae480632c Mon Sep 17 00:00:00 2001 From: Ibby Vanderwall <318502935+Bowen-Xue-ac2@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:43:15 +0000 Subject: [PATCH 1/4] fix(telemetry): mask error text after credential words --- .../src/altimate/telemetry/error-detail.ts | 26 ++++++++-- .../test/telemetry/error-detail.test.ts | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index a424f6c52a..122db3b6b9 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -2,6 +2,23 @@ import { Telemetry } from "./index" const MAX_MESSAGE = 200 const PLACEHOLDER = "" +const CREDENTIAL_WORDS: ReadonlySet = new Set([ + "auth", + "authorization", + "bearer", + "cookie", + "credential", + "credentials", + "key", + "passphrase", + "passwd", + "password", + "pwd", + "secret", + "session", + "token", +]) + /** * Fixed vocabulary of common error and diagnostic words. Credential words (password, * secret, token, key, auth, cookie, session, ...) are deliberately absent. Keep sorted. @@ -375,8 +392,9 @@ export const ERROR_VOCABULARY: ReadonlySet = new Set([ /** * Fixed-vocabulary template of an error's first line. Words are split on any non-letter * character and lowercased; a word survives only if it is in ERROR_VOCABULARY, every other - * run becomes ``. The output holds only vocabulary words, `` and spaces, so user - * text, identifiers, paths, values and SQL cannot appear. + * run becomes ``. A credential word and the rest of the line become one `` too, + * because a credential value may itself consist of vocabulary words. The output holds + * only vocabulary words, `` and spaces. */ export function templateErrorMessage(raw: string): string { const first = raw.split(/[\r\n\u2028\u2029]/, 1)[0] @@ -385,8 +403,8 @@ export function templateErrorMessage(raw: string): string { if (!token) continue const word = token.toLowerCase() const piece = ERROR_VOCABULARY.has(word) ? word : PLACEHOLDER - if (piece === PLACEHOLDER && out[out.length - 1] === PLACEHOLDER) continue - out.push(piece) + if (piece !== PLACEHOLDER || out[out.length - 1] !== PLACEHOLDER) out.push(piece) + if (CREDENTIAL_WORDS.has(word)) break } // non-letter characters (digits, symbols) only separate words and are dropped; unknown words they separated collapse into one placeholder return out.join(" ").slice(0, MAX_MESSAGE) diff --git a/packages/opencode/test/telemetry/error-detail.test.ts b/packages/opencode/test/telemetry/error-detail.test.ts index 58daf34d5c..d90121b5f4 100644 --- a/packages/opencode/test/telemetry/error-detail.test.ts +++ b/packages/opencode/test/telemetry/error-detail.test.ts @@ -12,10 +12,13 @@ const CREDENTIAL_WORDS = [ "token", "key", "auth", + "authorization", "credential", + "credentials", "bearer", "cookie", "session", + "passphrase", ] describe("templateErrorMessage", () => { @@ -28,6 +31,30 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage(raw)).toBe(expected) }) + test.each(CREDENTIAL_WORDS)("masks vocabulary words after %s", (word) => { + expect(templateErrorMessage(`request failed with ${word}: connection refused`)).toBe("request failed with ") + }) + + test.each([ + ["password is connection refused", ""], + ["password=wrong user", ""], + ["connection failed: PaSsWoRd=wrong user", "connection failed "], + ['request failed: "password": "connection refused"', "request failed "], + ["request failed: api_key=wrong user", "request failed "], + ["request failed: access-token=connection refused", "request failed access "], + ["request failed: opaque password=wrong user", "request failed "], + ["password", ""], + ["password=1234 !!!", ""], + ])("masks the credential suffix: %s", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test("matches whole credential words rather than substrings", () => { + expect(templateErrorMessage("keychain keyboard monkey tokenizer failed connection refused")).toBe( + " failed connection refused", + ) + }) + const adversarial = [ "password: hunter", "password: supersecret", @@ -79,6 +106,11 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage("first line\nsecond line")).toBe("first line") expect(templateErrorMessage("error ".repeat(200)).length).toBeLessThanOrEqual(200) }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])("does not inspect later lines after %j", (newline) => { + expect(templateErrorMessage(`connection refused${newline}password is wrong user`)).toBe("connection refused") + expect(templateErrorMessage(`password is wrong user${newline}connection refused`)).toBe("") + }) }) describe("error_fingerprint wiring", () => { @@ -105,6 +137,23 @@ describe("error_fingerprint wiring", () => { expect("stack" in ev).toBe(false) }) + test("credential suffix is hidden in the event without changing the hash", () => { + const raw = "request failed: password is connection refused" + const detail = describeToolError(raw) + const event = buildErrorFingerprintEvent("s1", { + ...detail, + errorClass: "unknown", + toolName: "bash", + toolCategory: "shell", + recovered: false, + recoveryTool: "", + }) + expect(event).toMatchObject({ + error_message: "request failed ", + error_hash: Telemetry.hashError(Telemetry.maskString(raw).slice(0, 500)), + }) + }) + test("prompt.ts emits through these helpers", async () => { const src = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url)).text() expect(src).toContain("...ErrorDetail.describeToolError(errorMsg)") From 4f2ddd600231cebbcb56f4286b79ff0d7daf95a1 Mon Sep 17 00:00:00 2001 From: Ibby Vanderwall <318502935+Bowen-Xue-ac2@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:59:42 +0000 Subject: [PATCH 2/4] fix(telemetry): address credential masking review --- .../src/altimate/telemetry/error-detail.ts | 36 +++++-- .../test/telemetry/error-detail.test.ts | 97 ++++++++++++++----- 2 files changed, 103 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index 122db3b6b9..f4ed4133bb 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -1,21 +1,38 @@ import { Telemetry } from "./index" +import stripAnsi from "strip-ansi" const MAX_MESSAGE = 200 const PLACEHOLDER = "" -const CREDENTIAL_WORDS: ReadonlySet = new Set([ +export const CREDENTIAL_WORDS: ReadonlySet = new Set([ + "accesstoken", + "apikey", + "appsecret", "auth", "authorization", + "authtoken", "bearer", + "bearertoken", + "clientsecret", + "connectionstring", "cookie", "credential", "credentials", + "jwtsecret", + "jwttoken", "key", + "pass", "passphrase", "passwd", "password", + "passwords", "pwd", + "refreshtoken", "secret", + "secretkey", "session", + "sessionid", + "sig", + "signature", "token", ]) @@ -390,14 +407,21 @@ export const ERROR_VOCABULARY: ReadonlySet = new Set([ ]) /** - * Fixed-vocabulary template of an error's first line. Words are split on any non-letter - * character and lowercased; a word survives only if it is in ERROR_VOCABULARY, every other - * run becomes ``. A credential word and the rest of the line become one `` too, - * because a credential value may itself consist of vocabulary words. The output holds + * Fixed-vocabulary template of an error's first line, with ANSI escapes removed. + * Words are split on any non-letter character and lowercased; a word survives only if + * it is in ERROR_VOCABULARY, every other run becomes ``. A credential word and the + * rest of the line become one `` too, because a credential value may itself consist + * of vocabulary words. This deliberately loses diagnostic text after ambiguous words + * such as key or session: privacy takes priority over template detail. The output holds * only vocabulary words, `` and spaces. */ export function templateErrorMessage(raw: string): string { - const first = raw.split(/[\r\n\u2028\u2029]/, 1)[0] + // Split first so an OSC sequence spanning a newline cannot erase the line boundary. + // Normalize only this compound credential key; prose "connection string" stays readable. + const first = stripAnsi(raw.split(/[\r\n\u2028\u2029]/, 1)[0]).replace( + /\bconnection[_-]string\b/gi, + "connectionstring", + ) const out: string[] = [] for (const token of first.split(/[^A-Za-z]+/)) { if (!token) continue diff --git a/packages/opencode/test/telemetry/error-detail.test.ts b/packages/opencode/test/telemetry/error-detail.test.ts index d90121b5f4..6f6086b2d2 100644 --- a/packages/opencode/test/telemetry/error-detail.test.ts +++ b/packages/opencode/test/telemetry/error-detail.test.ts @@ -1,25 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Telemetry } from "../../src/altimate/telemetry" import { ErrorDetail } from "../../src/altimate/telemetry/error-detail" -const { buildErrorFingerprintEvent, describeToolError, templateErrorMessage, ERROR_VOCABULARY } = ErrorDetail - -const CREDENTIAL_WORDS = [ - "password", - "passwd", - "pwd", - "secret", - "token", - "key", - "auth", - "authorization", - "credential", - "credentials", - "bearer", - "cookie", - "session", - "passphrase", -] +const { buildErrorFingerprintEvent, describeToolError, templateErrorMessage, ERROR_VOCABULARY, CREDENTIAL_WORDS } = + ErrorDetail describe("templateErrorMessage", () => { test.each([ @@ -31,7 +14,7 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage(raw)).toBe(expected) }) - test.each(CREDENTIAL_WORDS)("masks vocabulary words after %s", (word) => { + test.each([...CREDENTIAL_WORDS])("masks vocabulary words after %s", (word) => { expect(templateErrorMessage(`request failed with ${word}: connection refused`)).toBe("request failed with ") }) @@ -55,6 +38,70 @@ describe("templateErrorMessage", () => { ) }) + test.each([ + "apikey", + "apiKey", + "accessToken", + "authToken", + "refreshToken", + "bearerToken", + "jwtToken", + "jwtSecret", + "clientSecret", + "appSecret", + "signature", + "sig", + "pass", + "passwords", + "secretKey", + "sessionId", + "connectionString", + ])("masks the exact credential alias %s", (key) => { + expect(templateErrorMessage(`request failed: ${key}=wrong user`)).toBe("request failed ") + expect(templateErrorMessage(`${key}: connection refused`)).toBe("") + }) + + test.each([ + "request failed: connection_string=wrong user", + "request failed: CONNECTION_STRING: connection refused", + 'request failed: "connection_string": "wrong user"', + "request failed: connection-string=wrong user", + "request failed: connection_\x1b[31mstring=wrong user", + ])("masks the exact connection string key: %j", (raw) => { + expect(templateErrorMessage(raw)).toBe("request failed ") + }) + + test.each([ + ["connection string is invalid", "connection string is invalid"], + ["connection refused", "connection refused"], + ["signal received", "signal received"], + ["connection_stringify is invalid", "connection is invalid"], + ["disconnection_string is invalid", " string is invalid"], + ["connection_strings are invalid", "connection are invalid"], + ["apikeyboard sigil signatured tokenizer failed", " failed"], + ])("keeps diagnostics without an exact credential marker: %s", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each([ + "request failed: \x1b[31mpassword\x1b[0m=wrong user", + "request failed: pass\x1b[31mword=wrong user", + "request failed: api\x1b[31mKey=wrong user", + "request failed: \x1b[1;31mpassword\x1b[0m=connection refused", + "request failed: \x1b]8;;https://example.test\x07password\x1b]8;;\x07=wrong user", + "request failed: \x9b31mpassword\x9b0m=wrong user", + ])("ignores ANSI sequences when matching credentials: %j", (raw) => { + expect(templateErrorMessage(raw)).toBe("request failed ") + }) + + test("keeps diagnostic words inside ANSI styling", () => { + expect(templateErrorMessage("connection \x1b[31mrefused\x1b[0m")).toBe("connection refused") + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])("does not let an OSC sequence consume %j", (newline) => { + expect(templateErrorMessage(`first line \x1b]8;;${newline}\x07wrong user`)).toBe("first line") + }) + const adversarial = [ "password: hunter", "password: supersecret", @@ -86,7 +133,7 @@ describe("templateErrorMessage", () => { expect(tok === "" || ERROR_VOCABULARY.has(tok)).toBe(true) } expect(out).not.toContain(" ") - for (const w of CREDENTIAL_WORDS) expect(out).not.toContain(w) + for (const w of CREDENTIAL_WORDS) expect(out.split(" ")).not.toContain(w) }) test("user words never survive", () => { @@ -137,8 +184,10 @@ describe("error_fingerprint wiring", () => { expect("stack" in ev).toBe(false) }) - test("credential suffix is hidden in the event without changing the hash", () => { - const raw = "request failed: password is connection refused" + test.each([ + "request failed: password is connection refused", + "request failed: \x1b[31mpassword\x1b[0m is connection refused", + ])("credential suffix is hidden in the event without changing the hash: %j", (raw) => { const detail = describeToolError(raw) const event = buildErrorFingerprintEvent("s1", { ...detail, @@ -150,7 +199,7 @@ describe("error_fingerprint wiring", () => { }) expect(event).toMatchObject({ error_message: "request failed ", - error_hash: Telemetry.hashError(Telemetry.maskString(raw).slice(0, 500)), + error_hash: "ae7e754cfb2d72a2", }) }) From f4cf548e3806ffd6e4d0a8afcba967a1020051c6 Mon Sep 17 00:00:00 2001 From: Ibby Vanderwall <318502935+Bowen-Xue-ac2@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:51:34 +0000 Subject: [PATCH 3/4] fix(telemetry): close credential alias and assignment gaps --- .../src/altimate/telemetry/error-detail.ts | 10 ++-- .../test/telemetry/error-detail.test.ts | 54 ++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index f4ed4133bb..50be296ff0 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -25,6 +25,7 @@ export const CREDENTIAL_WORDS: ReadonlySet = new Set([ "passwd", "password", "passwords", + "privatekey", "pwd", "refreshtoken", "secret", @@ -417,11 +418,10 @@ export const ERROR_VOCABULARY: ReadonlySet = new Set([ */ export function templateErrorMessage(raw: string): string { // Split first so an OSC sequence spanning a newline cannot erase the line boundary. - // Normalize only this compound credential key; prose "connection string" stays readable. - const first = stripAnsi(raw.split(/[\r\n\u2028\u2029]/, 1)[0]).replace( - /\bconnection[_-]string\b/gi, - "connectionstring", - ) + // Use the tokenizer's letter boundaries; spaced keys require an assignment separator. + const first = stripAnsi(raw.split(/[\r\n\u2028\u2029]/, 1)[0]) + .replace(/(? { "secretKey", "sessionId", "connectionString", + "privatekey", + "privateKey", + "PRIVATEKEY", ])("masks the exact credential alias %s", (key) => { expect(templateErrorMessage(`request failed: ${key}=wrong user`)).toBe("request failed ") expect(templateErrorMessage(`${key}: connection refused`)).toBe("") @@ -71,14 +74,63 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage(raw)).toBe("request failed ") }) + test.each([ + ["DATABASE_CONNECTION_STRING=wrong user", "database "], + ["db_connection_string=wrong user", ""], + ["connection_string_value=wrong user", ""], + ["request failed: DB_Connection_String_Value=connection refused", "request failed "], + ["request failed: 123connection_string456=wrong user", "request failed "], + ["request failed: _connection-string_=wrong user", "request failed "], + ["request failed: éconnection_string中=wrong user", "request failed "], + ["request failed: \x1b[31mDATABASE_CONNECTION_STRING\x1b[0m=wrong user", "request failed database "], + ["request failed: db_connection_string_value=wrong user", "request failed "], + ])("uses tokenizer boundaries for connection string keys: %j", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each([ + ["connection string=wrong user", ""], + ["connection string:wrong user", ""], + ["connection string: invalid", ""], + ["request failed: connection string=wrong user", "request failed "], + ["request failed: Connection String : connection refused", "request failed "], + ["request failed: CONNECTION\tSTRING\t=\twrong user", "request failed "], + ["request failed: connection string = connection refused", "request failed "], + ['request failed: "connection string" = "wrong user"', "request failed "], + ["request failed: 'connection string': 'wrong user'", "request failed "], + ["request failed: `connection string` = wrong user", "request failed "], + ["request failed: \x1b[31mconnection string\x1b[0m=wrong user", "request failed "], + ["request failed: connec\x1b[31mtion str\x1b[0ming=wrong user", "request failed "], + ])("masks spaced connection string assignments: %j", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "keeps spaced key assignments on the first line: %j", + (newline) => { + expect(templateErrorMessage(`connection${newline}string=wrong user`)).toBe("connection") + expect(templateErrorMessage(`connection string${newline}=wrong user`)).toBe("connection string") + expect(templateErrorMessage(`connection string: wrong user${newline}connection refused`)).toBe("") + }, + ) + test.each([ ["connection string is invalid", "connection string is invalid"], + ["connection string", "connection string"], + ["connection string invalid", "connection string invalid"], + ['"connection string" is invalid', "connection string is invalid"], + ["disconnection string=wrong user", " string wrong user"], + ["connection stringify=wrong user", "connection wrong user"], + ["connection strings: invalid", "connection invalid"], ["connection refused", "connection refused"], ["signal received", "signal received"], ["connection_stringify is invalid", "connection is invalid"], ["disconnection_string is invalid", " string is invalid"], ["connection_strings are invalid", "connection are invalid"], - ["apikeyboard sigil signatured tokenizer failed", " failed"], + ["db_disconnection_string is invalid", " string is invalid"], + ["db_connection_stringify is invalid", " connection is invalid"], + ["connection_strings_value is invalid", "connection value is invalid"], + ["privatekeyboard apikeyboard sigil signatured tokenizer failed", " failed"], ])("keeps diagnostics without an exact credential marker: %s", (raw, expected) => { expect(templateErrorMessage(raw)).toBe(expected) }) From 2c99e1a8d7e6a318ddac0b5531509f3b74b7ed67 Mon Sep 17 00:00:00 2001 From: BowenX <318502935+Bowen-Xue-ac2@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:29:13 -0700 Subject: [PATCH 4/4] fix(telemetry): bound shared ANSI normalization Use a shared ANSI entry point with explicit template and legacy profiles. Bound raw first-line processing while preserving historical maskString output and error fingerprints. Add input-limit, malformed ANSI and fingerprint compatibility regressions. Refs #1588 --- .../opencode/src/altimate/telemetry/ansi.ts | 31 ++++ .../src/altimate/telemetry/error-detail.ts | 10 +- .../opencode/src/altimate/telemetry/index.ts | 11 +- packages/opencode/test/telemetry/ansi.test.ts | 132 ++++++++++++++++++ .../test/telemetry/error-detail.test.ts | 27 ++++ 5 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/src/altimate/telemetry/ansi.ts create mode 100644 packages/opencode/test/telemetry/ansi.test.ts diff --git a/packages/opencode/src/altimate/telemetry/ansi.ts b/packages/opencode/src/altimate/telemetry/ansi.ts new file mode 100644 index 0000000000..241cadeb10 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/ansi.ts @@ -0,0 +1,31 @@ +import stripAnsi from "strip-ansi" + +// JavaScript string lengths are UTF-16 code units, matching maskString's historical limits. +export const MAX_INPUT = 8192 +export const LOOKAHEAD = 2048 + +/** + * Shared bounded ANSI entry point with explicit compatibility profiles. + * `legacy` preserves maskString's 7-bit rules and existing lookahead window, so hashes + * do not change. `template` handles the broader strip-ansi grammar on the original first + * line. An oversized first line returns undefined: callers must redact the whole line, + * rather than emit a prefix whose ANSI sequence or credential marker may have been cut. + */ +export function normalize(raw: string, profile: "legacy"): string +export function normalize(raw: string, profile: "template"): string | undefined +export function normalize(raw: string, profile: "legacy" | "template"): string | undefined { + if (profile === "legacy") { + // maskString already supplies at most MAX_INPUT + LOOKAHEAD code units. Keep this + // historical regex frozen; adopting the template profile here would change hashes. + return raw + .slice(0, MAX_INPUT + LOOKAHEAD) + .replace(/\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g, "") + } + // One extra code unit distinguishes an exact-limit line followed by a newline from + // an oversized line. Never scan the unbounded input or strip across a line boundary. + const first = raw.slice(0, MAX_INPUT + 1).split(/[\r\n\u2028\u2029]/, 1)[0] + if (first.length > MAX_INPUT) return undefined + return stripAnsi(first) +} + +export * as Ansi from "./ansi" diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index 50be296ff0..863081f57a 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -1,5 +1,5 @@ import { Telemetry } from "./index" -import stripAnsi from "strip-ansi" +import { Ansi } from "./ansi" const MAX_MESSAGE = 200 const PLACEHOLDER = "" @@ -414,12 +414,14 @@ export const ERROR_VOCABULARY: ReadonlySet = new Set([ * rest of the line become one `` too, because a credential value may itself consist * of vocabulary words. This deliberately loses diagnostic text after ambiguous words * such as key or session: privacy takes priority over template detail. The output holds - * only vocabulary words, `` and spaces. + * only vocabulary words, `` and spaces. Raw first lines over 8192 UTF-16 code units + * become `` in full; partially normalized prefixes could cut ANSI or credential keys. */ export function templateErrorMessage(raw: string): string { - // Split first so an OSC sequence spanning a newline cannot erase the line boundary. + const line = Ansi.normalize(raw, "template") + if (line === undefined) return PLACEHOLDER // Use the tokenizer's letter boundaries; spaced keys require an assignment separator. - const first = stripAnsi(raw.split(/[\r\n\u2028\u2029]/, 1)[0]) + const first = line .replace(/(? pmSpan(sep) + "*" + pmChunks(sep) const PM_NOT_ROUTE = "(? + pieces + .map((_, j) => pieces[(i * (j + 1) + j * 7) % pieces.length]) + .join(i % 2 ? " " : "_") + .repeat(i % 5 === 0 ? 300 : 1), +) + +const hashes: Record = { + plain: "ae7e754cfb2d72a2", + csi: "ae7e754cfb2d72a2", + c1: "40678db384156848", + "osc-bel": "914eb30c45c8b094", + "osc-st": "914eb30c45c8b094", + "osc-c1-st": "bd974ba379a9b36f", + "incomplete-osc": "821db0d35dce78e7", + "incomplete-csi": "bb977ba58d3d2d21", + multiline: "7ee1b11bcf0c4db1", + "at-cap": "80b18d6fbcfa3063", + "beyond-cap": "80b18d6fbcfa3063", + "open-quote": "80b18d6fbcfa3063", + "path-cut": "80b18d6fbcfa3063", + lookahead: "bf3ce936232f73c1", + unbroken: "e3b0c44298fc1c14", +} + +describe("bounded ANSI profiles", () => { + test("shares the historical input and lookahead limits", () => { + expect(Ansi.MAX_INPUT).toBe(8192) + expect(Ansi.LOOKAHEAD).toBe(2048) + }) + + test.each([ + ["CSI", "\x1b[31mpassword\x1b[0m=wrong user"], + ["C1 CSI", "\x9b31mpassword\x9b0m=wrong user"], + ["OSC BEL", "\x1b]0;synthetic\x07password=wrong user"], + ["OSC ST", "\x1b]0;synthetic\x1b\\password=wrong user"], + ["OSC C1 ST", "\x1b]0;synthetic\x9cpassword=wrong user"], + ])("normalizes the template's %s sequences", (_, raw) => { + expect(Ansi.normalize(raw, "template")).toBe("password=wrong user") + }) + + test.each([ + ["text", "x".repeat(8193)], + ["ANSI-only text", "\x1b[0m".repeat(3000)], + ["unfinished CSI", "\x1b[" + "0;".repeat(5000)], + ["OSC closing past the cap", "\x1b]0;" + "x".repeat(8192) + "\x07"], + ["credential marker crossing the cap", " ".repeat(8190) + "password=wrong user"], + ["large unfinished OSC", "\x1b]0;" + "x".repeat(2_000_000)], + ])("rejects oversized %s before stripping", (_, raw) => { + expect(Ansi.normalize(raw, "template")).toBeUndefined() + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "preserves the original first-line boundary for %j", + (newline) => { + const first = "x".repeat(8192) + expect(Ansi.normalize(first + newline + "password=wrong user", "template")).toBe(first) + expect(Ansi.normalize(newline + "x".repeat(100000), "template")).toBe("") + }, + ) + + test("does not emit a surrogate split by the input cap", () => { + expect(Ansi.normalize(" ".repeat(8191) + "😀", "template")).toBeUndefined() + expect(Ansi.normalize(" ".repeat(8190) + "😀", "template")).toBe(" ".repeat(8190) + "😀") + }) + + test("bounds the legacy normalization window without dropping line separators", () => { + expect(Ansi.normalize("x".repeat(10240) + "wrong user", "legacy")).toBe("x".repeat(10240)) + expect(Ansi.normalize("connection\nrefused", "legacy")).toBe("connection\nrefused") + }) + + test("keeps the historical C1 hash profile explicitly separate", () => { + const raw = "request failed: \x9b31mpassword\x9b0m is connection refused" + const cleaned = Ansi.normalize(raw, "template")! + expect(cleaned).toBe("request failed: password is connection refused") + expect(ErrorDetail.describeToolError(raw).errorMessage).toBe("request failed ") + expect(ErrorDetail.describeToolError(raw).errorHash).toBe("40678db384156848") + expect(Telemetry.hashError(Telemetry.maskString(cleaned).slice(0, 500))).toBe("ae7e754cfb2d72a2") + }) +}) + +describe("pre-V4 maskString compatibility", () => { + test.each(cases)("pins the historical error hash for %s", (name, raw) => { + expect(ErrorDetail.describeToolError(raw).errorHash).toBe(hashes[name]) + }) + + test("preserves complete masked output over the frozen 96-input corpus", () => { + expect(corpus.length).toBe(96) + const digest = createHash("sha256") + .update(JSON.stringify(corpus.map((raw) => Telemetry.maskString(raw)))) + .digest("hex") + expect(digest).toBe("1fe645e8c04b5f30482bce034f9ddde64a36ac20beb475696a384edb39e1028c") + }) +}) diff --git a/packages/opencode/test/telemetry/error-detail.test.ts b/packages/opencode/test/telemetry/error-detail.test.ts index fa802cdf3b..060026f7a6 100644 --- a/packages/opencode/test/telemetry/error-detail.test.ts +++ b/packages/opencode/test/telemetry/error-detail.test.ts @@ -206,6 +206,33 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage("error ".repeat(200)).length).toBeLessThanOrEqual(200) }) + test.each([8191, 8192, 8193])("bounds the raw first line at %i characters", (length) => { + const raw = "connection refused" + " ".repeat(length - 18) + expect(templateErrorMessage(raw)).toBe(length > 8192 ? "" : "connection refused") + }) + + test.each([ + ["diagnostic words", "error ".repeat(2000)], + ["closed CSI across the limit", "connection \x1b[" + "0;".repeat(5000) + "mpassword=wrong user"], + ["closed OSC across the limit", "connection \x1b]0;" + "x".repeat(8192) + "\x07password=wrong user"], + ["unfinished CSI", "connection \x1b[" + "0;".repeat(5000)], + ["unfinished OSC", "connection \x1b]0;" + "wrong user ".repeat(1000)], + ["credential word across the limit", " ".repeat(8190) + "privateKey=wrong user"], + ["spaced key across the limit", "connection " + " ".repeat(8180) + "connection string=wrong user"], + ["styled key across the limit", " ".repeat(8190) + "pass\x1b[31mword=wrong user"], + ])("fails closed for an oversized first line: %s", (_, raw) => { + expect(templateErrorMessage(raw)).toBe("") + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "keeps a complete line at the input limit before %j", + (newline) => { + const first = "connection refused" + " ".repeat(8192 - 18) + expect(templateErrorMessage(first + newline + "password=wrong user")).toBe("connection refused") + expect(templateErrorMessage("connection refused" + newline + "\x1b[31m".repeat(10000))).toBe("connection refused") + }, + ) + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])("does not inspect later lines after %j", (newline) => { expect(templateErrorMessage(`connection refused${newline}password is wrong user`)).toBe("connection refused") expect(templateErrorMessage(`password is wrong user${newline}connection refused`)).toBe("")