@redue/cli 0.1.0-alpha.30 → 0.1.0-alpha.32
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.
- package/CHANGELOG.md +73 -0
- package/README.md +18 -3
- package/bin/redue.mjs +148 -19
- package/docs/user/agents.md +26 -0
- package/docs/user/configuration.md +8 -0
- package/docs/user/getting-started.md +2 -1
- package/docs/user/json.md +18 -0
- package/docs/user/sandbox.md +14 -0
- package/package.json +1 -1
- package/src/agent-integration.mjs +45 -14
- package/src/cached-state.mjs +9 -6
- package/src/claude-hook.mjs +134 -0
- package/src/cli.mjs +45 -17
- package/src/daemon.mjs +62 -8
- package/src/landlock.py +3 -0
- package/src/presentation.mjs +4 -3
- package/src/sandbox.mjs +43 -13
- package/src/setup.mjs +22 -3
package/src/daemon.mjs
CHANGED
|
@@ -25,7 +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 {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
28
|
+
import {compactKey,installedTreeOf,isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
29
29
|
|
|
30
30
|
const configFile=path.resolve(process.argv[2]);
|
|
31
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -338,7 +338,49 @@ function combinedFiles(name) {
|
|
|
338
338
|
Object.entries(entry.index.exportFiles(name)))files[`external:${entry.root}:${rel}`]=hash;
|
|
339
339
|
return files;
|
|
340
340
|
}
|
|
341
|
+
// The files of a sandboxed run's snapshot and receipt, smaller: each
|
|
342
|
+
// installed tree (a node_modules or .venv directory, outermost) as one entry,
|
|
343
|
+
// `<tree>/:installed:`, with a digest of its files and their identities, and
|
|
344
|
+
// every other file as it is. The fingerprint still covers every file; this map
|
|
345
|
+
// names a changed input and decides which changes a hidden run could see. A
|
|
346
|
+
// receipt says it holds this form (filesCompact), and is compared with it.
|
|
347
|
+
// The entry's name is a path inside its tree, so whether a hidden run could
|
|
348
|
+
// see a change to it is decided as for the files it stands for: beneath a
|
|
349
|
+
// hidden entry, or new where git ignores it now.
|
|
350
|
+
const compactCache=new Map();
|
|
351
|
+
function compactFiles(name) {
|
|
352
|
+
const summary=combinedSummary(name),key=`${summary.revision}\0${summary.fingerprint}`;
|
|
353
|
+
const cached=compactCache.get(name);
|
|
354
|
+
if(cached?.key===key)return {...cached.files};
|
|
355
|
+
const out={},trees=new Map();
|
|
356
|
+
for(const [rel,hash] of Object.entries(combinedFiles(name))){
|
|
357
|
+
const tree=installedTreeOf(rel);
|
|
358
|
+
if(tree===null){out[rel]=hash;continue;}
|
|
359
|
+
let list=trees.get(tree);if(!list)trees.set(tree,list=[]);
|
|
360
|
+
list.push([rel,hash]);
|
|
361
|
+
}
|
|
362
|
+
for(const [tree,list] of trees){list.sort(([a],[b])=>a<b?-1:a>b?1:0);out[compactKey(tree)]=sha(JSON.stringify(list));}
|
|
363
|
+
compactCache.set(name,{key,files:out});
|
|
364
|
+
return {...out};
|
|
365
|
+
}
|
|
366
|
+
// The current files in the form receipt `r` holds them.
|
|
367
|
+
const filesFor=(name,r)=>r?.filesCompact===true?compactFiles(name):combinedFiles(name);
|
|
368
|
+
// Whether a receipt's run, which passed, is the second in a row during which
|
|
369
|
+
// the same inputs changed (selfChange), and its plan, environment and every
|
|
370
|
+
// other input are as they were at its start.
|
|
371
|
+
function onlySelfChanged(name,row,r,contextHashes) {
|
|
372
|
+
const self=r.selfChange,context=contextHashes?.[name];
|
|
373
|
+
if(r.result!=='PASS'||!(self?.streak>=2)||!Array.isArray(self.files)||r.planId!==row.plan.id||!r.files||!context)return false;
|
|
374
|
+
for(const [key,value] of Object.entries(context))if(!key.startsWith('__redue_env_')&&r.environmentHashes?.[key]!==value)return false;
|
|
375
|
+
const skip=new Set(self.files),strip=files=>Object.fromEntries(Object.entries(files).filter(([file])=>!skip.has(file)));
|
|
376
|
+
const before=strip(r.files),now=strip(filesFor(name,r)),paths=[...new Set([...Object.keys(before),...Object.keys(now)])];
|
|
377
|
+
if(hiddenRun(r))return sandboxVisibleChange(root,r.sandbox.hidden,before,now,paths,{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null})===null;
|
|
378
|
+
return paths.every(file=>before[file]===now[file]);
|
|
379
|
+
}
|
|
380
|
+
// A changed input as status names it: an installed tree by its path.
|
|
381
|
+
const inputName=file=>file?.endsWith('/:installed:')?file.slice(0,-'/:installed:'.length):file;
|
|
341
382
|
function describeChangedInput(plan,file) {
|
|
383
|
+
if(file.endsWith('/:installed:'))return `${file.slice(0,-'/:installed:'.length)} (installed files) changed`;
|
|
342
384
|
const boundary=[...external.keys()].find(value=>file.startsWith(`external:${value}:`));
|
|
343
385
|
const physical=boundary?path.join(boundary,file.slice(`external:${boundary}:`.length)):
|
|
344
386
|
path.join(root,file);
|
|
@@ -355,7 +397,7 @@ function describeChangedInput(plan,file) {
|
|
|
355
397
|
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
356
398
|
function visibleChange(row,r,name) {
|
|
357
399
|
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
358
|
-
if(row.visibleKey!==key){const current=
|
|
400
|
+
if(row.visibleKey!==key){const current=filesFor(name,r);
|
|
359
401
|
row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
|
|
360
402
|
{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
|
|
361
403
|
return row.visibleChange;
|
|
@@ -389,6 +431,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
389
431
|
`; declared input checkpoint changed (${row.lastReason||'content or membership'})`:'';
|
|
390
432
|
const probeIssue=row.probeError?`; state probe unavailable: ${row.probeError}`:'';
|
|
391
433
|
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
434
|
+
// Recording-only (no qualification contract, inputs it cannot resolve):
|
|
435
|
+
// no run can make it CURRENT. A qualified check's unresolved inputs can
|
|
436
|
+
// be fixed, so it stays due.
|
|
437
|
+
if(!row.plan.qualification)item.verifiable=false;
|
|
392
438
|
return item;
|
|
393
439
|
}
|
|
394
440
|
if(row.probeError){item.reason=row.probeError;return item;}
|
|
@@ -410,6 +456,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
410
456
|
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
411
457
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
412
458
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
459
|
+
// The same inputs changed during its last two passing runs, and nothing
|
|
460
|
+
// else changed since: no run of it can be confirmed. Anything else
|
|
461
|
+
// changing makes it due again.
|
|
462
|
+
if(!r.stable&&onlySelfChanged(name,row,r,contextHashes)){
|
|
463
|
+
item.verifiable=false;
|
|
464
|
+
item.reason=`the same inputs changed during its last two runs (${r.selfChange.files.slice(0,5).join(', ')}${r.selfChange.files.length>5?' and more':''}), so no result of it can be confirmed; it is due again when anything else changes`;
|
|
465
|
+
return item;}
|
|
413
466
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
414
467
|
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
415
468
|
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
|
|
@@ -446,16 +499,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
446
499
|
else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
|
|
447
500
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
448
501
|
if(row.staleReasonKey!==key&&hiddenRun(r)){
|
|
449
|
-
|
|
502
|
+
const seen=visibleChange(row,r,name);row.staleInputs=[inputName(seen)];row.staleReason=describeChangedInput(row.plan,seen);row.staleReasonKey=key;}
|
|
450
503
|
if(row.staleReasonKey!==key){
|
|
451
504
|
let changed=row.lastReason;row.staleInputs=[];
|
|
452
505
|
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
453
|
-
if(candidate&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
506
|
+
if(candidate&&!(r.filesCompact===true&&installedTreeOf(candidate)!==null)&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
454
507
|
changed=describeChangedInput(row.plan,candidate);row.staleInputs=[candidate];}
|
|
455
508
|
else {
|
|
456
|
-
const current=
|
|
509
|
+
const current=filesFor(name,r);
|
|
457
510
|
if(r.files)for(const f of new Set([...Object.keys(r.files),...Object.keys(current)]))
|
|
458
|
-
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
|
|
511
|
+
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[inputName(f)];break;}
|
|
459
512
|
}
|
|
460
513
|
row.staleReason=changed||
|
|
461
514
|
(['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
@@ -500,7 +553,7 @@ function knownStale(name,row) {
|
|
|
500
553
|
const r=receipts[name];
|
|
501
554
|
if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
|
|
502
555
|
row.probeCache?.input===inputKey(row))return false;
|
|
503
|
-
const current=
|
|
556
|
+
const current=filesFor(name,r),files=Object.keys(current);
|
|
504
557
|
return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
|
|
505
558
|
}
|
|
506
559
|
// A run can rebuild a certificate that no longer applies only because indexed
|
|
@@ -1174,7 +1227,8 @@ async function request(message) {
|
|
|
1174
1227
|
if(message.action==='snapshot'||message.action==='prelaunch') {
|
|
1175
1228
|
data.snapshots={};for(const [name,row] of index?.checks||[])
|
|
1176
1229
|
if(!message.name||message.name===name)
|
|
1177
|
-
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
1230
|
+
data.snapshots[name]={...combinedSummary(name),files:message.compact===1?compactFiles(name):combinedFiles(name),
|
|
1231
|
+
...message.compact===1?{filesCompact:true}:{},
|
|
1178
1232
|
planGeneration:row.plan.id,
|
|
1179
1233
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
1180
1234
|
// Complete only when the oldest kept event follows the given serial.
|
package/src/landlock.py
CHANGED
|
@@ -321,6 +321,9 @@ def main(argv):
|
|
|
321
321
|
version, err = abi()
|
|
322
322
|
if version is None:
|
|
323
323
|
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
324
|
+
# Below ABI 4 Landlock cannot refuse TCP: a run that must be off the network does not start.
|
|
325
|
+
if no_tcp and version < 4:
|
|
326
|
+
sys.exit(f'landlock.py: Landlock ABI {version} cannot keep a run off the network')
|
|
324
327
|
reading = EXECUTE | READ_FILE | READ_DIR
|
|
325
328
|
handled = reading | (WRITE if deny_write else 0)
|
|
326
329
|
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
package/src/presentation.mjs
CHANGED
|
@@ -6,7 +6,7 @@ const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-
|
|
|
6
6
|
'sandbox-v2':'sandboxed recipe, git-ignored files hidden','sandbox-v3':'sandboxed recipe, git-ignored files and docs hidden'};
|
|
7
7
|
export const recipeLabel=recipe=>recipes[recipe]??null;
|
|
8
8
|
export function checkLabel(row) {
|
|
9
|
-
return row.result==='FAIL'?'FAILED':row.freshness;
|
|
9
|
+
return row.verifiable===false?'NOT VERIFIABLE':row.result==='FAIL'?'FAILED':row.freshness;
|
|
10
10
|
}
|
|
11
11
|
export function explanation(row) {
|
|
12
12
|
const reason=row.reason||'No verification evidence is available.';
|
|
@@ -25,11 +25,12 @@ export function renderStatus(value,{short=false}={}) {
|
|
|
25
25
|
const lines=[];
|
|
26
26
|
for(const row of value.checks||[]) {
|
|
27
27
|
const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
|
|
28
|
-
const mark=label==='STALE'?'⚠':symbol;
|
|
28
|
+
const mark=label==='STALE'?'⚠':label==='NOT VERIFIABLE'?'–':symbol;
|
|
29
29
|
const historical=row.result?` / ${row.result}`:'';
|
|
30
30
|
const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
|
|
31
31
|
lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
|
|
32
|
-
if(label
|
|
32
|
+
if(label==='NOT VERIFIABLE')lines.push(` Not verifiable: ${explanation(row)}`);
|
|
33
|
+
else if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
|
|
33
34
|
}
|
|
34
35
|
if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
|
|
35
36
|
if(!value.observation?.healthy)lines.push('Observation unavailable or pending. Previous results are not reusable.');
|
package/src/sandbox.mjs
CHANGED
|
@@ -100,8 +100,15 @@ export function sandboxEnvironment(python=false,manager=null,kind=null){
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Whether this machine can sandbox a run: {mode} or {issue}. Asked once per process.
|
|
103
|
+
// On Linux, `cache` (a file in REDUE's state) keeps a Landlock answer for the
|
|
104
|
+
// same kernel, launcher Python and launcher, so a run does not start Python to
|
|
105
|
+
// ask again. The launcher checks again when it starts a run, and a run it
|
|
106
|
+
// cannot sandbox is not reused (launcherFailure).
|
|
103
107
|
let support=null;
|
|
104
|
-
|
|
108
|
+
const launcherKey=python=>{try{const st=fs.statSync(fs.realpathSync(python),{bigint:true});
|
|
109
|
+
return digest(JSON.stringify([os.release(),python,...['dev','ino','size','mtimeNs'].map(key=>String(st[key])),digest(fs.readFileSync(landlockScript))]));}
|
|
110
|
+
catch{return null;}};
|
|
111
|
+
export function sandboxSupport({cache=null}={}){
|
|
105
112
|
if(support)return support;
|
|
106
113
|
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_SANDBOX==='unavailable')
|
|
107
114
|
return support={mode:null,issue:'the sandbox is turned off for this test'};
|
|
@@ -114,11 +121,15 @@ export function sandboxSupport(){
|
|
|
114
121
|
if(process.platform==='linux'){
|
|
115
122
|
const python=launcherPython();
|
|
116
123
|
if(!python)return support={mode:null,issue:'the Landlock launcher needs Python 3'};
|
|
124
|
+
const key=cache?launcherKey(python):null;
|
|
125
|
+
if(key)try{const kept=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
126
|
+
if(kept?.key===key&&Number.isInteger(kept.abi)&&kept.abi>=4)return support={mode:'landlock',abi:kept.abi,python};}catch{}
|
|
117
127
|
let abi=null;
|
|
118
128
|
try{abi=JSON.parse(spawnSync(python,['-I','-W','ignore',landlockScript,'--probe'],{encoding:'utf8',timeout:10000}).stdout).abi;}catch{}
|
|
119
129
|
if(!Number.isInteger(abi))return support={mode:null,issue:'Landlock is unavailable in this kernel'};
|
|
120
130
|
// ABI 4 (Linux 6.7) is the first that can refuse TCP connections.
|
|
121
131
|
if(abi<4)return support={mode:null,issue:`Landlock ABI ${abi} cannot keep a run off the network; it needs ABI 4 (Linux 6.7)`};
|
|
132
|
+
if(key)try{const temp=`${cache}.${process.pid}.tmp`;fs.writeFileSync(temp,JSON.stringify({key,abi}),{mode:0o600});fs.renameSync(temp,cache);}catch{}
|
|
122
133
|
return support={mode:'landlock',abi,python};
|
|
123
134
|
}
|
|
124
135
|
return support={mode:null,issue:'the sandboxed recipe runs on macOS and Linux only'};
|
|
@@ -267,14 +278,19 @@ export function sandboxDirs(state,name){
|
|
|
267
278
|
// git cannot list them. Only the project's rules count (.gitignore files and
|
|
268
279
|
// .git/info/exclude), not a user's global excludes file, as on CI.
|
|
269
280
|
const installedTree=/(?:^|\/)(?:node_modules|\.venv)(?:\/|$)/;
|
|
281
|
+
// The outermost installed tree a checkout path is in (`a/node_modules`), or
|
|
282
|
+
// null. Never hidden itself; the observer keeps such a tree as one entry in a
|
|
283
|
+
// sandboxed run's files, `<tree>/:installed:` (compactKey).
|
|
284
|
+
export const installedTreeOf=rel=>rel.startsWith('external:')?null:/^(.*?(?:^|\/)(?:node_modules|\.venv))(?:\/|$)/.exec(rel)?.[1]??null;
|
|
285
|
+
export const compactKey=tree=>`${tree}/:installed:`;
|
|
286
|
+
const compactTree=key=>key.endsWith('/:installed:')?key.slice(0,-':installed:'.length):null;
|
|
270
287
|
const runnerCache=picomatch(sandboxCaches,{dot:true});
|
|
271
288
|
const projectGit=['-c','core.excludesFile=/dev/null'],gitEnv=()=>({...process.env,GIT_OPTIONAL_LOCKS:'0'});
|
|
289
|
+
const hiddenArgs=[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'];
|
|
290
|
+
const hiddenOf=r=>r.status!==0?null:r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
|
|
291
|
+
!runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
|
|
272
292
|
export function hiddenEntries(root){
|
|
273
|
-
|
|
274
|
-
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
275
|
-
if(r.status!==0)return null;
|
|
276
|
-
return r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
|
|
277
|
-
!runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
|
|
293
|
+
return hiddenOf(spawnSync('git',hiddenArgs,{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
// The documentation files docPatterns names among the files git lists and
|
|
@@ -283,9 +299,11 @@ export function hiddenEntries(root){
|
|
|
283
299
|
// Relative; null when git cannot tell. git filters by pathspec, so a large
|
|
284
300
|
// checkout is not listed whole.
|
|
285
301
|
const docMatch=picomatch(docPatterns,{dot:true});
|
|
302
|
+
const docsArgs=()=>[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)];
|
|
286
303
|
export function hiddenDocs(root){
|
|
287
|
-
|
|
288
|
-
|
|
304
|
+
return docsOf(root,spawnSync('git',docsArgs(),{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
|
|
305
|
+
}
|
|
306
|
+
function docsOf(root,r){
|
|
289
307
|
if(r.status!==0)return null;
|
|
290
308
|
const out=new Set();
|
|
291
309
|
for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
|
|
@@ -343,7 +361,8 @@ export function visibleChange(root,hidden,before,current,paths,{during=false,doc
|
|
|
343
361
|
const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
|
|
344
362
|
if(covered.length){
|
|
345
363
|
const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
346
|
-
|
|
364
|
+
// An installed tree kept as one entry counts when git tracks any file in it.
|
|
365
|
+
const found=covered.find(file=>!tracked||tracked.has(file)||compactTree(file)!==null&&[...tracked].some(rel=>rel.startsWith(compactTree(file))));
|
|
347
366
|
if(found)return found;
|
|
348
367
|
}
|
|
349
368
|
if(!fresh.length)return null;
|
|
@@ -556,6 +575,11 @@ export function stopGroup(pid){
|
|
|
556
575
|
export function endWithChild(child){
|
|
557
576
|
child.on('exit',()=>{stopGroup(child.pid);setTimeout(()=>{child.stdout?.destroy();child.stderr?.destroy();},1000).unref();});
|
|
558
577
|
}
|
|
578
|
+
// The launcher's own line when it could not sandbox a run (Landlock
|
|
579
|
+
// unavailable, or a rule it could not add), or null.
|
|
580
|
+
// Only its refusals before the command starts, and only as the run's whole output.
|
|
581
|
+
export const launcherFailure=text=>/^landlock\.py: (?:Landlock unavailable|Landlock ABI \d+ cannot|create_ruleset|add_rule|no_new_privs|restrict_self)[^\n]*$/
|
|
582
|
+
.exec(String(text).trim())?.[0].slice(0,200)??null;
|
|
559
583
|
// Runs argv in its own process group to the end, with no terminal input:
|
|
560
584
|
// {status, signal, error, timedOut, stdout, stderr}, each output kept up to
|
|
561
585
|
// 64 MB and decoded at the end. `echo` also passes the output through.
|
|
@@ -595,13 +619,17 @@ function refusedHidden(plain,sandboxed,entries,supervisor){
|
|
|
595
619
|
const others=sandboxed.output.split(/\r?\n/).filter(text=>!hiddenRefusal(text,entries)).join('\n');
|
|
596
620
|
return denialLine(others)?null:line;
|
|
597
621
|
}
|
|
598
|
-
//
|
|
622
|
+
// init's limit on each trial run, in ms; a test can shorten it.
|
|
623
|
+
export const trialLimit=process.env.VSTATE_TEST_FAULTS==='1'&&Number(process.env.VSTATE_TEST_TRIAL_LIMIT)||300000;
|
|
624
|
+
// {same, plain, sandboxed, line, refusedHidden, timedOut} or {issue} when the sandbox cannot run here.
|
|
625
|
+
// A run that reaches `timeout` (0: none) is never the same as another:
|
|
626
|
+
// `timedOut` says which, and when the plain run does, no sandboxed run follows.
|
|
599
627
|
// With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
|
|
600
628
|
// With `hide`, the git-ignored entries hiddenEntries lists are kept from the
|
|
601
629
|
// sandboxed run, and it also returns them, their count, the run times and, on
|
|
602
630
|
// Linux, the launcher's rule count and setup time.
|
|
603
631
|
// `plain`: the plain run of an earlier trial of the same check, reused.
|
|
604
|
-
export async function sandboxTrial(root,check,{timeout=
|
|
632
|
+
export async function sandboxTrial(root,check,{timeout=trialLimit,hide=false,plain:earlier=null}={}){
|
|
605
633
|
const supported=sandboxSupport();
|
|
606
634
|
if(supported.issue)return {issue:supported.issue};
|
|
607
635
|
const python=Boolean(pythonTool(check));
|
|
@@ -621,6 +649,7 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
621
649
|
const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
|
|
622
650
|
return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
|
|
623
651
|
const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
|
|
652
|
+
if(plain.status==='timeout')return {same:false,plain:'timeout',sandboxed:null,timedOut:'plain',line:null,refusedHidden:null,plainRun:plain};
|
|
624
653
|
const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
625
654
|
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
|
|
626
655
|
...hide==='docs'?{docs:{root,entries:docEntries}}:{},
|
|
@@ -644,8 +673,9 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
644
673
|
const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
|
|
645
674
|
const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
|
|
646
675
|
const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
|
|
647
|
-
const
|
|
648
|
-
|
|
676
|
+
const timedOut=sandboxed.status==='timeout'?'sandboxed':null;
|
|
677
|
+
const same=!timedOut&&plain.status===sandboxed.status&&!denied&&!differs&&!supervisor;
|
|
678
|
+
return {same,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,timedOut,
|
|
649
679
|
line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
|
|
650
680
|
refusedHidden:same?null:refusedHidden(plain,sandboxed,entries,supervisor),
|
|
651
681
|
...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
|
package/src/setup.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import {createHash} from 'node:crypto';
|
|
14
14
|
import {spawnSync} from 'node:child_process';
|
|
15
|
-
import {testCounts} from './sandbox.mjs';
|
|
15
|
+
import {isSandboxed,testCounts} from './sandbox.mjs';
|
|
16
16
|
import {createInterface} from 'node:readline/promises';
|
|
17
17
|
import {projectState} from './project-location.mjs';
|
|
18
18
|
import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
|
|
@@ -31,6 +31,18 @@ async function confirm(question,yes,{defaultYes}){
|
|
|
31
31
|
return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The setup record of the checkout at `root`, in its shared Git directory.
|
|
35
|
+
function recordOf(root){
|
|
36
|
+
const common=git(root,'rev-parse','--git-common-dir');
|
|
37
|
+
return common.status===0?path.join(path.resolve(root,common.stdout.trim()),`redue-setup-${hash(root).slice(0,16)}.json`):null;
|
|
38
|
+
}
|
|
39
|
+
// When init updates the config setup created, the record follows, so that
|
|
40
|
+
// redue remove still deletes it.
|
|
41
|
+
export function configRewritten(root,before,after){
|
|
42
|
+
const file=recordOf(root),record=file&&JSON.parse(readText(file)??'null');
|
|
43
|
+
if(record?.configCreated&&record.configHash===hash(before))fs.writeFileSync(file,JSON.stringify({...record,configHash:hash(after)},null,2)+'\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
export async function setupCommand(args,{entry,cwd=process.cwd()}){
|
|
35
47
|
const action=args.shift();
|
|
36
48
|
const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
|
|
@@ -50,8 +62,7 @@ export async function setupCommand(args,{entry,cwd=process.cwd()}){
|
|
|
50
62
|
stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
|
|
51
63
|
// One record per checkout, beside the others in the shared Git directory:
|
|
52
64
|
// linked worktrees share .git/info/exclude.
|
|
53
|
-
const
|
|
54
|
-
const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
|
|
65
|
+
const recordFile=recordOf(root),common=path.dirname(recordFile);
|
|
55
66
|
// Other checkouts' records, for checkouts that still exist.
|
|
56
67
|
const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
|
|
57
68
|
.map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
|
|
@@ -78,6 +89,12 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
|
78
89
|
const found=JSON.parse(preview.stdout);
|
|
79
90
|
if(!configExisted&&!found.writable)throw Error(`nothing to set up: ${found.issue||(found.checks.length?found.checks.map(check=>`${check.name}: ${check.reason}`).join('; '):'no typecheck, lint or test script REDUE can use')}`);
|
|
80
91
|
const levels=new Map(found.checks.map(check=>[check.name,check.level]));
|
|
92
|
+
// The checks whose trial ran past init's time limit, unless the config
|
|
93
|
+
// already runs them in the sandbox, and the kinds init's --check takes.
|
|
94
|
+
const sandboxed=new Set(configExisted?JSON.parse(readText(configPath)).checks.filter(check=>isSandboxed(check.qualification)).map(check=>check.name):[]);
|
|
95
|
+
const limited=[...found.checks.map(check=>[check.name,check.time_limit]),...(found.skipped||[]).map(row=>[row.kind,row.time_limit])]
|
|
96
|
+
.filter(([name,kind])=>kind&&!sandboxed.has(name));
|
|
97
|
+
const limitedKinds=[...new Set(limited.map(([,kind])=>kind))];
|
|
81
98
|
const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
|
|
82
99
|
const ready=names.filter(name=>levels.get(name)==='ready');
|
|
83
100
|
console.log(`Setting up REDUE for Claude Code in ${root}`);
|
|
@@ -161,6 +178,8 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
|
161
178
|
if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
|
|
162
179
|
for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
|
|
163
180
|
levels.get(row.name)!=='ready'?' — recording only':' — '+row.reason}`);
|
|
181
|
+
if(limited.length)console.log(`${limited.map(([name])=>name).join(', ')} ran past init's 5-minute trial limit, so ${limited.length>1?'they are':'it is'} not sandboxed. `+
|
|
182
|
+
`To try ${limited.length>1?'them':'it'} without the limit: redue init${limitedKinds.length>1?'':` --check ${limitedKinds[0]}`} --no-time-limit`);
|
|
164
183
|
console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
|
|
165
184
|
return {applied:true};
|
|
166
185
|
}
|