1- import { chmodSync , existsSync , lstatSync , mkdirSync , mkdtempSync , readdirSync , readFileSync , rmSync } from 'node:fs' ;
1+ import { chmodSync , existsSync , lstatSync , mkdirSync , mkdtempSync , readdirSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
22import { tmpdir } from 'node:os' ;
33import { join } from 'node:path' ;
44import { afterEach , expect , it } from 'vitest' ;
55import type { InvocationHandle , InvocationInput , InvocationResult , StopReason } from '../agents/contract.ts' ;
66import type { AgentAdapterRequest } from '../agents/adapters/types.ts' ;
77import type { TaskFilesystems } from '../agents/container/storage.ts' ;
88import { SuggestionCoordinator } from '../core/planning-suggestions.ts' ;
9- import { CODEX_PLANNING_REFUSED , createPlanningProvider , planningLeftovers , type PlanningDependencies } from '../runner/planning-provider.ts' ;
9+ import { CODEX_PLANNING_REFUSED , createPlanningProvider , PlanningStorage , removePlanningRoot , type PlanningDependencies } from '../runner/planning-provider.ts' ;
1010import { RetainedStorage , StopError } from '../runner/question-container.ts' ;
1111import { Store } from '../runner/store.ts' ;
1212import { prepareRecording , recordingContext , recordingInput , recordingPreviousPlan } from './fixtures/planning/recording-inputs.ts' ;
@@ -47,7 +47,7 @@ function fakeDeps(result: Partial<InvocationResult> | 'hold' = {}, env: Record<s
4747 } ;
4848 return { deps, events, captured, started, cancels, settle : ( value : Partial < InvocationResult > ) => settle ( value ) } ;
4949}
50- const provider = ( deps : PlanningDependencies , extra : { vendor ?: 'claude' | 'codex' ; retained ?: RetainedStorage } = { } ) =>
50+ const provider = ( deps : PlanningDependencies , extra : { vendor ?: 'claude' | 'codex' ; retained ?: PlanningStorage } = { } ) =>
5151 createPlanningProvider ( { vendor : extra . vendor ?? 'claude' , repository : '/repo' , head : HEAD , snapshotId : 'snapshot-1' ,
5252 runnerOwner : OWNER , deps, retained : extra . retained } ) ;
5353const draftRequest = ( requestId = 'b3c1f3b2-6a55-4d7e-9a47-0c7c8f6f1a01' ) => prepareRecording ( 'draft' , HEAD , requestId ) . request ;
@@ -127,7 +127,7 @@ it.each([
127127} ) ;
128128
129129it ( 'refuses later requests after lane D reports resources it could not release' , async ( ) => {
130- const retained = planningLeftovers ( ) , fake = fakeDeps ( { unreleased : [ ] , stopReason : 'capture-failure' , exitCode : null } ) ;
130+ const retained = new PlanningStorage ( ) , fake = fakeDeps ( { unreleased : [ ] , stopReason : 'capture-failure' , exitCode : null } ) ;
131131 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( ) , new AbortController ( ) . signal ) ) . rejects . toThrow ( ) ;
132132 const next = fakeDeps ( ) ;
133133 await expect ( provider ( next . deps , { retained } ) . invoke ( draftRequest ( 'c4d2e4c3-7b66-4e8f-8b58-1d8d907f2b12' ) , new AbortController ( ) . signal ) )
@@ -136,7 +136,7 @@ it('refuses later requests after lane D reports resources it could not release',
136136} ) ;
137137
138138it ( 'marks an allocation lane D could not clean up as untracked, and refuses later requests' , async ( ) => {
139- const retained = planningLeftovers ( ) , fake = fakeDeps ( ) ;
139+ const retained = new PlanningStorage ( ) , fake = fakeDeps ( ) ;
140140 fake . deps . prepareFilesystems = ( ) => { throw new AggregateError ( [ new Error ( 'docker rm failed' ) ] , 'setup cleanup' ) ; } ;
141141 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( ) , new AbortController ( ) . signal ) ) . rejects . toThrow ( 'setup cleanup' ) ;
142142 expect ( retained . untracked ) . toBe ( 1 ) ;
@@ -146,14 +146,14 @@ it('marks an allocation lane D could not clean up as untracked, and refuses late
146146} ) ;
147147
148148it ( 'does not mark an ordinary setup failure as untracked' , async ( ) => {
149- const retained = planningLeftovers ( ) , fake = fakeDeps ( ) ;
149+ const retained = new PlanningStorage ( ) , fake = fakeDeps ( ) ;
150150 fake . deps . prepareFilesystems = ( ) => { throw new Error ( 'no space' ) ; } ;
151151 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( ) , new AbortController ( ) . signal ) ) . rejects . toThrow ( 'no space' ) ;
152152 expect ( retained . untracked ) . toBe ( 0 ) ;
153153} ) ;
154154
155155it ( 'keeps storage Docker did not remove, refuses until it is removed, then runs again' , async ( ) => {
156- const retained = planningLeftovers ( ) , fake = fakeDeps ( { stdout : '{"plan":true}' } ) ;
156+ const retained = new PlanningStorage ( ) , fake = fakeDeps ( { stdout : '{"plan":true}' } ) ;
157157 let removable = false ;
158158 fake . deps . removeFilesystems = ( ) => { if ( ! removable ) throw new Error ( 'docker volume rm failed' ) ; } ;
159159 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( ) , new AbortController ( ) . signal ) )
@@ -168,31 +168,52 @@ it('keeps storage Docker did not remove, refuses until it is removed, then runs
168168} ) ;
169169
170170it ( 'keeps a failed request\'s own error when its cleanup also fails, and refuses the next request' , async ( ) => {
171- const retained = planningLeftovers ( ) , fake = fakeDeps ( { exitCode : 1 , stdout : 'Invalid API key' } ) ;
171+ const retained = new PlanningStorage ( ) , fake = fakeDeps ( { exitCode : 1 , stdout : 'Invalid API key' } ) ;
172172 fake . deps . removeFilesystems = ( ) => { throw new Error ( 'docker volume rm failed' ) ; } ;
173173 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( ) , new AbortController ( ) . signal ) )
174- . rejects . toThrow ( ' Claude could not write the plan.' ) ;
174+ . rejects . toThrow ( / ^ C l a u d e c o u l d n o t w r i t e t h e p l a n \. . * C l e a n u p a l s o d i d n o t s e t t l e : d o c k e r v o l u m e r m f a i l e d $ / ) ;
175175 expect ( retained . size ) . toBe ( 1 ) ;
176176 await expect ( provider ( fake . deps , { retained } ) . invoke ( draftRequest ( 'a8b6c8a7-bfaa-4c23-8f9c-5bc1d4b26f56' ) , new AbortController ( ) . signal ) )
177177 . rejects . toThrow ( / P l a n n i n g s t o r a g e f r o m a n e a r l i e r r e q u e s t c o u l d n o t b e r e m o v e d / ) ;
178178} ) ;
179179
180+ /** A real planning root that rmSync cannot empty: its child directory sits in a read-only parent. */
181+ function lockedPlanningRoot ( ) : string {
182+ const root = mkdtempSync ( join ( tmpdir ( ) , 'codeboost-planning-' ) ) ;
183+ mkdirSync ( join ( root , 'staging' , 'child' ) , { recursive : true } ) ; chmodSync ( join ( root , 'staging' ) , 0o500 ) ;
184+ cleanups . push ( ( ) => chmodSync ( join ( root , 'staging' ) , 0o700 ) ) ; roots . push ( root ) ;
185+ return root ;
186+ }
180187const refusal = ( retained : RetainedStorage ) => {
181188 try { retained . release ( ( ) => { throw new Error ( 'still there' ) ; } ) ; } catch ( error ) { return ( error as Error ) . message ; }
182189 throw new Error ( 'release did not refuse' ) ;
183190} ;
184191it . each ( [
185192 [ 'an allocation Docker did not remove' , ( r : RetainedStorage ) => r . retain ( { } as TaskFilesystems ) , / ^ P l a n n i n g s t o r a g e f r o m a n e a r l i e r r e q u e s t c o u l d n o t b e r e m o v e d \( 1 a l l o c a t i o n \) / ] ,
186193 [ 'an allocation lane D returned no handle for' , ( r : RetainedStorage ) => r . markUntracked ( ) , / ^ A p l a n n i n g a g e n t ' s s e t u p o r c l e a n u p f a i l e d .* P l a n n i n g i s o f f u n t i l c o d e b o o s t r e s t a r t s \. $ / ] ,
187- [ 'a host copy that cannot be deleted' , ( r : RetainedStorage ) => {
188- const parent = mkdtempSync ( join ( tmpdir ( ) , 'planning-locked-' ) ) , root = join ( parent , 'root' ) ;
189- mkdirSync ( join ( root , 'child' ) , { recursive : true } ) ; chmodSync ( root , 0o500 ) ;
190- roots . push ( parent ) ; cleanups . push ( ( ) => chmodSync ( root , 0o700 ) ) ;
191- r . retainPath ( join ( root , 'child' ) ) ;
192- } , / ^ A c o p y o f t h e p l a n n e d c o d e f r o m a n e a r l i e r r e q u e s t c o u l d n o t b e d e l e t e d \( .* c h i l d \) \. P l a n n i n g s t a y s o f f / ] ,
194+ [ 'a host copy that cannot be deleted' , ( r : RetainedStorage ) => r . retainPath ( lockedPlanningRoot ( ) ) ,
195+ / ^ A c o p y o f t h e p l a n n e d c o d e f r o m a n e a r l i e r r e q u e s t c o u l d n o t b e d e l e t e d \( .* c o d e b o o s t - p l a n n i n g - .* \) \. P l a n n i n g s t a y s o f f / ] ,
193196] as const ) ( 'words the refusal for %s as planning, never as Ask' , ( _ , leave , message ) => {
194- const retained = planningLeftovers ( ) ; leave ( retained ) ;
197+ const retained = new PlanningStorage ( ) ; leave ( retained ) ;
195198 const text = refusal ( retained ) ;
196199 expect ( text ) . toMatch ( message ) ;
197200 expect ( text ) . not . toMatch ( / A s k | q u e s t i o n / ) ;
198201} ) ;
202+
203+ it ( 'removes only planning roots, and makes a read-only input directory writable first' , ( ) => {
204+ const foreign = mkdtempSync ( join ( tmpdir ( ) , 'not-planning-' ) ) ; roots . push ( foreign ) ;
205+ expect ( ( ) => removePlanningRoot ( foreign ) ) . toThrow ( / n o t a p l a n n i n g r o o t / ) ;
206+ expect ( existsSync ( foreign ) ) . toBe ( true ) ;
207+ const nested = mkdtempSync ( join ( foreign , 'codeboost-planning-' ) ) ;
208+ expect ( ( ) => removePlanningRoot ( nested ) ) . toThrow ( / n o t a p l a n n i n g r o o t / ) ;
209+ const root = mkdtempSync ( join ( tmpdir ( ) , 'codeboost-planning-' ) ) ; roots . push ( root ) ;
210+ mkdirSync ( join ( root , 'input' ) ) ; writeFileSync ( join ( root , 'input' , 'schema.json' ) , '{}' ) ; chmodSync ( join ( root , 'input' ) , 0o555 ) ;
211+ removePlanningRoot ( root ) ;
212+ expect ( existsSync ( root ) ) . toBe ( false ) ;
213+ } ) ;
214+
215+ it ( 'accepts only planning storage, never Ask\'s' , ( ) => {
216+ const fake = fakeDeps ( ) ;
217+ // @ts -expect-error Ask's RetainedStorage words refusals for Ask and cannot remove planning roots.
218+ createPlanningProvider ( { vendor : 'claude' , repository : '/repo' , head : HEAD , snapshotId : 's' , runnerOwner : OWNER , deps : fake . deps , retained : new RetainedStorage ( ) } ) ;
219+ } ) ;
0 commit comments