@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +831 -0
- package/README.md +157 -99
- package/bin/redue.mjs +322 -66
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +50 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +326 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +659 -0
- package/src/setup.mjs +224 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/sandbox.mjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// The sandboxed recipe: a typecheck or lint command run inside an OS sandbox
|
|
2
|
+
// that lets it read only the checkout, its installed packages, the toolchain,
|
|
3
|
+
// system directories and a private home and temporary directory, with no
|
|
4
|
+
// network: Seatbelt (sandbox-exec) on macOS, Landlock (landlock.py) on Linux.
|
|
5
|
+
// The sandbox holds for every process the run starts, so programs REDUE
|
|
6
|
+
// cannot record (native compilers and linters, Python tools) are covered.
|
|
7
|
+
// The claim: the result holds for a run that sees only the project, its
|
|
8
|
+
// installed packages and the toolchain, the way CI does.
|
|
9
|
+
//
|
|
10
|
+
// The run gets a controlled environment: the check's declared variables and
|
|
11
|
+
// prefixes, a normalized PATH, a private HOME kept between runs, and a fresh
|
|
12
|
+
// TMPDIR. All of it is fingerprinted (sandboxContext), with the identity of
|
|
13
|
+
// the toolchain's executables.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import {createHash} from 'node:crypto';
|
|
18
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
19
|
+
import {fileURLToPath} from 'node:url';
|
|
20
|
+
import {lookup,scriptCaches,scriptCheckInputs,scriptProject,toolchainDirs} from './node-script.mjs';
|
|
21
|
+
import {displayVariables} from './test-direct.mjs';
|
|
22
|
+
import picomatch from 'picomatch';
|
|
23
|
+
|
|
24
|
+
export const sandboxQualification='sandbox-v1';
|
|
25
|
+
export const sandboxInterpretation='sandbox@1';
|
|
26
|
+
// The same recipe with the checkout's git-ignored files hidden from the run
|
|
27
|
+
// (hiddenEntries): they are not its inputs, so build and test outputs do not
|
|
28
|
+
// make it STALE. init gives it when the run gives the same result that way.
|
|
29
|
+
export const hiddenQualification='sandbox-v2';
|
|
30
|
+
export const hiddenInterpretation='sandbox@2';
|
|
31
|
+
export const hiddenListIssue='git cannot list the ignored files';
|
|
32
|
+
// And the project's documentation hidden too (docPatterns): an agent edits it
|
|
33
|
+
// often, and a typecheck, lint or test suite seldom reads it.
|
|
34
|
+
export const docsQualification='sandbox-v3';
|
|
35
|
+
export const docsInterpretation='sandbox@3';
|
|
36
|
+
// Under docs/, only documentation files: code there (examples a typecheck
|
|
37
|
+
// includes) stays visible.
|
|
38
|
+
const docsFolderTypes=['txt','adoc','png','jpg','jpeg','gif','svg','webp','pdf'];
|
|
39
|
+
export const docPatterns=['**/*.md','**/*.mdx','**/*.rst','**/LICENSE*','**/CHANGELOG*',...docsFolderTypes.map(type=>`docs/**/*.${type}`)];
|
|
40
|
+
export const hiddenDocsIssue='git cannot list the documentation';
|
|
41
|
+
// What macOS refuses by path pattern: the extensions, in any case (Seatbelt
|
|
42
|
+
// matches regexes without regard to case). Every other listed file
|
|
43
|
+
// (LICENSE*, CHANGELOG*) it refuses by its path; a pattern would refuse
|
|
44
|
+
// license-check.ts too.
|
|
45
|
+
const docsByPattern=rel=>/^(.+\/)?[^/]*\.(md|mdx|rst)$/i.test(rel)||new RegExp(`^docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`,'i').test(rel);
|
|
46
|
+
// One path each makes the profile larger and slower to compile (1,000 add
|
|
47
|
+
// about 190 ms to a run; a few thousand do not compile). Past this many,
|
|
48
|
+
// those files stay visible and count as inputs, on every platform.
|
|
49
|
+
export const namedDocsLimit=100;
|
|
50
|
+
export const isSandboxed=qualification=>[sandboxQualification,hiddenQualification,docsQualification].includes(qualification);
|
|
51
|
+
// Whether the recipe hides the checkout's git-ignored entries (v2 and v3).
|
|
52
|
+
export const hidesIgnored=qualification=>qualification===hiddenQualification||qualification===docsQualification;
|
|
53
|
+
// A macOS test run has had no TCP since alpha.29: its own interpretation, so a
|
|
54
|
+
// result recorded with loopback is not the same plan.
|
|
55
|
+
export const sandboxInterpretationOf=(qualification,kind=null,platform=process.platform)=>
|
|
56
|
+
(qualification===docsQualification?docsInterpretation:qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation)+
|
|
57
|
+
(kind==='test'&&platform==='darwin'?'+no-tcp':'');
|
|
58
|
+
export const sandboxKinds=['typecheck','lint','test'];
|
|
59
|
+
// A test run gets a private /tmp (sandboxArgv's `writes`) and, on Linux,
|
|
60
|
+
// supervised loopback networking (`network`); a typecheck or lint, and a
|
|
61
|
+
// test run on macOS, get no network but Unix sockets.
|
|
62
|
+
export const testRun=check=>check?.kind==='test';
|
|
63
|
+
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
64
|
+
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
65
|
+
const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
|
|
66
|
+
const within=(file,dir)=>file===dir||file.startsWith(dir.endsWith('/')?dir:dir+'/');
|
|
67
|
+
const landlockScript=path.join(path.dirname(fileURLToPath(import.meta.url)),'landlock.py');
|
|
68
|
+
|
|
69
|
+
// Python's caches in the checkout, as runner caches: not inputs.
|
|
70
|
+
const pythonCaches=['**/__pycache__/**','**/.mypy_cache/**','**/.ruff_cache/**','**/.pytest_cache/**'];
|
|
71
|
+
export const sandboxCaches=[...scriptCaches,...pythonCaches];
|
|
72
|
+
// The project's virtual environment: installed files, observed by identity,
|
|
73
|
+
// without Python's bytecode caches. Its interpreter links lead out of the
|
|
74
|
+
// checkout to the interpreter it was made from, which sandboxContext
|
|
75
|
+
// identifies instead.
|
|
76
|
+
// Python 3.14 adds a `𝜋thon` link beside `python`.
|
|
77
|
+
const venvInstalled=['.venv/**','!.venv/**/__pycache__/**','!.venv/bin/python*','!.venv/bin/pypy*','!.venv/bin/𝜋thon*'];
|
|
78
|
+
// Inputs: the whole checkout, git-ignored files included, except .git, the
|
|
79
|
+
// installed trees (node_modules, .venv) and runner caches.
|
|
80
|
+
export function sandboxCheckInputs(dirs=[]){
|
|
81
|
+
const base=scriptCheckInputs(dirs);
|
|
82
|
+
return {inputs:[...base.inputs,'!.venv/**',...pythonCaches.map(pattern=>`!${pattern}`)],
|
|
83
|
+
installedInputs:[...base.installedInputs,...venvInstalled]};
|
|
84
|
+
}
|
|
85
|
+
// A Python check runs a tool from the project's .venv: `.venv/bin/TOOL ARGS`.
|
|
86
|
+
export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')?
|
|
87
|
+
path.basename(check.command[0]):null;
|
|
88
|
+
// The variables a run gets from the caller, compared before reuse. HOME,
|
|
89
|
+
// TMPDIR, PATH and the XDG directories are set by REDUE (sandboxEnv). A
|
|
90
|
+
// script check names its package manager first among the executables, as the
|
|
91
|
+
// general recipe does.
|
|
92
|
+
const managers=['npm','pnpm','yarn','corepack'];
|
|
93
|
+
export function sandboxEnvironment(python=false,manager=null,kind=null){
|
|
94
|
+
return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
|
|
95
|
+
prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
|
|
96
|
+
['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_'],
|
|
97
|
+
// A test runner's own variables.
|
|
98
|
+
...kind==='test'?python?['PYTEST_']:['VITEST_','JEST_']:[]],
|
|
99
|
+
...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Whether this machine can sandbox a run: {mode} or {issue}. Asked once per process.
|
|
103
|
+
let support=null;
|
|
104
|
+
export function sandboxSupport(){
|
|
105
|
+
if(support)return support;
|
|
106
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_SANDBOX==='unavailable')
|
|
107
|
+
return support={mode:null,issue:'the sandbox is turned off for this test'};
|
|
108
|
+
if(process.platform==='darwin'){
|
|
109
|
+
const r=spawnSync('/usr/bin/sandbox-exec',['-p','(version 1)(allow default)','/usr/bin/true'],{encoding:'utf8',timeout:10000});
|
|
110
|
+
// Seatbelt cannot start inside another Seatbelt sandbox (Claude Code's, for one).
|
|
111
|
+
return support=r.status===0?{mode:'seatbelt'}:{mode:null,issue:/Operation not permitted/.test(r.stderr||'')?
|
|
112
|
+
'sandbox-exec cannot start inside another sandbox':`sandbox-exec cannot start (${(r.stderr||r.error?.code||r.status||'').toString().trim().slice(0,120)})`};
|
|
113
|
+
}
|
|
114
|
+
if(process.platform==='linux'){
|
|
115
|
+
const python=launcherPython();
|
|
116
|
+
if(!python)return support={mode:null,issue:'the Landlock launcher needs Python 3'};
|
|
117
|
+
let abi=null;
|
|
118
|
+
try{abi=JSON.parse(spawnSync(python,['-I','-W','ignore',landlockScript,'--probe'],{encoding:'utf8',timeout:10000}).stdout).abi;}catch{}
|
|
119
|
+
if(!Number.isInteger(abi))return support={mode:null,issue:'Landlock is unavailable in this kernel'};
|
|
120
|
+
// ABI 4 (Linux 6.7) is the first that can refuse TCP connections.
|
|
121
|
+
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)`};
|
|
122
|
+
return support={mode:'landlock',abi,python};
|
|
123
|
+
}
|
|
124
|
+
return support={mode:null,issue:'the sandboxed recipe runs on macOS and Linux only'};
|
|
125
|
+
}
|
|
126
|
+
const launcherPython=()=>['/usr/bin/python3','/bin/python3'].find(file=>{try{fs.accessSync(file,fs.constants.X_OK);return true;}catch{return false;}})||
|
|
127
|
+
lookup('python3',process.env.PATH,process.cwd());
|
|
128
|
+
|
|
129
|
+
export const systemPlaces=process.platform==='darwin'?
|
|
130
|
+
['/usr','/bin','/sbin','/System','/Library','/Applications','/opt/homebrew','/private/etc','/private/var/db','/private/var/select','/dev']:
|
|
131
|
+
['/usr','/bin','/sbin','/lib','/lib32','/lib64','/libx32','/etc','/proc','/sys','/dev','/run'];
|
|
132
|
+
// The PATH a run gets: the caller's, without the project's .venv/bin, which
|
|
133
|
+
// `uv run`, `poetry run` and an activated environment put first; a Python
|
|
134
|
+
// check gets it first again. So the forms of a command agree.
|
|
135
|
+
export function sandboxPath(check,env,root){
|
|
136
|
+
const venvBin=path.join(root,'.venv','bin');
|
|
137
|
+
const rest=String(env.PATH??'').split(path.delimiter).filter(dir=>dir&&path.resolve(root,dir)!==venvBin);
|
|
138
|
+
return [...pythonTool(check)?[venvBin]:[],...rest].join(path.delimiter);
|
|
139
|
+
}
|
|
140
|
+
// The interpreter a .venv was made from: its real executable and installation
|
|
141
|
+
// (pyvenv.cfg's `home` is the directory of the executable it was made with).
|
|
142
|
+
export function venvBase(root){
|
|
143
|
+
const venv=path.join(root,'.venv');
|
|
144
|
+
let home=null;
|
|
145
|
+
try{home=/^home\s*=\s*(.+)$/m.exec(fs.readFileSync(path.join(venv,'pyvenv.cfg'),'utf8'))?.[1].trim()??null;}catch{}
|
|
146
|
+
const executable=real(path.join(venv,'bin','python'));
|
|
147
|
+
return {home,executable,prefix:home&&path.isAbsolute(home)?path.dirname(home):null};
|
|
148
|
+
}
|
|
149
|
+
// Program files a run starts from outside the checkout: Node, the package
|
|
150
|
+
// managers, and a Python check's interpreter. Their identity is compared
|
|
151
|
+
// before reuse.
|
|
152
|
+
function toolchainExecutables(check,env,root){
|
|
153
|
+
if(pythonTool(check))return [venvBase(root).executable];
|
|
154
|
+
const searchPath=sandboxPath(check,env,root);
|
|
155
|
+
return ['node','npm','npx','pnpm','yarn','corepack'].map(name=>lookup(name,searchPath,root));
|
|
156
|
+
}
|
|
157
|
+
const sharedPrefixes=()=>{const home=os.homedir();return [home,path.join(home,'.local'),'/usr/local','/usr','/opt/homebrew','/'];};
|
|
158
|
+
// What a run may read and execute outside the checkout: the toolchain's
|
|
159
|
+
// installations and stores, and system directories.
|
|
160
|
+
export function sandboxPlaces(check,env,root){
|
|
161
|
+
const home=os.homedir(),places=new Set();
|
|
162
|
+
const add=dir=>{const resolved=dir&&path.isAbsolute(dir)?real(dir):null;
|
|
163
|
+
if(resolved&&resolved!=='/'&&resolved!==home&&!within(home,resolved))places.add(resolved);};
|
|
164
|
+
// An installation root (bin/..), or the program alone when bin/.. is shared (~/.local, /usr/local).
|
|
165
|
+
const addRoot=file=>{const dir=path.dirname(path.dirname(file));add(sharedPrefixes().includes(dir)?file:dir);};
|
|
166
|
+
for(const file of toolchainExecutables(check,env,root))if(file)addRoot(file);
|
|
167
|
+
if(pythonTool(check)){const base=venvBase(root);add(base.prefix);add(base.home);}
|
|
168
|
+
else{
|
|
169
|
+
for(const dir of toolchainDirs(env))add(dir);
|
|
170
|
+
const cache=path.isAbsolute(env.XDG_CACHE_HOME||'')?env.XDG_CACHE_HOME:path.join(home,'.cache');
|
|
171
|
+
add(path.isAbsolute(env.COREPACK_HOME||'')?env.COREPACK_HOME:path.join(cache,'node','corepack'));
|
|
172
|
+
add(path.join(home,'.yarn','berry'));
|
|
173
|
+
}
|
|
174
|
+
return [...places].sort();
|
|
175
|
+
}
|
|
176
|
+
// The environment a run gets.
|
|
177
|
+
export function sandboxEnv(check,env,{root,home,tmp}){
|
|
178
|
+
const variables=check.environment?.variables||[],prefixes=(check.environment?.prefixes||[]).map(prefix=>prefix.toLowerCase());
|
|
179
|
+
const out={};
|
|
180
|
+
for(const [key,value] of Object.entries(env))
|
|
181
|
+
if(variables.includes(key)||prefixes.some(prefix=>key.toLowerCase().startsWith(prefix))||displayVariables.includes(key))out[key]=value;
|
|
182
|
+
delete out._;delete out.REDUE_RUN_RECORD;
|
|
183
|
+
Object.assign(out,{PATH:sandboxPath(check,env,root),HOME:home,XDG_CONFIG_HOME:path.join(home,'.config'),
|
|
184
|
+
XDG_CACHE_HOME:path.join(home,'.cache'),XDG_DATA_HOME:path.join(home,'.local','share'),XDG_STATE_HOME:path.join(home,'.local','state'),
|
|
185
|
+
TMPDIR:tmp,TMP:tmp,TEMP:tmp,
|
|
186
|
+
// The checkout's .git is not an input. Tools may still see it, as in CI,
|
|
187
|
+
// but git commands find no repository.
|
|
188
|
+
GIT_DIR:path.join(home,'.no-repository'),GIT_CEILING_DIRECTORIES:path.dirname(root)});
|
|
189
|
+
if(pythonTool(check))out.VIRTUAL_ENV=path.join(root,'.venv');
|
|
190
|
+
else{
|
|
191
|
+
// Corepack's downloads stay where Corepack keeps them; npm's and pnpm's
|
|
192
|
+
// update checks read the network; Nx would hand work to its daemon.
|
|
193
|
+
const cache=path.isAbsolute(env.XDG_CACHE_HOME||'')?env.XDG_CACHE_HOME:path.join(os.homedir(),'.cache');
|
|
194
|
+
out.COREPACK_HOME=path.isAbsolute(env.COREPACK_HOME||'')?env.COREPACK_HOME:path.join(cache,'node','corepack');
|
|
195
|
+
Object.assign(out,{npm_config_update_notifier:'false',NX_DAEMON:'false'});
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
// The files in the repository's .git that tools read to choose files (ruff's
|
|
200
|
+
// and biome's ignore rules), and that decide what sandbox-v2 hides: its
|
|
201
|
+
// exclude file and config. For a project in a subdirectory of the
|
|
202
|
+
// repository, also the .gitignore files above it. The observer watches
|
|
203
|
+
// neither, so they are compared as context.
|
|
204
|
+
function gitIgnoreSources(root){
|
|
205
|
+
let top=root;
|
|
206
|
+
while(!fs.existsSync(path.join(top,'.git'))){const up=path.dirname(top);if(up===top)return [];top=up;}
|
|
207
|
+
let dir=path.join(top,'.git');
|
|
208
|
+
try{if(fs.statSync(dir).isFile()){
|
|
209
|
+
const gitdir=path.resolve(top,/^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dir,'utf8'))?.[1].trim()??'');
|
|
210
|
+
let common=gitdir;try{common=path.resolve(gitdir,fs.readFileSync(path.join(gitdir,'commondir'),'utf8').trim());}catch{}
|
|
211
|
+
dir=common;}}catch{return [];}
|
|
212
|
+
const read=file=>{try{return digest(fs.readFileSync(file));}catch{return null;}};
|
|
213
|
+
const above=[];
|
|
214
|
+
for(let at=path.dirname(root);top!==root&&(at===top||at.startsWith(top+path.sep));at=path.dirname(at)){
|
|
215
|
+
above.push([path.relative(top,path.join(at,'.gitignore')),read(path.join(at,'.gitignore'))]);
|
|
216
|
+
if(at===top)break;
|
|
217
|
+
}
|
|
218
|
+
return [...['info/exclude','config'].map(rel=>[rel,read(path.join(dir,rel))]),...above];
|
|
219
|
+
}
|
|
220
|
+
// What a sandboxed result depends on outside the observed files, compared
|
|
221
|
+
// before reuse, one entry each so a change can be named (sandboxChange): every
|
|
222
|
+
// variable the run gets (but its private directories and display variables),
|
|
223
|
+
// the places it may read, the identity of the toolchain's executables and of
|
|
224
|
+
// every directory on PATH (an installed or upgraded program changes its
|
|
225
|
+
// directory), and the ignore rules in .git.
|
|
226
|
+
export const sandboxPrefix='__redue_sandbox_';
|
|
227
|
+
export function sandboxContext(check,env,root){
|
|
228
|
+
const identity=(file,keys=['dev','ino','mode','size','mtimeNs','ctimeNs'])=>{try{const st=fs.statSync(file,{bigint:true});
|
|
229
|
+
return [file,...keys.map(key=>String(st[key]))];}catch{return [file,'<unavailable>'];}};
|
|
230
|
+
const environment=sandboxEnv(check,env,{root,home:'<home>',tmp:'<tmp>'});
|
|
231
|
+
for(const key of displayVariables)delete environment[key];
|
|
232
|
+
const out={[`${sandboxPrefix}places__`]:digest(JSON.stringify(sandboxPlaces(check,env,root))),
|
|
233
|
+
[`${sandboxPrefix}executables__`]:digest(JSON.stringify(toolchainExecutables(check,env,root).map(file=>file?identity(file):null))),
|
|
234
|
+
[`${sandboxPrefix}git__`]:digest(JSON.stringify(gitIgnoreSources(root)))};
|
|
235
|
+
for(const [key,value] of Object.entries(environment))out[`${sandboxPrefix}variable:${key}__`]=digest(value);
|
|
236
|
+
for(const dir of environment.PATH.split(path.delimiter).filter(dir=>path.isAbsolute(dir)))
|
|
237
|
+
out[`${sandboxPrefix}directory:${dir}__`]=digest(JSON.stringify(identity(dir,['dev','ino','mtimeNs','ctimeNs'])));
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
// What changed between two sandboxed contexts, in words, or null.
|
|
241
|
+
export function sandboxChange(before={},now={}){
|
|
242
|
+
const keys=[...new Set([...Object.keys(before),...Object.keys(now)])].filter(key=>key.startsWith(sandboxPrefix)).sort();
|
|
243
|
+
const key=keys.find(key=>before[key]!==now[key]);
|
|
244
|
+
if(!key)return null;
|
|
245
|
+
const name=key.slice(sandboxPrefix.length,-2);
|
|
246
|
+
return name==='places'?'the places a sandboxed run may read changed':
|
|
247
|
+
name==='executables'?'Node, a package manager or the interpreter changed':
|
|
248
|
+
name==='git'?'.git/info/exclude or .git/config changed':
|
|
249
|
+
name.startsWith('variable:')?`environment variable ${name.slice(9)} differs from the last run`:
|
|
250
|
+
!(key in now)?`PATH no longer has directory ${name.slice(10)}`:!(key in before)?`PATH has a new directory ${name.slice(10)}`:
|
|
251
|
+
`PATH directory ${name.slice(10)} changed (a program in it was installed, removed or upgraded)`;
|
|
252
|
+
}
|
|
253
|
+
// A run's private directories, in REDUE's state: the home it keeps between
|
|
254
|
+
// runs, and a fresh temporary directory.
|
|
255
|
+
// The temporary directory is under the system's, so a Unix socket path in it
|
|
256
|
+
// stays short enough.
|
|
257
|
+
export function sandboxDirs(state,name){
|
|
258
|
+
const home=path.join(state,'sandbox',digest(name).slice(0,16),'home');
|
|
259
|
+
fs.mkdirSync(home,{recursive:true,mode:0o700});
|
|
260
|
+
return {home,tmp:fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'redue-run-')))};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The checkout's git-ignored entries a run may be kept from reading: what
|
|
264
|
+
// `git ls-files --others --ignored --exclude-standard --directory` lists (a
|
|
265
|
+
// wholly ignored directory as one entry, ending in /), but the installed
|
|
266
|
+
// trees (node_modules, .venv) and runner caches. Relative to root. null when
|
|
267
|
+
// git cannot list them. Only the project's rules count (.gitignore files and
|
|
268
|
+
// .git/info/exclude), not a user's global excludes file, as on CI.
|
|
269
|
+
const installedTree=/(?:^|\/)(?:node_modules|\.venv)(?:\/|$)/;
|
|
270
|
+
const runnerCache=picomatch(sandboxCaches,{dot:true});
|
|
271
|
+
const projectGit=['-c','core.excludesFile=/dev/null'],gitEnv=()=>({...process.env,GIT_OPTIONAL_LOCKS:'0'});
|
|
272
|
+
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'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The documentation files docPatterns names among the files git lists and
|
|
281
|
+
// does not ignore (git-ignored ones are hidden as such), as regular files:
|
|
282
|
+
// a link is not hidden. Files, never folders, so a folder stays listable.
|
|
283
|
+
// Relative; null when git cannot tell. git filters by pathspec, so a large
|
|
284
|
+
// checkout is not listed whole.
|
|
285
|
+
const docMatch=picomatch(docPatterns,{dot:true});
|
|
286
|
+
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()});
|
|
289
|
+
if(r.status!==0)return null;
|
|
290
|
+
const out=new Set();
|
|
291
|
+
for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
|
|
292
|
+
try{if(fs.lstatSync(path.join(root,rel)).isFile())out.add(rel);}catch{}
|
|
293
|
+
const named=[...out].filter(rel=>!docsByPattern(rel));
|
|
294
|
+
if(named.length>namedDocsLimit)for(const rel of named)out.delete(rel);
|
|
295
|
+
return [...out].sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// What git lists now as ignored at or beneath `paths` (relative files, or
|
|
299
|
+
// directories ending in /), as hiddenEntries would: nested repositories are
|
|
300
|
+
// not entered, and an entry that is an installed tree is not ignored (one
|
|
301
|
+
// inside an ignored build directory is). {has(rel)} for a file, or null when
|
|
302
|
+
// git cannot tell. A directory git ignores holds no file it does not, so
|
|
303
|
+
// asking a new directory once covers its files.
|
|
304
|
+
export function ignoredNow(root,paths){
|
|
305
|
+
const asked=paths;
|
|
306
|
+
if(!asked.length)return {has:()=>false};
|
|
307
|
+
const listed=new Set();
|
|
308
|
+
for(let at=0;at<asked.length;at+=500){
|
|
309
|
+
const r=spawnSync('git',[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory','--',...asked.slice(at,at+500)],
|
|
310
|
+
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
311
|
+
if(r.status!==0)return null;
|
|
312
|
+
for(const entry of r.stdout.split('\0'))if(entry)listed.add(entry);
|
|
313
|
+
}
|
|
314
|
+
const entries=[...listed].filter(entry=>!installedTree.test(entry));
|
|
315
|
+
return {has:rel=>entries.some(entry=>rel===entry||entry.endsWith('/')&&rel.startsWith(entry))};
|
|
316
|
+
}
|
|
317
|
+
// The files git tracks now beneath `entries` (relative). null when git cannot tell.
|
|
318
|
+
export function trackedBeneath(root,entries){
|
|
319
|
+
if(!entries.length)return new Set();
|
|
320
|
+
const r=spawnSync('git',['ls-files','-z','--cached','--',...entries],
|
|
321
|
+
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
322
|
+
return r.status===0?new Set(r.stdout.split('\0').filter(Boolean)):null;
|
|
323
|
+
}
|
|
324
|
+
// For a run that hid `hidden` (relative entries), the first path of `paths`
|
|
325
|
+
// that changes its inputs, between `before` and `current` ({rel: identity}),
|
|
326
|
+
// or null. A file the run saw always counts. A file beneath a hidden entry
|
|
327
|
+
// counts only once git tracks it, and a new file only when git does not list
|
|
328
|
+
// it as ignored now, as the next run's hidden entries would not. When git
|
|
329
|
+
// cannot tell, every change counts. `external:` paths always count. With
|
|
330
|
+
// `during` (changes while the run ran), a new file outside the hidden
|
|
331
|
+
// entries counts too: the run could read it.
|
|
332
|
+
// With `docs` (sandbox@3: the documentation files the run hid, relative), a
|
|
333
|
+
// later change to one of those files does not count. A new documentation
|
|
334
|
+
// file does, and so does any change while the run ran: the run is not shown
|
|
335
|
+
// to have been kept from it.
|
|
336
|
+
export function visibleChange(root,hidden,before,current,paths,{during=false,docs=null}={}){
|
|
337
|
+
const entries=hidden.map(entry=>entry.replace(/\/$/,''));
|
|
338
|
+
const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
|
|
339
|
+
const hiddenDoc=new Set(during?[]:docs||[]);
|
|
340
|
+
const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]&&!hiddenDoc.has(file)).sort();
|
|
341
|
+
const seen=changed.filter(file=>file in before&&!beneath(file));
|
|
342
|
+
if(seen.length)return seen[0];
|
|
343
|
+
const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
|
|
344
|
+
if(covered.length){
|
|
345
|
+
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));
|
|
347
|
+
if(found)return found;
|
|
348
|
+
}
|
|
349
|
+
if(!fresh.length)return null;
|
|
350
|
+
if(during)return fresh[0];
|
|
351
|
+
// Asked once per directory the run had no file in, then the files outside one.
|
|
352
|
+
const dirs=new Set(Object.keys(before).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
|
|
353
|
+
const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!dirs.has(dir))return dir+'/';}return null;};
|
|
354
|
+
const ignored=ignoredNow(root,[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))]);
|
|
355
|
+
return fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
|
|
356
|
+
}
|
|
357
|
+
// A refusal line that names a hidden entry (relative): a refusal by the
|
|
358
|
+
// sandbox, or a missing file, which Linux tools report for an unreadable one.
|
|
359
|
+
export function hiddenRefusal(text,hidden){
|
|
360
|
+
const names=hidden.map(file=>file.replace(/\/$/,'')).filter(Boolean)
|
|
361
|
+
.map(name=>new RegExp(`(?:^|[\\s'"\`(=/])${escape(name)}(?:[/\\s'"\`):,]|$)`));
|
|
362
|
+
for(const line of String(text).split(/\r?\n/))
|
|
363
|
+
if((denial.test(line)||missing.test(line))&&names.some(name=>name.test(line)))return line.trim().slice(0,200);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=path.dirname(dir);at!=='/'&&at!=='.';at=path.dirname(at))out.push(at);return out;}))];
|
|
368
|
+
// The Seatbelt profile: reads and executes only beneath the places, the
|
|
369
|
+
// metadata of their parent directories, and no network but Unix sockets in
|
|
370
|
+
// the run's own temporary directory (`own`), which is new for each run: a
|
|
371
|
+
// daemon an earlier run left behind cannot answer a later one. `hidden`:
|
|
372
|
+
// absolute paths whose contents it may not read or execute.
|
|
373
|
+
// A test run gets no TCP either: Seatbelt cannot refuse one loopback port
|
|
374
|
+
// while allowing the others (a per-port deny under a loopback allow is not
|
|
375
|
+
// applied; an allow list of 65,000 ports does not compile), so a suite could
|
|
376
|
+
// reach a database started outside the run. A suite that needs loopback
|
|
377
|
+
// fails init's trial and runs plainly. With `writes`, a test run writes
|
|
378
|
+
// nothing beneath /tmp and /var/tmp but beneath those places (its checkout,
|
|
379
|
+
// home and own temporary directory).
|
|
380
|
+
// `docs` ({root, entries}): the checkout's documentation it may not read,
|
|
381
|
+
// regular files only, so folders stay listable. Extensions go by path
|
|
382
|
+
// pattern, so the profile stays small however many files match: every file
|
|
383
|
+
// hiddenDocs lists, and more (one in node_modules, README.MD). Seatbelt
|
|
384
|
+
// matches a regex without regard to case, so LICENSE* and CHANGELOG* go by
|
|
385
|
+
// the files hiddenDocs listed: a pattern would hide license-check.ts too.
|
|
386
|
+
export function seatbeltProfile(reads,own=null,hidden=[],{writes=null,docs=null}={}){
|
|
387
|
+
const quote=value=>JSON.stringify(value);
|
|
388
|
+
const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
389
|
+
const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
390
|
+
return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
|
|
391
|
+
`(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
|
|
392
|
+
`(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
|
|
393
|
+
// The entry's own path, by its directory's real path: a hidden link's target is not hidden.
|
|
394
|
+
// Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
|
|
395
|
+
...hidden.length?[`(deny file-read-data process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
|
|
396
|
+
.map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
|
|
397
|
+
...docs?[docsRule(docs,both([docs.root]))]:[],
|
|
398
|
+
'(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
|
|
399
|
+
...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
|
|
400
|
+
`(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
|
|
401
|
+
}
|
|
402
|
+
// docsByPattern's patterns for each root, and each listed file they do not cover by its path.
|
|
403
|
+
function docsRule(docs,roots){
|
|
404
|
+
const named=docs.entries.filter(rel=>!docsByPattern(rel));
|
|
405
|
+
const patterns=roots.map(dir=>escape(dir)).flatMap(dir=>[`^${dir}/(.+/)?[^/]*\\.(md|mdx|rst)$`,`^${dir}/docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`])
|
|
406
|
+
.map(pattern=>`(regex #"${pattern.replace(/"/g,'\\"')}")`);
|
|
407
|
+
const files=roots.flatMap(dir=>named.map(rel=>`(literal ${JSON.stringify(path.join(dir,rel))})`));
|
|
408
|
+
return `(deny file-read-data process-exec* (require-all (vnode-type REGULAR-FILE) (require-any ${[...patterns,...files].join(' ')})))`;
|
|
409
|
+
}
|
|
410
|
+
// The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
|
|
411
|
+
// Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
|
|
412
|
+
// On Linux a hidden directory can still be listed and its entries stat'ed:
|
|
413
|
+
// Landlock restricts neither beneath a listable parent.
|
|
414
|
+
// `network` "loopback" and `writes`: a test run's. Only Linux gives it a
|
|
415
|
+
// network (seatbeltProfile says why macOS does not), supervised: `stats`
|
|
416
|
+
// then says whether the supervisor served the whole run (supervisorIssue).
|
|
417
|
+
// `docs` ({root, entries}): the checkout's documentation, hidden too.
|
|
418
|
+
export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null,docs=null}){
|
|
419
|
+
if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{writes,docs}),...argv];
|
|
420
|
+
if(docs)hidden=[...hidden,...docs.entries.map(rel=>path.join(docs.root,rel.replace(/\/$/,'')))];
|
|
421
|
+
// The launcher's own warnings would become the check's output.
|
|
422
|
+
if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
|
|
423
|
+
...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
|
|
424
|
+
...network==='loopback'?['--loopback']:['--no-tcp'],
|
|
425
|
+
...writes?['--deny-write','/tmp','--deny-write','/var/tmp',...writes.flatMap(dir=>['--write',dir])]:[],
|
|
426
|
+
...fault?['--test-fault',fault]:[],'--',...argv];
|
|
427
|
+
throw Error(`unknown sandbox ${mode}`);
|
|
428
|
+
}
|
|
429
|
+
// Why a supervised run's network was not supervised throughout, from the
|
|
430
|
+
// launcher's stats file, or null when it was.
|
|
431
|
+
export function supervisorIssue(stats){
|
|
432
|
+
let value=null;try{value=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
433
|
+
return value?.supervisor==='ok'?null:`the network supervisor ${value?.supervisor?`reported: ${value.supervisor}`:'left no report'}`;
|
|
434
|
+
}
|
|
435
|
+
// The counts a test runner's summary gives, the last of each: pytest, Vitest,
|
|
436
|
+
// Jest, Mocha, node --test.
|
|
437
|
+
const ansi=/\u001b\[[0-9;]*m/g;
|
|
438
|
+
export function testCounts(text){
|
|
439
|
+
const plain=String(text).replace(ansi,'');
|
|
440
|
+
const last=(...patterns)=>{for(const re of patterns){let m,v=null;const g=new RegExp(re,'gim');while((m=g.exec(plain)))v=Number(m[1]);if(v!==null)return v;}return null;};
|
|
441
|
+
return {passed:last('(\\d+) (?:tests? )?(?:passed|passing)\\b','^(?:#|ℹ) pass (\\d+)'),failed:last('(\\d+) (?:tests? )?(?:failed|failing)\\b','^(?:#|ℹ) fail (\\d+)'),
|
|
442
|
+
skipped:last('(\\d+) (?:tests? )?(?:skipped|pending|todo)\\b','^(?:#|ℹ) (?:skip|skipped) (\\d+)')};
|
|
443
|
+
}
|
|
444
|
+
// A line of a failed run's output that looks like the sandbox refused
|
|
445
|
+
// something: a refused read or write, or the network unreachable. Such a
|
|
446
|
+
// failure is run again outside the sandbox to tell a denial from a real one.
|
|
447
|
+
const denial=/operation not permitted|permission denied|read-only file system|\bE(?:PERM|ACCES|ROFS|NOTFOUND|AI_AGAIN|CONNREFUSED|NETUNREACH|HOSTUNREACH)\b|sandbox-exec:|landlock\.py:|network is unreachable|could not resolve host|name or service not known|nodename nor servname|temporary failure in name resolution|getaddrinfo|connection refused/i;
|
|
448
|
+
// A missing file counts only when the line names a path the run was kept
|
|
449
|
+
// from (`hidden`, relative to the checkout or absolute): Linux tools report
|
|
450
|
+
// an unreadable file as not found.
|
|
451
|
+
const missing=/not found|no such file|cannot find|can't find|could not find|cannot read|\bENOENT\b/i;
|
|
452
|
+
const escape=text=>text.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
453
|
+
export function denialLine(text,hidden=[]){
|
|
454
|
+
// A name as a whole path or path component, not inside a longer word.
|
|
455
|
+
const names=hidden.map(file=>file.replace(/\/$/,'')).filter(Boolean)
|
|
456
|
+
.map(name=>new RegExp(`(?:^|[\\s'"\`(=/])${escape(name)}(?:[/\\s'"\`):,]|$)`));
|
|
457
|
+
for(const line of String(text).split(/\r?\n/))
|
|
458
|
+
if(denial.test(line)||missing.test(line)&&names.some(name=>name.test(line)))return line.trim().slice(0,200);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Links in the .venv that lead out of the checkout, other than the
|
|
463
|
+
// interpreter's in bin/: REDUE cannot observe what they point to. Looks in
|
|
464
|
+
// the .venv itself, bin/, and the top of each site-packages.
|
|
465
|
+
export function venvOutsideLinks(root){
|
|
466
|
+
const venv=path.join(root,'.venv'),found=[],realRoot=real(root)||root;
|
|
467
|
+
const look=dir=>{let entries;try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{return;}
|
|
468
|
+
for(const entry of entries){
|
|
469
|
+
if(!entry.isSymbolicLink())continue;
|
|
470
|
+
const file=path.join(dir,entry.name),rel=path.relative(root,file).split(path.sep).join('/');
|
|
471
|
+
if(/^\.venv\/bin\/(?:python|pypy|𝜋thon)/.test(rel))continue;
|
|
472
|
+
const target=real(file);
|
|
473
|
+
if(!target||!within(target,realRoot))found.push(rel);
|
|
474
|
+
}};
|
|
475
|
+
look(venv);look(path.join(venv,'bin'));
|
|
476
|
+
// lib64 is often a link to lib: looked at once.
|
|
477
|
+
for(const lib of ['lib','lib64']){let versions=[];
|
|
478
|
+
try{if(lib==='lib64'&&fs.lstatSync(path.join(venv,lib)).isSymbolicLink())continue;versions=fs.readdirSync(path.join(venv,lib));}catch{}
|
|
479
|
+
for(const version of versions)look(path.join(venv,lib,version,'site-packages'));}
|
|
480
|
+
return [...new Set(found)].sort();
|
|
481
|
+
}
|
|
482
|
+
// Plan qualification: the check is what `init` writes for the project now.
|
|
483
|
+
export function qualifySandbox(root,selected){
|
|
484
|
+
const issues=[];
|
|
485
|
+
if(!['darwin','linux'].includes(process.platform))issues.push('the sandboxed recipe runs on macOS and Linux only');
|
|
486
|
+
if(!sandboxKinds.includes(selected.kind))issues.push(`the sandboxed recipe takes ${sandboxKinds.join(' and ')} checks`);
|
|
487
|
+
if(selected.cwd&&selected.cwd!=='.')issues.push('the sandboxed recipe runs checks at the project root');
|
|
488
|
+
const tool=pythonTool(selected);
|
|
489
|
+
try{
|
|
490
|
+
let dirs=[];
|
|
491
|
+
if(tool){
|
|
492
|
+
try{fs.accessSync(path.join(root,selected.command[0]),fs.constants.X_OK);}catch{fail(`${selected.command[0]} is not installed`);}
|
|
493
|
+
const base=venvBase(root);
|
|
494
|
+
if(!base.home||!base.executable)fail('.venv has no pyvenv.cfg home or bin/python');
|
|
495
|
+
// The interpreter's own site-packages are not observed.
|
|
496
|
+
if(/^include-system-site-packages\s*=\s*true\s*$/mi.test(fs.readFileSync(path.join(root,'.venv','pyvenv.cfg'),'utf8')))
|
|
497
|
+
issues.push('.venv uses the system site-packages, which REDUE does not observe');
|
|
498
|
+
for(const rel of venvOutsideLinks(root))issues.push(`${rel} links outside the checkout, where REDUE does not observe it`);
|
|
499
|
+
}else{
|
|
500
|
+
if(typeof selected.script!=='string')fail('script-missing');
|
|
501
|
+
const project=scriptProject(root,selected.script);
|
|
502
|
+
if(JSON.stringify(selected.command?.slice(1))!==JSON.stringify(['run',selected.script])||selected.environment?.pathExecutables?.[1]!==project.manager)
|
|
503
|
+
issues.push('the package manager or script changed; run redue init again');
|
|
504
|
+
dirs=project.dirs;
|
|
505
|
+
}
|
|
506
|
+
const expected=sandboxCheckInputs(dirs),sorted=list=>JSON.stringify([...(list||[])].sort());
|
|
507
|
+
if(sorted(selected.inputs)!==sorted(expected.inputs)||sorted(selected.installedInputs)!==sorted(expected.installedInputs))
|
|
508
|
+
issues.push('inputs must be the whole checkout, with node_modules and .venv observed as installed files; run redue init again');
|
|
509
|
+
const environment=sandboxEnvironment(Boolean(tool));
|
|
510
|
+
if(!environment.variables.every(key=>selected.environment?.variables?.includes(key))||
|
|
511
|
+
!environment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
|
|
512
|
+
issues.push('execution context incomplete; run redue init again');
|
|
513
|
+
}catch(e){issues.push(e.code||'sandboxed check prerequisites unavailable');}
|
|
514
|
+
if(selected.probes?.length)issues.push('the sandboxed recipe takes no probes');
|
|
515
|
+
if(selected.generatedInputs?.length)issues.push('the sandboxed recipe takes no generated inputs');
|
|
516
|
+
return {qualified:issues.length===0,issues};
|
|
517
|
+
}
|
|
518
|
+
// Installed paths a sandboxed plan leaves out: the .venv's links that lead out
|
|
519
|
+
// of the checkout, which the index cannot observe. Qualification reports them.
|
|
520
|
+
export const sandboxInstalledExclusions=root=>venvOutsideLinks(root).map(rel=>`!${rel}`);
|
|
521
|
+
// A run's output for comparison: without colors, timings, the temporary and
|
|
522
|
+
// home directories each run had, and lines about caches, which differ
|
|
523
|
+
// between two runs of the same check (a task runner's cache hit).
|
|
524
|
+
const units='(?:ns|µs|us|ms|s|sec|secs|seconds?|m|min|mins|minutes?|h)';
|
|
525
|
+
export function comparableOutput(text,paths=[]){
|
|
526
|
+
let out=String(text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g,'');
|
|
527
|
+
for(const [from,to] of paths.filter(([from])=>from).sort((a,b)=>b[0].length-a[0].length))out=out.split(from).join(to);
|
|
528
|
+
return out.split(/\r?\n/).filter(line=>!/\bcache[ds]?\b|\bcached\b/i.test(line))
|
|
529
|
+
.map(line=>line.replace(new RegExp(`\\d+(?:[.,]\\d+)?\\s?${units}\\b`,'g'),'<time>')
|
|
530
|
+
.replace(/\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?\b/g,'<clock>').replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g,'<date>').trimEnd())
|
|
531
|
+
.filter(Boolean).join('\n');
|
|
532
|
+
}
|
|
533
|
+
// `init` runs a proposed check both ways before it writes it: plainly, as the
|
|
534
|
+
// project would, and sandboxed. The check is proposed only when both give
|
|
535
|
+
// the same exit status and the same output (comparableOutput), and a failure
|
|
536
|
+
// shows no sign of a refusal by the sandbox; else the reason names the
|
|
537
|
+
// difference.
|
|
538
|
+
// Stops what a run left running when it ends, as a CI job's end does: the
|
|
539
|
+
// run's process group gets SIGTERM, and SIGKILL a second later if it still
|
|
540
|
+
// has members. Its pid is not signalled again once the group is empty, so a
|
|
541
|
+
// reused number is never hit. The timer keeps REDUE alive until then, and
|
|
542
|
+
// the promise settles then. On Linux the Landlock launcher also stops
|
|
543
|
+
// processes that left the group (landlock.py).
|
|
544
|
+
export function stopGroup(pid){
|
|
545
|
+
const signal=name=>{try{process.kill(-pid,name);return true;}catch{return false;}};
|
|
546
|
+
if(!signal('SIGTERM'))return Promise.resolve();
|
|
547
|
+
const until=Date.now()+1000;
|
|
548
|
+
return new Promise(resolve=>{const check=setInterval(()=>{
|
|
549
|
+
if(!signal(0)){clearInterval(check);resolve();return;}
|
|
550
|
+
if(Date.now()>=until){signal('SIGKILL');clearInterval(check);resolve();}
|
|
551
|
+
},100);});
|
|
552
|
+
}
|
|
553
|
+
// A sandboxed run's end: when the command exits, what it left running is
|
|
554
|
+
// stopped, and its pipes are closed a second later, so a helper that holds
|
|
555
|
+
// them open does not hold the run.
|
|
556
|
+
export function endWithChild(child){
|
|
557
|
+
child.on('exit',()=>{stopGroup(child.pid);setTimeout(()=>{child.stdout?.destroy();child.stderr?.destroy();},1000).unref();});
|
|
558
|
+
}
|
|
559
|
+
// Runs argv in its own process group to the end, with no terminal input:
|
|
560
|
+
// {status, signal, error, timedOut, stdout, stderr}, each output kept up to
|
|
561
|
+
// 64 MB and decoded at the end. `echo` also passes the output through.
|
|
562
|
+
// `started` gets the child, so a caller can forward signals to its group;
|
|
563
|
+
// without one, an interrupt stops the group and then REDUE.
|
|
564
|
+
const outputCap=64*1024*1024;
|
|
565
|
+
export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
|
|
566
|
+
return new Promise(resolve=>{
|
|
567
|
+
const kept={stdout:[],stderr:[]},size={stdout:0,stderr:0};
|
|
568
|
+
let timer=null,timedOut=false;
|
|
569
|
+
const child=spawn(argv[0],argv.slice(1),{cwd,env,detached:true,stdio:['ignore','pipe','pipe']});
|
|
570
|
+
const stop=async name=>{process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
|
|
571
|
+
await stopGroup(child.pid);process.kill(process.pid,name);};
|
|
572
|
+
const interrupt=()=>stop('SIGINT'),terminate=()=>stop('SIGTERM');
|
|
573
|
+
if(started)started(child);else{process.once('SIGINT',interrupt);process.once('SIGTERM',terminate);}
|
|
574
|
+
for(const name of ['stdout','stderr'])child[name].on('data',chunk=>{
|
|
575
|
+
if(echo)process[name].write(chunk);
|
|
576
|
+
if(size[name]<outputCap){kept[name].push(chunk);size[name]+=chunk.length;}
|
|
577
|
+
});
|
|
578
|
+
if(timeout)timer=setTimeout(()=>{timedOut=true;stopGroup(child.pid);},timeout);
|
|
579
|
+
endWithChild(child);
|
|
580
|
+
const finish=value=>{clearTimeout(timer);process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
|
|
581
|
+
resolve({...value,timedOut,stdout:Buffer.concat(kept.stdout).toString('utf8'),stderr:Buffer.concat(kept.stderr).toString('utf8')});};
|
|
582
|
+
child.on('error',error=>finish({status:null,signal:null,error}));
|
|
583
|
+
child.on('close',(status,signal)=>finish({status,signal}));
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// The line that shows a run that failed in the sandbox, and passed outside
|
|
587
|
+
// it, was refused a file it hid, when nothing else differs that a refusal
|
|
588
|
+
// could explain: no other refusal line, no supervisor issue, and not a line
|
|
589
|
+
// the plain run printed too. A run without the hiding then could only fail
|
|
590
|
+
// where the plain one passes, never pass where it fails.
|
|
591
|
+
function refusedHidden(plain,sandboxed,entries,supervisor){
|
|
592
|
+
if(plain.status!==0||supervisor||!entries.length)return null;
|
|
593
|
+
const line=hiddenRefusal(sandboxed.output,entries);
|
|
594
|
+
if(!line||plain.output.includes(line))return null;
|
|
595
|
+
const others=sandboxed.output.split(/\r?\n/).filter(text=>!hiddenRefusal(text,entries)).join('\n');
|
|
596
|
+
return denialLine(others)?null:line;
|
|
597
|
+
}
|
|
598
|
+
// init's limit on each trial run, in ms; a test can shorten it.
|
|
599
|
+
export const trialLimit=process.env.VSTATE_TEST_FAULTS==='1'&&Number(process.env.VSTATE_TEST_TRIAL_LIMIT)||300000;
|
|
600
|
+
// {same, plain, sandboxed, line, refusedHidden, timedOut} or {issue} when the sandbox cannot run here.
|
|
601
|
+
// A run that reaches `timeout` (0: none) is never the same as another:
|
|
602
|
+
// `timedOut` says which, and when the plain run does, no sandboxed run follows.
|
|
603
|
+
// With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
|
|
604
|
+
// With `hide`, the git-ignored entries hiddenEntries lists are kept from the
|
|
605
|
+
// sandboxed run, and it also returns them, their count, the run times and, on
|
|
606
|
+
// Linux, the launcher's rule count and setup time.
|
|
607
|
+
// `plain`: the plain run of an earlier trial of the same check, reused.
|
|
608
|
+
export async function sandboxTrial(root,check,{timeout=trialLimit,hide=false,plain:earlier=null}={}){
|
|
609
|
+
const supported=sandboxSupport();
|
|
610
|
+
if(supported.issue)return {issue:supported.issue};
|
|
611
|
+
const python=Boolean(pythonTool(check));
|
|
612
|
+
const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
|
|
613
|
+
const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
|
|
614
|
+
const tests=testRun(check);
|
|
615
|
+
// A test run's home is beneath its temporary directory, as in cli.mjs: on
|
|
616
|
+
// macOS that is where its Unix sockets may be.
|
|
617
|
+
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),tmp=path.join(scratch,'tmp'),home=tests?path.join(tmp,'home'):path.join(scratch,'home');
|
|
618
|
+
fs.mkdirSync(tmp);fs.mkdirSync(home);
|
|
619
|
+
try{
|
|
620
|
+
const ignored=hide?hiddenEntries(root):[],docEntries=hide==='docs'?hiddenDocs(root):[];
|
|
621
|
+
if(!ignored)return {issue:hiddenListIssue};
|
|
622
|
+
if(!docEntries)return {issue:hiddenDocsIssue};
|
|
623
|
+
const entries=[...ignored,...docEntries];
|
|
624
|
+
const hidden=ignored.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
|
|
625
|
+
const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
|
|
626
|
+
return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
|
|
627
|
+
const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
|
|
628
|
+
if(plain.status==='timeout')return {same:false,plain:'timeout',sandboxed:null,timedOut:'plain',line:null,refusedHidden:null,plainRun:plain};
|
|
629
|
+
const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
630
|
+
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
|
|
631
|
+
...hide==='docs'?{docs:{root,entries:docEntries}}:{},
|
|
632
|
+
...tests?{...supported.mode==='landlock'?{network:'loopback'}:{},writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
|
|
633
|
+
// A test run's network was supervised throughout, on Linux.
|
|
634
|
+
const supervisor=tests&&stats?supervisorIssue(stats):null;
|
|
635
|
+
// A failure both ways counts as the same only without a sign of a refusal.
|
|
636
|
+
const denied=sandboxed.status===0?null:denialLine(sandboxed.output,entries);
|
|
637
|
+
const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
|
|
638
|
+
const paths=[[home,'<home>'],[realOf(home),'<home>'],[tmp,'<tmp>'],[realOf(tmp),'<tmp>'],[plainTmp,'<tmp>'],[realOf(plainTmp),'<tmp>'],
|
|
639
|
+
[os.homedir(),'<home>'],[realOf(os.homedir()),'<home>']];
|
|
640
|
+
const [a,b]=[plain,sandboxed].map(r=>comparableOutput(r.output,paths).split('\n'));
|
|
641
|
+
// The first line that differs; past the end of the shorter output when one goes on.
|
|
642
|
+
let at=a.findIndex((line,i)=>line!==b[i]);
|
|
643
|
+
if(at<0&&b.length>a.length)at=a.length;
|
|
644
|
+
const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
|
|
645
|
+
let launcher=null;if(stats)try{launcher=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
646
|
+
// Tests print in an order their workers decide: the counts their summary
|
|
647
|
+
// gives are compared, not every line.
|
|
648
|
+
// When neither summary can be read, every line is compared, as for a typecheck.
|
|
649
|
+
const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
|
|
650
|
+
const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
|
|
651
|
+
const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
|
|
652
|
+
const timedOut=sandboxed.status==='timeout'?'sandboxed':null;
|
|
653
|
+
const same=!timedOut&&plain.status===sandboxed.status&&!denied&&!differs&&!supervisor;
|
|
654
|
+
return {same,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,timedOut,
|
|
655
|
+
line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
|
|
656
|
+
refusedHidden:same?null:refusedHidden(plain,sandboxed,entries,supervisor),
|
|
657
|
+
...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
|
|
658
|
+
}finally{fs.rmSync(scratch,{recursive:true,force:true});}
|
|
659
|
+
}
|