From cb50fa3dd8d8911006ee453be7b5353801f8dd19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:11:59 +0000 Subject: [PATCH 01/14] test(web): reject a performance fixture with a split session identity The synthetic snapshot kept the host session path while replacing the id, so the UI stayed on reconnect and the suite never reached its measurements. Require one id and path, and fail that mismatch before the UI wait. The suite stays outside required CI. Co-authored-by: tt-a1i --- tests/web/web-performance-session.test.ts | 60 +++++++++++++++++++++++ tests/web/web-performance-session.ts | 46 +++++++++++++++++ tests/web/web-performance.e2e.ts | 20 ++++++-- 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/web/web-performance-session.test.ts create mode 100644 tests/web/web-performance-session.ts diff --git a/tests/web/web-performance-session.test.ts b/tests/web/web-performance-session.test.ts new file mode 100644 index 000000000..919c9fdaa --- /dev/null +++ b/tests/web/web-performance-session.test.ts @@ -0,0 +1,60 @@ +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", () => { + const withoutPath = { ...consistent }; + delete withoutPath.currentSessionPath; + assert.throws( + () => assertPerformanceSessionIdentity(withoutPath), + /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); } }); From 297e5819f2aa004fb7d335f3791eeb55d087c818 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:12:00 +0000 Subject: [PATCH 02/14] fix(setup): query Windows process start time without PowerShell Lock recovery still treats a failed or unparseable start-time query as unknown and keeps the 5s fail-closed deadline. Windows now asks cscript for the WMI creation time instead of paying for a PowerShell cold start. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 67 ++++++++++++++++--- extensions/shared/win32-process-started-at.js | 22 ++++++ tests/extensions/shared/setup-config.test.ts | 37 +++++++--- 3 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 extensions/shared/win32-process-started-at.js diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index 1db1c5905..b210fe470 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,57 @@ 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()); +const WIN32_PROCESS_STARTED_AT_SCRIPT = fileURLToPath( + new URL("./win32-process-started-at.js", import.meta.url), +); + +// 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})$/; + +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 +1262,13 @@ 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("; "); + // WMI via cscript avoids the PowerShell CLR cold start. The query still + // has a 1s budget; failure stays unknown and the 5s lock does not recover. return { - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", script], + command: "cscript.exe", + args: ["//Nologo", "//B", WIN32_PROCESS_STARTED_AT_SCRIPT, String(pid)], env: undefined, - parseOutput: parsePowerShellProcessStartedAt, + parseOutput: parseCimProcessStartedAt, }; } 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..2cbe701b5 --- /dev/null +++ b/extensions/shared/win32-process-started-at.js @@ -0,0 +1,22 @@ +// 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. +var raw = + WScript.Arguments.length === 1 ? String(WScript.Arguments.Item(0)) : ""; +if (!/^[1-9][0-9]*$/.test(raw)) { + WScript.Quit(1); +} +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()) WScript.Quit(1); + var startedAt = enumerator.item().CreationDate; + if (!startedAt) WScript.Quit(1); + WScript.StdOut.Write(String(startedAt)); +} catch (error) { + WScript.Quit(1); +} diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index aad3237a7..d6f7e8205 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -246,15 +246,36 @@ 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, 2), ["//Nologo", "//B"]); + assert.match(windowsQuery.args[2] ?? "", /win32-process-started-at\.js$/); + assert.equal(windowsQuery.args[3], "123"); + 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"); From e0d6bcd4788d2c0e2d55be8b15355d6866584ae7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:12:00 +0000 Subject: [PATCH 03/14] test(antigravity): separate the first SSE event from the lifetime deadline timeoutMs still bounds both waits. The lifetime test now parses the metadata event before moving the clock onto that deadline, so a later stall is the next event. A deadline that expires before any payload stays the first event. Co-authored-by: tt-a1i --- .../ai-providers/antigravity.test.ts | 125 ++++++++++++++---- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index 0afaca01b..390772b92 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. Hold the clock until the metadata event is +// parsed, then move it onto the lifetime deadline. Advancing inside the first +// read is too early: the post-read check still has sawEvent false. +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 === 1) { + 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 () => { From a7c00717c313760155a9c120814c67756818ee12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:12:00 +0000 Subject: [PATCH 04/14] test(web): compare session worktree index identity with git stage Index bytes change when fsmonitor or untracked-cache extensions are enabled. Compare staged entries with git ls-files --stage, and keep porcelain status on the fork. A real staged-content change still fails that comparison. Co-authored-by: tt-a1i --- tests/web/session-worktree.test.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) 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, From 924d6516d587aca0398f47e9e5a1b514a53d6381 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:12:00 +0000 Subject: [PATCH 05/14] fix(provenance): identify the OpenPI checkout by realpath A directory name containing openpi is not a source. Keep the single reported path whose realpath is this checkout, including duplicate and symlink lines, and still reject zero, foreign, or multiple OpenPI package roots. Co-authored-by: tt-a1i --- scripts/provenance.mjs | 138 ++++++++++++++++++++---------- tests/scripts/provenance.test.ts | 141 +++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 46 deletions(-) create mode 100644 tests/scripts/provenance.test.ts 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/scripts/provenance.test.ts b/tests/scripts/provenance.test.ts new file mode 100644 index 000000000..2811a6a44 --- /dev/null +++ b/tests/scripts/provenance.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + 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 foreign = packageDir(root, OPENPI_PACKAGE_NAME, "foreign"); + const result = runProvenance([foreign]); + assert.equal(result.status, 1); + assert.match(result.stdout, /Provenance match: no/); + assert.match( + result.stdout, + new RegExp(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/); +}); From 4cfc770e16b1267e7a673af5b6d590ef0f8a5749 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:16:37 +0000 Subject: [PATCH 06/14] test(antigravity): move the lifetime clock after the prefetched chunk The response body pulls a second chunk before the first read is parsed. Advancing the clock on that prefetch still reports the first SSE event. The third pull is the read after the metadata event. Co-authored-by: tt-a1i --- tests/extensions/ai-providers/antigravity.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index 390772b92..ee74a90b0 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -1533,9 +1533,9 @@ const SSE_METADATA = new TextEncoder().encode( 'data: {"response":{"usageMetadata":{"promptTokenCount":1}}}\n\n', ); -// timeoutMs is both deadlines. Hold the clock until the metadata event is -// parsed, then move it onto the lifetime deadline. Advancing inside the first -// read is too early: the post-read check still has sawEvent false. +// 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, ) { @@ -1569,7 +1569,7 @@ test("streamAntigravity bounds the full SSE lifetime after the first event", asy new ReadableStream({ pull(controller) { pulls += 1; - if (pulls === 1) { + if (pulls < 3) { controller.enqueue(SSE_METADATA); return; } From 69daa74905d5e16abb0af3bf52f13b5226ae28de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:18:05 +0000 Subject: [PATCH 07/14] fix: give provenance a declaration file and keep the identity fixture typed Typecheck requires a declaration for the provenance module, matching the other script exports. The missing-path case builds the snapshot without that field instead of deleting a required property. Co-authored-by: tt-a1i --- scripts/provenance.d.mts | 22 ++++++++++++++++++++++ tests/web/web-performance-session.test.ts | 9 ++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 scripts/provenance.d.mts 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/tests/web/web-performance-session.test.ts b/tests/web/web-performance-session.test.ts index 919c9fdaa..e4133f865 100644 --- a/tests/web/web-performance-session.test.ts +++ b/tests/web/web-performance-session.test.ts @@ -37,10 +37,13 @@ test("a host path with the synthetic id is rejected before the UI waits", () => }); test("a missing current path is rejected", () => { - const withoutPath = { ...consistent }; - delete withoutPath.currentSessionPath; assert.throws( - () => assertPerformanceSessionIdentity(withoutPath), + () => + assertPerformanceSessionIdentity({ + currentSessionId: consistent.currentSessionId, + sessions: consistent.sessions, + selectedSession: consistent.selectedSession, + }), /synthetic session identity mismatch/, ); }); From 2479c851290042a87e56d5260b91919f883d8866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:24:46 +0000 Subject: [PATCH 08/14] fix(setup): resolve the Windows start-time script when the query runs Vitest imports setup config without a file URL. Building that path at module load threw before any test in the settings catalog could start. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index b210fe470..d62efd33d 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -1198,10 +1198,6 @@ const sameOwner = (left: LockOwner, right: LockOwner) => const sameFile = (left: Stats, right: Stats) => left.dev === right.dev && left.ino === right.ino; -const WIN32_PROCESS_STARTED_AT_SCRIPT = fileURLToPath( - new URL("./win32-process-started-at.js", import.meta.url), -); - // 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 = @@ -1262,11 +1258,16 @@ export function processStartedAtQuery( platform = process.platform, ) { if (platform === "win32") { - // WMI via cscript avoids the PowerShell CLR cold start. The query still - // has a 1s budget; failure stays unknown and the 5s lock does not recover. + // 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: "cscript.exe", - args: ["//Nologo", "//B", WIN32_PROCESS_STARTED_AT_SCRIPT, String(pid)], + args: ["//Nologo", "//B", script, String(pid)], env: undefined, parseOutput: parseCimProcessStartedAt, }; From 0d693e041b3126d76920366b1ae43ed8bc1e9bd2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 04:01:45 +0000 Subject: [PATCH 09/14] fix(setup): read cscript start times from a UTF-16 pipe Redirected cscript output is UTF-16, so the CIM timestamp never matched and a reused PID stayed unknown until the 5s lock expired. Decode the pipe, keep the 1s query cap, and still fail closed on a bad result. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 30 +++++++++++++++---- extensions/shared/win32-process-started-at.js | 17 +++++++---- tests/extensions/shared/setup-config.test.ts | 20 +++++++++++-- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index d62efd33d..bacdd57c1 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -1203,6 +1203,21 @@ const sameFile = (left: Stats, right: Stats) => const CIM_PROCESS_STARTED_AT = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/; +// cscript //U writes a redirected pipe as 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; @@ -1260,14 +1275,15 @@ export function processStartedAtQuery( if (platform === "win32") { // 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. + // the PowerShell CLR cold start. //U makes the pipe UTF-16. 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: "cscript.exe", - args: ["//Nologo", "//B", script, String(pid)], + args: ["//Nologo", "//U", "//B", script, String(pid)], env: undefined, parseOutput: parseCimProcessStartedAt, }; @@ -1288,12 +1304,16 @@ const queryProcessStartedAt = (pid: number) => query.command, query.args, { - encoding: "utf8", + encoding: "buffer", env: query.env ? { ...process.env, ...query.env } : process.env, timeout: 1_000, }, (error, stdout) => { - resolve(error ? undefined : query.parseOutput(stdout)); + resolve( + error || stdout == null + ? undefined + : query.parseOutput(decodeProcessStartedAtOutput(stdout)), + ); }, ); } catch { diff --git a/extensions/shared/win32-process-started-at.js b/extensions/shared/win32-process-started-at.js index 2cbe701b5..128297843 100644 --- a/extensions/shared/win32-process-started-at.js +++ b/extensions/shared/win32-process-started-at.js @@ -6,17 +6,24 @@ var raw = if (!/^[1-9][0-9]*$/.test(raw)) { WScript.Quit(1); } +var startedAt = ""; try { var locator = new ActiveXObject("WbemScripting.SWbemLocator"); var service = locator.ConnectServer(".", "root\\cimv2"); + // Default Identify makes a local query fail closed. Impersonate is the + // level Win32_Process expects. 48 is forward-only and return-immediately. + service.Security_.ImpersonationLevel = 3; var processes = service.ExecQuery( "SELECT CreationDate FROM Win32_Process WHERE ProcessId=" + raw, + "WQL", + 48, ); var enumerator = new Enumerator(processes); - if (enumerator.atEnd()) WScript.Quit(1); - var startedAt = enumerator.item().CreationDate; - if (!startedAt) WScript.Quit(1); - WScript.StdOut.Write(String(startedAt)); + if (!enumerator.atEnd()) startedAt = String(enumerator.item().CreationDate); } catch (error) { - WScript.Quit(1); + 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.Quit(1); +WScript.Echo(startedAt); diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index d6f7e8205..6a8b345f9 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -32,6 +32,7 @@ const { formatSetupDiagnostics, parseSetupConfig, POST_EDIT_COMMAND_MAX_CHARS, + decodeProcessStartedAtOutput, processStartedAtQuery, saveSetupConfig, SETUP_CONFIG_PATH, @@ -247,9 +248,22 @@ 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, "cscript.exe"); - assert.deepEqual(windowsQuery.args.slice(0, 2), ["//Nologo", "//B"]); - assert.match(windowsQuery.args[2] ?? "", /win32-process-started-at\.js$/); - assert.equal(windowsQuery.args[3], "123"); + assert.deepEqual(windowsQuery.args.slice(0, 3), ["//Nologo", "//U", "//B"]); + assert.match(windowsQuery.args[3] ?? "", /win32-process-started-at\.js$/); + assert.equal(windowsQuery.args[4], "123"); + 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, From e3df017fe22384d28958bfde26b18175e67f25b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 04:01:45 +0000 Subject: [PATCH 10/14] test(provenance): compare a foreign OpenPI source by realpath Windows temp paths can be 8.3 names while pi list reports the long path. The matcher already rejected that foreign package. Co-authored-by: tt-a1i --- tests/scripts/provenance.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/scripts/provenance.test.ts b/tests/scripts/provenance.test.ts index 2811a6a44..7e821e82f 100644 --- a/tests/scripts/provenance.test.ts +++ b/tests/scripts/provenance.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -120,13 +121,16 @@ test("provenance accepts this checkout when pi list reports only its real path", test("provenance rejects a single foreign OpenPI package", () => { const root = mkdtempSync(join(tmpdir(), "openpi-provenance-")); try { - const foreign = packageDir(root, OPENPI_PACKAGE_NAME, "foreign"); - const result = runProvenance([foreign]); + 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(foreign.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + new RegExp( + `OpenPI source reported by pi list: ${foreign.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + ), ); } finally { rmSync(root, { recursive: true, force: true }); From b03b1df23d40ca8d38c61e0a89a8b6e20563f3c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 04:19:27 +0000 Subject: [PATCH 11/14] fix(setup): fall back to PowerShell when WMI start time is missing cscript still produced no usable CreationDate on the Windows runner, so a reused PID waited out the 5s lock. Keep the 1s WMI query, then allow one PowerShell read of at most 3.5s. A failed or non-integer result stays unknown and does not recover the lock. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 65 +++++++++++++++++-- extensions/shared/win32-process-started-at.js | 5 -- tests/extensions/shared/setup-config.test.ts | 15 ++++- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index bacdd57c1..38424470b 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -1268,6 +1268,11 @@ const parsePsProcessStartedAt = (stdout: string) => { return Number.isFinite(startedAt) ? startedAt : undefined; }; +const parsePowerShellProcessStartedAt = (stdout: string) => { + const startedAt = Number(stdout.trim()); + return isPositiveInteger(startedAt) ? startedAt : undefined; +}; + export function processStartedAtQuery( pid: number, platform = process.platform, @@ -1275,15 +1280,14 @@ export function processStartedAtQuery( if (platform === "win32") { // 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. //U makes the pipe UTF-16. The 1s - // query budget stays; failure is still unknown ownership, and the 5s - // lock does not recover. + // 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: "cscript.exe", - args: ["//Nologo", "//U", "//B", script, String(pid)], + args: ["//Nologo", "//B", script, String(pid)], env: undefined, parseOutput: parseCimProcessStartedAt, }; @@ -1296,9 +1300,44 @@ export function processStartedAtQuery( }; } -const queryProcessStartedAt = (pid: number) => +// Used only when the cscript query returns no time. One cold PowerShell +// start can exceed 1s and still has to finish inside the 5s lock. A timeout +// or a non-integer is unknown ownership and does not recover the lock. +const PROCESS_START_QUERY_TIMEOUT_MS = 1_000; +const PROCESS_START_FALLBACK_TIMEOUT_MS = 3_500; + +export function processStartedAtFallbackQuery( + pid: number, + platform = process.platform, +) { + if (platform !== "win32" || !isPositiveInteger(pid)) return undefined; + return { + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$ErrorActionPreference='Stop'", + `$p=Get-Process -Id ${pid}`, + "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())", + ].join("; "), + ], + env: undefined, + parseOutput: parsePowerShellProcessStartedAt, + }; +} + +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, @@ -1306,7 +1345,8 @@ const queryProcessStartedAt = (pid: number) => { encoding: "buffer", env: query.env ? { ...process.env, ...query.env } : process.env, - timeout: 1_000, + timeout, + windowsHide: true, }, (error, stdout) => { resolve( @@ -1321,6 +1361,17 @@ const queryProcessStartedAt = (pid: number) => } }); +const queryProcessStartedAt = async (pid: number) => { + const startedAt = await runProcessStartedAtQuery( + processStartedAtQuery(pid), + PROCESS_START_QUERY_TIMEOUT_MS, + ); + if (startedAt !== undefined) return startedAt; + const fallback = processStartedAtFallbackQuery(pid); + if (!fallback) return undefined; + return runProcessStartedAtQuery(fallback, PROCESS_START_FALLBACK_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 index 128297843..990760c28 100644 --- a/extensions/shared/win32-process-started-at.js +++ b/extensions/shared/win32-process-started-at.js @@ -10,13 +10,8 @@ var startedAt = ""; try { var locator = new ActiveXObject("WbemScripting.SWbemLocator"); var service = locator.ConnectServer(".", "root\\cimv2"); - // Default Identify makes a local query fail closed. Impersonate is the - // level Win32_Process expects. 48 is forward-only and return-immediately. - service.Security_.ImpersonationLevel = 3; var processes = service.ExecQuery( "SELECT CreationDate FROM Win32_Process WHERE ProcessId=" + raw, - "WQL", - 48, ); var enumerator = new Enumerator(processes); if (!enumerator.atEnd()) startedAt = String(enumerator.item().CreationDate); diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index 6a8b345f9..d404e9a45 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -33,6 +33,7 @@ const { parseSetupConfig, POST_EDIT_COMMAND_MAX_CHARS, decodeProcessStartedAtOutput, + processStartedAtFallbackQuery, processStartedAtQuery, saveSetupConfig, SETUP_CONFIG_PATH, @@ -248,9 +249,17 @@ 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, "cscript.exe"); - assert.deepEqual(windowsQuery.args.slice(0, 3), ["//Nologo", "//U", "//B"]); - assert.match(windowsQuery.args[3] ?? "", /win32-process-started-at\.js$/); - assert.equal(windowsQuery.args[4], "123"); + assert.deepEqual(windowsQuery.args.slice(0, 2), ["//Nologo", "//B"]); + assert.match(windowsQuery.args[2] ?? "", /win32-process-started-at\.js$/); + assert.equal(windowsQuery.args[3], "123"); + const fallback = processStartedAtFallbackQuery(123, "win32"); + assert.ok(fallback); + assert.equal(fallback.command, "powershell.exe"); + assert.equal(processStartedAtFallbackQuery(123, "linux"), undefined); + assert.equal(processStartedAtFallbackQuery(0, "win32"), undefined); + assert.equal(fallback.parseOutput("1700000000123"), 1_700_000_000_123); + assert.equal(fallback.parseOutput("0"), undefined); + assert.equal(fallback.parseOutput("not a timestamp"), undefined); const cim = "20231114221320.000000+000"; const utf16Pipe = Buffer.from(`\uFEFF${cim}\r\n`, "utf16le"); assert.equal( From 5bf446cc6e1bd545cd9716c065d8c6a78dd2a0e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 04:28:50 +0000 Subject: [PATCH 12/14] ci: retrigger checks after an unrelated UI timeout The b03b1df Windows shard passed. UI ubuntu Node 22.19.0 timed out in composer-context-navigation, and rerunning failed jobs was not permitted. Co-authored-by: tt-a1i From ea256c6e7860b429c77fbee225e08997708c8777 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 07:01:41 +0000 Subject: [PATCH 13/14] fix(setup): read Windows process start time only from WMI Drop the PowerShell fallback. One cscript query decodes UTF-16 and stays inside the 1s budget. A missing time is unknown ownership, so the 5s lock expires without taking the lock. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 48 +++------------- extensions/shared/win32-process-started-at.js | 8 ++- tests/extensions/shared/setup-config.test.ts | 57 +++++++++++++++---- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index 38424470b..528887829 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -1203,9 +1203,9 @@ const sameFile = (left: Stats, right: Stats) => const CIM_PROCESS_STARTED_AT = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/; -// cscript //U writes a redirected pipe as 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. +// 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 = @@ -1268,11 +1268,6 @@ const parsePsProcessStartedAt = (stdout: string) => { return Number.isFinite(startedAt) ? startedAt : undefined; }; -const parsePowerShellProcessStartedAt = (stdout: string) => { - const startedAt = Number(stdout.trim()); - return isPositiveInteger(startedAt) ? startedAt : undefined; -}; - export function processStartedAtQuery( pid: number, platform = process.platform, @@ -1300,33 +1295,9 @@ export function processStartedAtQuery( }; } -// Used only when the cscript query returns no time. One cold PowerShell -// start can exceed 1s and still has to finish inside the 5s lock. A timeout -// or a non-integer is unknown ownership and does not recover the lock. +// 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 PROCESS_START_FALLBACK_TIMEOUT_MS = 3_500; - -export function processStartedAtFallbackQuery( - pid: number, - platform = process.platform, -) { - if (platform !== "win32" || !isPositiveInteger(pid)) return undefined; - return { - command: "powershell.exe", - args: [ - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$ErrorActionPreference='Stop'", - `$p=Get-Process -Id ${pid}`, - "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())", - ].join("; "), - ], - env: undefined, - parseOutput: parsePowerShellProcessStartedAt, - }; -} const runProcessStartedAtQuery = ( query: { @@ -1361,16 +1332,11 @@ const runProcessStartedAtQuery = ( } }); -const queryProcessStartedAt = async (pid: number) => { - const startedAt = await runProcessStartedAtQuery( +const queryProcessStartedAt = (pid: number) => + runProcessStartedAtQuery( processStartedAtQuery(pid), PROCESS_START_QUERY_TIMEOUT_MS, ); - if (startedAt !== undefined) return startedAt; - const fallback = processStartedAtFallbackQuery(pid); - if (!fallback) return undefined; - return runProcessStartedAtQuery(fallback, PROCESS_START_FALLBACK_TIMEOUT_MS); -}; const makeLockIdentity = async (): Promise => { const processStartedAt = await queryProcessStartedAt(process.pid); diff --git a/extensions/shared/win32-process-started-at.js b/extensions/shared/win32-process-started-at.js index 990760c28..957fa19dc 100644 --- a/extensions/shared/win32-process-started-at.js +++ b/extensions/shared/win32-process-started-at.js @@ -14,7 +14,13 @@ try { "SELECT CreationDate FROM Win32_Process WHERE ProcessId=" + raw, ); var enumerator = new Enumerator(processes); - if (!enumerator.atEnd()) startedAt = String(enumerator.item().CreationDate); + if (!enumerator.atEnd()) { + // Properties_.Item returns the CIM datetime string. The default + // property can surface a locale date that the parser must reject. + startedAt = String( + enumerator.item().Properties_.Item("CreationDate").Value, + ); + } } catch (error) { startedAt = ""; } diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index d404e9a45..9c14241be 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, @@ -33,7 +33,6 @@ const { parseSetupConfig, POST_EDIT_COMMAND_MAX_CHARS, decodeProcessStartedAtOutput, - processStartedAtFallbackQuery, processStartedAtQuery, saveSetupConfig, SETUP_CONFIG_PATH, @@ -252,14 +251,10 @@ test("process start-time queries are platform-specific and conservative", () => assert.deepEqual(windowsQuery.args.slice(0, 2), ["//Nologo", "//B"]); assert.match(windowsQuery.args[2] ?? "", /win32-process-started-at\.js$/); assert.equal(windowsQuery.args[3], "123"); - const fallback = processStartedAtFallbackQuery(123, "win32"); - assert.ok(fallback); - assert.equal(fallback.command, "powershell.exe"); - assert.equal(processStartedAtFallbackQuery(123, "linux"), undefined); - assert.equal(processStartedAtFallbackQuery(0, "win32"), undefined); - assert.equal(fallback.parseOutput("1700000000123"), 1_700_000_000_123); - assert.equal(fallback.parseOutput("0"), undefined); - assert.equal(fallback.parseOutput("not a timestamp"), undefined); + 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( @@ -702,6 +697,37 @@ 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 }, + (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); @@ -732,7 +758,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"); @@ -778,6 +806,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"); `; From fe57ee4ed7caba2528d4c2d1adc1c9986c3bd403 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 07:10:58 +0000 Subject: [PATCH 14/14] fix(setup): make the WMI start-time script valid JScript A trailing comma in the cscript query is a syntax error. //B hid it, so the query exited in milliseconds with empty output and the lock waited out the 5s deadline. Drop //B, pass //Nologo verbatim, and leave a failed read non-zero. Co-authored-by: tt-a1i --- extensions/shared/setup-config.ts | 5 +++- extensions/shared/win32-process-started-at.js | 25 +++++++++++++------ tests/extensions/shared/setup-config.test.ts | 13 +++++++--- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index 528887829..429416843 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -1282,7 +1282,7 @@ export function processStartedAtQuery( ); return { command: "cscript.exe", - args: ["//Nologo", "//B", script, String(pid)], + args: ["//Nologo", script, String(pid)], env: undefined, parseOutput: parseCimProcessStartedAt, }; @@ -1318,6 +1318,9 @@ const runProcessStartedAtQuery = ( env: query.env ? { ...process.env, ...query.env } : process.env, 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( diff --git a/extensions/shared/win32-process-started-at.js b/extensions/shared/win32-process-started-at.js index 957fa19dc..8f4254362 100644 --- a/extensions/shared/win32-process-started-at.js +++ b/extensions/shared/win32-process-started-at.js @@ -1,30 +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, + "SELECT CreationDate FROM Win32_Process WHERE ProcessId=" + raw ); var enumerator = new Enumerator(processes); - if (!enumerator.atEnd()) { - // Properties_.Item returns the CIM datetime string. The default - // property can surface a locale date that the parser must reject. - startedAt = String( - enumerator.item().Properties_.Item("CreationDate").Value, - ); + 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.Quit(1); +if (!startedAt) { + WScript.StdOut.Write(failure); + WScript.Quit(1); +} WScript.Echo(startedAt); diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index 9c14241be..3d9374812 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -248,9 +248,9 @@ 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, "cscript.exe"); - assert.deepEqual(windowsQuery.args.slice(0, 2), ["//Nologo", "//B"]); - assert.match(windowsQuery.args[2] ?? "", /win32-process-started-at\.js$/); - assert.equal(windowsQuery.args[3], "123"); + 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, @@ -710,7 +710,12 @@ test("Windows reads this process start time from WMI alone", { execFile( query.command, query.args, - { encoding: "buffer", timeout: 1_000, windowsHide: true }, + { + encoding: "buffer", + timeout: 1_000, + windowsHide: true, + windowsVerbatimArguments: true, + }, (execError, execStdout, execStderr) => { resolve({ stdout: Buffer.isBuffer(execStdout) ? execStdout : Buffer.alloc(0),