mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
f9daca3480
commit
89c921d8b5
24 changed files with 260 additions and 68 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
10
packages/ai/src/auth/oauth/trusted-url.ts
Normal file
10
packages/ai/src/auth/oauth/trusted-url.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
23
packages/durable/test/harness-json.test.ts
Normal file
23
packages/durable/test/harness-json.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.
|
|||
// Utilities
|
||||
export {
|
||||
getOsc8LinkAtColumn,
|
||||
replaceControlCharacters,
|
||||
sliceByColumn,
|
||||
stripTerminalSequences,
|
||||
truncateToWidth,
|
||||
|
|
|
|||
|
|
@ -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\\`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue