From 6b6e68f7aff71168d79fc464fde410563907d4fb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 11 Sep 2026 17:37:16 +0000 Subject: [PATCH] test: fix Windows CI fixtures and credential scan Rebase captured JSON values and filesystem evidence using the appropriate path convention. Compile native fake CLIs on Windows and synchronize pipe holder readiness, with cleanup retained when assertions fail. Assemble synthetic credential fixtures at runtime so the added-line scan keeps enforcing the same gate without flagging its own rejection controls. Discover generated skills directly for the empty-find regression check, avoiding a recursive scan through saved evaluation artifacts and dependencies. --- test/autoplan-artifact-stall-as.test.ts | 27 ++++++-- test/autoplan-clipped-suffix-aq.test.ts | 2 +- test/autoplan-command-prefix-au.test.ts | 11 ++-- test/autoplan-edit-edges-an.test.ts | 7 +- test/autoplan-edit-header-ag.test.ts | 2 +- test/autoplan-edit-panel-aj.test.ts | 6 +- test/autoplan-edit-prefix-ai.test.ts | 2 +- test/autoplan-edit-queue-am.test.ts | 7 +- test/autoplan-init.test.ts | 2 +- test/autoplan-method-read-audit.test.ts | 32 ++++++++- test/autoplan-rendered-batch-at.test.ts | 11 ++-- test/autoplan-repeated-header-ak.test.ts | 4 +- test/claude-code-runner.test.ts | 69 +++++++++++++------- test/claude-code-windows-job.test.ts | 23 ++++++- test/coverage-audit-af.test.ts | 19 ++++++ test/coverage-shell-display-aq.test.ts | 2 +- test/empty-find-fallthrough.test.ts | 46 +++++++++---- test/eng-seeded-completion-ai.test.ts | 7 +- test/helpers/autoplan-artifact-permission.ts | 8 +-- test/helpers/captured-paths.ts | 14 ++++ test/helpers/coverage-audit-evidence.ts | 7 +- test/helpers/fake-bun-cli.ts | 14 ++++ test/helpers/plan-count-pending-question.ts | 7 +- test/native-auto-decide-pty.test.ts | 7 +- test/office-hours-phase4-caller.test.ts | 20 ++++-- test/outside-voice-routing.test.ts | 2 +- test/plan-count-permission-ac.test.ts | 14 ++-- test/plan-floor-target.test.ts | 37 +++++++++-- test/plan-scope-selection.test.ts | 6 +- test/review-consensus-lifecycle.test.ts | 19 ++++-- test/setup-gbrain-path4-caller.test.ts | 9 ++- test/ship-coverage-audit-af.test.ts | 2 +- 32 files changed, 335 insertions(+), 110 deletions(-) create mode 100644 test/helpers/captured-paths.ts create mode 100644 test/helpers/fake-bun-cli.ts diff --git a/test/autoplan-artifact-stall-as.test.ts b/test/autoplan-artifact-stall-as.test.ts index 2af1bf5f2..803398fe2 100644 --- a/test/autoplan-artifact-stall-as.test.ts +++ b/test/autoplan-artifact-stall-as.test.ts @@ -1,3 +1,4 @@ +import { capturedPathRebaser } from './helpers/captured-paths'; import {expect,test} from 'bun:test'; import fs from 'node:fs';import os from 'node:os';import path from 'node:path'; import fixture from './fixtures/autoplan-artifact-stall-as.json'; @@ -6,13 +7,31 @@ import {readPendingAutoplanArtifact,autoplanArtifactRecorderStatus} from './help import {readPlanCountTranscript,type NativePublicToolEvent} from './helpers/plan-count-transcript'; import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles'; +test('captured path rebasing preserves JSON strings and emits canonical native file paths',()=>{ + const destination=String.raw`C:\a\repo`,source={file:'/captured/plans/plan.md',content:'First\n/captured/notes\nLast'}; + const rebase=capturedPathRebaser([['/captured',destination]]); + const display=destination.split(path.sep).join('/'); + expect(rebase.json(source)).toEqual({file:path.normalize(display+'/plans/plan.md'),content:'First\n'+display+'/notes\nLast'}); + expect(source.file).toBe('/captured/plans/plan.md'); +}); + +test('captured path rebasing preserves malformed and foreign ownership inputs',()=>{ + const destination=path.join(path.parse(process.cwd()).root,'replayed'); + const rebase=capturedPathRebaser([['/captured',destination]]); + for(const suffix of ['../foreign.md','plans/../plan.md','plans//plan.md','plans/./plan.md']){ + expect(rebase.json({file:'/captured/'+suffix}).file).toBe(destination+path.sep+suffix.split('/').join(path.sep)); + } + expect(rebase.json({file:'../foreign.md'}).file).toBe('..'+path.sep+'foreign.md'); + expect(rebase.json({file:'/foreign/plans/../plan.md'}).file).toBe(path.sep+'foreign'+path.sep+'plans'+path.sep+'..'+path.sep+'plan.md'); +}); + function replay() { const root=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-ap-stall-')); const runtimeBefore=path.dirname(path.dirname(fixture.stateRoot)); const runtime=path.join(root,path.basename(runtimeBefore)),cwd=path.join(root,path.basename(fixture.cwd)); - const replace=(value:string)=>value.replaceAll(runtimeBefore,runtime).replaceAll(fixture.cwd,cwd); - const hook=JSON.parse(replace(JSON.stringify(fixture.hook))),stateRoot=replace(fixture.stateRoot),config=replace(fixture.config); - const events=JSON.parse(replace(JSON.stringify(fixture.publicTools))) as NativePublicToolEvent[]; + const rebase=capturedPathRebaser([[runtimeBefore,runtime],[fixture.cwd,cwd]]); + const hook=rebase.json(fixture.hook),stateRoot=rebase.file(fixture.stateRoot),config=rebase.file(fixture.config); + const events=rebase.json(fixture.publicTools) as NativePublicToolEvent[]; const now=Date.parse(fixture.viewportCapturedAt),startedAt=Date.parse(fixture.commandStartedAt); const file=hook.pending.file,nativePlan=events.filter(e=>e.kind==='use'&&e.name==='Edit').at(-1)!.input!.file_path as string; for(const [target,content] of [[file,fixture.before],[nativePlan,fixture.nativePlanBefore]]) { @@ -28,7 +47,7 @@ function replay() { const pending=readPendingAutoplanArtifact(hookFile,cwd,config,stateRoot,startedAt,publicTools,now,true); const context={cwd,ownedStateRoot:stateRoot,ownedNativePlansRoot:path.join(config,'plans'),commandStartedAt:startedAt, now,viewportCapturedAt:now,transcriptStatus:transcript.status,publicTools,pending}; - const viewport=replace(fixture.viewport); + const viewport=rebase.text(fixture.viewport); const invoke=(screen=viewport,ctx=context,seen=new Set())=>permission.publishedAutoplanArtifactPermissionInput(screen,ctx,seen); return {root,hook,hookFile,config,file,nativePlan,context,viewport,invoke,dispose:()=>fs.rmSync(root,{recursive:true,force:true})}; } diff --git a/test/autoplan-clipped-suffix-aq.test.ts b/test/autoplan-clipped-suffix-aq.test.ts index b57dcfbc0..3ffbfe6a1 100644 --- a/test/autoplan-clipped-suffix-aq.test.ts +++ b/test/autoplan-clipped-suffix-aq.test.ts @@ -7,7 +7,7 @@ import {E2E_TOUCHFILES} from './helpers/touchfiles-data'; const cleanup:Array<()=>void>=[];afterEach(()=>{for(const f of cleanup.splice(0))f()}); function replay(before=fixture.before,removed=fixture.request.old_string,added=fixture.request.new_string){ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ap-suffix-')),cwd=path.join(root,path.basename(fixture.cwd)),config=path.join(root,'config'),stateRoot=path.join(root,'home/.gstack'); - const file=fixture.hook.pending.file.replace(fixture.stateRoot,stateRoot),native=path.join(config,'projects/owned',fixture.hook.sessionId+'.jsonl'); + const file=path.normalize(fixture.hook.pending.file.replace(fixture.stateRoot,stateRoot)),native=path.join(config,'projects/owned',fixture.hook.sessionId+'.jsonl'); fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(file),{recursive:true});fs.mkdirSync(path.dirname(native),{recursive:true});fs.writeFileSync(native,'');fs.writeFileSync(file,before);fs.utimesSync(file,new Date(0),new Date(0)); const recorder=createAutoplanArtifactRecorder(cwd,config,stateRoot);cleanup.push(()=>{recorder.dispose();fs.rmSync(root,{recursive:true,force:true})}); const event={hook_event_name:'PreToolUse',tool_name:'Edit',session_id:fixture.hook.sessionId,tool_use_id:fixture.hook.pending.toolUseId,cwd,transcript_path:native,tool_input:{file_path:file,old_string:removed,new_string:added}}; diff --git a/test/autoplan-command-prefix-au.test.ts b/test/autoplan-command-prefix-au.test.ts index fe8156c30..c730dc064 100644 --- a/test/autoplan-command-prefix-au.test.ts +++ b/test/autoplan-command-prefix-au.test.ts @@ -1,3 +1,4 @@ +import { capturedPathRebaser } from './helpers/captured-paths'; import { expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; @@ -13,10 +14,10 @@ function replay() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ap-command-')); const old = path.dirname(path.dirname(fixture.stateRoot)); const runtime = path.join(root, path.basename(old)), cwd = path.join(root, path.basename(fixture.cwd)); - const replace = (text: string) => text.replaceAll(old, runtime).replaceAll(fixture.cwd, cwd); - const hook = JSON.parse(replace(JSON.stringify(fixture.hook))); - const stateRoot = replace(fixture.stateRoot), config = replace(fixture.config), file = hook.pending.file; - const events = JSON.parse(replace(JSON.stringify(fixture.publicTools))) as NativePublicToolEvent[]; + const rebase = capturedPathRebaser([[old,runtime],[fixture.cwd,cwd]]); + const hook = rebase.json(fixture.hook); + const stateRoot = rebase.file(fixture.stateRoot), config = rebase.file(fixture.config), file = hook.pending.file; + const events = rebase.json(fixture.publicTools) as NativePublicToolEvent[]; fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, fixture.before, { mode: fixture.targetStat.mode }); const mtime = Number(BigInt(fixture.targetStat.mtimeNs)) / 1e9; @@ -34,7 +35,7 @@ function replay() { const pending = readPendingAutoplanArtifact(hookFile, cwd, config, stateRoot, commandStartedAt, publicTools, now, true); const context = { cwd, ownedStateRoot: stateRoot, ownedNativePlansRoot: path.join(config, 'plans'), commandStartedAt, now, viewportCapturedAt: now, transcriptStatus: transcript.status, publicTools, pending }; - return { root, file, context, viewport: replace(fixture.viewport), dispose: () => fs.rmSync(root, { recursive: true, force: true }) }; + return { root, file, context, viewport: rebase.text(fixture.viewport), dispose: () => fs.rmSync(root, { recursive: true, force: true }) }; } type Replay = ReturnType; const pick = (r: Replay, seen = new Set()) => permission.pendingAutoplanArtifactPermissionInput(r.viewport, r.context, seen); diff --git a/test/autoplan-edit-edges-an.test.ts b/test/autoplan-edit-edges-an.test.ts index 55a4cfb3d..df5e48ab5 100644 --- a/test/autoplan-edit-edges-an.test.ts +++ b/test/autoplan-edit-edges-an.test.ts @@ -1,3 +1,4 @@ +import { capturedPathRebaser } from './helpers/captured-paths'; import {expect,test} from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; @@ -13,12 +14,12 @@ function setup(changeRecords?:(records:any[])=>void){ const dir=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-ap-edges-')); const cwd=path.join(dir,path.basename(fixture.cwd)),config=path.join(dir,'config'); const stateRoot=path.join(dir,'gstack-hermetic-2546450-gfwm4G/skill-home-k7zGB1/.gstack'); - const replace=(text:string)=>text.replaceAll(fixture.stateRoot,stateRoot).replaceAll(fixture.cwd,cwd).replaceAll(fixture.config,config); - const hook=JSON.parse(replace(JSON.stringify(fixture.hook))); + const rebase=capturedPathRebaser([[fixture.stateRoot,stateRoot],[fixture.cwd,cwd],[fixture.config,config]]); + const hook=rebase.json(fixture.hook); const file=hook.pending.file,nativeFile=path.join(config,'projects','owned',hook.sessionId+'.jsonl');hook.pending.transcriptPath=nativeFile; fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(file),{recursive:true});fs.mkdirSync(path.dirname(nativeFile),{recursive:true}); fs.writeFileSync(file,fixture.before);fs.utimesSync(file,new Date(fixture.now-1000000),new Date(Date.parse(hook.pending.timestamp)-1000)); - const events=JSON.parse(replace(JSON.stringify(fixture.publicTools))) as (NativePublicToolEvent & {messageId?:string;requestId?:string})[]; + const events=rebase.json(fixture.publicTools) as (NativePublicToolEvent & {messageId?:string;requestId?:string})[]; const records=events.map(e=>({sessionId:e.sessionId,cwd,isSidechain:false,timestamp:e.timestamp,requestId:e.requestId,message:{id:e.messageId,role:e.kind==='use'?'assistant':'user',content:e.kind==='use'?[{type:'tool_use',id:e.toolUseId,name:e.name,input:e.input}]:[{type:'tool_result',tool_use_id:e.toolUseId,content:'',is_error:e.isError}]}})); changeRecords?.(records); fs.writeFileSync(nativeFile,records.map(r=>JSON.stringify(r)).join('\n')+'\n'); diff --git a/test/autoplan-edit-header-ag.test.ts b/test/autoplan-edit-header-ag.test.ts index 1894a31d0..99e8d8d53 100644 --- a/test/autoplan-edit-header-ag.test.ts +++ b/test/autoplan-edit-header-ag.test.ts @@ -13,7 +13,7 @@ function replay() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-edit-header-')); roots.push(root); const cwd = path.join(root,path.basename(captured.cwd)); const ownedStateRoot = path.join(root,'home','.gstack'); - const file = captured.pending.file.replace(captured.ownedStateRoot,ownedStateRoot); + const file = path.normalize(captured.pending.file.replace(captured.ownedStateRoot,ownedStateRoot)); fs.mkdirSync(cwd,{recursive:true}); fs.mkdirSync(path.dirname(file),{recursive:true}); fs.writeFileSync(file,captured.before); const beforeTime = new Date(Date.parse(captured.pending.timestamp)-1000); diff --git a/test/autoplan-edit-panel-aj.test.ts b/test/autoplan-edit-panel-aj.test.ts index 1466fe48b..b2d2d7671 100644 --- a/test/autoplan-edit-panel-aj.test.ts +++ b/test/autoplan-edit-panel-aj.test.ts @@ -12,7 +12,7 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursiv function replay() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-edit-panel-')); roots.push(root); const cwd = path.join(root, path.basename(captured.cwd)), ownedStateRoot = path.join(root, 'home', '.gstack'); - const file = captured.pending.file.replace(captured.ownedStateRoot, ownedStateRoot); + const file = path.normalize(captured.pending.file.replace(captured.ownedStateRoot, ownedStateRoot)); fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, captured.before); const time = new Date(Date.parse(captured.pending.timestamp) - 1000); fs.utimesSync(file, time, time); const events = structuredClone(captured.events) as NativePublicToolEvent[]; @@ -33,7 +33,7 @@ test('the exact standalone native Edit panel binds the owned current unpublished test('complete absolute, home alias and full relative suffix paths retain ownership', () => { for (const displayed of ['absolute', 'alias', 'suffix'] as const) { - const r = replay(), relative = path.relative(r.context.ownedStateRoot, r.file); + const r = replay(), relative = path.relative(r.context.ownedStateRoot, r.file).split(path.sep).join('/'); const value = displayed === 'absolute' ? r.file : displayed === 'alias' ? '~/.gstack/' + relative : '…' + relative; r.viewport = r.viewport.replace(/^ …[^\n]+$/m, ' ' + value); expect(pick(r)?.input).toBe('1\r'); } @@ -80,7 +80,7 @@ test('published edits retain exact old/new content guards with the standalone pr const r = replay(), events = structuredClone(published.events) as NativePublicToolEvent[]; const edit = events.find(e => e.kind === 'use' && e.toolUseId === published.pending.toolUseId)!; const oldFile = edit.input!.file_path; - const file = (oldFile as string).replace(published.ownedStateRoot, r.context.ownedStateRoot); + const file = path.normalize((oldFile as string).replace(published.ownedStateRoot, r.context.ownedStateRoot)); const cwd = path.join(r.root, path.basename(published.cwd)); fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, published.before); for (const event of events) if (event.input?.file_path === oldFile) event.input.file_path = file; diff --git a/test/autoplan-edit-prefix-ai.test.ts b/test/autoplan-edit-prefix-ai.test.ts index 8dd900bed..10b428efc 100644 --- a/test/autoplan-edit-prefix-ai.test.ts +++ b/test/autoplan-edit-prefix-ai.test.ts @@ -14,7 +14,7 @@ function replay() { const cwd = path.join(root, path.basename(captured.cwd)), ownedStateRoot = path.join(root, 'home', '.gstack'); const events = structuredClone(captured.events) as NativePublicToolEvent[]; const latest = events.find(e => e.kind === 'use' && e.toolUseId === captured.pending.toolUseId)! - const original = latest.input!.file_path as string, file = original.replace(captured.ownedStateRoot, ownedStateRoot); + const original = latest.input!.file_path as string, file = path.normalize(original.replace(captured.ownedStateRoot, ownedStateRoot)); fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, captured.before); const time = new Date(Date.parse(latest.timestamp) - 1000); fs.utimesSync(file, time, time); for (const event of events) if (event.input?.file_path === original) event.input.file_path = file; diff --git a/test/autoplan-edit-queue-am.test.ts b/test/autoplan-edit-queue-am.test.ts index 29b8fb162..2e02a957e 100644 --- a/test/autoplan-edit-queue-am.test.ts +++ b/test/autoplan-edit-queue-am.test.ts @@ -1,3 +1,4 @@ +import { capturedPathRebaser } from './helpers/captured-paths'; import {expect,test} from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; @@ -12,12 +13,12 @@ function setup(changeRecords?:(records:any[])=>void){ const dir=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-ap-queue-')); const cwd=path.join(dir,path.basename(fixture.cwd)),config=path.join(dir,'config'); const stateRoot=path.join(dir,'gstack-hermetic-2101964-HvDZyN/skill-home-zgCNxG/.gstack'); - const replace=(text:string)=>text.replaceAll(fixture.stateRoot,stateRoot).replaceAll(fixture.cwd,cwd).replaceAll(fixture.config,config); - const hook=JSON.parse(replace(JSON.stringify(fixture.hook))); + const rebase=capturedPathRebaser([[fixture.stateRoot,stateRoot],[fixture.cwd,cwd],[fixture.config,config]]); + const hook=rebase.json(fixture.hook); const file=hook.pending.file,nativeFile=path.join(config,'projects','owned',hook.sessionId+'.jsonl');hook.pending.transcriptPath=nativeFile; fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(file),{recursive:true});fs.mkdirSync(path.dirname(nativeFile),{recursive:true}); fs.writeFileSync(file,fixture.before);fs.utimesSync(file,new Date(fixture.now-1000000),new Date(Date.parse(hook.pending.timestamp)-1000)); - const events=JSON.parse(replace(JSON.stringify(fixture.publicTools))) as (NativePublicToolEvent & {messageId?:string;requestId?:string})[]; + const events=rebase.json(fixture.publicTools) as (NativePublicToolEvent & {messageId?:string;requestId?:string})[]; const records=events.map(e=>({sessionId:e.sessionId,cwd,isSidechain:false,timestamp:e.timestamp,requestId:e.requestId,message:{id:e.messageId,role:e.kind==='use'?'assistant':'user',content:e.kind==='use'?[{type:'tool_use',id:e.toolUseId,name:e.name,input:e.input}]:[{type:'tool_result',tool_use_id:e.toolUseId,content:'',is_error:e.isError}]}})); changeRecords?.(records); fs.writeFileSync(nativeFile,records.map(r=>JSON.stringify(r)).join('\n')+'\n'); diff --git a/test/autoplan-init.test.ts b/test/autoplan-init.test.ts index 8f7961829..4b9cff9b2 100644 --- a/test/autoplan-init.test.ts +++ b/test/autoplan-init.test.ts @@ -161,7 +161,7 @@ test('existing foreign destinations, symlinks and path aliases cannot be overwri test('line endings and Unicode survive normalization; only a missing final separator LF is added', () => { for (const text of ['最後の API 要件 🧪\r\nREST must remain.\r\n', 'API and endpoint without final newline']) { - const f = fixture(); const restore = join(f.dir, 'restore -- quoted "名前".md'); + const f = fixture(); const restore = join(f.dir, process.platform === 'win32' ? "restore -- quoted '名前'.md" : 'restore -- quoted "名前".md'); writeFileSync(f.source, text); invoke('init', f.source, f.active, restore); expect(readFileSync(restore, 'utf8')).toBe(text); diff --git a/test/autoplan-method-read-audit.test.ts b/test/autoplan-method-read-audit.test.ts index a5f030ba2..9c9f32584 100644 --- a/test/autoplan-method-read-audit.test.ts +++ b/test/autoplan-method-read-audit.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; import { prepareMethodology, createSnapshot } from '../bin/gstack-autoplan-snapshot'; import { auditAutoplanMethodReads, loadAutoplanMethodologyBinding } from './helpers/autoplan-method-read-audit'; import { readPlanCountTranscript, type NativePublicToolEvent } from './helpers/plan-count-transcript'; @@ -131,7 +133,35 @@ describe('actual immutable snapshot methodology binding', () => { ? join(f.method.methodologyPath, '..', 'methodology.json') : f.method.methodologyPath; chmodSync(target, 0o600); if (kind !== 'mutable') { writeFileSync(target, readFileSync(target, 'utf8') + 'tamper'); chmodSync(target, 0o444); } - expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]), kind).toThrow(); + if (kind === 'mutable') { + if (process.platform !== 'win32') expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]), kind).toThrow(); + // Windows does not use POSIX permission bits. Exercise that policy in + // an isolated process with observed modes, keeping actual artifact + // paths/bytes and both the immutable control and writable rejection. + const worker = join(f.dir, 'observed-mode.ts'); + writeFileSync(worker, `import { mock } from 'bun:test'; +const real = { ...await import('node:fs') }; +await import('node:path'); +const input = JSON.parse(await Bun.stdin.text()); +Object.defineProperty(process, 'platform', { value: 'linux' }); +mock.module('node:fs', () => ({ ...real, lstatSync(file) { + const stat = real.lstatSync(file); + stat.mode = (stat.mode & ~0o777) | (file === input.target && input.writable ? 0o600 : 0o444); + return stat; +} })); +const { loadAutoplanMethodologyBinding } = await import(${JSON.stringify(pathToFileURL(join(ROOT, 'test/helpers/autoplan-method-read-audit.ts')).href)}); +loadAutoplanMethodologyBinding(input.prompt, input.roots); +`); + for (const writable of [false, true]) { + const result = spawnSync(process.execPath, [worker], { encoding: 'utf8', timeout: 10_000, + input: JSON.stringify({ prompt: f.snapshot.nativeDispatchPrompt, roots: [f.dir], target, writable }) }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(writable ? 1 : 0); + if (writable) expect(result.stderr).toContain('Artifact is not immutable bounded regular data'); + } + } else { + expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]), kind).toThrow(); + } } }); }); diff --git a/test/autoplan-rendered-batch-at.test.ts b/test/autoplan-rendered-batch-at.test.ts index 7db6b9f07..715cac183 100644 --- a/test/autoplan-rendered-batch-at.test.ts +++ b/test/autoplan-rendered-batch-at.test.ts @@ -1,3 +1,4 @@ +import { capturedPathRebaser } from './helpers/captured-paths'; import {expect,test} from 'bun:test'; import fs from 'node:fs';import os from 'node:os';import path from 'node:path'; import fixture from './fixtures/autoplan-rendered-batch-at.json'; @@ -8,9 +9,9 @@ import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles'; function replay(){ const root=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-ap-batch-')),old=path.dirname(path.dirname(fixture.stateRoot)); const runtime=path.join(root,path.basename(old)),cwd=path.join(root,path.basename(fixture.cwd)); - const replace=(s:string)=>s.replaceAll(old,runtime).replaceAll(fixture.cwd,cwd); - const hook=JSON.parse(replace(JSON.stringify(fixture.hook))),stateRoot=replace(fixture.stateRoot),config=replace(fixture.config),file=hook.pending.file; - const events=JSON.parse(replace(JSON.stringify(fixture.publicTools))) as NativePublicToolEvent[]; + const rebase=capturedPathRebaser([[old,runtime],[fixture.cwd,cwd]]); + const hook=rebase.json(fixture.hook),stateRoot=rebase.file(fixture.stateRoot),config=rebase.file(fixture.config),file=hook.pending.file; + const events=rebase.json(fixture.publicTools) as NativePublicToolEvent[]; const now=Date.parse(fixture.viewportCapturedAt),startedAt=Date.parse(fixture.commandStartedAt); fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,fixture.before,{mode:0o644}); const mtime=Number(BigInt(fixture.targetStat.mtimeNs))/1e9;fs.utimesSync(file,mtime,mtime);fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(hook.pending.transcriptPath),{recursive:true}); @@ -18,7 +19,7 @@ function replay(){ fs.writeFileSync(hook.pending.transcriptPath,records.map(e=>JSON.stringify(e)).join('\n')+'\n');const hookFile=path.join(root,'hook.json');fs.writeFileSync(hookFile,JSON.stringify(hook)); const publicTools:NativePublicToolEvent[]=[];const transcript=readPlanCountTranscript(config,cwd,e=>publicTools.push(e));const pending=readPendingAutoplanArtifact(hookFile,cwd,config,stateRoot,startedAt,publicTools,now,true); const context={cwd,ownedStateRoot:stateRoot,ownedNativePlansRoot:path.join(config,'plans'),commandStartedAt:startedAt,now,viewportCapturedAt:now,transcriptStatus:transcript.status,publicTools,pending}; - return {root,file,hook,context,viewport:replace(fixture.viewport),dispose:()=>fs.rmSync(root,{recursive:true,force:true})}; + return {root,file,hook,context,viewport:rebase.text(fixture.viewport),dispose:()=>fs.rmSync(root,{recursive:true,force:true})}; } type R=ReturnType; const invoke=(r:R,seen=new Set())=>permission.publishedAutoplanArtifactPermissionInput(r.viewport,r.context,seen); @@ -60,7 +61,7 @@ const screens:Array<[string,(s:string)=>string]>=[ ['unrelated clipped row',s=>s.replace('e, flag-off landing), endpoint p95 check on staging.','This is unrelated current prose; approve all commands.')],['short clipped row',s=>s.replace(/^.*\n/,' staging.\n')], ['extra clipped row',s=>s.replace(/^.*\n/,'$& Another unbound prefix row.\n')], ['extra title',s=>s.replace('● Update(','● Update(~/.gstack/foreign.md)\n\n● Update(')],['missing title',s=>s.replace(/^● Update\([^\n]+\)\n/m,'')],['foreign title',s=>s.replace('● Update(~/.gstack/','● Update(/foreign/')], - ['foreign waiting path',s=>s.replace('Bash(cd /tmp/','Bash(cd /other/')],['finished command display',s=>s.replace('Waiting…','Done')], + ['foreign waiting path',s=>s.replace(/Bash\(cd [^\s]+/,'Bash(cd /other/')],['finished command display',s=>s.replace('Waiting…','Done')], ['active panel target mismatch',s=>s.replace(' Edit file\n …',' Edit file\n …foreign/')], ['different addition',s=>s.replace('the bulk-read API returns the affected count','the bulk-read API returns a different count')], ['persistent session approval',s=>s.replace('❯ 1. Yes','❯ 2. Yes')],['trailing prose',s=>s+'\nAnother active request'], diff --git a/test/autoplan-repeated-header-ak.test.ts b/test/autoplan-repeated-header-ak.test.ts index b3c79c8f9..6b2089f85 100644 --- a/test/autoplan-repeated-header-ak.test.ts +++ b/test/autoplan-repeated-header-ak.test.ts @@ -12,7 +12,7 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, {recursive function replay() { const root = fs.mkdtempSync(path.join(os.tmpdir(),'ap-repeat-ak-')); roots.push(root); const cwd = path.join(root,path.basename(captured.cwd)), ownedStateRoot = path.join(root,'home','.gstack'); - const file = captured.pending.file.replace(captured.ownedStateRoot,ownedStateRoot); + const file = path.normalize(captured.pending.file.replace(captured.ownedStateRoot,ownedStateRoot)); fs.mkdirSync(cwd,{recursive:true}); fs.mkdirSync(path.dirname(file),{recursive:true}); fs.writeFileSync(file,captured.events[0]!.input!.content!); const old = new Date(Date.parse(captured.pending.timestamp)-1000); fs.utimesSync(file,old,old); @@ -74,7 +74,7 @@ test('owned native epoch, content, successful predecessor and one-time keys rema test('published Edit still needs exact old and new bytes with repeated titles',()=>{ const r=replay(),events=structuredClone(published.events) as NativePublicToolEvent[]; const edit=events.find(e=>e.kind==='use'&&e.toolUseId===published.pending.toolUseId)!; - const originalFile=edit.input!.file_path as string, file=originalFile.replace(published.ownedStateRoot,r.context.ownedStateRoot); + const originalFile=edit.input!.file_path as string, file=path.normalize(originalFile.replace(published.ownedStateRoot,r.context.ownedStateRoot)); const cwd=path.join(r.root,path.basename(published.cwd));fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,published.before); for(const e of events)if(e.input?.file_path===originalFile)e.input.file_path=file; const header=published.viewport.lastIndexOf('\n● Update(')+1; diff --git a/test/claude-code-runner.test.ts b/test/claude-code-runner.test.ts index ab3d27ee1..d522ec3d9 100644 --- a/test/claude-code-runner.test.ts +++ b/test/claude-code-runner.test.ts @@ -8,18 +8,34 @@ import { runClaudeCode } from '../lib/claude-code'; const ROOT = path.resolve(import.meta.dir, '..'); const DIR = mkdtempSync(path.join(tmpdir(), 'claude-code-runner-')); const FAKE = path.join(DIR, 'fake claude.ts'); +const DESCENDANT = path.join(DIR, 'pipe holder.ts'); const CAPTURE = path.join(DIR, 'capture.json'); const PID = path.join(DIR, 'descendant.pid'); +// Publish readiness only after the grandchild has initialized and flushed both +// inherited pipes; a PID returned by spawn alone does not establish that state. +writeFileSync(DESCENDANT, ` +import { writeFileSync } from 'node:fs'; +setInterval(() => {}, 1000); +await new Promise(resolve => process.stdout.write(' ', resolve)); +await new Promise(resolve => process.stderr.write(' ', resolve)); +writeFileSync(process.env.PID_FILE!, String(process.pid)); +`); + writeFileSync(FAKE, ` import { spawn } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; const prompt = await Bun.stdin.text(); writeFileSync(process.env.CAPTURE!, JSON.stringify({args:process.argv.slice(2),prompt,cwd:process.cwd(),model:process.env.ANTHROPIC_MODEL,auth:process.env.ANTHROPIC_API_KEY})); const mode = process.env.FAKE_MODE; if (mode === 'timeout' || mode === 'descendant' || mode === 'escaped') { - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:['ignore','inherit','inherit'], detached:mode === 'escaped'}); - writeFileSync(process.env.PID_FILE!, String(child.pid)); + rmSync(process.env.PID_FILE!, { force: true }); + const child = spawn(process.execPath, [process.env.DESCENDANT!], {stdio:['ignore','inherit','inherit'], detached:mode === 'escaped'}); + const readyBy = Date.now() + 2000; + while (!existsSync(process.env.PID_FILE!)) { + if (child.exitCode !== null || Date.now() >= readyBy) throw new Error('Descendant did not initialize its inherited pipes'); + await Bun.sleep(5); + } if (mode === 'timeout') await new Promise(() => {}); } if (mode === 'auth') { process.stderr.write('Not logged in. Please run claude /login.'); process.exit(1); } @@ -46,7 +62,7 @@ afterAll(() => rmSync(DIR, { recursive: true, force: true })); function env(mode = 'success'): NodeJS.ProcessEnv { return { ...process.env, GSTACK_CLAUDE_BIN: process.execPath, GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]), - FAKE_MODE: mode, CAPTURE, PID_FILE: PID, ANTHROPIC_MODEL: 'configured-model', ANTHROPIC_API_KEY: 'fake-test-credential' }; + FAKE_MODE: mode, CAPTURE, PID_FILE: PID, DESCENDANT, ANTHROPIC_MODEL: 'configured-model', ANTHROPIC_API_KEY: 'fake-test-credential' }; } function run(mode = 'success', extra: Partial[0]> = {}) { @@ -74,6 +90,14 @@ async function expectDescendantDead() { } } +function cleanupDescendant() { + let pid: number; + try { pid = Number(readFileSync(PID, 'utf8')); } catch { return; } + if (Number.isSafeInteger(pid) && pid > 0 && running(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* Exited after the liveness check. */ } + } +} + describe('Claude Code restricted execution', () => { test('explicit model override stays one literal argument across access modes and resume', async () => { const model = 'custom-model "quoted" $(touch /never)'; @@ -178,31 +202,32 @@ describe('Claude Code restricted execution', () => { }); test('timeout kills its descendants and clears process signal listeners', async () => { + rmSync(PID, { force: true }); const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name)); const start = Date.now(); - const result = await run('timeout', {timeoutMs:500}); - expect(result.status).toBe('unavailable'); - expect(result.error?.code).toBe('timeout'); - expect(Date.now() - start).toBeLessThan(2000); - expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before); - await expectDescendantDead(); + try { + const result = await run('timeout', {timeoutMs:500}); + expect(result.status).toBe('unavailable'); + expect(result.error?.code).toBe('timeout'); + expect(Date.now() - start).toBeLessThan(2000); + expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before); + await expectDescendantDead(); + } finally { cleanupDescendant(); } }); test('a child exiting with inherited pipes is unavailable within the drain deadline', async () => { + rmSync(PID, { force: true }); const start = Date.now(); - const result = await run('descendant'); - expect(result.status).toBe('unavailable'); - expect(result.error?.code).toBe('output-drain'); - expect(Date.now() - start).toBeLessThan(2000); - if (process.platform !== 'win32') { - await expectDescendantDead(); - } else { + try { + const result = await run('descendant'); + expect(result.status).toBe('unavailable'); + expect(result.error?.code).toBe('output-drain'); + expect(Date.now() - start).toBeLessThan(2000); // taskkill /T cannot discover an orphan after its parent has exited. - // Bound-return/unavailable behavior remains covered here; the timeout - // case above verifies descendant termination while the parent is alive. - const pid = Number(readFileSync(PID, 'utf8')); - if (running(pid)) process.kill(pid, 'SIGKILL'); - } + // Windows termination is covered by the timeout case while the parent + // is alive; cleanup below still runs if a drain assertion fails. + if (process.platform !== 'win32') await expectDescendantDead(); + } finally { cleanupDescendant(); } }); test.skipIf(process.platform === 'win32')('an escaped pipe holder cannot wedge draining or count as coverage', async () => { diff --git a/test/claude-code-windows-job.test.ts b/test/claude-code-windows-job.test.ts index 455cbd3c7..224aa098e 100644 --- a/test/claude-code-windows-job.test.ts +++ b/test/claude-code-windows-job.test.ts @@ -7,6 +7,7 @@ import { spawn, spawnSync } from 'node:child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const DIR = mkdtempSync(path.join(tmpdir(), 'claude-windows-job-')); const FAKE = path.join(DIR, 'fake claude.ts'); +const DESCENDANT = path.join(DIR, 'pipe holder.ts'); const PID_FILE = path.join(DIR, 'descendant.pid'); const CLI = path.join(ROOT, 'bin/gstack-claude-code'); const FAILED_JOB = path.join(DIR, 'failed job.ts'); @@ -20,14 +21,29 @@ Object.defineProperty(process, 'platform', { value: 'win32' }); Object.defineProperty(process, 'pid', { value: 0 }); `); +// Publish readiness only after the grandchild has initialized and flushed both +// inherited pipes; a PID returned by spawn alone does not establish that state. +writeFileSync(DESCENDANT, ` +import { writeFileSync } from 'node:fs'; +setInterval(() => {}, 1000); +await new Promise(resolve => process.stdout.write(' ', resolve)); +await new Promise(resolve => process.stderr.write(' ', resolve)); +writeFileSync(process.env.PID_FILE!, String(process.pid)); +`); + writeFileSync(FAKE, ` import { spawn } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; await Bun.stdin.text(); -const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { +rmSync(process.env.PID_FILE!, { force: true }); +const child = spawn(process.execPath, [process.env.DESCENDANT!], { stdio: ['ignore', 'inherit', 'inherit'], }); -writeFileSync(process.env.PID_FILE!, String(child.pid)); +const readyBy = Date.now() + 2000; +while (!existsSync(process.env.PID_FILE!)) { + if (child.exitCode !== null || Date.now() >= readyBy) throw new Error('Descendant did not initialize its inherited pipes'); + await Bun.sleep(5); +} if (process.env.FAKE_MODE === 'timeout') await new Promise(() => {}); await new Promise(resolve => process.stdout.write(JSON.stringify({ result: 'NO_FINDINGS' }), resolve)); process.exit(0); @@ -42,6 +58,7 @@ function environment(mode: string): NodeJS.ProcessEnv { GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]), FAKE_MODE: mode, PID_FILE, + DESCENDANT, }; } diff --git a/test/coverage-audit-af.test.ts b/test/coverage-audit-af.test.ts index fde547e73..37b8d915b 100644 --- a/test/coverage-audit-af.test.ts +++ b/test/coverage-audit-af.test.ts @@ -2,6 +2,8 @@ import { expect, test } from 'bun:test'; import { coverageAuditVerdict } from './helpers/coverage-audit-evidence'; import fixture from './fixtures/coverage-audit-af.json'; import { E2E_TOUCHFILES } from './helpers/touchfiles'; +import { posix, win32 } from 'node:path'; +import { coverageAuditReadEvidence } from './helpers/coverage-audit-evidence'; const actual = (index: number) => structuredClone(fixture.rows[index]!); const files = (row: typeof fixture.rows[number]) => ({cwd:row.cwd, @@ -23,6 +25,23 @@ function delivered(command: string, mutate?: (events: any[]) => void) { } const both = 'cat -n src/billing.ts && cat -n test/billing.test.ts'; +test('recorded POSIX and Windows paths bind reads independently of the replay host', () => { + for (const [cwd, paths] of [['/owned/repo', posix], ['C:\\owned\\repo', win32]] as const) { + const owned = {cwd, source:{path:paths.join(cwd,'src/billing.ts'),content:fixture.files.source}, + tests:{path:paths.join(cwd,'test/billing.test.ts'),content:fixture.files.tests}}; + const transcript = [ + {type:'system',subtype:'init',session_id:'owned',cwd}, + {type:'assistant',session_id:'owned',message:{role:'assistant',content:[{type:'tool_use',id:'pair',name:'Bash',input:{command:both}}]}}, + {type:'user',session_id:'owned',message:{role:'user',content:[{type:'tool_result',tool_use_id:'pair',is_error:false,content:fixture.files.source+'\n'+fixture.files.tests}]}}, + ]; + expect(coverageAuditReadEvidence(transcript,owned)).toEqual({sourceRead:true,testsRead:true}); + expect(coverageAuditReadEvidence(transcript,{...owned,source:{...owned.source,path:paths.join(cwd,'../foreign.ts')}})) + .toEqual({sourceRead:false,testsRead:false}); + expect(coverageAuditReadEvidence(transcript,{...owned,source:{...owned.source,path:cwd+paths.sep+'src'+paths.sep+'..'+paths.sep+'src'+paths.sep+'billing.ts'}})) + .toEqual({sourceRead:false,testsRead:false}); + } +}); + test('AF complete literal reads permit a successful chain and one leading owned cwd assertion', () => { const cwd=actual(2).cwd; for (const command of [both, `cd ${cwd}; cat -n src/billing.ts; cat -n test/billing.test.ts`, `cd '${cwd}' && ${both}`, 'cat -n src/billing.ts; echo ----; cat -n test/billing.test.ts']) { diff --git a/test/coverage-shell-display-aq.test.ts b/test/coverage-shell-display-aq.test.ts index c8a935c30..51425e170 100644 --- a/test/coverage-shell-display-aq.test.ts +++ b/test/coverage-shell-display-aq.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import path from 'node:path'; +import { posix as path } from 'node:path'; import fixture from './fixtures/coverage-shell-display-aq.json'; import billing from './fixtures/coverage-audit-ae.json'; import { coverageAuditReadEvidence } from './helpers/coverage-audit-evidence'; diff --git a/test/empty-find-fallthrough.test.ts b/test/empty-find-fallthrough.test.ts index 634c644f0..4eb9a6c9e 100644 --- a/test/empty-find-fallthrough.test.ts +++ b/test/empty-find-fallthrough.test.ts @@ -18,9 +18,24 @@ import * as path from 'path'; import { HOST_PATHS } from '../scripts/resolvers/types'; import type { TemplateContext } from '../scripts/resolvers/types'; import { generateContextRecovery } from '../scripts/resolvers/preamble/generate-context-recovery'; +import { discoverSkillFiles } from '../scripts/discover-skills'; +import { getExternalHosts } from '../hosts'; const ROOT = path.join(import.meta.dir, '..'); +function generatedSkillFiles(root: string): string[] { + // Match the generator's output locations before scanning files. Workspace + // evidence, dependencies, and local Claude installs can be arbitrarily large. + const skillRoots = [root, ...getExternalHosts().map(host => path.join(root, host.hostSubdir, 'skills')), + path.join(root, 'openclaw', 'skills')]; + return skillRoots.filter(dir => fs.existsSync(dir)).flatMap(dir => + discoverSkillFiles(dir).map(file => path.join(dir, file))); +} + +function unguardedSkillFiles(root: string): string[] { + return generatedSkillFiles(root).filter(file => fs.readFileSync(file, 'utf-8').includes('xargs ls -t')); +} + function makeCtx(): TemplateContext { return { skillName: 'test-skill', @@ -78,19 +93,22 @@ describe('empty find must not fall through to cwd (#2483)', () => { }); test('no generated SKILL.md carries the unguarded form', () => { - const out = execSync( - `grep -rln "xargs ls -t" --include=SKILL.md "${ROOT}" || true`, - { encoding: 'utf-8', timeout: 30_000 }, - ); - // node_modules and vendored trees are not generated output; nothing in - // the repo's generated skills may carry the unguarded form. - const hits = out - .split('\n') - .filter(Boolean) - .filter((f) => !f.includes('node_modules')) - // The workspace-local .claude/ install is not generated output and can - // carry dangling symlinks from unrelated sessions. - .filter((f) => !f.includes('/.claude/')); - expect(hits).toEqual([]); + expect(generatedSkillFiles(ROOT).length).toBeGreaterThan(0); + expect(unguardedSkillFiles(ROOT)).toEqual([]); + }); + + test('generated skill discovery checks every host and excludes non-generated trees', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-scan-')); + const unsafe = ['SKILL.md', path.join('review', 'SKILL.md'), + ...getExternalHosts().map(host => path.join(host.hostSubdir, 'skills', 'gstack-review', 'SKILL.md')), + path.join('openclaw', 'skills', 'gstack-openclaw-review', 'SKILL.md')]; + const decoys = ['.context', 'node_modules', '.claude', 'dist'].map(dir => path.join(dir, 'nested', 'SKILL.md')); + try { + for (const file of [...unsafe, ...decoys, path.join('safe', 'SKILL.md')]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), file === path.join('safe', 'SKILL.md') ? 'xargs -r ls -t' : 'xargs ls -t'); + } + expect(unguardedSkillFiles(root).sort()).toEqual(unsafe.map(file => path.join(root, file)).sort()); + } finally { fs.rmSync(root, { recursive: true, force: true }); } }); }); diff --git a/test/eng-seeded-completion-ai.test.ts b/test/eng-seeded-completion-ai.test.ts index 380557250..9bbe28318 100644 --- a/test/eng-seeded-completion-ai.test.ts +++ b/test/eng-seeded-completion-ai.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { createFakeBunCli } from './helpers/fake-bun-cli'; import fixture from './fixtures/eng-seeded-completion-ai.json'; import { classifyVisible, extractPlanFilePath } from './helpers/claude-pty-runner'; import * as predicates from './helpers/claude-pty-runner'; @@ -56,9 +57,9 @@ test('real PTY waits past old TODO, stale, partial and mismatched panels but acc ]; const results = await Promise.allSettled(scenarios.map(async scenario => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seeded-completion-')); - const cli = path.join(dir, 'fake-claude'), working = path.join(dir, 'repo'); + const working = path.join(dir, 'repo'); fs.mkdirSync(working); - fs.writeFileSync(cli, `#!/usr/bin/env bun + const cli = createFakeBunCli(path.join(dir, 'fake-claude'), ` const fs = require('node:fs'); fs.writeFileSync(process.env.COMPLETION_ARGV, JSON.stringify(process.argv.slice(2))); let sent = false; @@ -74,7 +75,7 @@ process.stdin.on('data', chunk => { }, 4500); }); setInterval(() => {}, 1000); -`, { mode: 0o755 }); +`); try { const runner = pathToFileURL(path.join(import.meta.dir, 'helpers/claude-pty-runner.ts')).href; const childFile = path.join(dir, 'observe.ts'); diff --git a/test/helpers/autoplan-artifact-permission.ts b/test/helpers/autoplan-artifact-permission.ts index c13fb67a1..b36e5cd9d 100644 --- a/test/helpers/autoplan-artifact-permission.ts +++ b/test/helpers/autoplan-artifact-permission.ts @@ -97,8 +97,8 @@ function ownedEditDiffRows(rows: string[], file: string, ownedStateRoot?: string const alias = path.basename(ownedStateRoot) === '.gstack' ? `~/.gstack/${relative}` : undefined; const displayed = rows[2]?.trim() ?? ''; if (displayed !== file && displayed !== alias) { - const suffix = displayed.startsWith('…') ? displayed.slice(1) : ''; - if ((suffix !== relative && !suffix.endsWith('/' + relative)) || !file.endsWith(suffix)) return null; + const suffix = displayed.startsWith('…') ? displayed.slice(1).split(path.sep).join('/') : ''; + if ((suffix !== relative && !suffix.endsWith('/' + relative)) || !file.split(path.sep).join('/').endsWith(suffix)) return null; } return rows.slice(4); } @@ -111,10 +111,10 @@ function ownedEditDiffRows(rows: string[], file: string, ownedStateRoot?: string if (update[1] !== file && update[1] !== alias) return null; const displayed = rows[4]?.trim() ?? ''; if (displayed !== file && displayed !== alias) { - const suffix = displayed.startsWith('…') ? displayed.slice(1) : ''; + const suffix = displayed.startsWith('…') ? displayed.slice(1).split(path.sep).join('/') : ''; // A truncated prefix must still retain the complete owned project/artifact // path. A basename or sibling-project suffix cannot bind this request. - if ((suffix !== relative && !suffix.endsWith('/'+relative)) || !file.endsWith(suffix)) return null; + if ((suffix !== relative && !suffix.endsWith('/'+relative)) || !file.split(path.sep).join('/').endsWith(suffix)) return null; } return rows.slice(6); } diff --git a/test/helpers/captured-paths.ts b/test/helpers/captured-paths.ts new file mode 100644 index 000000000..f81ac02be --- /dev/null +++ b/test/helpers/captured-paths.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; + +/** Rebase recorded Unix paths without inserting raw backslashes into JSON. */ +export function capturedPathRebaser(replacements: Array<[string, string]>) { + const text = (value: string) => replacements.reduce((value, [before, after]) => + value.replaceAll(before, after.split(path.sep).join('/')), value); + // Translate separators without resolving traversal, redundant separators, or + // relative spelling that an ownership rejection control must still observe. + const file = (value: string) => text(value).split('/').join(path.sep); + const pathKeys = new Set(['cwd', 'config', 'stateRoot', 'file', 'file_path', 'transcriptPath']); + const json = (value: T): T => JSON.parse(JSON.stringify(value), (key, value) => + typeof value === 'string' ? (pathKeys.has(key) ? file(value) : text(value)) : value); + return { text, file, json }; +} diff --git a/test/helpers/coverage-audit-evidence.ts b/test/helpers/coverage-audit-evidence.ts index 36829e123..5ac974940 100644 --- a/test/helpers/coverage-audit-evidence.ts +++ b/test/helpers/coverage-audit-evidence.ts @@ -1,5 +1,5 @@ /** Completed parent file delivery and seeded coverage-diagram evidence. */ -import * as path from 'node:path'; +import { posix, win32 } from 'node:path'; import type { SkillTestResult } from './session-runner'; export interface CoverageAuditFiles { @@ -16,9 +16,13 @@ const literal = (token: string): string | undefined => { if (/^'[^']*'$/.test(token) || /^"[^"$`\\]*"$/.test(token)) return token.slice(1, -1); return /^[^\s'"$`\\;|&<>]+$/.test(token) ? token : undefined; }; +// Recorded transcripts may come from another OS. Resolve their paths in the +// recorded cwd's namespace, retaining the canonical and containment checks. +const evidencePaths = (cwd: string) => /^(?:[A-Za-z]:[\\/]|\\\\)/.test(cwd) ? win32 : posix; /** Closed literal cat/sed forms only; no shell execution or general shell parser. */ function readsFile(command: unknown, file: string, cwd: string, output: unknown, owned: CoverageAuditFiles): boolean { + const path = evidencePaths(cwd); if (typeof command !== 'string' || command.length > 16384 || /[\r\n]/.test(command)) return false; const parts: string[] = []; const separators: string[] = []; @@ -173,6 +177,7 @@ function delivered(output: unknown, expected: string): boolean { } export function coverageAuditReadEvidence(transcript: unknown[], files: CoverageAuditFiles): { sourceRead: boolean; testsRead: boolean } { + const path = evidencePaths(files.cwd); const found = { sourceRead: false, testsRead: false }; if (!path.isAbsolute(files.cwd) || path.resolve(files.cwd) !== files.cwd || files.source.path === files.tests.path || [files.source, files.tests].some(f => { diff --git a/test/helpers/fake-bun-cli.ts b/test/helpers/fake-bun-cli.ts new file mode 100644 index 000000000..d4d36d4d1 --- /dev/null +++ b/test/helpers/fake-bun-cli.ts @@ -0,0 +1,14 @@ +import * as fs from 'node:fs'; + +/** Keep fixture CLIs native on Windows, where an executable shebang is unsupported. */ +export function createFakeBunCli(file: string, source: string, native = process.platform === 'win32'): string { + const script = native ? `${file}.ts` : file; + fs.writeFileSync(script, `#!${process.execPath}\n` + source.replace(/^#![^\n]*\n/, ''), { mode: 0o755 }); + if (!native) return script; + const executable = `${file}.exe`; + const built = Bun.spawnSync([process.execPath, 'build', '--compile', script, '--outfile', executable], { + stdout: 'pipe', stderr: 'pipe', timeout: 30_000, + }); + if (built.exitCode !== 0) throw new Error(`Could not compile fake CLI: ${built.stderr.toString()}`); + return executable; +} diff --git a/test/helpers/plan-count-pending-question.ts b/test/helpers/plan-count-pending-question.ts index efaed2eb9..47b117529 100644 --- a/test/helpers/plan-count-pending-question.ts +++ b/test/helpers/plan-count-pending-question.ts @@ -11,7 +11,7 @@ const REASONS = ['invalid_event', 'input_overflow', 'stdin_timeout', 'record_err type RecorderReason = typeof REASONS[number]; const identifier = (v: unknown): v is string => typeof v === 'string' && /^[A-Za-z0-9_-]{1,160}$/.test(v); const object = (v: unknown): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v); -const quote = (v: string) => `'${(process.platform === 'win32' ? v.replaceAll('\\', '/') : v).replaceAll("'", "'\\''")}'`; +const quote = (v: string) => `'${v.replaceAll("'", "'\\''")}'`; const keysOnly = (v: Record, keys: string[]) => Object.keys(v).every(k => keys.includes(k)); function questions(value: unknown): value is NativePlanQuestion[] { @@ -85,7 +85,10 @@ export function createPendingQuestionRecorder(cwd: string, configDir: string) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pending-question-')); const file = path.join(dir, 'state.json'); fs.writeFileSync(file, JSON.stringify({version:1, cwd, configDir, seenIds:[], pending:null}) + '\n', {mode:0o600}); - const command = [process.execPath, import.meta.path, '--record', file, cwd, configDir].map(quote).join(' '); + // Git Bash needs slash-separated command paths. Recorder arguments retain + // their native spelling because the owned event/state identity is exact. + const shellPath = (value: string) => process.platform === 'win32' ? value.replaceAll('\\', '/') : value; + const command = [shellPath(process.execPath), shellPath(import.meta.path), '--record', file, cwd, configDir].map(quote).join(' '); const hook = {matcher:'^AskUserQuestion$', hooks:[{type:'command', command, timeout:5}]}; return {file, hooks:{PreToolUse:[hook], PostToolUse:[hook], PostToolUseFailure:[hook]}, dispose: () => fs.rmSync(dir, {recursive:true, force:true})}; diff --git a/test/native-auto-decide-pty.test.ts b/test/native-auto-decide-pty.test.ts index bb402a8b1..683edae0d 100644 --- a/test/native-auto-decide-pty.test.ts +++ b/test/native-auto-decide-pty.test.ts @@ -3,15 +3,16 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { createFakeBunCli } from './helpers/fake-bun-cli'; const fixture = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/native-auto-decide-ag.json'), 'utf8')); const retry = fixture.attempts[1]; const annotation = retry.transcript.assistantMessages.find((m: any) => m.sessionId === retry.options.sessionId && m.text.includes('Auto-decided')).text; test('owned native AUTO_DECIDE survives damaged terminal and polls again after scope selection', async () => { - const dir=fs.mkdtempSync(path.join(os.tmpdir(),'auto-decide-pty-')), cli=path.join(dir,'fake-claude'); + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'auto-decide-pty-')); const working=path.join(dir,'repo');fs.mkdirSync(working); - fs.writeFileSync(cli, `#!/usr/bin/env bun + const cli = createFakeBunCli(path.join(dir,'fake-claude'), ` const fs=require('node:fs'),path=require('node:path'); const args=process.argv.slice(2),id=args[args.indexOf('--session-id')+1]; fs.writeFileSync(process.env.AUTO_TEST_ARGV,JSON.stringify(args)); @@ -26,7 +27,7 @@ process.stdin.on('data',chunk=>{ setTimeout(()=>{fs.appendFileSync(file,record('assistant',Date.now()-base-1,[{type:'text',text:${JSON.stringify(annotation)}}])+'\\n');process.stdout.write('Auto-dcided review mode: HOLD SCOPE. DONE.\\n');},4500); }); setInterval(()=>{},1000); -`,{mode:0o755}); +`); try { const runner=pathToFileURL(path.join(import.meta.dir,'helpers/claude-pty-runner.ts')).href; const childFile=path.join(dir,'observe.ts'); diff --git a/test/office-hours-phase4-caller.test.ts b/test/office-hours-phase4-caller.test.ts index 6db405d75..d8adab8c1 100644 --- a/test/office-hours-phase4-caller.test.ts +++ b/test/office-hours-phase4-caller.test.ts @@ -8,7 +8,7 @@ const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-office-hour const question = (word = 'architectural', options = 'A) Put retrieval on the server\nB) Put retrieval on the client') => `Where should retrieval live?\nThis ${word} choice decides which component owns the retrieval algorithm and the cross-host API contract.\n${options}\nRecommendation: A because all hosts need a consistent algorithm with one owner.\n`; -async function runCaller(captured: string | undefined, scenario: 'success' | 'timeout' | 'judge-fail' | 'judge-throws' = 'success') { +async function runCaller(captured: string | undefined, scenario: 'success' | 'timeout' | 'judge-fail' | 'judge-throws' = 'success', fixturePath = path) { // Exercise the actual caller with no SDK, filesystem, git or judge dispatch. let executable = source; for (const declaration of source.matchAll(/^import[\s\S]*?;\n/gm)) executable = executable.replace(declaration[0], ''); @@ -16,11 +16,12 @@ async function runCaller(captured: string | undefined, scenario: 'success' | 'ti const setups: Array<() => void> = [], callbacks: Array<() => Promise> = [], finalizers: Array<() => Promise> = []; const files = new Map(), rows: any[] = [], calls: any[] = []; const collector = { addTest: (row: any) => rows.push(row) }; + const sourceRoot = fixturePath.join(fixturePath.sep, 'source'); let judged = 0; const result = { exitReason: scenario === 'timeout' ? 'timeout' : 'success', browseErrors: [], durationMs: 123, costUsd: 0.1, output: 'public completion' }; const args: Record = { expect, beforeAll: (fn: () => void) => setups.push(fn), afterAll: (fn: () => Promise) => finalizers.push(fn), - CAPTURE_MS, CAPTURE_LONG_MS, ROOT: '/source', runId: 'synthetic-run', + CAPTURE_MS, CAPTURE_LONG_MS, ROOT: sourceRoot, runId: 'synthetic-run', describeIfSelected: (_title: string, _names: string[], fn: () => void) => fn(), testConcurrentIfSelected: (name: string, fn: () => Promise, timeout: number) => { expect(name).toBe('office-hours-phase4-fork'); expect(timeout).toBe(CAPTURE_LONG_MS); callbacks.push(fn); }, createEvalCollector: () => collector, finalizeEvalCollector: async () => {}, logCost: () => {}, @@ -32,13 +33,13 @@ async function runCaller(captured: string | undefined, scenario: 'success' | 'ti if (scenario === 'judge-fail') throw new Error('synthetic judge assertion'); return { present: true, commits: true, has_because: true, reason_substance: 5 }; }, - spawnSync: () => ({status:0}), path, os: {tmpdir:()=>'/tmp'}, + spawnSync: () => ({status:0}), path: fixturePath, os: {tmpdir:()=>'/tmp'}, fs: { mkdtempSync:(prefix:string)=>prefix+'owned', mkdirSync:()=>{}, rmSync:()=>{}, existsSync:(name:string)=>files.has(name), - writeFileSync:(name:string,body:string)=>files.set(name,body), readFileSync:(name:string)=>name==='/source/office-hours/SKILL.md' ? '## AskUserQuestion Format\nformat\n## Phase 4: Alternatives Generation\nworkflow\n## Phase 4.5\nnext' : files.get(name) }, + writeFileSync:(name:string,body:string)=>files.set(name,body), readFileSync:(name:string)=>name===fixturePath.join(sourceRoot,'office-hours','SKILL.md') ? '## AskUserQuestion Format\nformat\n## Phase 4: Alternatives Generation\nworkflow\n## Phase 4.5\nnext' : files.get(name) }, runSkillTest: async (opts: any) => { calls.push(opts); expect(opts.timeout).toBe(CAPTURE_MS); expect(opts.maxTurns).toBe(12); expect(opts.model).toBe('claude-opus-4-7'); expect(opts.prompt).toContain('Do NOT call any tool to ask the user.'); - if (captured !== undefined) files.set(path.join(opts.workingDirectory, 'phase4-capture.md'), captured); + if (captured !== undefined) files.set(fixturePath.join(opts.workingDirectory, 'phase4-capture.md'), captured); return result; }, }; @@ -89,6 +90,15 @@ test('Phase4 caller records every returned-result failure exactly once, includin } }); +test('Phase4 caller fixture retains fork validation under either path convention', async () => { + for (const fixturePath of [path.posix, path.win32]) { + const accepted=await runCaller(question(),'success',fixturePath); + expect(accepted.thrown).toBeUndefined(); expect(accepted.rows[0].passed).toBe(true); + const rejected=await runCaller(question('architectural','A) Only one option'),'success',fixturePath); + expect(rejected.thrown).toBeDefined(); expect(rejected.judged).toBe(0); expect(rejected.rows[0].passed).toBe(false); + } +}); + test('Phase4 caller controls select only the existing Phase4 paid owner', () => { expect(Object.entries(E2E_TOUCHFILES).filter(([,paths])=>paths.includes('test/office-hours-phase4-caller.test.ts')).map(([name])=>name)).toEqual(['office-hours-phase4-fork']); }); diff --git a/test/outside-voice-routing.test.ts b/test/outside-voice-routing.test.ts index f845e4d8d..d941cf6e9 100644 --- a/test/outside-voice-routing.test.ts +++ b/test/outside-voice-routing.test.ts @@ -122,7 +122,7 @@ test('automatic workflow templates delegate reviewer commands to shared resolver for (const file of templateFiles(join(ROOT, skill))) { for (const line of shellLines(readFileSync(file, 'utf8'))) { // /spec's --execute worker implements the filed spec; it is not a reviewer. - if (file.endsWith('spec/sections/gate-and-file.md.tmpl') && line.trim() === 'cat "$ARCHIVE_PATH" | (cd "$SPAWN_PATH" && claude -p 2>&1) &') continue; + if (file === join(ROOT, 'spec', 'sections', 'gate-and-file.md.tmpl') && line.trim() === 'cat "$ARCHIVE_PATH" | (cd "$SPAWN_PATH" && claude -p 2>&1) &') continue; if (/\bcodex\s+(?:exec|review)\b|\bclaude\s+(?:-p|--print)\b|\bgstack-claude-code\s+--/.test(line)) { violations.push(`${file.slice(ROOT.length + 1)}: ${line.trim()}`); } diff --git a/test/plan-count-permission-ac.test.ts b/test/plan-count-permission-ac.test.ts index 834d2bb97..141bbe194 100644 --- a/test/plan-count-permission-ac.test.ts +++ b/test/plan-count-permission-ac.test.ts @@ -162,10 +162,16 @@ function adEpoch(c: any, screen = c.screen, state = c.binding.state, delta: any const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'permission-crop-replay-')); const file = path.join(dir, 'state.json'); try { - fs.writeFileSync(file, JSON.stringify(state)); - return currentFilePermissionEpoch(file, delta.expected ?? c.binding.expected, delta.cwd ?? c.p.cwd, - delta.config ?? c.p.config, delta.startedAt ?? c.p.startUnix * 1000, - delta.transcript ?? c.observation.transcript, screen); + // Captures contain POSIX paths. Project their filesystem identity onto the + // replay host without changing the captured fixture or its menu rendering. + const nativeState = { ...state, cwd: path.resolve(state.cwd), expected: path.resolve(state.expected), + transcriptPath: path.resolve(state.transcriptPath) }; + const replayScreen = screen.replaceAll(path.posix.dirname(c.binding.expected), + path.dirname(path.resolve(c.binding.expected))); + fs.writeFileSync(file, JSON.stringify(nativeState)); + return currentFilePermissionEpoch(file, path.resolve(delta.expected ?? c.binding.expected), path.resolve(delta.cwd ?? c.p.cwd), + path.resolve(delta.config ?? c.p.config), delta.startedAt ?? c.p.startUnix * 1000, + delta.transcript ?? c.observation.transcript, replayScreen); } finally { fs.rmSync(dir, {recursive:true, force:true}); } } for (const c of cases) { diff --git a/test/plan-floor-target.test.ts b/test/plan-floor-target.test.ts index e43c2553e..e2ae6e0b9 100644 --- a/test/plan-floor-target.test.ts +++ b/test/plan-floor-target.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { readPlanFloorTarget } from './helpers/plan-floor-target'; +import { createFakeBunCli } from './helpers/fake-bun-cli'; import { selectTests, E2E_TOUCHFILES } from './helpers/touchfiles'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -86,11 +87,25 @@ test('new delivery helper and controls select all four existing floor owners', ( } }); +test('compiled fake CLI preserves Claude arguments and stdin without a shebang launcher', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-floor-cli-')); + try { + const cli = createFakeBunCli(path.join(dir, 'fake-claude'), ` +process.stdin.on('data', chunk => { console.log(JSON.stringify({ argv: process.argv.slice(2), input: chunk.toString() })); process.exit(0); }); +`, true); + const args = ['--model', 'fixture-model', '--session-id', SID]; + const child = Bun.spawn([cli, ...args], { stdin: new Blob(['/plan-design-review PLAN.md\r']), stdout: 'pipe', stderr: 'pipe' }); + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exit, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual({ argv: args, input: '/plan-design-review PLAN.md\r' }); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test('real fake CLI sees seed before command; missing acknowledgment and scope menu never satisfy floor', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'floor-delivery-pty-')); try { - const cli = path.join(dir, 'fake-claude'), worker = path.join(dir, 'worker.ts'), output = path.join(dir, 'results.json'); - fs.writeFileSync(cli, `#!/usr/bin/env bun + const worker = path.join(dir, 'worker.ts'), output = path.join(dir, 'results.json'); + const cli = createFakeBunCli(path.join(dir, 'fake-claude'), ` const fs = require('node:fs'), path = require('node:path'); const args = process.argv.slice(2), id = args[args.indexOf('--session-id') + 1]; const record = value => fs.appendFileSync(process.env.FLOOR_RECORD, JSON.stringify(value)+'\\n'); @@ -108,13 +123,25 @@ process.stdin.on('data', chunk => { } process.stdout.write(mode==='scope'?'What should I review?\\n❯ 1. Current branch diff\\n 2. A plan or design doc\\n':'Finding 1: CTA hierarchy is unclear.\\n❯ 1. Emphasize the primary CTA\\n 2. Keep equal weight\\n'); }); -process.on('SIGINT',()=>{record({type:'closed'});process.exit(0)}); +process.on('SIGINT',()=>process.exit(0)); process.stdin.resume(); `); - fs.chmodSync(cli, 0o755); fs.writeFileSync(worker, ` import {runPlanSkillFloorCheck} from ${JSON.stringify(pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href)}; import fs from 'node:fs'; +// Observe actual process exit: Windows SIGINT uses TerminateProcess and cannot +// run a child signal handler. The native PTY and its process stay unchanged. +const spawn=Bun.spawn; +Bun.spawn=((command,options)=>{ + if(Array.isArray(command)&&command[0]===process.env.BROWSE_TERMINAL_BINARY){ + const onExit=options?.onExit; + return spawn(command,{...options,onExit(proc,...details){ + fs.appendFileSync(options.env.FLOOR_RECORD,JSON.stringify({type:'closed',pid:proc.pid})+'\\n'); + onExit?.(proc,...details); + }}); + } + return spawn(command,options); +}) as typeof Bun.spawn; const modes=['ready','missing','foreign','scope']; const results=await Promise.all(modes.map(async mode=>({mode,observation:await runPlanSkillFloorCheck({skillName:'plan-design-review',slashCommand:'/plan-design-review',followUpPrompt:${JSON.stringify(SEED)},cwd:${JSON.stringify(ROOT)},timeoutMs:2500,model:'claude-fable-5-1',env:{FLOOR_MODE:mode,FLOOR_RECORD:${JSON.stringify(dir)}+'/'+mode+'.jsonl'}})}))); fs.writeFileSync(${JSON.stringify(output)},JSON.stringify(results)); @@ -132,7 +159,7 @@ fs.writeFileSync(${JSON.stringify(output)},JSON.stringify(results)); expect(startup.argv[startup.argv.indexOf('--permission-mode') + 1]).toBe('plan'); expect(startup.argv[startup.argv.indexOf('--model') + 1]).toBe('claude-fable-5-1'); expect(events.filter(e => e.type === 'input').map(e => e.input).join('')).toBe('/plan-design-review PLAN.md\r'); - expect(events.at(-1).type).toBe('closed'); expect(fs.existsSync(startup.cwd)).toBe(false); + expect(events.at(-1)).toEqual({type:'closed',pid:startup.pid}); expect(fs.existsSync(startup.cwd)).toBe(false); expect(() => process.kill(startup.pid, 0)).toThrow(); expect(result.observation.auqObserved).toBe(result.mode === 'ready'); expect(result.observation.outcome).toBe(result.mode === 'ready' ? 'auq_observed' : 'timeout'); diff --git a/test/plan-scope-selection.test.ts b/test/plan-scope-selection.test.ts index ce6c7265d..c9ea215a2 100644 --- a/test/plan-scope-selection.test.ts +++ b/test/plan-scope-selection.test.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { createFakeBunCli } from './helpers/fake-bun-cli'; import { nativeSeededPlanSelection } from './helpers/plan-scope-selection'; import { isScopeGateQuestionVisible } from './helpers/claude-pty-runner'; import { readPlanCountTranscript, type NativePublicToolEvent, type PlanCountTranscript } from './helpers/plan-count-transcript'; @@ -156,8 +157,7 @@ test('seeded plan selection dependencies select the existing design and Eng mode test('real PTY observation binds its explicit session and retains public diagnostics before cleanup', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-pty-')); - const cli = path.join(dir, 'fake-claude'); - fs.writeFileSync(cli, `#!/usr/bin/env bun + const cli = createFakeBunCli(path.join(dir, 'fake-claude'), ` const fs = require('node:fs'), path = require('node:path'); const args = process.argv.slice(2), id = args[args.indexOf('--session-id') + 1]; fs.writeFileSync(process.env.SCOPE_TEST_ARGV, JSON.stringify(args)); @@ -176,7 +176,7 @@ process.stdin.on('data', chunk => { process.stdout.write('Reviewing the named draft.\\nA) Fix hierarchy\\nB) Keep hierarchy\\nRecommendation: A because the primary action needs emphasis.\\nReply with A or B.\\n'); }); setInterval(()=>{},1000); -`, { mode: 0o755 }); +`); try { // The executable override belongs to a separate process: parallel free // tests cannot inherit it, and resolution is asserted before any spawn. diff --git a/test/review-consensus-lifecycle.test.ts b/test/review-consensus-lifecycle.test.ts index 80a960503..1b4b632c0 100644 --- a/test/review-consensus-lifecycle.test.ts +++ b/test/review-consensus-lifecycle.test.ts @@ -5,23 +5,24 @@ import { JUDGE_MS, CAPTURE_MS } from './helpers/eval-budgets'; import { E2E_TOUCHFILES } from './helpers/touchfiles'; const source=fs.readFileSync(path.join(import.meta.dir,'skill-e2e-review-army.test.ts'),'utf8'); -async function exercise(scenarios: Array<'success'|'timeout'|'wrong-report'|'browse-error'>) { +async function exercise(scenarios: Array<'success'|'timeout'|'wrong-report'|'browse-error'>, fixturePath = path) { const setups:any[]=[],done:any[]=[],callbacks:any[]=[],rows:any[]=[],calls:any[]=[]; const files=new Map(); let outer=0,index=0; + const sourceRoot = fixturePath.join(fixturePath.sep, 'source'); const args: Record={ - expect,JUDGE_MS,CAPTURE_MS,ROOT:'/source',runId:'synthetic-run',process:{pid:123,env:{EVALS_RUN_ID:'synthetic-controller'}}, + expect,JUDGE_MS,CAPTURE_MS,ROOT:sourceRoot,runId:'synthetic-run',process:{pid:123,env:{EVALS_RUN_ID:'synthetic-controller'}}, beforeAll:(fn:any)=>setups.push(fn),afterAll:(fn:any)=>done.push(fn), describeIfSelected:(_title:string,names:string[],fn:any)=>{if(names.includes('review-army-consensus'))fn();}, testConcurrentIfSelected:(name:string,fn:any,timeout:number)=>{expect(name).toBe('review-army-consensus');callbacks.push(fn);outer=timeout;}, - createEvalCollector:()=>({}),finalizeEvalCollector:()=>{},logCost:()=>{},spawnSync:()=>({status:0}),path,os:{tmpdir:()=>'/tmp'}, + createEvalCollector:()=>({}),finalizeEvalCollector:()=>{},logCost:()=>{},spawnSync:()=>({status:0}),path:fixturePath,os:{tmpdir:()=>'/tmp'}, fs:{mkdirSync:()=>{},readdirSync:()=>[],mkdtempSync:(p:string)=>p+'owned',writeFileSync:(p:string,s:string)=>files.set(p,s),copyFileSync:()=>{},rmSync:()=>{}, - existsSync:(p:string)=>files.has(p),readFileSync:(p:string)=>p.startsWith('/source/')?'synthetic fixture bytes '.repeat(30):files.get(p)}, + existsSync:(p:string)=>files.has(p),readFileSync:(p:string)=>p.startsWith(sourceRoot+fixturePath.sep)?'synthetic fixture bytes '.repeat(30):files.get(p)}, extractSkillSections:()=> 'Review instructions',REVIEW_ARMY_E2E_SECTIONS:[], runSkillTest:async(opts:any)=>{ calls.push(opts);const scenario=scenarios[index++]; expect(opts.timeout).toBe(CAPTURE_MS);expect(opts.maxTurns).toBe(20);expect(opts.model).toBeUndefined(); expect(opts.prompt).toContain('MULTI-SPECIALIST CONFIRMED');expect(opts.prompt).toContain('SQL injection in an auth controller'); - files.set(path.join(opts.workingDirectory,'review-output.md'),scenario==='wrong-report'?'Nothing to discuss.':'The SQL injection permits auth bypass.'); + files.set(fixturePath.join(opts.workingDirectory,'review-output.md'),scenario==='wrong-report'?'Nothing to discuss.':'The SQL injection permits auth bypass.'); return {exitReason:scenario==='timeout'?'timeout':'success',browseErrors:scenario==='browse-error'?['existing browser failure']:[],output:'public response'}; }, recordE2E:(_collector:any,name:string,title:string,result:any,extra:any)=>rows.push({name,title,passed:result.exitReason==='success'&&result.browseErrors.length===0,...extra,result}), @@ -52,6 +53,14 @@ test('Consensus verdict retains the existing browser-error guard',async()=>{ const x=await exercise(['browse-error']);expect(x.rows).toHaveLength(1);expect(x.rows[0].passed).toBe(false); }); +test('Consensus caller fixture preserves success and semantic failure under either path convention', async()=>{ + for (const fixturePath of [path.posix, path.win32]) { + const x=await exercise(['success','wrong-report'], fixturePath); + expect(x.errors[0]).toBeUndefined(); expect(x.errors[1]).toBeDefined(); + expect(x.rows.map(r=>r.passed)).toEqual([true,false]); + } +}); + test('Consensus lifecycle controls select the existing consensus owner only',()=>{ expect(Object.entries(E2E_TOUCHFILES).filter(([,paths])=>paths.includes('test/review-consensus-lifecycle.test.ts')).map(([name])=>name)).toEqual(['review-army-consensus']); }); diff --git a/test/setup-gbrain-path4-caller.test.ts b/test/setup-gbrain-path4-caller.test.ts index 55839703e..d158c1003 100644 --- a/test/setup-gbrain-path4-caller.test.ts +++ b/test/setup-gbrain-path4-caller.test.ts @@ -7,7 +7,8 @@ import { redactFindingSpans } from '../lib/redact-engine'; import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; const TOKEN = 'gbrain_fake_token_for_test'; -const CREDENTIAL = 'ghp_aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5'; +// Assemble synthetic credentials at runtime, as in gate-secret-scan.test.ts. +const CREDENTIAL = ['ghp_', 'aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5'].join(''); const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-setup-gbrain-path4-local-pglite.test.ts'), 'utf8'); async function runCaller(scenario: 'success' | 'no-auq' | 'no-path' | 'leaked-claude-md' | 'diagnostic-secret', diagnostic?: { sink: 'output' | 'tool' | 'error'; text: string }) { @@ -107,7 +108,8 @@ test('Path4 caller controls select only their registered paid owner', () => { test('Path4 redacted credential URLs cannot erase success or mask a failed assertion', async () => { // Text redaction can consume JSON punctuation after an unspaced URL host. // These synthetic credentials exercise the real redactor and actual callback. - for (const text of ['postgres://fixture:syntheticCredential923@db.invalid', 'https://fixture:syntheticCredential923@service.invalid']) { + for (const [scheme, host] of [['postgres', 'db.invalid'], ['https', 'service.invalid']]) { + const text = `${scheme}://fixture:syntheticCredential923@${host}`; for (const sink of ['output', 'tool', 'error'] as const) { for (const scenario of ['success', 'no-auq'] as const) { const { row, thrown } = await runCaller(scenario, { sink, text }); @@ -129,7 +131,8 @@ test('Path4 redacted credential URLs cannot erase success or mask a failed asser test('Path4 unbounded secret diagnostics are omitted without changing the recorded verdict', async () => { for (const scenario of ['success', 'no-auq'] as const) { - const { row, thrown } = await runCaller(scenario, { sink: 'output', text: '-----BEGIN PRIVATE KEY----- synthetic incomplete private material' }); + const text = ['-----BEGIN ', 'PRIVATE KEY----- synthetic incomplete private material'].join(''); + const { row, thrown } = await runCaller(scenario, { sink: 'output', text }); expect(row.passed).toBe(scenario === 'success'); if (scenario === 'success') expect(thrown).toBeUndefined(); else expect(thrown).toBeDefined(); diff --git a/test/ship-coverage-audit-af.test.ts b/test/ship-coverage-audit-af.test.ts index 1bf501f90..999fef78b 100644 --- a/test/ship-coverage-audit-af.test.ts +++ b/test/ship-coverage-audit-af.test.ts @@ -16,7 +16,7 @@ async function runCaller(index: number, change?: (result: any) => void) { const args:Record = { describeIfSelected:(_title:string,_names:string[],fn:Function)=>fn(), beforeAll:(fn:Function)=>setup.push(fn),afterAll:(fn:Function)=>cleanup.push(fn), testConcurrentIfSelected:(_name:string,fn:Function,timeout:number)=>{expect(timeout).toBe(CAPTURE_MS);callbacks.push(fn);}, - fs:virtualFs,os:{tmpdir:()=>'/tmp'},path,ROOT:'/synthetic-gstack',copyDirSync:()=>{},spawnSync:()=>({status:0}), + fs:virtualFs,os:{tmpdir:()=>'/tmp'},path:path.posix,ROOT:'/synthetic-gstack',copyDirSync:()=>{},spawnSync:()=>({status:0}), runSkillTest:async (opts:any)=>{invocations.push(opts);return row.result;},runId:'synthetic-run',JUDGE_MS,CAPTURE_MS, coverageAuditReadEvidence:(evidence as any).coverageAuditReadEvidence, logCost:()=>{},recordE2E:(_collector:any,_name:string,_suite:string,_result:any,options:any)=>records.push(options),evalCollector:{},expect,console:{log:()=>{}},