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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/research/WEB_EVIDENCE_TERMINAL_STRINGS_2026-10-07.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Terminal strings in Web evidence

- Status: validated source and regression evidence
- Created / verified: 2026-10-07
- Source: origin/main at 3cb2ecfe1bfbb98252651885311d309f83749428
- Issue: [#700](https://github.com/openpi-dev/openpi/issues/700)
- Supersedes: none

## Verified facts

Web evidence stripped escape introducers while retaining hidden DCS, PM, APC and SOS payloads. It now uses the canonical shared terminal sanitizer on its bounded candidate. Tests cover ESC/C1 introducers, terminators, unterminated strings and actual tool projections.

## Verification boundary

Eight evidence tests pass. Unicode and tab behavior follows the shared sanitizer. Canonical execution and persisted evidence are unchanged.

Full repository validation results and CI are recorded on the linked PR. Local
Windows failures are retained separately and are not reported as passing.
64 changes: 59 additions & 5 deletions tests/web/evidence-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ it.each(["failed", "cancelled", "unknown", "running"] as const)(

it("expands a file summary without nested controls and opens the resolved file through the active artifact context", () => {
const open = vi.fn();
const identity = "/workspace/a\u200bb.md";
const { container } = render(
createElement(
ArtifactContext.Provider,
Expand All @@ -249,8 +250,8 @@ it("expands a file summary without nested controls and opens the resolved file t
name: "read",
arguments: JSON.stringify({ path: "report.md" }),
evidenceArguments: {
path: "report.md",
resolvedPath: "/workspace/report.md",
path: "a\u200bb.md",
resolvedPath: identity,
},
},
result: { content: "report", isError: false },
Expand All @@ -259,12 +260,12 @@ it("expands a file summary without nested controls and opens the resolved file t
);

const summary = container.querySelector(".tool-evidence-card > summary")!;
expect(summary.textContent).toContain("report.md");
expect(summary.textContent).toContain("ab.md");
expect(summary.querySelector("button, a, input, [tabindex]")).toBeNull();
fireEvent.click(summary);
expect(open).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "/workspace/report.md" }));
expect(open).toHaveBeenCalledWith("/workspace/report.md");
fireEvent.click(screen.getByRole("button", { name: "/workspace/ab.md" }));
expect(open).toHaveBeenCalledWith(identity);
});

it("opens local links using authenticated API and stops preview reads on close", async () => {
Expand Down Expand Up @@ -521,3 +522,56 @@ it("polls metadata with backoff, retains changed text until refresh, and stops o
vi.useRealTimers();
}
});

it("keeps oversized evidence references non-actionable in projected and legacy calls", () => {
const huge = "x".repeat(1024 * 1024);
const open = vi.fn();
for (const evidenceArguments of [
undefined,
{ path: huge, resolvedPath: "small.txt" },
{ path: "small.txt", resolvedPath: huge },
]) {
const { container, unmount } = render(
createElement(
ArtifactContext.Provider,
{ value: { open } },
createElement(ToolEvidence, {
call: {
type: "toolCall",
name: "read",
arguments: JSON.stringify({ path: huge }),
evidenceArguments,
},
defaultOpen: true,
}),
),
);
expect(screen.queryByRole("button")).toBeNull();
expect(
screen.getByText("File reference exceeds the evidence byte limit."),
).toBeTruthy();
expect(container.textContent!.length).toBeLessThan(64 * 1024);
expect(open).not.toHaveBeenCalled();
unmount();
}
});

it("opens admitted exact identities even when their display label is empty", () => {
const open = vi.fn();
render(
createElement(
ArtifactContext.Provider,
{ value: { open } },
createElement(ToolEvidence, {
call: {
type: "toolCall",
name: "read",
arguments: JSON.stringify({ path: "\u200b" }),
},
defaultOpen: true,
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "File reference" }));
expect(open).toHaveBeenCalledWith("\u200b");
});
205 changes: 205 additions & 0 deletions tests/web/evidence.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
evidenceText,
Expand All @@ -8,6 +11,7 @@ import {
} from "../../web/protocol/evidence.ts";
import { projectMessage } from "../../web/protocol/types.ts";
import { reduceLiveTools } from "../../web/protocol/live-tools.ts";
import { ArtifactReader } from "../../web/host/artifacts.ts";

function call(name: string, args: Record<string, unknown> = {}) {
const part = projectMessage({
Expand All @@ -17,6 +21,36 @@ function call(name: string, args: Record<string, unknown> = {}) {
return part;
}

test("Web evidence strips terminal-owned hidden strings rather than exposing their payloads", () => {
for (const start of [
"\x1bP",
"\x1bX",
"\x1b^",
"\x1b_",
"\u0090",
"\u0098",
"\u009e",
"\u009f",
]) {
for (const end of ["\x1b\\", "\u009c"]) {
assert.equal(
evidenceText(`before${start}hidden${end}after`).text,
"beforeafter",
);
}
assert.equal(evidenceText(`before${start}unterminated`).text, "before");
}
assert.equal(
evidenceText("中文\t👩‍💻\x1b[31mvisible\x1b[0m").text,
"中文 👩‍💻visible",
);
const result = projectToolEvidence(call("bash"), {
content: "before\x1bPhidden\x1b\\after",
isError: false,
});
assert.equal(result.output, "beforeafter");
});

test("bounded messages preserve final bash receipts before output truncation", () => {
const message = projectMessage({
role: "toolResult",
Expand Down Expand Up @@ -84,6 +118,61 @@ test("file evidence preserves requested range after oversized arguments and neve
);
});

test("file evidence keeps artifact identities separate from sanitized display labels", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-evidence-path-"));
const identity = "a\u200bb.txt";
const resolvedPath = join(root, identity);
const reader = new ArtifactReader(() => ({ sessionId: "s", cwd: root }));
try {
await writeFile(resolvedPath, "zero-width identity");
await writeFile(join(root, "ab.txt"), "different file");
const message = projectMessage(
{
content: [
{
type: "toolCall",
id: "call",
name: "read",
arguments: { path: identity },
},
],
},
(path) => join(root, path),
);
const part = message.parts?.[0];
assert.ok(part?.type === "toolCall");
const view = projectToolEvidence(part);
assert.equal(view.path, identity);
assert.equal(view.displayPath, "ab.txt");
assert.equal(view.resolvedPath, resolvedPath);
const handle = await reader.resolveFile("s", view.resolvedPath!);
assert.equal(
(await reader.read(handle, "s")).preview.text,
"zero-width identity",
);

const tabView = projectToolEvidence(call("read", { path: "a\tb.txt" }));
assert.equal(tabView.path, "a\tb.txt");
assert.equal(tabView.displayPath, "a b.txt");
if (process.platform !== "win32") {
// Windows filenames cannot contain tab characters.
await writeFile(join(root, "a\tb.txt"), "tab identity");
assert.equal(
await readFile(join(root, tabView.path!), "utf8"),
"tab identity",
);
}
// Artifact policy still rejects controls; evidence must not silently retarget
// the sanitized label to another real file to bypass that boundary.
await assert.rejects(reader.resolveFile("s", tabView.path!), {
code: "ARTIFACT_DENIED",
});
} finally {
reader.dispose();
await rm(root, { recursive: true, force: true });
}
});

test("terminal receipts, TAP observations, and tool returns remain separate", () => {
const bash = call("bash", { command: "node --test" });
const output =
Expand Down Expand Up @@ -262,3 +351,119 @@ test("background process outcomes require literal status identities and integral
);
}
});

test("evidence admits exact UTF-8 path bytes and rejects oversized identities without fallback", () => {
const budget = 4096;
for (const exact of [
"x".repeat(budget),
"中".repeat(1365) + "x",
"😀".repeat(1024),
]) {
for (const path of [exact.slice(0, -2), exact]) {
assert.equal(projectToolEvidence(call("read", { path })).path, path);
}
const path = exact + "x";
const view = projectToolEvidence(call("read", { path }));
assert.equal(view.path, undefined);
assert.equal(view.resolvedPath, undefined);
assert.equal(
view.pathUnavailable,
"File reference exceeds the evidence byte limit.",
);
assert.equal(view.truncated, true);
for (const legacy of [
{
type: "toolCall" as const,
name: "read",
arguments: JSON.stringify({ path }),
},
{
type: "toolCall" as const,
name: "read",
arguments: "{}",
evidenceArguments: { path, resolvedPath: "short.txt" },
},
{
type: "toolCall" as const,
name: "read",
arguments: "{}",
evidenceArguments: { path: "short.txt", resolvedPath: path },
},
]) {
const projected = projectToolEvidence(legacy);
assert.equal(projected.path, undefined);
assert.equal(projected.resolvedPath, undefined);
}
}
});

test("production evidence serialization bounds raw and resolved 1 MiB paths", () => {
const huge = "x".repeat(1024 * 1024);
let resolutions = 0;
for (const path of [huge, "short.txt"]) {
const message = projectMessage(
{ content: [{ type: "toolCall", name: "read", arguments: { path } }] },
() => {
resolutions++;
return huge;
},
);
const part = message.parts?.[0];
assert.ok(part?.type === "toolCall");
const view = projectToolEvidence(part);
assert.equal(view.path, undefined);
assert.equal(view.resolvedPath, undefined);
assert.ok(view.pathUnavailable);
assert.ok(Buffer.byteLength(JSON.stringify(message)) < 64 * 1024);
assert.ok(Buffer.byteLength(JSON.stringify(view)) < 64 * 1024);
assert.ok(Buffer.byteLength(view.displayPath ?? "") <= 12 * 1024);
}
assert.equal(resolutions, 1);
const exact = "中".repeat(1365) + "x";
const projected = projectMessage(
{
content: [{ type: "toolCall", name: "read", arguments: { path: "ok" } }],
},
() => exact,
);
const part = projected.parts?.[0];
assert.ok(part?.type === "toolCall");
assert.equal(projectToolEvidence(part).resolvedPath, exact);
});

test("raw tool arguments cannot supply the trusted resolved evidence identity", () => {
for (const path of ["~/.note", "@named/path", "report.md"]) {
let resolverCalls = 0;
const message = projectMessage(
{
content: [
{
type: "toolCall",
name: "read",
arguments: { path, resolvedPath: "different.txt" },
},
],
},
() => {
resolverCalls++;
return "/workspace/report.md";
},
);
const part = message.parts?.[0];
assert.ok(part?.type === "toolCall");
const view = projectToolEvidence(part);
assert.equal(view.path, path);
assert.equal(
view.resolvedPath,
path === "report.md" ? "/workspace/report.md" : undefined,
);
assert.equal(resolverCalls, path === "report.md" ? 1 : 0);
const legacy = projectToolEvidence({
type: "toolCall",
name: "read",
arguments: JSON.stringify({ path, resolvedPath: "different.txt" }),
});
assert.equal(legacy.path, path);
assert.equal(legacy.resolvedPath, undefined);
}
});
117 changes: 59 additions & 58 deletions web/dist/app.js

Large diffs are not rendered by default.

Loading
Loading