@redue/cli 0.1.0-alpha.24 → 0.1.0-alpha.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,10 +22,41 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
22
22
  const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
23
23
  /^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
24
24
  // A general recipe check: a root typecheck, test or lint script run through the package manager.
25
- const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)&&
26
- typeof check.script==='string';
27
- const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
28
- export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
25
+ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
26
+ sandboxed(check))&&typeof check.script==='string';
27
+ // A sandboxed check: a root package script, or a tool from the project's .venv.
28
+ const sandboxed=check=>check?.qualification==='sandbox-v1'&&['typecheck','lint'].includes(check.kind);
29
+ const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
30
+ check.command.every(word=>typeof word==='string'&&safe.test(word));
31
+ const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
32
+ export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
33
+ // The ways an agent runs a Python check's tool: through uv or Poetry, with
34
+ // `python -m`, from .venv/bin, or by name. A form that starts with `python`
35
+ // or the tool's name depends on PATH, so the hook takes it only when PATH
36
+ // finds that program in the project's .venv/bin first (pathForm).
37
+ const wrappers=['uv run ','poetry run '];
38
+ function pythonForms(check){
39
+ const [file,...args]=check.command,tool=file.split('/').at(-1),rest=args.length?' '+args.join(' '):'';
40
+ return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
41
+ ...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
42
+ `python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
43
+ }
44
+ // For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
45
+ // whether PATH finds it first in the project's .venv/bin.
46
+ export function pathForm(form,projectRoot,env=process.env){
47
+ const word=form.split(' ')[0];
48
+ if(word.includes('/')||wrappers.some(wrapper=>form.startsWith(wrapper)))return true;
49
+ if(!projectRoot)return false;
50
+ for(const dir of String(env.PATH??'').split(path.delimiter)){
51
+ if(!dir||!path.isAbsolute(dir))continue;
52
+ try{fs.accessSync(path.join(dir,word),fs.constants.X_OK);}catch{continue;}
53
+ try{return fs.realpathSync(dir)===fs.realpathSync(path.join(projectRoot,'.venv','bin'));}catch{return false;}
54
+ }
55
+ return false;
56
+ }
57
+ // The wrapper a matched form starts with, kept when the hook runs the check
58
+ // through REDUE: `uv run` and `poetry run` sync the environment first.
59
+ const wrapperOf=form=>wrappers.find(wrapper=>form.startsWith(wrapper))||'';
29
60
 
30
61
  export const defaultConfig='redue.config.json';
31
62
  // The `redue` prefix for a config beside the project root; null when the name
@@ -141,7 +172,8 @@ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
141
172
  const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
142
173
  group=key?scripts.get(key):null,part=group?.length>1;
143
174
  const own=group?null:checkScript(check);
144
- if(dir==='.'){
175
+ if(dir==='.'&&python(check))forms.push(...pythonForms(check));
176
+ else if(dir==='.'){
145
177
  forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
146
178
  if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
147
179
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
@@ -258,11 +290,16 @@ function split(command){
258
290
  // {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
259
291
  // script that runs one; null when nothing matches. `dir` is where the command
260
292
  // runs, relative to the project root.
261
- function matched(command,config,configFile,dir='.',scripts={}){
293
+ function matched(command,config,configFile,dir='.',scripts={},projectRoot=null){
262
294
  const parts=split(command);
263
295
  if(!parts)return null;
264
296
  const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
265
297
  row.dir===dir&&row.local.includes(parts.base));
298
+ // A Python check's form that depends on PATH matches only where PATH finds
299
+ // the project's .venv/bin.
300
+ const check=row&&config.checks.find(item=>item.name===row.check);
301
+ if(python(check)&&!parts.base.startsWith('redue'))
302
+ return pathForm(parts.base,projectRoot)?{check:row.check,rest:parts.rest,wrapper:wrapperOf(parts.base)}:null;
266
303
  // A root script that a general check runs is that check, even when the script fans out.
267
304
  if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
268
305
  if(dir==='.'){
@@ -331,7 +368,7 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
331
368
  const names=[];let plain=true;
332
369
  const text=parts.map(({separator,text})=>{
333
370
  const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
334
- const match=matched(body,config,configFile,dir,scripts),split_=split(body);
371
+ const match=matched(body,config,configFile,dir,scripts,projectRoot),split_=split(body);
335
372
  // A compiler script is its checks in the order of its runs.
336
373
  const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
337
374
  if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
@@ -339,22 +376,23 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
339
376
  if(!plain)return null;
340
377
  names.push(...checks);
341
378
  // Grouped, so a separator before it applies to all its runs.
342
- const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
379
+ const runs=checks.map(name=>`${match.wrapper||''}${redue} run ${name} --if-due`).join(' && ');
343
380
  return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
344
381
  });
345
382
  return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
346
383
  }
347
- export function matchCheck(command,config,configFile,dir,scripts){
348
- const match=matched(command,config,configFile,dir,scripts);
384
+ export function matchCheck(command,config,configFile,dir,scripts,projectRoot=null){
385
+ const match=matched(command,config,configFile,dir,scripts,projectRoot);
349
386
  return match?.row?match.row.checks.join(' && '):match?.check||null;
350
387
  }
351
388
  // The same check (or checks, in order) through REDUE, keeping the output
352
389
  // suffix; null if it already is, or if more commands follow it.
353
- export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
390
+ // A `uv run` or `poetry run` wrapper stays in front, so the environment syncs first.
391
+ export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue',wrapper=''){
354
392
  const names=Array.isArray(name)?name:[name];
355
393
  const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
356
394
  if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
357
- const run=names.map(value=>`${redue} run ${value}`).join(' && ');
395
+ const run=names.map(value=>`${wrapper}${redue} run ${value}`).join(' && ');
358
396
  return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
359
397
  }
360
398
 
@@ -488,7 +526,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
488
526
  return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
489
527
  {hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
490
528
  }
491
- const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
529
+ const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
492
530
  if(!match)return null;
493
531
  // A check followed by more than a simple chain runs untouched.
494
532
  if(match.rest!==null&&!match.fanout)return null;
@@ -530,7 +568,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
530
568
  // stays too: the rewrite returns "allow", which would also skip the prompt
531
569
  // for the rest of the chain.
532
570
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
533
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
571
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
534
572
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
535
573
  // to the rewritten command; "allow" skips only the prompt.
536
574
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -7,7 +7,9 @@ import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
7
  import {patternMatcher} from './glob.mjs';
8
8
  import {observedBy} from './installed-observation.mjs';
9
9
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
10
- import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
10
+ import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
11
+ import {denialLine,endWithChild,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,sandboxInterpretation,sandboxPlaces,
12
+ sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
11
13
  import path from 'node:path';
12
14
  import net from 'node:net';
13
15
  import os from 'node:os';
@@ -55,7 +57,17 @@ function publishedProbeDeadline() {
55
57
  try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
56
58
  catch{return 0;}
57
59
  }
58
- const decisionDeadline=()=>Math.max(decisionBaseMs,publishedProbeDeadline()+10000);
60
+ // The wait for a decision, read when it starts: the base at once, and the
61
+ // published deadline when status.json has been read. That file is rewritten
62
+ // at every decision, and a first read of a fresh file is slow where a scanner
63
+ // checks files as they are opened, so it is read off the main thread while
64
+ // the call runs. On Windows it is read before the call, as before: the
65
+ // observer replaces it during the call, and a replacement fails there while a
66
+ // reader holds the file open. An override is exact.
67
+ const decisionWait=override=>override?{timeout:Number(override),deadline:null}:
68
+ process.platform==='win32'?{timeout:Math.max(decisionBaseMs,publishedProbeDeadline()+10000),deadline:null}:{timeout:decisionBaseMs,
69
+ deadline:fs.promises.readFile(path.join(state,'status.json'),'utf8')
70
+ .then(text=>Math.max(decisionBaseMs,publishedDeadline(JSON.parse(text).probe_deadline_ms)+10000),()=>decisionBaseMs)};
59
71
  const digest=value=>createHash('sha256').update(value).digest('hex');
60
72
  function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
61
73
  return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
@@ -73,7 +85,8 @@ function environmentReads() {
73
85
  try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
74
86
  }
75
87
  const recordedRecipes=[testsQualification,scriptQualification];
76
- function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
88
+ // A sandboxed check's run is recorded where its sandbox cannot start (execute).
89
+ function contextHashes() {const reads=config.checks.some(check=>[...recordedRecipes,sandboxQualification].includes(check.qualification))?environmentReads():{};
77
90
  return Object.fromEntries(config.checks.map(check=>[
78
91
  check.name,Object.fromEntries([...(check.environment?.variables||[])
79
92
  .map(key=>[key,digest(process.env[key]??'<unset>')]),
@@ -85,6 +98,8 @@ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.
85
98
  ...(check.environment?.pathExecutables||[]).map(name=>[
86
99
  `__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
87
100
  ...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
101
+ // A sandboxed run's environment, toolchain and places (sandbox.mjs).
102
+ ...(check.qualification===sandboxQualification?Object.entries(sandboxContext(check,process.env,root)):[]),
88
103
  // The variables the last test run read directly, compared here: which
89
104
  // list was compared, and the first variable whose value now differs.
90
105
  // Control requests are small, so the values stay out of them.
@@ -120,20 +135,26 @@ function owner(){
120
135
  atomicJson(ownerFile,expected);
121
136
  }
122
137
  }
123
- function call(message,timeout=5000) {
138
+ // deadline, when given, resolves to the whole wait in ms, which replaces
139
+ // timeout when it is longer.
140
+ function call(message,timeout=5000,deadline=null) {
124
141
  return new Promise((resolve,reject)=>{
125
142
  let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
126
- const timer=setTimeout(()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));},timeout);
143
+ const started=Date.now(),expire=()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));};
144
+ let timer=setTimeout(expire,timeout);
145
+ deadline?.then(total=>{if(done||total<=timeout)return;mark('client.control_deadline',{deadlineMs:total});
146
+ clearTimeout(timer);timer=setTimeout(expire,Math.max(0,total-(Date.now()-started)));});
127
147
  client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
128
148
  client.on('data',chunk=>data+=chunk);
129
149
  client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
130
150
  try{resolve(JSON.parse(data));}catch(e){reject(e);}});
131
- client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
151
+ client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);done=true;reject(e);});
132
152
  });
133
153
  }
134
- async function decisionCall(message,timeout) {
135
- const started=Date.now(),remaining=()=>Math.max(1,timeout-(Date.now()-started));
136
- try{return await call(message,remaining());}
154
+ async function decisionCall(message,{timeout,deadline}) {
155
+ let total=timeout;deadline?.then(value=>{total=Math.max(total,value);});
156
+ const started=Date.now(),remaining=()=>Math.max(1,total-(Date.now()-started));
157
+ try{return await call(message,timeout,deadline);}
137
158
  catch(error){
138
159
  if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
139
160
  const pidFile=path.join(state,'observer.lock','pid');
@@ -195,10 +216,11 @@ async function execute() {
195
216
  const lockStarted=performance.now(),runLock=acquireRunLock(state);
196
217
  timing('cli.run_lock',lockStarted);
197
218
  const captureIssues=[];
198
- let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
219
+ let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false,ownGroup=false;
199
220
  const forward=signal=>{requestedSignal=signal;
200
221
  if(activeChild&&activeChild.exitCode===null){
201
- if(process.platform==='linux'&&activeChild.pid){
222
+ // A Linux run, and a sandboxed one on macOS, has its own process group.
223
+ if((process.platform==='linux'||ownGroup)&&activeChild.pid){
202
224
  try{process.kill(-activeChild.pid,signal);}
203
225
  catch(e){if(e.code!=='ESRCH')throw e;}
204
226
  }else if(process.platform==='win32'&&activeChild.pid){
@@ -233,8 +255,7 @@ async function execute() {
233
255
  const environmentBefore=contextHashes()[name];
234
256
  const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
235
257
  timing('cli.context_before',contextStarted);
236
- const
237
- captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
258
+ const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
238
259
  let before=null,after=null;
239
260
  // Expensive state probes can take seconds. Keep the filesystem checkpoint
240
261
  // adjacent to process launch so a late notification from earlier work
@@ -244,7 +265,7 @@ async function execute() {
244
265
  let probeBefore=callerCertificate?null:probeHash(probePlan);
245
266
  const startSnapshotStarted=performance.now();
246
267
  try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
247
- ...(callerCertificate?{runCertificate:1}:{})},captureTimeout);}
268
+ ...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
248
269
  catch(error){captureIssues.push('start observation unavailable'+
249
270
  (typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
250
271
  timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
@@ -275,10 +296,15 @@ async function execute() {
275
296
  const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
276
297
  const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
277
298
  ...command.slice(1)]:command;
299
+ // A sandboxed check runs inside the OS sandbox. Where the sandbox cannot
300
+ // start, a script the general recipe takes is recorded as that recipe
301
+ // records it (`fallback`); any other runs as it is, and is not reused.
302
+ const sandboxed=selected.qualification===sandboxQualification,support=sandboxed?sandboxSupport():null;
303
+ const fallback=Boolean(support?.issue&&typeof selected.script==='string'&&!generalIssue(root,selected.script));
278
304
  // A test run's processes record what the guard checks (test-recorder.cjs),
279
305
  // and so do a general recipe run's, through the package manager.
280
- const general=selected.qualification===scriptQualification;
281
- const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
306
+ const general=selected.qualification===scriptQualification||fallback;
307
+ const testRun=(recordedRecipes.includes(selected.qualification)||fallback)&&['darwin','linux'].includes(process.platform);
282
308
  let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
283
309
  if(testRun){
284
310
  // The package directory and installed node_modules, as the plan has them.
@@ -298,6 +324,27 @@ async function execute() {
298
324
  // npm's and pnpm's update checks read state outside the checkout and the network.
299
325
  if(general)launchEnv.npm_config_update_notifier='false';
300
326
  }
327
+ // The sandboxed run, with the environment REDUE gives it.
328
+ let sandbox=null;
329
+ if(sandboxed&&!fallback){
330
+ if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
331
+ else{
332
+ const dirs=sandboxDirs(state,name);
333
+ const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
334
+ sandbox={mode:support.mode,dirs,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python}),
335
+ env:sandboxEnv(selected,process.env,{root,...dirs})};
336
+ }
337
+ }
338
+ const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
339
+ // Output kept, and its own process group, for a run as this recipe makes it.
340
+ const own=sandboxed&&!fallback;
341
+ ownGroup=own&&process.platform!=='win32';
342
+ // A sandboxed run's output is passed through and its start and end kept,
343
+ // to tell a refusal by the sandbox from a real failure.
344
+ const output={head:'',tail:''};
345
+ const keep=chunk=>{const text=chunk.toString('utf8');
346
+ if(output.head.length<65536)output.head+=text.slice(0,65536-output.head.length);
347
+ output.tail=(output.tail+text).slice(-65536);};
301
348
  let runnerPid=null;
302
349
  const record=[];
303
350
  // A Windows full reconciliation may finish while queued watcher callbacks
@@ -307,7 +354,7 @@ async function execute() {
307
354
  await new Promise(resolve=>setTimeout(resolve,100));
308
355
  const prelaunchStarted=performance.now();
309
356
  try{before=await call({action:'prelaunch',name,
310
- contextHashes:contextHashes()},Math.min(captureTimeout,5000));}
357
+ contextHashes:contextHashes()},Math.min(captureWait.timeout,5000));}
311
358
  catch{before=null;captureIssues.push('prelaunch observation unavailable');}
312
359
  timing('cli.prelaunch_checkpoint',prelaunchStarted,
313
360
  {available:before?.snapshots?.[name]?1:0});
@@ -320,11 +367,19 @@ async function execute() {
320
367
  if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
321
368
  let child,spawnError=null;
322
369
  try{runLock.phase('launching');
323
- const launch=windowsLaunch(launched);
370
+ const launch=windowsLaunch(sandbox?.argv??launched);
324
371
  child=spawn(launch.file,launch.args,{cwd:plan.cwd,
325
- stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
326
- detached:process.platform==='linux',...launch.options});
327
- child.stdio[3]?.on('data',chunk=>record.push(chunk));}
372
+ // A sandboxed run's own group gets no terminal input, which would stop it.
373
+ stdio:recorded?['inherit','inherit','inherit','pipe']:own?['ignore','pipe','pipe']:'inherit',
374
+ env:sandbox?.env??(own?plainEnv:launchEnv),
375
+ detached:process.platform==='linux'||ownGroup,...launch.options});
376
+ child.stdio[3]?.on('data',chunk=>record.push(chunk));
377
+ if(own){child.stdout.on('data',chunk=>{process.stdout.write(chunk);keep(chunk);});
378
+ child.stderr.on('data',chunk=>{process.stderr.write(chunk);keep(chunk);});
379
+ // What the check left running is stopped when it exits, as a CI
380
+ // job's end does (on Linux the launcher also stops daemons that
381
+ // left the group), and the run ends a second after its exit.
382
+ endWithChild(child);}}
328
383
  catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
329
384
  activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
330
385
  mark('cli.child_started',{childPid:child.pid});
@@ -332,7 +387,7 @@ async function execute() {
332
387
  child.on('error',e=>{spawnError=e.code||e.name;});
333
388
  child.on('close',async(exit,signal)=>{
334
389
  if(treeStop)await treeStop;
335
- if(process.platform==='linux'&&requestedSignal&&child.pid){
390
+ if((process.platform==='linux'||ownGroup)&&requestedSignal&&child.pid){
336
391
  // A verification command may have forked children in its process
337
392
  // group. Normal cancellation must reach them even if the leader has
338
393
  // already exited. Commands that daemonize into a new session remain
@@ -354,7 +409,7 @@ async function execute() {
354
409
  invocation.argc=command.length;
355
410
  invocation.runId=runId;
356
411
  invocation.reporting='normal';
357
- const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
412
+ let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
358
413
  const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
359
414
  const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
360
415
  timing('cli.context_after',afterContextStarted);
@@ -393,11 +448,12 @@ async function execute() {
393
448
  if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
394
449
  const endSnapshotStarted=performance.now();
395
450
  if(!unobservedDirectRun){
451
+ // The start checkpoint's probe may have raised the deadline.
452
+ const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
396
453
  try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
397
- ...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
454
+ ...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
398
455
  ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
399
- // The start checkpoint's probe may have raised the deadline.
400
- Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
456
+ endWait.timeout,endWait.deadline);}
401
457
  catch{captureIssues.push('end observation unavailable');}
402
458
  finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
403
459
  timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
@@ -460,12 +516,40 @@ async function execute() {
460
516
  // A recorded certificate changes only the probe part of the fingerprint.
461
517
  if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
462
518
  snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
519
+ // A sandboxed run that failed with what looks like a refusal by the
520
+ // sandbox runs again outside it. When that run exits differently, the
521
+ // sandbox changed the result: the plain run's result is kept, not reused.
522
+ // An interrupt before or during it stops there: the run is interrupted.
523
+ let sandboxRerun=null;
524
+ if(sandbox&&result==='FAIL'&&!requestedSignal){
525
+ const line=denialLine(output.head+'\n'+output.tail);
526
+ if(line){
527
+ console.error(`redue: the sandboxed run failed with what looks like a refusal by the sandbox (${line}); running it again outside the sandbox`);
528
+ // Interrupting REDUE interrupts the rerun's group too (forward).
529
+ const plain=await runToEnd(command,{cwd:plan.cwd,env:plainEnv,echo:true,started:child=>{activeChild=child;}});
530
+ activeChild=null;
531
+ sandboxRerun={line,exitCode:plain.status,signal:plain.signal||null,...plain.error?{errorCode:plain.error.code||plain.error.name}:{}};
532
+ if(requestedSignal){
533
+ captureIssues.push('interrupted during the run outside the sandbox');
534
+ Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
535
+ }
536
+ // A rerun that did not start or finish tells nothing: the sandboxed result stands, not reused.
537
+ else if(plain.error||plain.status===null)
538
+ captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
539
+ else if(plain.status!==invocation.exitCode){
540
+ captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})`);
541
+ invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
542
+ }
543
+ }
544
+ }
545
+ if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
463
546
  const stable=captureIssues.length===0;
464
547
  // Two runs on the same inputs, environment and plan that disagree make the
465
548
  // check nondeterministic until its inputs change.
466
549
  let nondeterministic=false;
467
550
  const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
468
- if(testRun&&stable&&result){
551
+ if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
552
+ if((testRun||sandboxed)&&stable&&result){
469
553
  const prior=readReceipts(state).checks[name];
470
554
  // A difference explained by inputs REDUE did not observe proves nothing.
471
555
  nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
@@ -478,7 +562,7 @@ async function execute() {
478
562
  // A recorded run's receipt carries its run facts, even none; observers that
479
563
  // predate run facts do not reuse version 2.
480
564
  // The recipe that made the receipt, which status and the hook name.
481
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
565
+ const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:(fallback?scriptQualification:selected.qualification)||null,
482
566
  ...(recorded||testRun?{runFacts}:{}),
483
567
  ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
484
568
  environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
@@ -492,6 +576,8 @@ async function execute() {
492
576
  planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
493
577
  provider:plan.provider,environmentHashes:environmentBefore,
494
578
  ...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
579
+ ...(own?{verificationRecipe:sandboxInterpretation,sandbox:{mode:sandbox?.mode??null,...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
580
+ ...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
495
581
  files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
496
582
  startRevision:snap?.revision??null,endRevision:end?.revision??null};
497
583
  const persistenceStarted=performance.now();
@@ -601,7 +687,7 @@ async function main(){
601
687
  observedNpmDigest:observedNpmContext(),proxyShapes});
602
688
  }
603
689
  let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
604
- Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadline()));
690
+ decisionWait(process.env.VSTATE_SYNC_TIMEOUT_MS));
605
691
  // A client upgrade can encounter an older running observer. Its successful
606
692
  // control response does not establish that it evaluated the latest receipt.
607
693
  if(value.receipt_revision===null){
@@ -615,7 +701,9 @@ async function main(){
615
701
  }}
616
702
  catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
617
703
  timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
618
- if(action==='sync')console.log(JSON.stringify(value));
704
+ // In the front's process (bin/redue.mjs), the value is handed back.
705
+ if(action==='sync'&&process.argv.includes('--value'))syncValue=value;
706
+ else if(action==='sync')console.log(JSON.stringify(value));
619
707
  else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
620
708
  return;
621
709
  }
@@ -623,4 +711,5 @@ async function main(){
623
711
  if(action==='run'){await execute();return;}
624
712
  throw Error(`unknown action ${action}`);
625
713
  }
626
- main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
714
+ export let syncValue=null;
715
+ export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
package/src/daemon.mjs CHANGED
@@ -25,6 +25,7 @@ import {npmCacheDir} from './npm-integrity.mjs';
25
25
  import {runFromArgv} from './typescript-invocation.mjs';
26
26
  import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
27
27
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
28
+ import {sandboxChange} from './sandbox.mjs';
28
29
 
29
30
  const configFile=path.resolve(process.argv[2]);
30
31
  const config=JSON.parse(fs.readFileSync(configFile));
@@ -63,7 +64,9 @@ let identityChanged=false;
63
64
  let identityGapKey=null;
64
65
  let triggerHashes=new Map();
65
66
  let candidateDescendants=new Map();
66
- let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
67
+ // A Python project may have no package.json.
68
+ let workspacePatterns=(()=>{let w;try{w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;}
69
+ catch(e){if(e.code==='ENOENT')return [];throw e;}
67
70
  return Array.isArray(w)?w:w?.packages||[];})();
68
71
  let planningWorker=null;
69
72
  let validatedPlan=null;
@@ -269,6 +272,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
269
272
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
270
273
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
271
274
  'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
275
+ 'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
272
276
  'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
273
277
  'npm-installation-unobservable':'npm installation contains unobservable entries',
274
278
  'npm-configuration-unsupported':'npm script configuration needs review',
@@ -395,13 +399,19 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
395
399
  if(r.target.status==='failed'&&result!=='FAIL'){
396
400
  item.reason='target failure conflicts with outer invocation outcome';return item;}
397
401
  if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
398
- item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
402
+ // A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
403
+ const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox)/.test(issue));
404
+ item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
399
405
  const names=[...(row.plan.environment.variables||[]),
400
406
  ...(row.plan.environment.prefixes||[]).map(prefix=>
401
407
  `__vstate_env_prefix_${prefix.toLowerCase()}__`),
402
408
  ...(row.plan.environment.pathExecutables||[]).map(name=>
403
409
  `__vstate_path_executable_${name}__`),
404
410
  ...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
411
+ // A sandboxed run's variables, toolchain, PATH directories and places
412
+ // (sandbox.mjs): every entry either side has, and the first change named.
413
+ const sandboxChanged=row.plan.qualification==='sandbox-v1'&&contextHashes?.[name]?
414
+ sandboxChange(r?.environmentHashes,contextHashes[name]):null;
405
415
  if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
406
416
  item.freshness='STALE';
407
417
  if(r.planId!==row.plan.id){
@@ -410,6 +420,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
410
420
  item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
411
421
  if(changed)item.changed_inputs=[changed.path];
412
422
  }
423
+ else if(sandboxChanged)item.reason=sandboxChanged;
413
424
  else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
414
425
  item.reason='declared execution environment changed';
415
426
  // Every variable a test run read directly must have the same value. The
@@ -795,8 +806,8 @@ function markInputEvent(name,rel,type,replay) {
795
806
  if(!hasHistory||historyDisabled||replay){
796
807
  const serial=(inputEventSerial.get(name)||0)+1;
797
808
  inputEventSerial.set(name,serial);
798
- // Kept only for recorded test and script checks; trimmed in batches.
799
- if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
809
+ // Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
810
+ if(['npm-tests-direct-v1','node-script-v1','sandbox-v1'].includes(index?.checks.get(name)?.plan.qualification)){
800
811
  const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
801
812
  if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
802
813
  }
@@ -17,7 +17,8 @@ export function planGuard(bundle) {
17
17
  ...bundle.workspaceManifests||[],...bundle.configurationInputs||[],
18
18
  ...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[])]);
19
19
  const patterns=bundle.workspacePatterns?.length?bundle.workspacePatterns:
20
- (()=>{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
20
+ (()=>{let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));}
21
+ catch(e){if(e.code==='ENOENT')return [];throw e;}
21
22
  return Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages||[];})();
22
23
  if(patterns.length){
23
24
  const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
@@ -8,6 +8,7 @@ import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
9
  import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
10
  import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
11
+ import {qualifySandbox,sandboxInstalledExclusions,sandboxQualification} from './sandbox.mjs';
11
12
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
12
13
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
13
14
  import {checkRun} from './typescript-invocation.mjs';
@@ -78,12 +79,17 @@ export function discoverDeclared(config) {
78
79
  if(selected.qualification==='npm-typescript-direct-v1')
79
80
  try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
80
81
  const excluded=buildInfo?['!'+buildInfo]:[];
82
+ // A sandboxed check leaves out the .venv's links that lead out of the
83
+ // checkout; its qualification names them.
84
+ const sandboxed=selected.qualification===sandboxQualification;
85
+ if(sandboxed)excluded.push(...sandboxInstalledExclusions(root));
81
86
  const installedStarted=performance.now();
82
87
  const installed=resolveDeclaredInstalled(root,
83
88
  [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
84
89
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
85
90
  const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
86
91
  selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
92
+ sandboxed?qualifySandbox(root,selected):
87
93
  qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
88
94
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
89
95
  ...(workspace?.generated||[]),...excluded])].sort();
@@ -97,7 +103,8 @@ export function discoverDeclared(config) {
97
103
  // A workspace test check, and a general check in a workspace, is qualified
98
104
  // against the current package list: a package manifest appearing or pnpm's
99
105
  // package list changing re-plans.
100
- if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification)try{
106
+ if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification||
107
+ sandboxed&&typeof selected.script==='string')try{
101
108
  for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
102
109
  workspacePatterns.add(item);
103
110
  if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
@@ -136,6 +143,8 @@ export function discoverDeclared(config) {
136
143
  probes:selected.probes||[],environment:selected.environment||{},
137
144
  qualification:selected.qualification||null,
138
145
  ...(buildInfo?{buildInfo}:{}),
146
+ // A sandboxed run's reads are not recorded: git-ignored files are inputs too.
147
+ ...(sandboxed?{ignoredInputs:true}:{}),
139
148
  executionProvenance:'declared-command',
140
149
  unresolved:[...new Set(unresolved)].sort()};
141
150
  plan.resolutionCandidates=workspace?.resolutionCandidates||[];
@@ -7,7 +7,7 @@ import {createHash} from 'node:crypto';
7
7
  import {inflateSync} from 'node:zlib';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {timing} from './decision-profile.mjs';
10
- import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
10
+ import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue,viteCacheQuestion} from './direct-typescript.mjs';
11
11
  import {withinPath} from './path-identity.mjs';
12
12
  const hash=value=>createHash('sha256').update(value).digest('hex');
13
13
  // Whether a direct run is recorded at all: on macOS and Linux, always, so its
@@ -158,6 +158,8 @@ export function runRecordIssue(record,certificate,runFacts=null) {
158
158
  if(kept.issue)return kept.issue;
159
159
  if(record.main!==compiler.entry||!Array.isArray(record.node)||
160
160
  directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
161
+ const vite=viteCacheQuestion(compiler.root,kept.facts);
162
+ if(vite)return `the compiler asked about ${vite}, which Vitest rewrites and REDUE does not observe`;
161
163
  const inCompiler=withinCompiler(compiler);
162
164
  const facts=new Map();
163
165
  for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
2
+ import {directProject,directInterpretation,directContextHash,directProbeOutput,viteCacheQuestion} from './direct-typescript.mjs';
3
3
  import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
4
  import {contextOnly,probeResult} from './probe-result.mjs';
5
5
  import {realObservedPath} from './path-identity.mjs';
@@ -18,6 +18,9 @@ try{
18
18
  const contextHash=directContextHash(compiler,context.node);
19
19
  if(!contextOnly(contextHash)){
20
20
  const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
21
+ // Captured questions include every config and program file read.
22
+ if(viteCacheQuestion(root,queries||[...configs,...files].map(([file])=>['readFile',file])))
23
+ throw Object.assign(Error('typescript-vite-cache-consumed'),{code:'typescript-vite-cache-consumed'});
21
24
  const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
22
25
  probeResult(directProbeOutput(layout),contextHash,queries,layout);
23
26
  }