fix: harden handling of untrusted URLs, terminal text, and streamed data

- ai: OAuth logins only accept http(s) URLs from servers (shared
  trustedHttpUrl, now also checked by Radius); pi-messages rejects
  contentIndex values that are not the next or an existing block; the
  CLI auth file is written with mode 0600
- ai: OpenCode DeepSeek V4.1 Flash (renamed from V4 Flash upstream) no
  longer sends prompt_cache_retention
- tui: setTitle() replaces control characters; export
  replaceControlCharacters()
- coding-agent: the login dialog only opens web URLs and never prints
  control characters from sign-in, device-code, or info URLs
- durable: partial commits no longer merge a new __proto__ member into
  Object.prototype
This commit is contained in:
Armin Ronacher 2026-10-26 20:46:31 +01:00
parent f9daca3480
commit 89c921d8b5
24 changed files with 260 additions and 68 deletions

View file

@ -23,6 +23,10 @@
- Fixed grammar (custom) tool input streaming copying the whole input so far on every delta ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed bounded error texts (telemetry span messages, truncated provider error bodies, pi-messages diagnostics, Radius gateway errors) keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed Kimi K3.1 Pro and K3.1 Pro Highspeed on Kimi Coding, Moonshot, and OpenCode Go using Kimi K2 request settings. They now send `reasoning_effort` (low/high/max), return earlier reasoning, accept tools added mid-conversation, and report Moonshot rates on Kimi Coding. Kimi Coding `k3-256k` now reports Kimi K3 rates.
- Fixed OAuth logins opening server-supplied URLs that are not `http:` or `https:` URLs. Radius now checks its browser authorization endpoint and device verification URI, and GitHub Copilot, Kimi Coding, and Meta share one check
- Fixed pi-messages streams accepting a `contentIndex` that is not the next or an existing content block, such as `__proto__` or a huge index
- Fixed `pi-ai` CLI auth file being readable by other users. It is now written with mode `0600`
- Fixed OpenCode DeepSeek V4.1 Flash sending `prompt_cache_retention`, which the OpenCode gateway rejects, with long cache retention
## [1.4.0] - 2026-10-21

View file

@ -485,6 +485,7 @@ const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = {
const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([
"opencode:deepseek-v4-flash",
"opencode:deepseek-v4.1-flash",
"opencode:deepseek-v4-pro",
"opencode:kimi-k2.5",
"opencode:kimi-k2.6",

View file

@ -195,6 +195,15 @@ function createEventConverter(model: Model<"pi-messages">) {
const toolArguments = new Map<number, ToolCallArgumentsStream>();
return (event: PiMessagesEvent): AssistantMessageEvent => {
if ("contentIndex" in event) {
// The index comes from the wire and keys the content array: `__proto__` would reach Array.prototype, and a huge
// index would make a sparse array of billions of holes. A start adds the next block; other events need one.
const index: unknown = event.contentIndex;
const limit = event.type.endsWith("_start") ? partial.content.length : partial.content.length - 1;
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > limit) {
throw new Error(`Invalid contentIndex ${JSON.stringify(index)} in pi-messages ${event.type} event`);
}
}
switch (event.type) {
case "done":
Object.assign(partial, {

View file

@ -6,6 +6,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
@ -239,22 +240,15 @@ async function startDeviceFlow(domain: string, signal: AbortSignal): Promise<Dev
throw new Error("Invalid device code response fields");
}
// The verification URI is opened in the user's browser and to prevent `open` from
// opening an executable or similar, we force it to be a URL.
let parsedUri: URL;
try {
parsedUri = new URL(verificationUri);
} catch {
throw new Error("Untrusted verification_uri in device code response");
}
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
const trustedVerificationUri = trustedHttpUrl(verificationUri);
if (!trustedVerificationUri) {
throw new Error("Untrusted verification_uri in device code response");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: parsedUri.href,
verification_uri: trustedVerificationUri,
interval,
expires_in: expiresIn,
};

View file

@ -10,6 +10,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts";
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
@ -55,18 +56,6 @@ async function readJson(response: Response): Promise<Record<string, unknown> | n
}
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
method: "POST",
@ -86,16 +75,9 @@ async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal):
const json = await readJson(response);
const deviceCode = json?.device_code;
const userCode = json?.user_code;
const verificationUri = json?.verification_uri;
const verificationUriComplete = json?.verification_uri_complete;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof verificationUriComplete !== "string" ||
!trustedHttpUrl(verificationUriComplete) ||
!trustedHttpUrl(verificationUri)
) {
const verificationUri = trustedHttpUrl(json?.verification_uri);
const verificationUriComplete = trustedHttpUrl(json?.verification_uri_complete);
if (typeof deviceCode !== "string" || typeof userCode !== "string" || !verificationUri || !verificationUriComplete) {
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
}

View file

@ -15,6 +15,7 @@
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
// Muse Code CLI client id.
const CLIENT_ID = "1031625952748946";
@ -54,18 +55,6 @@ function errorDetail(json: Record<string, unknown> | null): string {
return "";
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
function positiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}

View file

@ -14,6 +14,7 @@ import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../typ
import { startOAuthCallbackServer } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { generatePKCE } from "./pkce.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const CALLBACK_HOST = "127.0.0.1";
const CALLBACK_PORT = 1456;
@ -51,10 +52,11 @@ async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): P
}
const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
if (typeof discovery.authorizationEndpoint !== "string") {
const authorizationEndpoint = trustedHttpUrl(discovery.authorizationEndpoint);
if (!authorizationEndpoint) {
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
}
return { authorizationEndpoint: discovery.authorizationEndpoint };
return { authorizationEndpoint };
}
class OAuthResponseError extends Error {
@ -210,11 +212,15 @@ async function requestDeviceAuthorization(gateway: string, signal: AbortSignal):
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
throw new Error("Radius OAuth device authorization response is missing required fields");
}
const verificationUri = trustedHttpUrl(data.verification_uri);
if (!verificationUri) {
throw new Error("Untrusted verification_uri in Radius OAuth device authorization response");
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri: verificationUri,
expires_in: data.expires_in,
interval: data.interval,
};

View file

@ -0,0 +1,10 @@
/**
* The `http:` or `https:` URL `value` names, normalized, or null. Login flows open server-supplied URLs with the
* platform URL handler, which would also launch a `file:` URL or a custom protocol handler. Normalizing
* percent-encodes control characters, so the URL can also be embedded in terminal hyperlinks.
*/
export function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !URL.canParse(value)) return null;
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
}

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
import { builtinProviders } from "./providers/all.ts";
@ -26,7 +26,10 @@ function loadAuth(): Record<string, OAuthCredential> {
}
function saveAuth(auth: Record<string, OAuthCredential>): void {
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
// OAuth tokens: readable by the user only. `mode` applies when the file is created; chmod covers a file an older
// version created, before the new tokens are in it.
if (existsSync(AUTH_FILE)) chmodSync(AUTH_FILE, 0o600);
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
}
async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {

View file

@ -505,7 +505,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
});
it.each([
MODELS.opencode["deepseek-v4-flash"],
MODELS.opencode["deepseek-v4.1-flash"],
MODELS.opencode["deepseek-v4-pro"],
MODELS.opencode["kimi-k2.5"],
MODELS.opencode["kimi-k2.6"],

View file

@ -267,6 +267,34 @@ describe("pi-messages", () => {
expect(message.errorMessage).toContain("No API key provided");
});
it("rejects content indexes that are not the next or an existing block", async () => {
const cases: unknown[][] = [
[{ type: "text_start", contentIndex: 4294967000 }],
[{ type: "text_start", contentIndex: "__proto__" }],
[
{ type: "text_start", contentIndex: 0 },
{
type: "toolcall_end",
contentIndex: "__proto__",
toolCall: { polluted: true },
},
],
[{ type: "text_delta", contentIndex: 0, delta: "x" }],
];
for (const events of cases) {
const { baseUrl } = await startServer({
events: [{ type: "start" }, ...events, { type: "done", reason: "stop", usage }],
});
const message = await stream(createModel(baseUrl), normalizeContext(context), { apiKey: "test-key" }).result();
server?.close();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toContain("Invalid contentIndex");
expect(message.content.length).toBeLessThanOrEqual(1);
}
expect(([] as unknown as Record<string, unknown>).polluted).toBeUndefined();
});
it("errors when the stream ends without a terminal event", async () => {
const { baseUrl } = await startServer({
events: [

View file

@ -128,6 +128,39 @@ describe("Radius OAuth", () => {
expect(fetchMock).toHaveBeenCalledOnce();
});
it("rejects an authorization endpoint that is not an http(s) URL", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ authorizationEndpoint: "file:///Users/victim/Downloads/payload.app" })),
);
const events: AuthEvent[] = [];
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
await expect(oauth.login(interaction("browser", events))).rejects.toThrow(
`Invalid Radius OAuth config from ${GATEWAY}`,
);
expect(events).toEqual([]);
});
it("rejects a device verification URI that is not an http(s) URL", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({
device_code: "device-code",
user_code: "ABCD-1234",
verification_uri: "file:///Users/victim/Downloads/payload.app",
expires_in: 600,
}),
),
);
const events: AuthEvent[] = [];
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
await expect(oauth.login(interaction("device-code", events))).rejects.toThrow("Untrusted verification_uri");
expect(events).toEqual([]);
});
it("exchanges the browser callback code before showing the sign-in page", async () => {
let tokenStatus = 400;
vi.stubGlobal(

View file

@ -22,6 +22,7 @@
- Fixed HTML export not showing system messages: the system prompt and tools, and later section updates such as after `/reload`. `pi --export` now includes the system prompt and tools too ([#11261](https://github.com/earendil-works/pi/issues/11261), [#3464](https://github.com/earendil-works/pi/issues/3464), [#11290](https://github.com/earendil-works/pi/pull/11290))
- Fixed aborting a bash command on macOS sometimes running the next command of a list such as `long_step; next_step`. pi now stops the process group before killing it ([#11252](https://github.com/earendil-works/pi/issues/11252), [#11289](https://github.com/earendil-works/pi/pull/11289))
- Fixed streamed tool calls re-running their call renderer on every argument delta; the interactive mode now renders the latest arguments once per frame, so a large streamed `write` no longer re-highlights its whole content per delta ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed the login dialog opening sign-in URLs that are not web URLs, and printing control characters from sign-in, device-code, and info URLs, which could end a terminal hyperlink and send escape sequences to the terminal
## [1.4.0] - 2026-10-21

View file

@ -3,7 +3,9 @@ import {
Container,
type Focusable,
getKeybindings,
hyperlink,
Input,
replaceControlCharacters,
Spacer,
Text,
TruncatedText,
@ -16,6 +18,31 @@ import { AuthUrlComponent } from "./auth-url.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/**
* The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom
* protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a
* terminal hyperlink early.
*/
function webUrl(value: string): string | undefined {
if (!URL.canParse(value)) return undefined;
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
}
/**
* `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a
* hyperlink early or send its own escape sequences to the terminal.
*/
function displayUrl(value: string): string {
return webUrl(value) ?? replaceControlCharacters(value);
}
/** `text` as a terminal hyperlink to `url` when that is a web URL, else plain `text`. */
function webLink(text: string, url: string): string {
const target = webUrl(url);
return target === undefined ? text : hyperlink(text, target);
}
/**
* Login dialog component - replaces editor during OAuth login flow
*/
@ -121,7 +148,8 @@ export class LoginDialogComponent extends Container implements Focusable {
this.clearDeviceCodeAction();
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
this.authUrl = new AuthUrlComponent(this.tui, url);
const target = webUrl(url);
this.authUrl = new AuthUrlComponent(this.tui, target ?? replaceControlCharacters(url));
this.contentContainer.addChild(this.authUrl);
if (instructions) {
@ -129,7 +157,7 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
}
openBrowser(url);
if (target !== undefined) openBrowser(target);
this.tui.requestRender();
}
@ -146,12 +174,11 @@ export class LoginDialogComponent extends Container implements Focusable {
this.clearDeviceCodeAction();
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const shownUrl = displayUrl(info.verificationUri);
this.contentContainer.addChild(new Text(theme.fg("accent", webLink(shownUrl, shownUrl)), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("dim", webLink(clickHint, info.verificationUri)), 1, 0));
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
@ -187,11 +214,10 @@ export class LoginDialogComponent extends Container implements Focusable {
}
if (info.openBrowserOnConfirm) {
try {
// The shared launcher also opens files; device-code login must only open web URLs.
const url = new URL(info.verificationUri);
if (url.protocol === "https:" || url.protocol === "http:") openBrowser(url.href);
const target = webUrl(info.verificationUri);
if (target !== undefined) openBrowser(target);
} catch {
// Invalid URLs or launcher failures must not interrupt login.
// Launcher failures must not interrupt login.
}
}
}
@ -260,9 +286,9 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0));
for (const link of links) {
const text = link.label ? `${link.label}: ${link.url}` : link.url;
const hyperlink = `\x1b]8;;${link.url}\x07${text}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
const url = displayUrl(link.url);
const text = link.label ? `${link.label}: ${url}` : url;
this.contentContainer.addChild(new Text(theme.fg("accent", webLink(text, url)), 1, 0));
}
if (showCloseHint) {
this.contentContainer.addChild(new Spacer(1));

View file

@ -140,3 +140,47 @@ describe("device-code login", () => {
expect(onComplete).not.toHaveBeenCalled();
});
});
describe("login URLs", () => {
beforeAll(() => initTheme("dark"));
beforeEach(() => {
vi.resetAllMocks();
setKeybindings(new KeybindingsManager());
});
it("opens only http(s) sign-in URLs", () => {
const { dialog } = createDialog();
dialog.showAuth("file:///Users/victim/Downloads/payload.app");
expect(openBrowser).not.toHaveBeenCalled();
dialog.showAuth("https://example.com/authorize");
expect(openBrowser).toHaveBeenCalledExactlyOnceWith("https://example.com/authorize");
});
it("keeps control characters in URLs from ending terminal hyperlinks", () => {
const { dialog } = createDialog();
dialog.showDeviceCode({ ...device, verificationUri: "https://example.com/device\x07\x1b]52;c;Zm9v\x07" });
dialog.showInfo("Docs", [{ label: "Help", url: "https://example.com/help\x07\x1b]52;c;Zm9v\x07" }]);
const output = dialog.render(120).join("\n");
expect(output).not.toContain("\x1b]52");
expect(render(dialog)).toContain("https://example.com/device%07%1B]52;c;Zm9v");
expect(render(dialog)).toContain("Help: https://example.com/help%07%1B]52;c;Zm9v");
});
it("replaces control characters in URLs that are not web URLs", () => {
const injected = "x\x07\x1b]52;c;Zm9v\x07";
const { dialog } = createDialog();
dialog.showAuth(`file:///tmp/${injected}`);
expect(dialog.render(120).join("\n")).not.toContain("\x1b]52");
dialog.showDeviceCode({ ...device, verificationUri: injected });
expect(dialog.render(120).join("\n")).not.toContain("\x1b]52");
expect(render(dialog)).toContain("x ]52;c;Zm9v");
dialog.showInfo("Docs", [{ label: "Help", url: injected }]);
expect(dialog.render(120).join("\n")).not.toContain("\x1b]52");
expect(render(dialog)).toContain("Help: x ]52;c;Zm9v");
expect(openBrowser).not.toHaveBeenCalled();
});
});

View file

@ -28,6 +28,7 @@
- Fixed task span error messages and codemode call previews keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed partial commits of a streamed answer copying every growing text, thinking and tool-call argument string in full on each commit (about 20 s of CPU and a 100 MB heap peak for a 500 KB tool call): they now append new text with Chord's `appendText()`, and apply the argument changes of their own parse of the streamed argument text ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed stored assistant messages lacking `thinkingLevel`: a generation records the Pi thinking level its request asked for on the response, also after polling a deferred one, as pi-agent-core's agent loop does
- Fixed partial commits polluting `Object.prototype` when a streamed tool call's arguments gained a `__proto__` member after an earlier commit: the merge read the missing member through to `Object.prototype` and wrote into it
## [1.4.0] - 2026-10-21

View file

@ -26,7 +26,9 @@ export function assignJson<T extends object, K extends keyof T & (string | numbe
if (plan?.kind === "keep") return;
// Every target is a document container: JSON members or items, whatever its declared type names them.
const slots = target as Record<string | number, JsonValue>;
const current: JsonValue | undefined = slots[key];
// A `__proto__` member the target lacks reads through to Object.prototype; merging into that would pollute every
// object in the process. Other inherited members are functions, which are not merged into.
const current: JsonValue | undefined = key === "__proto__" && !Object.hasOwn(slots, key) ? undefined : slots[key];
const fields = plan?.kind === "fields" ? plan.fields : undefined;
if (isRecord(current) && isRecord(value)) {
for (const name of Object.keys(current)) if (!Object.hasOwn(value, name)) delete current[name];

View file

@ -0,0 +1,23 @@
import { type JsonValue, track } from "@earendil-works/chord/delta";
import { describe, expect, it } from "vitest";
import { assignJson } from "../src/harness/json.ts";
type Doc = { message?: JsonValue };
function assign(tracker: ReturnType<typeof track<Doc>>, value: JsonValue): void {
const change = tracker.beginChange();
assignJson(change.state as Doc, "message", value);
tracker.adopt(change.prepare());
}
describe("assignJson", () => {
it("does not merge a new __proto__ member into Object.prototype", () => {
const tracker = track<Doc>({});
assign(tracker, { args: JSON.parse('{"path":"x"}') });
assign(tracker, { args: JSON.parse('{"path":"xy","__proto__":{"polluted":true}}') });
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
const args = (tracker.value.message as { args: object }).args;
expect(Object.getOwnPropertyDescriptor(args, "__proto__")?.value).toEqual({ polluted: true });
});
});

View file

@ -2,10 +2,15 @@
## [Unreleased]
### Added
- Added `replaceControlCharacters()` to replace runs of C0 and C1 control characters with a space
### Fixed
- Fixed fullscreen double-click selection splitting paths and tokens at `~`, `.`, `\`, and `+`. Word characters now match iTerm2's defaults ([#10980](https://github.com/earendil-works/pi/issues/10980))
- Fixed `fuzzyMatch()` ranking a query scattered over an earlier part of the text above a contiguous match later in it. It now also scores each contiguous occurrence of the query and keeps the best alignment ([#11220](https://github.com/earendil-works/pi/issues/11220), [#11296](https://github.com/earendil-works/pi/pull/11296))
- Fixed `ProcessTerminal.setTitle()` writing control characters from the title, which could end the title sequence and send the rest to the terminal as escape sequences
## [1.4.0] - 2026-10-21

View file

@ -186,6 +186,7 @@ export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.
// Utilities
export {
getOsc8LinkAtColumn,
replaceControlCharacters,
sliceByColumn,
stripTerminalSequences,
truncateToWidth,

View file

@ -5,6 +5,8 @@
* Spec: https://www.superlogical.com/rex/docs/build/program-status
*/
import { replaceControlCharacters } from "./utils.ts";
export interface ProgramStatus {
/** `clear` removes the status instead of reporting one. */
state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
@ -25,7 +27,6 @@ export function isProgramStatusReply(sequence: string): boolean {
}
const APP_PATTERN = /^[A-Za-z0-9_.+-]{1,32}$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g;
/** Decoded `msg` limit. Its base64 encoding stays under the 2732-byte encoded limit. */
const MAX_MESSAGE_BYTES = 2048;
@ -47,7 +48,7 @@ export function formatProgramStatus(status: ProgramStatus): string {
const pairs = [`state=${status.state}`];
if (status.app !== undefined && APP_PATTERN.test(status.app)) pairs.push(`app=${status.app}`);
if (status.state === "blocked" && status.kind) pairs.push(`kind=${status.kind}`);
const message = truncateUtf8((status.message ?? "").replace(CONTROL_CHARACTERS, " ").trim(), MAX_MESSAGE_BYTES);
const message = truncateUtf8(replaceControlCharacters(status.message ?? "").trim(), MAX_MESSAGE_BYTES);
if (message) pairs.push(`msg=${Buffer.from(message, "utf8").toString("base64")}`);
return `\x1b]7501;${pairs.join(":")}\x1b\\`;
}

View file

@ -10,6 +10,7 @@ import {
type ProgramStatus,
} from "./program-status.ts";
import { StdinBuffer } from "./stdin-buffer.ts";
import { replaceControlCharacters } from "./utils.ts";
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
@ -590,8 +591,9 @@ export class ProcessTerminal implements Terminal {
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`);
// OSC 0;title BEL - set terminal window title. A BEL or ESC in the title would end the sequence early and
// send the rest to the terminal as escape sequences (session names come from session files).
process.stdout.write(`\x1b]0;${replaceControlCharacters(title)}\x07`);
}
setProgramStatus(status: ProgramStatus): void {

View file

@ -326,6 +326,13 @@ export function stripTerminalSequences(str: string): string {
return result;
}
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g;
/** Replace each run of C0 and C1 control characters (ESC, BEL, newlines, ...) with one space. */
export function replaceControlCharacters(str: string): string {
return str.replace(CONTROL_CHARACTERS, " ");
}
interface GraphemeCellRange {
start: number;
end: number;

View file

@ -395,6 +395,26 @@ describe("ProcessTerminal progress", () => {
});
});
describe("ProcessTerminal title", () => {
it("replaces control characters so the title cannot end the OSC sequence", () => {
const terminal = new ProcessTerminal();
const writes: string[] = [];
const previousWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
writes.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
terminal.setTitle("pi - work\x07\x1b]52;c;Zm9v\x07\u009b2J - repo");
assert.deepEqual(writes, ["\x1b]0;pi - work ]52;c;Zm9v 2J - repo\x07"]);
} finally {
process.stdout.write = previousWrite;
}
});
});
describe("ProcessTerminal dimensions", () => {
it("falls back to COLUMNS and LINES before default dimensions", () => {
const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");