diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index 1db1c5905..429416843 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -12,6 +12,7 @@ import { writeFile, } from "node:fs/promises"; import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { @@ -1197,8 +1198,68 @@ const sameOwner = (left: LockOwner, right: LockOwner) => const sameFile = (left: Stats, right: Stats) => left.dev === right.dev && left.ino === right.ino; -const parsePowerShellProcessStartedAt = (stdout: string) => { - const startedAt = Number(stdout.trim()); +// CIM datetime is local time plus a UTC offset in minutes. Truncate the +// microsecond field so the integer matches FILETIME / ToUnixTimeMilliseconds. +const CIM_PROCESS_STARTED_AT = + /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/; + +// Redirected cscript writes UTF-16 LE, often with a BOM. Reading that +// pipe as UTF-8 leaves NULs or U+FFFD and the CIM parser rejects it, so +// a live PID stays unknown until the 5s lock expires. +export function decodeProcessStartedAtOutput(output: Buffer | string) { + const buffer = Buffer.isBuffer(output) ? output : Buffer.from(output); + const utf16 = + (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) || + (buffer.length >= 4 && buffer[1] === 0 && buffer[3] === 0); + const text = utf16 ? buffer.toString("utf16le") : buffer.toString("utf8"); + return text + .replace(/^\uFEFF/, "") + .replaceAll("\0", "") + .trim(); +} + +const parseCimProcessStartedAt = (stdout: string) => { + const match = CIM_PROCESS_STARTED_AT.exec(stdout.trim()); + if (!match) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const millisecond = Number(match[7]?.slice(0, 3)); + const offsetMinutes = (match[8] === "+" ? 1 : -1) * Number(match[9]); + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hour > 23 || + minute > 59 || + second > 59 || + !Number.isInteger(offsetMinutes) + ) { + return undefined; + } + const localAsUtc = Date.UTC( + year, + month - 1, + day, + hour, + minute, + second, + millisecond, + ); + if (!Number.isFinite(localAsUtc)) return undefined; + const parsed = new Date(localAsUtc); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) { + return undefined; + } + const startedAt = localAsUtc - offsetMinutes * 60_000; return isPositiveInteger(startedAt) ? startedAt : undefined; }; @@ -1212,16 +1273,18 @@ export function processStartedAtQuery( platform = process.platform, ) { if (platform === "win32") { - const script = [ - "$ErrorActionPreference='Stop'", - `$p=Get-Process -Id ${pid}`, - "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())", - ].join("; "); + // Resolve on use. Vitest loads this module without a file URL, so a + // top-level fileURLToPath throws during import. WMI via cscript avoids + // the PowerShell CLR cold start. The 1s query budget stays; failure is + // still unknown ownership, and the 5s lock does not recover. + const script = fileURLToPath( + new URL("./win32-process-started-at.js", import.meta.url), + ); return { - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", script], + command: "cscript.exe", + args: ["//Nologo", script, String(pid)], env: undefined, - parseOutput: parsePowerShellProcessStartedAt, + parseOutput: parseCimProcessStartedAt, }; } return { @@ -1232,20 +1295,39 @@ export function processStartedAtQuery( }; } -const queryProcessStartedAt = (pid: number) => +// One query. A timeout, non-zero exit, or unparseable value is unknown +// ownership. The 5s lock then expires without a second command. +const PROCESS_START_QUERY_TIMEOUT_MS = 1_000; + +const runProcessStartedAtQuery = ( + query: { + command: string; + args: string[]; + env: Record | undefined; + parseOutput: (stdout: string) => number | undefined; + }, + timeout: number, +) => new Promise((resolve) => { - const query = processStartedAtQuery(pid); try { execFile( query.command, query.args, { - encoding: "utf8", + encoding: "buffer", env: query.env ? { ...process.env, ...query.env } : process.env, - timeout: 1_000, + timeout, + windowsHide: true, + // cscript treats //Nologo as a host switch. Verbatim keeps Node + // from quoting it into an ordinary argument. + windowsVerbatimArguments: process.platform === "win32", }, (error, stdout) => { - resolve(error ? undefined : query.parseOutput(stdout)); + resolve( + error || stdout == null + ? undefined + : query.parseOutput(decodeProcessStartedAtOutput(stdout)), + ); }, ); } catch { @@ -1253,6 +1335,12 @@ const queryProcessStartedAt = (pid: number) => } }); +const queryProcessStartedAt = (pid: number) => + runProcessStartedAtQuery( + processStartedAtQuery(pid), + PROCESS_START_QUERY_TIMEOUT_MS, + ); + const makeLockIdentity = async (): Promise => { const processStartedAt = await queryProcessStartedAt(process.pid); return { diff --git a/extensions/shared/win32-process-started-at.js b/extensions/shared/win32-process-started-at.js new file mode 100644 index 000000000..8f4254362 --- /dev/null +++ b/extensions/shared/win32-process-started-at.js @@ -0,0 +1,39 @@ +// Windows Script Host JScript. Node does not execute this file. +// setup-config asks cscript.exe for one process CreationDate. A missing +// process or a failed query exits non-zero so the lock stays fail-closed. +// JScript rejects trailing commas, so keep every call list tight. +var raw = + WScript.Arguments.length === 1 ? String(WScript.Arguments.Item(0)) : ""; +if (!/^[1-9][0-9]*$/.test(raw)) { + WScript.StdOut.Write("wmi-args"); + WScript.Quit(1); +} +var startedAt = ""; +var failure = "wmi-error"; +try { + var locator = new ActiveXObject("WbemScripting.SWbemLocator"); + var service = locator.ConnectServer(".", "root\\cimv2"); + var processes = service.ExecQuery( + "SELECT CreationDate FROM Win32_Process WHERE ProcessId=" + raw + ); + var enumerator = new Enumerator(processes); + if (enumerator.atEnd()) { + failure = "wmi-empty"; + } else { + var value = enumerator.item().CreationDate; + if (value) { + startedAt = String(value); + failure = ""; + } + } +} catch (error) { + failure = "wmi-error"; + startedAt = ""; +} +// Quit outside try. JScript turns WScript.Quit into a catchable exception, +// so a failure exit inside try/catch can fall off the end with status 0. +if (!startedAt) { + WScript.StdOut.Write(failure); + WScript.Quit(1); +} +WScript.Echo(startedAt); diff --git a/scripts/provenance.d.mts b/scripts/provenance.d.mts new file mode 100644 index 000000000..8955fa609 --- /dev/null +++ b/scripts/provenance.d.mts @@ -0,0 +1,22 @@ +export const OPENPI_PACKAGE_NAME: "@tt-a1i/openpi"; + +export function normalizePath(value: string, base?: string): string; + +export function collectReportedPaths( + stdout?: string, + stderr?: string, +): string[]; + +export function evaluateOpenPiProvenance( + reportedPaths: readonly string[], + checkoutPath?: string, + options?: { + normalize?: (value: string) => string; + packageName?: string; + readPackageName?: (directory: string) => string | undefined; + }, +): { + checkoutPath: string; + openPiSources: string[]; + matches: boolean; +}; diff --git a/scripts/provenance.mjs b/scripts/provenance.mjs index 141962041..acb33e1bb 100644 --- a/scripts/provenance.mjs +++ b/scripts/provenance.mjs @@ -1,11 +1,13 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -function normalizePath(value, base) { +export const OPENPI_PACKAGE_NAME = "@tt-a1i/openpi"; + +export function normalizePath(value, base = ROOT) { const absolute = isAbsolute(value) ? value : resolve(base, value); try { return realpathSync.native(absolute); @@ -14,54 +16,98 @@ function normalizePath(value, base) { } } -const checkoutRevision = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: ROOT, - encoding: "utf8", -}).trim(); - -const localPi = join( - ROOT, - "node_modules", - ".bin", - process.platform === "win32" ? "pi.exe" : "pi", -); -const piCommand = existsSync(localPi) - ? localPi - : (process.env.OPENPI_PI_BIN ?? - (process.platform === "win32" ? "pi.exe" : "pi")); -const piResult = spawnSync(piCommand, ["list"], { - cwd: ROOT, - encoding: "utf8", - windowsHide: true, -}); -if (piResult.error || piResult.status !== 0) { - throw new Error( - `pi list failed: ${piResult.error?.message ?? piResult.stderr ?? "unknown error"}`, - ); +function readPackageName(directory) { + try { + const parsed = JSON.parse( + readFileSync(join(directory, "package.json"), "utf8"), + ); + return typeof parsed?.name === "string" ? parsed.name : undefined; + } catch { + return undefined; + } } -const reportedLines = [ - ...(piResult.stdout ?? "").split(/\r?\n/), - ...(piResult.stderr ?? "").split(/\r?\n/), -].map((line) => line.trim()); -const absoluteLines = reportedLines.filter((line) => isAbsolute(line)); -const reportedPaths = (absoluteLines.length > 0 ? absoluteLines : reportedLines) - .map((line) => line.trim()) - .filter( +export function collectReportedPaths(stdout = "", stderr = "") { + const reportedLines = [ + ...stdout.split(/\r?\n/), + ...stderr.split(/\r?\n/), + ].map((line) => line.trim()); + const absoluteLines = reportedLines.filter((line) => isAbsolute(line)); + return (absoluteLines.length > 0 ? absoluteLines : reportedLines).filter( (line) => line.length > 0 && !/^(user|project) packages:$/i.test(line) && /[\\/]/.test(line), - ) - .map((line) => normalizePath(line, ROOT)); -const sources = [...new Set(reportedPaths)]; -const openPiSources = sources.filter((source) => /openpi/i.test(source)); -const checkoutPath = normalizePath(ROOT, ROOT); -const matches = openPiSources.length === 1 && openPiSources[0] === checkoutPath; + ); +} + +export function evaluateOpenPiProvenance( + reportedPaths, + checkoutPath = ROOT, + options = {}, +) { + const normalize = options.normalize ?? normalizePath; + const packageName = options.packageName ?? OPENPI_PACKAGE_NAME; + const readName = options.readPackageName ?? readPackageName; + const checkout = normalize(checkoutPath); + const sources = []; + for (const reported of reportedPaths) { + const source = normalize(reported); + if (!sources.includes(source)) sources.push(source); + } + const openPiSources = sources.filter( + (source) => source === checkout || readName(source) === packageName, + ); + return { + checkoutPath: checkout, + openPiSources, + matches: openPiSources.length === 1 && openPiSources[0] === checkout, + }; +} + +function invokedDirectly() { + const entry = process.argv[1]; + if (!entry) return false; + return normalizePath(fileURLToPath(import.meta.url)) === normalizePath(entry); +} -process.stdout.write(`Checkout revision: ${checkoutRevision}\n`); -process.stdout.write( - `OpenPI source reported by pi list: ${openPiSources.join(", ") || "none"}\n`, -); -process.stdout.write(`Provenance match: ${matches ? "yes" : "no"}\n`); -if (!matches) process.exitCode = 1; +if (invokedDirectly()) { + const checkoutRevision = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: ROOT, + encoding: "utf8", + }).trim(); + + const localPi = join( + ROOT, + "node_modules", + ".bin", + process.platform === "win32" ? "pi.exe" : "pi", + ); + const piCommand = existsSync(localPi) + ? localPi + : (process.env.OPENPI_PI_BIN ?? + (process.platform === "win32" ? "pi.exe" : "pi")); + const piResult = spawnSync(piCommand, ["list"], { + cwd: ROOT, + encoding: "utf8", + windowsHide: true, + }); + if (piResult.error || piResult.status !== 0) { + throw new Error( + `pi list failed: ${piResult.error?.message ?? piResult.stderr ?? "unknown error"}`, + ); + } + + const provenance = evaluateOpenPiProvenance( + collectReportedPaths(piResult.stdout ?? "", piResult.stderr ?? ""), + ROOT, + ); + process.stdout.write(`Checkout revision: ${checkoutRevision}\n`); + process.stdout.write( + `OpenPI source reported by pi list: ${provenance.openPiSources.join(", ") || "none"}\n`, + ); + process.stdout.write( + `Provenance match: ${provenance.matches ? "yes" : "no"}\n`, + ); + if (!provenance.matches) process.exitCode = 1; +} diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index 0afaca01b..ee74a90b0 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -1527,39 +1527,110 @@ test("streamAntigravity bounds the first event wait and releases both bodies", a assert.match(error.error.errorMessage ?? "", /first SSE event/); }); +const SSE_CLOCK_ORIGIN = 1_700_000_000_000; +const SSE_TIMEOUT_MS = 1_000; +const SSE_METADATA = new TextEncoder().encode( + 'data: {"response":{"usageMetadata":{"promptTokenCount":1}}}\n\n', +); + +// timeoutMs is both deadlines. The body prefetches one extra chunk before the +// first read is parsed, so the third pull is the read after that event. Move +// the clock there. Advancing earlier still reports the first SSE event. +async function withSseClock( + run: (clock: { advance: (ms: number) => void }) => Promise, +) { + const realNow = Date.now; + const previousVersion = process.env.OPENPI_ANTIGRAVITY_VERSION; + let now = SSE_CLOCK_ORIGIN; + Date.now = () => now; + process.env.OPENPI_ANTIGRAVITY_VERSION = "1.2.3"; + try { + return await run({ + advance(ms) { + now += ms; + }, + }); + } finally { + Date.now = realNow; + if (previousVersion === undefined) { + delete process.env.OPENPI_ANTIGRAVITY_VERSION; + } else process.env.OPENPI_ANTIGRAVITY_VERSION = previousVersion; + } +} + test("streamAntigravity bounds the full SSE lifetime after the first event", async () => { const urls: string[] = []; let cancellations = 0; - globalThis.fetch = (async (input: unknown) => { - urls.push(String(input)); - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"response":{"usageMetadata":{"promptTokenCount":1}}}\n\n', - ), - ); - }, - cancel() { - cancellations++; - }, + await withSseClock(async (clock) => { + globalThis.fetch = (async (input: unknown) => { + urls.push(String(input)); + let pulls = 0; + return new Response( + new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls < 3) { + controller.enqueue(SSE_METADATA); + return; + } + clock.advance(SSE_TIMEOUT_MS); + controller.enqueue(new TextEncoder().encode(": stall\n")); + }, + cancel() { + cancellations++; + }, + }), + { status: 200 }, + ); + }) as typeof fetch; + + const events = await collectEvents( + streamAntigravity(GEMINI_MODEL, SIMPLE_CONTEXT, { + apiKey: API_KEY, + timeoutMs: SSE_TIMEOUT_MS, }), - { status: 200 }, ); - }) as typeof fetch; + assert.equal(urls.length, 2); + assert.equal(cancellations, 2); + const error = events.find((event) => event.type === "error"); + assert.ok(error && error.type === "error"); + assert.match(error.error.errorMessage ?? "", /next SSE event/); + }); +}); - const events = await collectEvents( - streamAntigravity(GEMINI_MODEL, SIMPLE_CONTEXT, { - apiKey: API_KEY, - timeoutMs: 5, - }), - ); - assert.equal(urls.length, 2); - assert.equal(cancellations, 2); - const error = events.find((event) => event.type === "error"); - assert.ok(error && error.type === "error"); - assert.match(error.error.errorMessage ?? "", /next SSE event/); +test("streamAntigravity reports the first SSE event when the deadline passes before a payload", async () => { + const urls: string[] = []; + let cancellations = 0; + await withSseClock(async (clock) => { + globalThis.fetch = (async (input: unknown) => { + urls.push(String(input)); + return new Response( + new ReadableStream({ + pull(controller) { + clock.advance(SSE_TIMEOUT_MS); + controller.enqueue(SSE_METADATA); + }, + cancel() { + cancellations++; + }, + }), + { status: 200 }, + ); + }) as typeof fetch; + + const events = await collectEvents( + streamAntigravity(GEMINI_MODEL, SIMPLE_CONTEXT, { + apiKey: API_KEY, + timeoutMs: SSE_TIMEOUT_MS, + }), + ); + assert.equal(urls.length, 2); + assert.equal(cancellations, 2); + const error = events.find((event) => event.type === "error"); + assert.ok(error && error.type === "error"); + assert.match(error.error.errorMessage ?? "", /first SSE event/); + assert.doesNotMatch(error.error.errorMessage ?? "", /next SSE event/); + }); }); test("streamAntigravity bounds the response-header wait and fails over", async () => { diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index aad3237a7..3d9374812 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; import { existsSync, @@ -32,6 +32,7 @@ const { formatSetupDiagnostics, parseSetupConfig, POST_EDIT_COMMAND_MAX_CHARS, + decodeProcessStartedAtOutput, processStartedAtQuery, saveSetupConfig, SETUP_CONFIG_PATH, @@ -246,15 +247,53 @@ test("FAIL: session child execution admission is opt-in and bounded", () => { test("process start-time queries are platform-specific and conservative", () => { const windowsQuery = processStartedAtQuery(123, "win32"); - assert.equal(windowsQuery.command, "powershell.exe"); - assert.deepEqual(windowsQuery.args.slice(0, 3), [ - "-NoProfile", - "-NonInteractive", - "-Command", - ]); - assert.match(windowsQuery.args.at(-1) ?? "", /Get-Process -Id 123/); - assert.equal(windowsQuery.parseOutput("1700000000000"), 1_700_000_000_000); + assert.equal(windowsQuery.command, "cscript.exe"); + assert.deepEqual(windowsQuery.args.slice(0, 1), ["//Nologo"]); + assert.match(windowsQuery.args[1] ?? "", /win32-process-started-at\.js$/); + assert.equal(windowsQuery.args[2], "123"); + assert.equal( + windowsQuery.args.some((arg) => arg.toLowerCase().includes("powershell")), + false, + ); + const cim = "20231114221320.000000+000"; + const utf16Pipe = Buffer.from(`\uFEFF${cim}\r\n`, "utf16le"); + assert.equal( + windowsQuery.parseOutput(decodeProcessStartedAtOutput(utf16Pipe)), + 1_700_000_000_000, + ); + assert.equal(windowsQuery.parseOutput(utf16Pipe.toString("utf8")), undefined); + assert.equal( + windowsQuery.parseOutput( + decodeProcessStartedAtOutput(Buffer.from(`${cim}\n`, "utf8")), + ), + 1_700_000_000_000, + ); + assert.equal( + windowsQuery.parseOutput("20231114221320.000000+000"), + 1_700_000_000_000, + ); + assert.equal( + windowsQuery.parseOutput("20231115061320.000000+480"), + 1_700_000_000_000, + ); + assert.equal( + windowsQuery.parseOutput("20231114171320.000000-300"), + 1_700_000_000_000, + ); + assert.equal( + windowsQuery.parseOutput("20231114221320.123999+000\n"), + 1_700_000_000_123, + ); + assert.equal(windowsQuery.parseOutput("1700000000000"), undefined); assert.equal(windowsQuery.parseOutput("not a timestamp"), undefined); + assert.equal( + windowsQuery.parseOutput("20231314221320.000000+000"), + undefined, + ); + assert.equal( + windowsQuery.parseOutput("19700101000000.000000+000"), + undefined, + ); const posixQuery = processStartedAtQuery(123, "linux"); assert.equal(posixQuery.command, "ps"); @@ -658,6 +697,42 @@ process.stdout.write(JSON.stringify({ result: "success", ms: Date.now() - starte } }); +test("Windows reads this process start time from WMI alone", { + skip: process.platform !== "win32", +}, async () => { + const query = processStartedAtQuery(process.pid); + assert.equal(query.command, "cscript.exe"); + const { stdout, stderr, error } = await new Promise<{ + stdout: Buffer; + stderr: Buffer; + error: Error | null; + }>((resolve) => { + execFile( + query.command, + query.args, + { + encoding: "buffer", + timeout: 1_000, + windowsHide: true, + windowsVerbatimArguments: true, + }, + (execError, execStdout, execStderr) => { + resolve({ + stdout: Buffer.isBuffer(execStdout) ? execStdout : Buffer.alloc(0), + stderr: Buffer.isBuffer(execStderr) ? execStderr : Buffer.alloc(0), + error: execError, + }); + }, + ); + }); + const text = decodeProcessStartedAtOutput(stdout); + const startedAt = error ? undefined : query.parseOutput(text); + assert.ok( + startedAt !== undefined && startedAt > 1, + `WMI start time missing: ${error?.message ?? "unparseable"}; decoded=${JSON.stringify(text)}; stderr=${JSON.stringify(decodeProcessStartedAtOutput(stderr))}`, + ); +}); + test("a reused PID does not impersonate the dead lock owner", async () => { removeLockArtifacts(); await saveSetupConfig(DEFAULT_SETUP_CONFIG); @@ -688,7 +763,9 @@ test("process start-time query failures do not authorize lock recovery", async ( const source = ` const childProcess = await import("node:child_process"); const { syncBuiltinESMExports } = await import("node:module"); +const commands = []; childProcess.default.execFile = (...args) => { + commands.push(String(args[0])); const callback = args.at(-1); if (typeof callback !== "function") { throw new Error("expected execFile callback"); @@ -734,6 +811,15 @@ if (mutated) throw new Error("mutator ran under unknown lock ownership"); if (!existsSync(lockPath) || readFileSync(lockPath, "utf8") !== metadata) { throw new Error("unknown lock ownership was modified"); } +if (commands.some((command) => command.toLowerCase().includes("powershell"))) { + throw new Error("failed start-time read fell through to PowerShell: " + commands.join(",")); +} +if (process.platform === "win32" && !commands.includes("cscript.exe")) { + throw new Error("Windows lock recovery did not query WMI: " + commands.join(",")); +} +if (process.platform === "win32" && commands.some((command) => command !== "cscript.exe")) { + throw new Error("Windows lock recovery used another start-time command: " + commands.join(",")); +} rmSync(process.env.PI_CODING_AGENT_DIR, { recursive: true, force: true }); process.stdout.write("preserved\\n"); `; diff --git a/tests/scripts/provenance.test.ts b/tests/scripts/provenance.test.ts new file mode 100644 index 000000000..7e821e82f --- /dev/null +++ b/tests/scripts/provenance.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + collectReportedPaths, + evaluateOpenPiProvenance, + OPENPI_PACKAGE_NAME, +} from "../../scripts/provenance.mjs"; + +const checkout = resolve("."); + +function packageDir(root: string, name: string, folder: string) { + const dir = join(root, folder); + mkdirSync(dir); + writeFileSync(join(dir, "package.json"), `${JSON.stringify({ name })}\n`); + return dir; +} + +test("pi list lines keep absolute package paths", () => { + assert.deepEqual( + collectReportedPaths("User packages:\n /workspace\n /workspace\n"), + ["/workspace", "/workspace"], + ); + assert.deepEqual(collectReportedPaths("No packages installed.\n"), []); +}); + +test("the checkout matches by realpath even when the directory is not named openpi", () => { + const root = mkdtempSync(join(tmpdir(), "openpi-provenance-")); + try { + const source = packageDir(root, OPENPI_PACKAGE_NAME, "checkout"); + const linked = join(root, "link"); + symlinkSync(source, linked); + const match = evaluateOpenPiProvenance( + [linked, source, `${source}/`], + source, + ); + assert.equal(match.matches, true); + assert.deepEqual(match.openPiSources, [match.checkoutPath]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("unrelated packages are not OpenPI sources", () => { + const root = mkdtempSync(join(tmpdir(), "openpi-provenance-")); + try { + const source = packageDir(root, OPENPI_PACKAGE_NAME, "checkout"); + const notes = packageDir(root, "notes", "openpi-notes"); + const other = packageDir(root, "pi-intercom", "intercom"); + const match = evaluateOpenPiProvenance([notes, other, source], source); + assert.equal(match.matches, true); + assert.deepEqual(match.openPiSources, [match.checkoutPath]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("zero, foreign, and multiple OpenPI sources do not match", () => { + const root = mkdtempSync(join(tmpdir(), "openpi-provenance-")); + try { + const source = packageDir(root, OPENPI_PACKAGE_NAME, "checkout"); + const foreign = packageDir(root, OPENPI_PACKAGE_NAME, "foreign"); + const second = packageDir(root, OPENPI_PACKAGE_NAME, "second"); + assert.equal(evaluateOpenPiProvenance([], source).matches, false); + assert.equal(evaluateOpenPiProvenance([foreign], source).matches, false); + assert.equal( + evaluateOpenPiProvenance([source, foreign], source).matches, + false, + ); + assert.equal( + evaluateOpenPiProvenance([foreign, second], source).matches, + false, + ); + assert.equal( + evaluateOpenPiProvenance([foreign], source).openPiSources.length, + 1, + ); + assert.notEqual( + evaluateOpenPiProvenance([foreign], source).openPiSources[0], + evaluateOpenPiProvenance([foreign], source).checkoutPath, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function runProvenance(packages: string[]) { + const agent = mkdtempSync(join(tmpdir(), "openpi-provenance-agent-")); + writeFileSync( + join(agent, "settings.json"), + `${JSON.stringify({ packages })}\n`, + ); + try { + return spawnSync(process.execPath, ["scripts/provenance.mjs"], { + cwd: checkout, + encoding: "utf8", + env: { ...process.env, PI_CODING_AGENT_DIR: agent }, + }); + } finally { + rmSync(agent, { recursive: true, force: true }); + } +} + +test("provenance accepts this checkout when pi list reports only its real path", () => { + const result = runProvenance([checkout]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Provenance match: yes/); + assert.match(result.stdout, /Checkout revision: [0-9a-f]{40}/); +}); + +test("provenance rejects a single foreign OpenPI package", () => { + const root = mkdtempSync(join(tmpdir(), "openpi-provenance-")); + try { + const created = packageDir(root, OPENPI_PACKAGE_NAME, "foreign"); + const foreign = realpathSync.native(created); + const result = runProvenance([created]); + assert.equal(result.status, 1); + assert.match(result.stdout, /Provenance match: no/); + assert.match( + result.stdout, + new RegExp( + `OpenPI source reported by pi list: ${foreign.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + ), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("provenance rejects an empty package list", () => { + const result = runProvenance([]); + assert.equal(result.status, 1); + assert.match(result.stdout, /OpenPI source reported by pi list: none/); + assert.match(result.stdout, /Provenance match: no/); +}); diff --git a/tests/web/session-worktree.test.ts b/tests/web/session-worktree.test.ts index 4c17126b4..cf11d9721 100644 --- a/tests/web/session-worktree.test.ts +++ b/tests/web/session-worktree.test.ts @@ -60,6 +60,10 @@ function git(cwd: string, ...args: string[]) { }); } +function stagedIndex(cwd: string) { + return git(cwd, "ls-files", "--stage", "-z"); +} + function fixture(t: TestContext) { const root = fs.mkdtempSync( path.join(os.tmpdir(), "openpi-session-worktree-test-"), @@ -86,7 +90,7 @@ function fixture(t: TestContext) { fs.symlinkSync("delete.txt", path.join(repo, "link")); git(repo, "add", "-A"); git(repo, "commit", "--quiet", "-m", "initial"); - return { root, repo, index: path.join(repo, ".git", "index") }; + return { root, repo }; } // The wrapper still executes real Git. It introduces a competing change at a @@ -111,7 +115,7 @@ const runReal = "const result=cp.spawnSync(realGit,args,{stdio:'inherit',env:process.env}); if(result.status!==0) process.exit(result.status??1);"; test("forks source HEAD detached and copies final tracked, untracked, binary, executable and link contents unstaged", async (t) => { - const { repo, index } = fixture(t); + const { repo } = fixture(t); const file = path.join(repo, "packages", "app", "file.txt"); fs.writeFileSync(file, "staged\n"); git(repo, "add", "--", "packages/app/file.txt"); @@ -127,7 +131,7 @@ test("forks source HEAD detached and copies final tracked, untracked, binary, ex fs.writeFileSync(path.join(repo, "staged.env"), "staged despite ignore\n"); git(repo, "add", "--force", "--", "staged.env"); fs.writeFileSync(path.join(repo, "private.env"), "do not copy\n"); - const beforeIndex = fs.readFileSync(index); + const beforeStage = stagedIndex(repo); const beforeStatus = git(repo, "status", "--porcelain=v1", "-z"); const head = git(repo, "rev-parse", "HEAD").trim(); const result = await createSessionWorktree( @@ -184,7 +188,7 @@ test("forks source HEAD detached and copies final tracked, untracked, binary, ex false, ); assert.equal(git(result.cwd, "diff", "--cached", "--name-only"), ""); - assert.deepEqual(fs.readFileSync(index), beforeIndex); + assert.equal(stagedIndex(repo), beforeStage); assert.equal(git(repo, "status", "--porcelain=v1", "-z"), beforeStatus); fs.writeFileSync(path.join(result.cwd, "file.txt"), "child edit\n"); assert.equal(fs.readFileSync(file, "utf8"), "final unstaged\n"); @@ -376,8 +380,8 @@ test("inspection rejects non-Git, unborn, sparse, unmerged and submodule sources }); test("cancellation before creation never creates or changes a checkout", async (t) => { - const { repo, index } = fixture(t); - const previous = fs.readFileSync(index); + const { repo } = fixture(t); + const previousStage = stagedIndex(repo); const controller = new AbortController(); controller.abort(); await assert.rejects( @@ -385,7 +389,7 @@ test("cancellation before creation never creates or changes a checkout", async ( (error: unknown) => error instanceof SessionWorktreeError && error.code === "cancelled", ); - assert.deepEqual(fs.readFileSync(index), previous); + assert.equal(stagedIndex(repo), previousStage); assert.equal( git(repo, "worktree", "list", "--porcelain").match(/^worktree /gm)?.length, 1, @@ -393,8 +397,8 @@ test("cancellation before creation never creates or changes a checkout", async ( }); test("cancellation during worktree creation waits for identity then removes only its new checkout", async (t) => { - const { repo, root, index } = fixture(t); - const originalIndex = fs.readFileSync(index); + const { repo, root } = fixture(t); + const originalStage = stagedIndex(repo); const started = path.join(root, "add-finished"); gitBoundary( t, @@ -414,7 +418,7 @@ test("cancellation during worktree creation waits for identity then removes only error.preservedPath === undefined, ); assert.equal(controller.signal.aborted, true); - assert.deepEqual(fs.readFileSync(index), originalIndex); + assert.equal(stagedIndex(repo), originalStage); assert.equal( git(repo, "worktree", "list", "--porcelain").match(/^worktree /gm)?.length, 1, @@ -491,12 +495,12 @@ test("a HEAD race fails and removes only the unchanged newly created checkout", }); test("a source content race is detected without reverting the user's new content", async (t) => { - const { repo, root, index } = fixture(t); + const { repo, root } = fixture(t); fs.writeFileSync( path.join(repo, "packages", "app", "file.txt"), "snapshot version\n", ); - const originalIndex = fs.readFileSync(index); + const originalStage = stagedIndex(repo); gitBoundary( t, root, @@ -510,7 +514,7 @@ test("a source content race is detected without reverting the user's new content fs.readFileSync(path.join(repo, "packages", "app", "file.txt"), "utf8"), "new user content\n", ); - assert.deepEqual(fs.readFileSync(index), originalIndex); + assert.equal(stagedIndex(repo), originalStage); assert.equal( git(repo, "worktree", "list", "--porcelain").match(/^worktree /gm)?.length, 1, diff --git a/tests/web/web-performance-session.test.ts b/tests/web/web-performance-session.test.ts new file mode 100644 index 000000000..e4133f865 --- /dev/null +++ b/tests/web/web-performance-session.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertPerformanceSessionIdentity, + performanceFixturePath, + performanceSessionId, +} from "./web-performance-session.ts"; + +const consistent = { + currentSessionId: performanceSessionId, + currentSessionPath: performanceFixturePath, + sessions: [ + { + id: performanceSessionId, + path: performanceFixturePath, + }, + ], + selectedSession: { + id: performanceSessionId, + path: performanceFixturePath, + }, +}; + +test("a consistent synthetic session identity is accepted", () => { + assert.doesNotThrow(() => assertPerformanceSessionIdentity(consistent)); +}); + +test("a host path with the synthetic id is rejected before the UI waits", () => { + assert.throws( + () => + assertPerformanceSessionIdentity({ + ...consistent, + currentSessionPath: "/tmp/host/session.jsonl", + }), + /synthetic session identity mismatch/, + ); +}); + +test("a missing current path is rejected", () => { + assert.throws( + () => + assertPerformanceSessionIdentity({ + currentSessionId: consistent.currentSessionId, + sessions: consistent.sessions, + selectedSession: consistent.selectedSession, + }), + /synthetic session identity mismatch/, + ); +}); + +test("a selected session on a different path is rejected", () => { + assert.throws( + () => + assertPerformanceSessionIdentity({ + ...consistent, + selectedSession: { + id: performanceSessionId, + path: "/m12-fixture/other.jsonl", + }, + }), + /synthetic session identity mismatch/, + ); +}); diff --git a/tests/web/web-performance-session.ts b/tests/web/web-performance-session.ts new file mode 100644 index 000000000..f0a48bae6 --- /dev/null +++ b/tests/web/web-performance-session.ts @@ -0,0 +1,46 @@ +export const performanceSessionId = "m12-synthetic-session"; +export const performanceFixturePath = "/m12-fixture/session.jsonl"; + +type PerformanceSessionIdentity = { + currentSessionId?: string; + currentSessionPath?: string; + sessions?: readonly { id: string; path: string }[]; + selectedSession?: { id: string; path: string } | null; +}; + +/** The synthetic snapshot must be one Session, or the UI stays on reconnect. */ +export function assertPerformanceSessionIdentity( + snapshot: PerformanceSessionIdentity, +) { + const session = snapshot.sessions?.find( + (item) => item.id === performanceSessionId, + ); + const actual = { + currentSessionId: snapshot.currentSessionId ?? null, + currentSessionPath: snapshot.currentSessionPath ?? null, + sessionId: session?.id ?? null, + sessionPath: session?.path ?? null, + selectedSessionId: snapshot.selectedSession?.id ?? null, + selectedSessionPath: snapshot.selectedSession?.path ?? null, + }; + const expected = { + currentSessionId: performanceSessionId, + currentSessionPath: performanceFixturePath, + sessionId: performanceSessionId, + sessionPath: performanceFixturePath, + selectedSessionId: performanceSessionId, + selectedSessionPath: performanceFixturePath, + }; + if ( + actual.currentSessionId !== expected.currentSessionId || + actual.currentSessionPath !== expected.currentSessionPath || + actual.sessionId !== expected.sessionId || + actual.sessionPath !== expected.sessionPath || + actual.selectedSessionId !== expected.selectedSessionId || + actual.selectedSessionPath !== expected.selectedSessionPath + ) { + throw new Error( + `synthetic session identity mismatch: ${JSON.stringify(actual)}`, + ); + } +} diff --git a/tests/web/web-performance.e2e.ts b/tests/web/web-performance.e2e.ts index dad66dc7a..12032dc1d 100644 --- a/tests/web/web-performance.e2e.ts +++ b/tests/web/web-performance.e2e.ts @@ -5,10 +5,13 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, type Page, test } from "@playwright/test"; import type { WebSnapshot } from "../../web/protocol/types.ts"; +import { + assertPerformanceSessionIdentity, + performanceFixturePath as fixturePath, + performanceSessionId as sessionId, +} from "./web-performance-session.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const fixturePath = "/m12-fixture/session.jsonl"; -const sessionId = "m12-synthetic-session"; const stamp = "2026-09-18T00:00:00Z"; type Entry = NonNullable["entries"][number]; @@ -53,9 +56,10 @@ function entriesFor(scenario: Scenario): Entry[] { function projectedSnapshot(base: WebSnapshot, scenario: Scenario): WebSnapshot { const entries = entriesFor(scenario); const omitted = scenario === "truncated" ? 488 : 0; - return { + const snapshot: WebSnapshot = { ...base, currentSessionId: sessionId, + currentSessionPath: fixturePath, workspaces: [ { path: "/m12-fixture", name: "M12 synthetic", current: true }, ], @@ -92,6 +96,8 @@ function projectedSnapshot(base: WebSnapshot, scenario: Scenario): WebSnapshot { runtime: { status: "idle", capabilities: {} }, truncation: { ...base.truncation, truncated: omitted > 0 }, }; + assertPerformanceSessionIdentity(snapshot); + return snapshot; } function percentile(samples: number[], quantile: number) { @@ -113,7 +119,13 @@ async function installFixture(page: Page, scenario: Scenario) { base = (await response.json()) as WebSnapshot; } await route.fulfill({ json: projectedSnapshot(base, scenario) }); - } catch { + } catch (error) { + if ( + error instanceof Error && + error.message.includes("synthetic session identity mismatch") + ) { + throw error; + } await route.abort().catch(() => undefined); } });