diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b15a47a..49d9de8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.91.298.0] - 2026-10-28 **Dia is gone from gstack.** `/setup-browser-cookies` and `cookie-import-browser` no longer list Dia as a cookie source, and the Dia qualification harness is deleted. That harness was a set of macOS campaigns that never qualified a native import, and its `dia-launch-comparison` free test was failing the merge train on Ubicloud runners. Cookie import from Comet, Chrome, Chromium, Arc, Brave, Edge, Opera and Opera GX is unchanged. @@ -12,6 +12,35 @@ ### Tests - The cookie tests that used Dia as their macOS fixture browser now use Arc, which has the same `User Data` layout and Keychain shape: numbered-profile discovery, Keychain denial, the credential deadline, the Node bundle import and the picker UI. The historical cookie-workflow approval fixture adds one reverse edit so it still rebuilds the approved prompt byte for byte. No threshold or allowlist changed. +## [1.91.297.0] - 2026-10-28 + +**The terminal agent on Windows no longer waits on PowerShell to learn its own start time, so it boots in about a second and a slow PowerShell can no longer leave it without an identity.** Every agent boot ran `powershell.exe Get-CimInstance` with a 2 s timeout, twice when started standalone. On a loaded `windows-latest` runner one launch took 0.94 s at the median and up to 10.8 s, so agent boot took 2.4 to 2.8 s at the median and 5.1 s on a runner's first boot, past the owner-watchdog test's 5 s window. Measured again after the change under the same load: 0.86 to 0.91 s at the median, 1.18 s at most, 120 of 120 boots inside the window. + +### Fixed +- **`browse/src/terminal-agent-control.ts`**: under Bun the Windows start time comes from `GetProcessTimes` in-process and is written as the same `/Date()/` value PowerShell's `CreationDate` gives (147 of 147 processes matched), so records from earlier releases and from the Node daemon, which keeps the PowerShell reader, still compare. The kill-time check still reads the command line through PowerShell, and a process is stopped only when it carries the record's `--agent-gen`. On Windows a start time that disagrees between the two readers no longer reads as gone when the live process carries that generation or its command line cannot be read, so no second agent starts beside it. + +### Tests +- `browse/test/process-liveness-windows.test.ts`: the in-process reader, the Node fallback, the kill-time command-line check, a reader disagreement and the unreadable path (never signalled, never gone, blocks a second agent), plus two Windows-only cases: the in-process value equals PowerShell's `CreationDate`, and a live agent whose identity cannot be read blocks a second one. +- `browse/test/path-security-realpath.test.ts` builds its 8.3 alias with `GetShortPathNameW` instead of a PowerShell launch that stalled for 30 s in Windows Free Tests 40624015029 and was reported as "8.3 short names are disabled". A failed lookup now fails the test instead of skipping it. +- `browse/test/cookie-import-native-job.test.ts` prints the Restart Manager receipt when the owner assertion fails, so the next red names its cause. + +### Gate integrity +- Test 5 in `process-liveness-windows.test.ts` expected the PowerShell mock's `fixture-owner-start` as the owner start time on every platform. On Windows that expectation followed the old implementation, so it moved with the product. It now requires the in-process value, equality with a real read, and no PowerShell query for the owner. The Linux and macOS assertion is unchanged. + +## [1.91.296.0] - 2026-10-28 + +**The docsync gate stops failing ship-docsync-current and ship-docsync-store when the agent asks the docs helper for its usage in the middle of a line of reads.** Five red paid trials since 2026-10-27 each failed on a single refused line. In four, the agent ended a line of reads with `gstack-docs-candidate --help` (screen 40558235014, gate 40600731931, and screen 40627127462 twice). In the fifth, gate 40643690306, the line included `git tag`. v1.91.185.0 tried to stop the first slip by moving the "own Bash call" rule into the sentence that names `--help`, and the slip came back four times. The helper's `--help` prints its usage and returns before it touches Git or any file, so refusing it protected nothing. The 2026-10-27 backstop red (40515158924, `toContain`) ran on a tree from before #3516, which had already fixed it. + +### Fixed +- **`test/helpers/docsync-observer.ts`**: a line of reads may now include the installed candidate's `--help`, either alone or after `snapshot` or `compare`, optionally with `2>&1` or `2>/dev/null`. It counts as a neutral read, the same way a joined Git read does, but never when it is fed by a pipe and never in a line that also runs `cd`. `git tag`, `git tag -l` and `git tag --list` are now accepted as Git reads, alongside the branch listings #3516 added. A tag operand or any other option is still refused. +- **`test/helpers/docsync-interface.ts`**: the fixture interface declares both changes. It names the candidate's `--help` as the one exception to the own-call rule, and it adds tag listings to the Git read list. + +### Tests +- `test/docsync-command-grammar.test.ts` replays the five captured lines, which were refused before this change, and checks that each is now accepted, while the same line with `; git add app.ts` appended is still refused. Negative controls that stay refused: pipe-fed, `| sh`, `>` redirect and `cd` forms; `snapshot` and `compare` joined to reads; `-h`, `--version` and `--help snapshot`; the `~` and foreign helper paths; the lifecycle helpers' `--help` joined to reads; and 13 tag writes or filtered listings. + +### Gate integrity +- This is a gate edit, because the docs detector now accepts more lines. The test that pinned the joined `--help` as refused now expects it to be accepted. Focused paid trials at e967634: ship-docsync-current PASS ($0.84; its agent did join `--help` onto a line of reads) and ship-docsync-store PASS ($0.84). + ## [1.91.295.0] - 2026-10-28 **Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before. diff --git a/VERSION b/VERSION index c7e4e95c1..815f73bf6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.295.0 +1.91.298.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 358c63a00..1964c85a2 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.295.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.298.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index bda034539..2c364e1de 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -24,9 +24,10 @@ import { atomicWriteSync } from '../../lib/fs-atomic'; import { readPidCmdline, readPidStartTime } from './xvfb'; import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config'; -function agentProcessInfo(pid: number): { startTime: string; commandLine: string } { - if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' }; - if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) }; +const FILETIME_UNIX_EPOCH_MS = 11_644_473_600_000n; +let win32Kernel: any; + +function windowsProcessQuery(pid: number): { startTime: string; commandLine: string } { const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object CreationDate,CommandLine | ConvertTo-Json -Compress)`; try { const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 2000 }); @@ -36,8 +37,40 @@ function agentProcessInfo(pid: number): { startTime: string; commandLine: string } catch { return { startTime: '', commandLine: '' }; } } +export const win32CreationTimeSource = { + read(pid: number): string | undefined { + if (!process.versions.bun) return; + try { + const { dlopen, FFIType, ptr } = require('bun:ffi'); + win32Kernel ??= dlopen('kernel32.dll', { + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }).symbols; + const handle = win32Kernel.OpenProcess(0x1000, 0, pid); + if (!handle) return; + try { + const times = new BigUint64Array(4); + if (!win32Kernel.GetProcessTimes(handle, ptr(times, 0), ptr(times, 8), ptr(times, 16), ptr(times, 24)) || times[0] === 0n) return; + return `/Date(${times[0] / 10_000n - FILETIME_UNIX_EPOCH_MS})/`; + } finally { + win32Kernel.CloseHandle(handle); + } + } catch { return; } + }, +}; + +function agentProcessInfo(pid: number): { startTime: string; commandLine: string } { + if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' }; + if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) }; + const query = windowsProcessQuery(pid); + return { startTime: win32CreationTimeSource.read(pid) ?? query.startTime, commandLine: query.commandLine }; +} + export function readAgentStartTime(pid: number): string { - return agentProcessInfo(pid).startTime; + if (!Number.isSafeInteger(pid) || pid <= 0) return ''; + if (process.platform !== 'win32') return readPidStartTime(pid); + return win32CreationTimeSource.read(pid) ?? windowsProcessQuery(pid).startTime; } const pendingAgentExits = new Set(); @@ -335,7 +368,8 @@ function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' | if (ownerPid !== undefined && (record.ownerPid !== ownerPid || record.ownerStartTime !== readAgentStartTime(ownerPid))) return 'unknown'; const actual = agentProcessInfo(record.pid); if (!actual.startTime) return isAgentRecordLive(record) ? 'unknown' : 'gone'; - if (actual.startTime !== record.startTime) return 'gone'; + if (actual.startTime !== record.startTime && process.platform !== 'win32') return 'gone'; + if (actual.startTime !== record.startTime && !actual.commandLine) return 'unknown'; try { let state: string | undefined; if (process.platform === 'linux') { @@ -348,7 +382,7 @@ function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' | } catch {} if (!isAgentRecordLive(record)) return 'gone'; return actual.commandLine.split(/\s+/).some(arg => arg.replace(/^['"]|['"]$/g, '') === `--agent-gen=${record.gen}`) - ? 'owned' : 'unknown'; + ? 'owned' : actual.startTime === record.startTime ? 'unknown' : 'gone'; } export function isOurAgent(record: AgentRecord, ownerPid?: number): boolean { diff --git a/browse/test/cookie-import-native-job.test.ts b/browse/test/cookie-import-native-job.test.ts index 6b0431008..44b068d52 100644 --- a/browse/test/cookie-import-native-job.test.ts +++ b/browse/test/cookie-import-native-job.test.ts @@ -999,9 +999,9 @@ function safeNativeEnvelope(output: string): object { function assertOwnerReceipt(receipt: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] }, pid: number) { const before = JSON.stringify(receipt); - expect(receipt.available).toBe(true); - expect(Array.isArray(receipt.owners)).toBe(true); - expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost)).toBe(true); + expect(receipt.available, before).toBe(true); + expect(Array.isArray(receipt.owners), before).toBe(true); + expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost), before).toBe(true); expect(JSON.stringify(receipt)).toBe(before); } diff --git a/browse/test/path-security-realpath.test.ts b/browse/test/path-security-realpath.test.ts index aa3b03267..cc7518dd8 100644 --- a/browse/test/path-security-realpath.test.ts +++ b/browse/test/path-security-realpath.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; const VALIDATOR = path.resolve(import.meta.dir, '../src/path-security.ts'); const WIN = process.platform === 'win32'; @@ -43,11 +44,16 @@ function validate(tempDir: string, checks: Array<['output' | 'read', string]>): } function shortPath(dir: string): string { - const result = Bun.spawnSync(['powershell', '-NoProfile', '-NonInteractive', '-Command', - `(New-Object -ComObject Scripting.FileSystemObject).GetFolder($env:GSTACK_LONG_DIR).ShortPath`], { - env: { ...process.env, GSTACK_LONG_DIR: dir }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000, - }); - return result.stdout.toString().trim(); + const kernel = dlopen('kernel32.dll', { GetShortPathNameW: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.u32 } }); + try { + const input = Buffer.from(dir + '\0', 'utf16le'); + const output = Buffer.alloc(65536); + const length = kernel.symbols.GetShortPathNameW(ptr(input), ptr(output), 32768); + if (length === 0 || length >= 32768) throw new Error(`GetShortPathNameW failed for ${dir} (returned ${length})`); + return output.subarray(0, length * 2).toString('utf16le'); + } finally { + kernel.close(); + } } describe('#3104 one realpath for safe dirs and targets', () => { diff --git a/browse/test/process-liveness-windows.test.ts b/browse/test/process-liveness-windows.test.ts index b2f5f7e93..9e00a50e3 100644 --- a/browse/test/process-liveness-windows.test.ts +++ b/browse/test/process-liveness-windows.test.ts @@ -4,7 +4,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { isProcessAlive } from '../src/error-handling'; -import { spawnTerminalAgent } from '../src/terminal-agent-control'; +import { + isAgentRecordGone, isOurAgent, readAgentStartTime, spawnTerminalAgent, stopAgentByRecord, win32CreationTimeSource, writeAgentRecord, + type AgentRecord, +} from '../src/terminal-agent-control'; // REGRESSION TEST for the Windows terminal-agent leak. // @@ -117,8 +120,10 @@ describe('process liveness probe (Windows terminal-agent leak)', () => { } return originalProbe(command, options); }) as typeof Bun.spawnSync); + const powershellCalls: string[] = []; const windowsProbe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string, args: string[], options: any) => { if (command === 'powershell.exe') { + powershellCalls.push(args.join(' ')); const owner = args.join(' ').includes(`ProcessId = ${process.pid}'`); return { status: 0, stdout: JSON.stringify(owner ? { CreationDate: 'fixture-owner-start', CommandLine: 'test-owner' } : null), stderr: '' }; } @@ -143,7 +148,14 @@ describe('process liveness probe (Windows terminal-agent leak)', () => { // Owner-PID lifetime tie (#2019): the agent polls this and exits when // its owning browse server dies, so it can't be adopted by PID 1. expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid)); - expect(captured.env.BROWSE_OWNER_START_TIME).toBe('fixture-owner-start'); + if (process.platform === 'win32') { + expect(captured.env.BROWSE_OWNER_START_TIME).toMatch(/^\/Date\(\d+\)\/$/); + expect(captured.env.BROWSE_OWNER_START_TIME).toBe(readAgentStartTime(process.pid)); + expect(powershellCalls.filter(call => call.includes(`ProcessId = ${process.pid}'`))).toEqual([]); + expect(powershellCalls.every(call => call.includes("ProcessId = 2147483647'"))).toBe(true); + } else { + expect(captured.env.BROWSE_OWNER_START_TIME).toBe('fixture-owner-start'); + } // Detached background daemon — must not inherit a terminal either. expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']); await exited; @@ -174,3 +186,145 @@ describe('process liveness probe (Windows terminal-agent leak)', () => { expect(windowMs).toBeGreaterThanOrEqual(tick * guardMax); }); }); + +describe('win32 terminal-agent identity (GSTA-139)', () => { + const pid = 2147483645; + const gen = 'win32-identity-generation'; + const birth = '/Date(1761600000123)/'; + const ownedCommand = `"C:\\bun\\bun.exe" run terminal-agent.ts --agent-gen=${gen}`; + const record = (startTime = birth): AgentRecord => ({ pid, gen, startTime, startedAt: 0, ownerPid: pid, ownerStartTime: birth }); + type PowerShell = { status: number | null; creationDate?: string; commandLine?: string }; + + function onWin32(scenario: { ffi: string | undefined; powershell: PowerShell }, run: (calls: { powershell: number; signals: unknown[] }) => void): void { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const calls = { powershell: 0, signals: [] as unknown[] }; + let live = true; + const kill = spyOn(process, 'kill').mockImplementation(((target: number, signal: unknown) => { + expect(target).toBe(pid); + if (!live) throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + if (signal !== 0) { calls.signals.push(signal); live = false; } + return true; + }) as typeof process.kill); + const read = spyOn(win32CreationTimeSource, 'read').mockImplementation((target: number) => (target === pid && live ? scenario.ffi : undefined)); + const probe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string) => { + expect(command).toBe('powershell.exe'); + calls.powershell++; + const { status, creationDate, commandLine } = scenario.powershell; + return status === 0 + ? { status, stdout: JSON.stringify({ CreationDate: creationDate, CommandLine: commandLine }), stderr: '' } + : { status, stdout: '', stderr: '', error: Object.assign(new Error('spawnSync powershell.exe ETIMEDOUT'), { code: 'ETIMEDOUT' }) }; + }) as typeof childProcess.spawnSync); + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }); + try { + run(calls); + } finally { + Object.defineProperty(process, 'platform', platform); + probe.mockRestore(); + read.mockRestore(); + kill.mockRestore(); + } + } + + test('the start time is read in-process, so a PowerShell launch killed at its timeout cannot empty it', () => { + onWin32({ ffi: birth, powershell: { status: null } }, calls => { + expect(readAgentStartTime(pid)).toBe(birth); + expect(calls.powershell).toBe(0); + }); + }); + + test('without the in-process reader (Node daemon) the start time falls back to the PowerShell CreationDate', () => { + onWin32({ ffi: undefined, powershell: { status: 0, creationDate: birth, commandLine: ownedCommand } }, calls => { + expect(readAgentStartTime(pid)).toBe(birth); + expect(calls.powershell).toBe(1); + }); + }); + + test('kill-time ownership still requires the generation in the PowerShell command line', () => { + onWin32({ ffi: birth, powershell: { status: 0, creationDate: birth, commandLine: ownedCommand } }, calls => { + expect(isOurAgent(record())).toBe(true); + expect(stopAgentByRecord(record(), 0)).toBe(true); + expect(calls.signals).toEqual(['SIGTERM']); + }); + for (const powershell of [{ status: 0, creationDate: birth, commandLine: 'bun run unrelated.ts' }, { status: null }]) { + onWin32({ ffi: birth, powershell }, calls => { + expect(isOurAgent(record())).toBe(false); + expect(isAgentRecordGone(record())).toBe(false); + expect(stopAgentByRecord(record(), 0)).toBe(false); + expect(calls.signals).toEqual([]); + }); + } + onWin32({ ffi: '/Date(1761600000124)/', powershell: { status: 0, creationDate: birth, commandLine: 'bun run unrelated.ts' } }, calls => { + expect(isAgentRecordGone(record())).toBe(true); + expect(calls.signals).toEqual([]); + }); + }); + + test('a start time that disagrees across readers still identifies the live agent by its generation, so no second agent starts', () => { + const daemonRecorded = '/Date(1761596400123)/'; + onWin32({ ffi: birth, powershell: { status: 0, creationDate: daemonRecorded, commandLine: ownedCommand } }, calls => { + expect(isAgentRecordGone(record(daemonRecorded))).toBe(false); + expect(isOurAgent(record(daemonRecorded))).toBe(true); + expect(stopAgentByRecord(record(daemonRecorded), 0)).toBe(true); + expect(calls.signals).toEqual(['SIGTERM']); + }); + onWin32({ ffi: birth, powershell: { status: null } }, calls => { + expect(isAgentRecordGone(record(daemonRecorded))).toBe(false); + expect(stopAgentByRecord(record(daemonRecorded), 0)).toBe(false); + expect(calls.signals).toEqual([]); + }); + }); + + test('an unreadable start time is unknown: never signalled and never treated as gone', () => { + onWin32({ ffi: undefined, powershell: { status: null } }, calls => { + expect(readAgentStartTime(pid)).toBe(''); + expect(isOurAgent(record())).toBe(false); + expect(isAgentRecordGone(record())).toBe(false); + expect(stopAgentByRecord(record(), 0)).toBe(false); + expect(calls.signals).toEqual([]); + }); + }); + + test.skipIf(process.platform !== 'win32')('the in-process start time equals the CreationDate earlier releases recorded', () => { + const child = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', 'ignore'] }); + try { + for (const target of [process.pid, child.pid]) { + const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${target}' | Select-Object CreationDate | ConvertTo-Json -Compress)`; + const legacy = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 60_000 }); + expect(legacy.status).toBe(0); + const recorded = JSON.parse(legacy.stdout).CreationDate; + expect(recorded).toMatch(/^\/Date\(\d+\)\/$/); + expect(win32CreationTimeSource.read(target)).toBe(recorded); + expect(readAgentStartTime(target)).toBe(recorded); + } + } finally { + child.kill(); + } + }, 150_000); + + test.skipIf(process.platform !== 'win32')('a live agent whose identity cannot be read blocks a second agent', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-unknown-')); + const script = path.join(tmpDir, 'fake-agent.ts'); + fs.writeFileSync(script, '// no-op\n'); + const live = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', 'ignore'] }); + writeAgentRecord(tmpDir, { pid: live.pid, gen, startTime: birth, startedAt: 0, ownerPid: process.pid, ownerStartTime: birth }); + const original = childProcess.spawnSync; + const probe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string, args: string[], options: any) => ( + command === 'powershell.exe' ? { status: null, stdout: '', stderr: '' } : original(command, args, options) + )) as typeof childProcess.spawnSync); + const read = spyOn(win32CreationTimeSource, 'read').mockImplementation(() => undefined); + const origSpawn = (Bun as any).spawn; + let spawned = 0; + (Bun as any).spawn = () => { spawned++; throw new Error('a second agent must not start'); }; + try { + expect(spawnTerminalAgent({ stateFile: path.join(tmpDir, 'state.json'), serverPort: 12345, ownerPid: process.pid, cwd: tmpDir, scriptPath: script })).toBeNull(); + expect(spawned).toBe(0); + expect(isProcessAlive(live.pid)).toBe(true); + } finally { + (Bun as any).spawn = origSpawn; + read.mockRestore(); + probe.mockRestore(); + live.kill(); + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 }); + } + }); +}); diff --git a/package.json b/package.json index 3532e7b54..629f84a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.295", + "version": "1.91.298", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts index 8b24c1e11..c11cc4823 100644 --- a/test/docsync-command-grammar.test.ts +++ b/test/docsync-command-grammar.test.ts @@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json expect(docsNativeInterface(fixture)).toContain('compare and --help commands'); }); -// Train gate 39904966815 (ship-docsync-completion, attempt 1) and GSTA-63's focused run joined the candidate's -// --help onto a line of reads. The join stays refused; the own-call rule now sits in the sentence naming --help. -test('a candidate --help joined to reads stays refused, and the interface says so where it names the command', () => { +// Train gate 39904966815 and GSTA-63's focused run joined the candidate's --help onto a line of reads; v1.91.185.0 moved the +// own-call rule into the sentence naming --help and the slip came back four times (JOINED_READ_LINES below). The --help only +// prints usage, so it now joins a line of reads like a Git read; every other own-call command stays alone. +test('a candidate --help joined to reads is a read, and the interface names it as the one exception to the own-call rule', () => { const skills = fixture.skills.split(path.sep).join('/'); - const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${skills}/bin/gstack-docs-candidate --help`; - expect(docsCommandAllowed(joined, fixture)).toBe(false); - expect(docsCommandAllowed(`${skills}/bin/gstack-docs-candidate --help`, fixture)).toBe(true); + const candidate = `${skills}/bin/gstack-docs-candidate`; + const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${candidate} --help`; + expect(docsCommandAllowed(joined, fixture)).toBe(true); + for (const command of [`${candidate} --help`, `${candidate} --help 2>&1`, `git status; ${candidate} snapshot --help`, `${candidate} compare --help 2>/dev/null; git log --oneline -3`, + `${candidate} --help | head -20`]) expect(docsCommandAllowed(command, fixture), command).toBe(true); + const record = path.join(fixture.home, 'c.json'); + for (const command of [ + `git status; ${candidate} snapshot --out ${record} --audit-id a --mode edit --base main`, `git status; ${candidate} compare ${record}`, + `${candidate} --help; git add app.ts`, `${candidate} --help > ${path.join(fixture.home, 'usage.md')}`, `${candidate} --help | sh`, + `echo x | ${candidate} --help`, `git status | ${candidate} --help`, `cd ${fixture.repo} && ${candidate} --help`, `git status; ${candidate} --help snapshot`, + `git status; ${candidate} --version`, `git status; ${candidate} -h`, `git status; ${candidate} --help --exec rm`, + `git status; ~/.claude/skills/gstack/bin/gstack-docs-candidate --help`, `git status; ${path.join(path.dirname(fixture.home), 'bin/gstack-docs-candidate')} --help`, + `git status; ${skills}/bin/gstack-skill-start --help`, `git status; ${skills}/bin/gstack-skill-end --help`, + ]) expect(docsCommandAllowed(command, fixture), command).toBe(false); const sentence = docsNativeInterface(fixture).split(/(?<=\.)\s+(?=[A-Z])/).find(part => part.includes('gstack-docs-candidate snapshot')); expect(sentence).toContain('as its own Bash call, with nothing joined before or after it'); - expect(sentence).toContain('a --help is no exception'); + expect(sentence).not.toContain('a --help is no exception'); + expect(docsNativeInterface(fixture)).toContain(`So may the installed ${candidate} --help, alone or after snapshot or compare, because it only prints usage (the one exception)`); +}); + +// Five red ship-docsync parents, each red only on 'command outside declared docs observation interface', and each with exactly +// one refused line (every other Bash call of the five captures replays clean): four joined the candidate's --help onto a line of +// reads, and gate 40643690306 put `git tag` in one. {home} is the fixture home; the sha is the capture file's sha256 prefix. +const JOINED_READ_LINES: Array<[source: string, scenario: 'current' | 'store', line: string]> = [ + ['screen 40558235014 slice 5 @ 70cedc3d, capture c73fb9d0b1b0', 'current', + 'git diff main HEAD; cat personal-note.txt; ls -la; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['gate 40600731931 slice 4 @ 5fef38a5, capture 606503d8b78a', 'store', + 'cat handbook/reference/commands/widget.md.tmpl; echo ---; cat CHANGELOG.md TODOS.md VERSION package.json AGENTS.md; echo ---; cat personal-note.txt; echo ---; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 slice 5 @ 76a155d4, capture 67fa4dea377a', 'current', + 'git diff main...HEAD; cat handbook/reference/commands/widget.md.tmpl; ls -la handbook handbook/reference handbook/reference/commands; cat personal-note.txt | head -3; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 rerun 1 @ 76a155d4, capture 53d06fea1a89', 'current', + 'git diff main HEAD; git rev-parse origin/main; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help 2>&1'], + ['gate 40643690306 slice 4 @ c188a78c, capture 6ac1902bd83f', 'store', + 'git rev-parse HEAD origin/main; git merge-base main HEAD; git tag; cat {home}/fixture-interface.md | head -5; ls {home}'], +]; + +test.each(JOINED_READ_LINES)('captured joined read line from %s is a declared read', (_source, scenario, line) => { + const owned = fixtureDocs(scenario); + try { + const command = line.replaceAll('{home}', owned.home); + const publish = path.join(owned.home, 'fixture-publish.ts'); + expect(docsCommandAllowed(command, owned, [publish])).toBe(true); + expect(docsToolFailures(nativeResult(command, 'usage: gstack-docs-candidate snapshot ...'), owned, [publish], true)).toEqual([]); + expect(docsCommandAllowed(`${command}; git add app.ts`, owned, [publish])).toBe(false); + } finally { owned.clean(); } +}); + +test('operand-free tag listings are reads, and every tag write stays refused', () => { + for (const listing of ['git tag', 'git tag -l', 'git tag --list', 'git status; git tag; git log --oneline -3']) + expect(docsCommandAllowed(listing, fixture), listing).toBe(true); + for (const write of ['git tag v1', 'git tag -a v1 -m release', 'git tag -d v1', 'git tag -f v1 HEAD~1', 'git tag -s v1', 'git tag -v v1', 'git tag -l "v*"', + 'git tag --contains HEAD', 'git tag -n', 'git tag --sort=-v:refname', 'git tag -l v1', 'git tag; git tag v2', 'git tag | sh']) + expect(docsCommandAllowed(write, fixture), write).toBe(false); + expect(docsNativeInterface(fixture)).toContain('tag listings with no operand and only -l/--list'); }); test('the installed lifecycle helpers are admitted for their own --help, which starts nothing', () => { diff --git a/test/helpers/docsync-interface.ts b/test/helpers/docsync-interface.ts index 41370692d..607dd68ae 100644 --- a/test/helpers/docsync-interface.ts +++ b/test/helpers/docsync-interface.ts @@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re export function docsNativeInterface(fixture: Pick, 'home' | 'repo' | 'skills'> & Partial, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string { const skills = fixture.skills.split(path.sep).join('/'); const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state'); - return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) + return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), tag listings with no operand and only -l/--list, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (one exception follows the read grammar below): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) ${DOCS_TMP_EXCLUDE} then ${DOCS_AUDIT_RESET} and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it ${docsCapsCommand(skills)} -(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. +(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. So may the installed ${skills}/bin/gstack-docs-candidate --help, alone or after snapshot or compare, because it only prints usage (the one exception); neither a joined Git read nor that --help may be fed by a pipe. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no other own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index b97312e2b..8125fbe1e 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -322,9 +322,12 @@ function docsUnrollLoops(text: string): string | null { /** * A read-only line the shared grammar cannot see whole: loops are checked as - * their unrolled passes, and each declared Git read joined to grammar reads - * (never after a cd, never fed by a pipe) is checked literally and then stands - * in the line as a neutral read for the grammar. + * their unrolled passes, and each declared Git read or installed candidate + * --help joined to grammar reads (never after a cd, never fed by a pipe) is + * checked literally and then stands in the line as a neutral read for the grammar. + * The candidate's --help prints its usage and returns before it touches Git or a + * file (bin/gstack-docs-candidate main), so joining it reads nothing more (gates + * 40600731931 and 40627127462, screen 40558235014: the parent ended a line of reads with it). */ function docsComposedReadAllowed(text: string, fixture: ReturnType): boolean { const line = docsUnrollLoops(text); @@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType line.slice(segment.start, segment.end).trim()); - const git = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) ? [index] : []); - if (!git.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; + const declared = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) || command.startsWith(`${candidate} `) ? [index] : []); + if (!declared.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; let neutral = line; - for (const index of git.reverse()) { + for (const index of declared.reverse()) { const read = unwrapHarmlessRead(commands[index]!, fixture); - if (segments[index - 1]?.op === '|' || read === null || !/^git\s/.test(read) || !literalDocsCommandAllowed(read, fixture, [])) return false; + const help = read !== null && /^(?:(?:snapshot|compare) )?--help$/.test(read.slice(candidate.length + 1)); + if (segments[index - 1]?.op === '|' || read === null || !(/^git\s/.test(read) || (read.startsWith(`${candidate} `) && help)) + || !literalDocsCommandAllowed(read, fixture, [])) return false; neutral = `${neutral.slice(0, segments[index]!.start)} pwd ${neutral.slice(segments[index]!.end)}`; } return readOnlyCommandAllowed(neutral, docsReadOnlyScope(fixture)); @@ -384,6 +390,9 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType DOCS_BRANCH_LIST_FLAGS.test(arg)); + // A tag listing reads refs the same way (gate 40643690306: the parent ended a line of reads with `git tag`); + // an operand or any other option creates, deletes, signs or verifies a tag. + if (rest[0] === 'tag') return rest.slice(1).every(arg => arg === '-l' || arg === '--list'); if (rest[0] === 'describe') { const operands = rest.slice(1).filter(arg => !DOCS_DESCRIBE_FLAGS.test(arg)); return operands.length <= 1 && operands.every(arg => !arg.startsWith('-'));