@redue/cli 0.1.0-alpha.31 → 0.1.0-alpha.33
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 +81 -0
- package/README.md +117 -43
- package/bin/redue.mjs +82 -3
- package/docs/user/agents.md +38 -1
- package/docs/user/configuration.md +8 -0
- package/docs/user/direct-typescript.md +3 -2
- package/docs/user/json.md +18 -0
- package/docs/user/sandbox.md +7 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +61 -17
- package/src/cached-state.mjs +9 -6
- package/src/claude-hook.mjs +185 -5
- 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/python-onboarding.mjs +54 -12
- package/src/sandbox.mjs +33 -9
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.');
|
|
@@ -20,9 +20,11 @@ export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',
|
|
|
20
20
|
const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
|
|
21
21
|
if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
|
|
22
22
|
return (end<0?rest:rest.slice(0,end)).join('\n');};
|
|
23
|
-
// Ways a project starts a tool: through uv, poetry or pdm, `python -m`,
|
|
23
|
+
// Ways a project starts a tool: through uv, poetry or pdm, `python -m`,
|
|
24
|
+
// coverage (`coverage run -m pytest`), or from .venv/bin.
|
|
24
25
|
const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|all-groups))*(?: --(?:group|extra|with|python|package) [^ ]+)* /,
|
|
25
26
|
/^poetry run /,/^pdm run /,/^python3?(?:\.\d+)? -m /,/^(?:\.\/)?\.venv\/bin\//,
|
|
27
|
+
/^coverage run(?: --?[A-Za-z][\w-]*(?:=[^ "']+)?)* -m /,
|
|
26
28
|
// A variable that names the environment's bin directory: `${PREFIX}mypy`.
|
|
27
29
|
/^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
|
|
28
30
|
// A tool's arguments in one line of a script or workflow, or null. The line
|
|
@@ -30,22 +32,51 @@ const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|
|
|
|
30
32
|
// ones that change files or install packages, and pytest's that run a set
|
|
31
33
|
// chosen from its cache (--lf, --ff, --sw, --nf), show the cache, loop
|
|
32
34
|
// (-f), rewrite snapshots or add to earlier coverage data, are not taken.
|
|
35
|
+
// A quoted argument (`-m "not slow"`) is one argument, and may hold spaces
|
|
36
|
+
// and parentheses, nothing a shell would expand. Options that only write a
|
|
37
|
+
// report (JUnit XML, HTML, durations) are left out: they change no result,
|
|
38
|
+
// and a report written into the checkout would be an input the run changes.
|
|
39
|
+
const reportOption=/^--(?:junit-?xml|html|self-contained-html|durations|durations-min|junit-prefix|report-log)(?:=|$)/;
|
|
40
|
+
const reportValue=/^--(?:junit-?xml|html|durations|durations-min|junit-prefix|report-log)$/;
|
|
41
|
+
function words(text){
|
|
42
|
+
const out=[];let current=null,quote=null;
|
|
43
|
+
for(const c of text){
|
|
44
|
+
if(quote){if(c===quote)quote=null;else current+=c;continue;}
|
|
45
|
+
if(c==='"'||c==="'"){quote=c;current??='';current+=`\u0000`;continue;}
|
|
46
|
+
if(c===' '){if(current!==null)out.push(current);current=null;continue;}
|
|
47
|
+
current=(current??'')+c;
|
|
48
|
+
}
|
|
49
|
+
if(quote)return null;
|
|
50
|
+
if(current!==null)out.push(current);
|
|
51
|
+
// A quoted word is marked with NUL while it is read: its text must be plain.
|
|
52
|
+
return out.map(word=>word.includes('\u0000')?{quoted:true,text:word.replaceAll('\u0000','')}:{quoted:false,text:word});
|
|
53
|
+
}
|
|
33
54
|
export function toolArgs(line,{tool,sub}){
|
|
34
55
|
let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
|
|
35
56
|
text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
|
|
36
57
|
for(let changed=true;changed;){changed=false;for(const wrapper of wrappers)if(wrapper.test(text)){text=text.replace(wrapper,'');changed=true;}}
|
|
37
|
-
const words
|
|
38
|
-
if(
|
|
39
|
-
const
|
|
40
|
-
if(
|
|
58
|
+
const parsed=words(text);
|
|
59
|
+
if(!parsed)return null;
|
|
60
|
+
const names=parsed.map(word=>word.text);
|
|
61
|
+
if(parsed.slice(0,1+sub.length).some(word=>word.quoted)||names[0]!==tool||JSON.stringify(names.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
|
|
62
|
+
const given=parsed.slice(1+sub.length);
|
|
63
|
+
if(given.some(({quoted,text:arg})=>!(quoted?/^[A-Za-z0-9_./:=,@+% ()-]+$/:/^[A-Za-z0-9_./:=,@+%-]+$/).test(arg)||
|
|
64
|
+
/^--(?:fix|unsafe-fixes|write|install-types|watch|lf|last-failed|ff|failed-first|sw|sw-skip|sw-reset|stepwise|stepwise-skip|stepwise-reset|nf|new-first|cache-show|looponfail|snapshot-update|update-snapshots|inline-snapshot|cov-append)(?:=|$)/.test(arg)||
|
|
65
|
+
arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
|
|
66
|
+
const args=[];
|
|
67
|
+
for(let i=0;i<given.length;i++){
|
|
68
|
+
if(reportOption.test(given[i].text)){if(reportValue.test(given[i].text))i++;continue;}
|
|
69
|
+
args.push(given[i].text);
|
|
70
|
+
}
|
|
41
71
|
return args;
|
|
42
72
|
}
|
|
43
73
|
// The places a project writes how it runs its tools, in order of preference.
|
|
74
|
+
// A workflow that runs on pull requests is marked `pr`.
|
|
44
75
|
function scriptTexts(root){
|
|
45
76
|
const texts=[];
|
|
46
77
|
const workflows=path.join(root,'.github','workflows');
|
|
47
78
|
let files=[];try{files=fs.readdirSync(workflows).filter(file=>/\.ya?ml$/.test(file)).sort();}catch{}
|
|
48
|
-
for(const file of files)
|
|
79
|
+
for(const file of files){const text=read(path.join(workflows,file));texts.push({text,pr:/\bpull_request(?:_target)?\b/.test(text)});}
|
|
49
80
|
for(const file of ['Makefile','makefile','justfile','tox.ini','noxfile.py'])texts.push(read(path.join(root,file)));
|
|
50
81
|
// Shell scripts: *.sh, or a file whose #! line runs a shell (scripts/check).
|
|
51
82
|
for(const dir of ['scripts','script','bin','ci']){let names=[];try{names=fs.readdirSync(path.join(root,dir)).sort();}catch{}
|
|
@@ -53,13 +84,24 @@ function scriptTexts(root){
|
|
|
53
84
|
if(name.endsWith('.sh')||/^#!\S*(?:\/|env\s+)(?:ba|z|da)?sh\b/.test(text))texts.push(text);}}
|
|
54
85
|
return texts;
|
|
55
86
|
}
|
|
56
|
-
// The arguments the project runs `tool` with, or its default
|
|
87
|
+
// The arguments the project runs `tool` with, or its default: the first line
|
|
88
|
+
// that runs it, but a pull-request workflow's when it narrows the suite (-m,
|
|
89
|
+
// -k, --ignore, --deselect), as a project's quick tests leave out slow ones.
|
|
90
|
+
// A line continued with a backslash is one line.
|
|
91
|
+
const narrows=args=>args.some(arg=>/^(?:-m|-k|--ignore|--ignore-glob|--deselect)(?:=|$)/.test(arg));
|
|
57
92
|
export function projectArgs(root,spec,texts=scriptTexts(root)){
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
93
|
+
let first=null;
|
|
94
|
+
for(const entry of texts){
|
|
95
|
+
const {text,pr}=typeof entry==='string'?{text:entry,pr:false}:entry;
|
|
96
|
+
for(const line of text.replace(/\\\r?\n[ \t]*/g,' ').split(/\r?\n/)){
|
|
97
|
+
if(/^\s*#/.test(line))continue;
|
|
98
|
+
const args=toolArgs(line,spec);
|
|
99
|
+
if(!args)continue;
|
|
100
|
+
if(pr&&narrows(args))return {args,source:'project'};
|
|
101
|
+
first??={args,source:'project'};
|
|
102
|
+
}
|
|
62
103
|
}
|
|
104
|
+
if(first)return first;
|
|
63
105
|
if(spec.tool!=='mypy')return {args:[],source:'default'};
|
|
64
106
|
// mypy without targets needs `files` in its configuration.
|
|
65
107
|
const configured=[section(read(path.join(root,'pyproject.toml')),'[tool.mypy]'),section(read(path.join(root,'mypy.ini')),'[mypy]'),
|
|
@@ -83,7 +125,7 @@ export function discoverPythonProject(root){
|
|
|
83
125
|
const several=found.filter(other=>other.kind===spec.kind).length>1;
|
|
84
126
|
checks.push({config:{name:several?`${spec.kind}:${spec.tool}`:spec.kind,kind:spec.kind,qualification:sandboxQualification,
|
|
85
127
|
command:[`.venv/bin/${spec.tool}`,...spec.sub,...args],...sandboxCheckInputs()},level:'ready',qualification:sandboxQualification,
|
|
86
|
-
reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
|
|
128
|
+
reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].map(arg=>/\s/.test(arg)?JSON.stringify(arg):arg).join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
|
|
87
129
|
}
|
|
88
130
|
return {root,checks,skipped};
|
|
89
131
|
}
|
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.
|