@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/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=combinedFiles(name);
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
- row.staleInputs=[visibleChange(row,r,name)];row.staleReason=describeChangedInput(row.plan,row.staleInputs[0]);row.staleReasonKey=key;}
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=combinedFiles(name);
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=combinedFiles(name),files=Object.keys(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,
@@ -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!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
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`, or from .venv/bin.
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=text.split(' ');
38
- if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
39
- const args=words.slice(1+sub.length);
40
- if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?: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)||arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
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)texts.push(read(path.join(workflows,file)));
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
- for(const text of texts)for(const line of text.split(/\r?\n/)){
59
- if(/^\s*#/.test(line))continue;
60
- const args=toolArgs(line,spec);
61
- if(args)return {args,source:'project'};
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
- export function sandboxSupport(){
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
- const r=spawnSync('git',[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'],
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
- const r=spawnSync('git',[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)],
288
- {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
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
- const found=covered.find(file=>!tracked||tracked.has(file));
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.