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:()=>{}},