@vzn/vx 0.0.355 → 0.0.357
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/package.json +5 -5
- package/src/cache/archive.ts +3 -11
- package/src/cache/cache.ts +2 -2
- package/src/cache/inputs.ts +3 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +1 -1
- package/src/cli/upgrade.ts +2 -3
- package/src/exec/env.ts +0 -16
- package/src/exec/kill-tree.ts +0 -24
- package/src/exec/local-executor.ts +2 -3
- package/src/exec/runner.ts +41 -25
- package/src/exec/sandbox-binds.ts +39 -15
- package/src/exec/sandbox-runtime.ts +43 -4
- package/src/exec/sandbox-violations.ts +92 -15
- package/src/orchestrator/events.ts +8 -3
- package/src/orchestrator/execute-task.ts +31 -2
- package/src/orchestrator/run-lock.ts +1 -31
- package/src/orchestrator/sandbox-request.ts +1 -1
- package/src/util/real-path.ts +2 -5
- package/src/util/which.ts +2 -7
- package/src/workspace/config-cache.ts +15 -0
- package/src/workspace/migrate-scripts.ts +24 -0
- package/src/workspace/migration.ts +20 -4
- package/src/workspace/project-loader.ts +61 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.357",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.357",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.357",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.357",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.357"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -122,10 +122,6 @@ const drain = async (body: AsyncIterable<Uint8Array>): Promise<void> => {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
const WIN32 = process.platform === 'win32'
|
|
126
|
-
// A Windows link target may be spelled with either separator.
|
|
127
|
-
const LINK_SEP = WIN32 ? /[\\/]/ : '/'
|
|
128
|
-
|
|
129
125
|
/**
|
|
130
126
|
* Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
|
|
131
127
|
* pair, not an object, because the sidecar is real entropy inside an
|
|
@@ -271,9 +267,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
271
267
|
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
|
|
272
268
|
)
|
|
273
269
|
}
|
|
274
|
-
|
|
275
|
-
// Linux restore of that would be world-writable.
|
|
276
|
-
const mode = WIN32 ? 0o644 : st.mode & 0o777
|
|
270
|
+
const mode = st.mode & 0o777
|
|
277
271
|
meta.files[name] = [mode, Math.floor(st.mtimeMs)]
|
|
278
272
|
return {
|
|
279
273
|
name,
|
|
@@ -788,9 +782,7 @@ class Extractor {
|
|
|
788
782
|
let n = 0
|
|
789
783
|
for (const s of this.staged) {
|
|
790
784
|
const [mode, mtimeMs] = metaFor(s.name)
|
|
791
|
-
|
|
792
|
-
// attribute, and a read-only restore refuses the next rename over it.
|
|
793
|
-
if ((mode & 0o777) !== createdMode && !WIN32) chmodSync(s.tmp, mode & 0o777)
|
|
785
|
+
if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
|
|
794
786
|
if (mtimeMs !== undefined) {
|
|
795
787
|
// A Date, not seconds: Bun reads a negative number of seconds as
|
|
796
788
|
// "now", and a Date before 1970 as itself (Bun 1.4.2).
|
|
@@ -904,7 +896,7 @@ async function resolveThrough(p: string): Promise<string> {
|
|
|
904
896
|
at = path.parse(link).root
|
|
905
897
|
rest = link.slice(at.length)
|
|
906
898
|
}
|
|
907
|
-
pending.push(...rest.split(
|
|
899
|
+
pending.push(...rest.split('/').filter(Boolean).reverse())
|
|
908
900
|
}
|
|
909
901
|
return at
|
|
910
902
|
}
|
package/src/cache/cache.ts
CHANGED
|
@@ -2026,8 +2026,8 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
|
2026
2026
|
* Close for real. A plain `close()` leaves the connection open while any
|
|
2027
2027
|
* statement from `db.prepare()` lives (bun:sqlite 1.4.2 defers it, as
|
|
2028
2028
|
* `sqlite3_close_v2` does), so `cache.db` and its `-wal` and `-shm` stayed
|
|
2029
|
-
* open after `Cache.close()`: a leaked descriptor per run for an embedder
|
|
2030
|
-
*
|
|
2029
|
+
* open after `Cache.close()`: a leaked descriptor per run for an embedder
|
|
2030
|
+
* (O-10).
|
|
2031
2031
|
* `close(true)` finalizes them and closes.
|
|
2032
2032
|
*/
|
|
2033
2033
|
function closeDb(db: Database): void {
|
package/src/cache/inputs.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { rm, rmdir } from 'node:fs/promises'
|
|
|
22
22
|
import type { CacheInputs } from '../config.js'
|
|
23
23
|
import {
|
|
24
24
|
asTrees,
|
|
25
|
-
executablePath,
|
|
26
25
|
isExecutableMissing,
|
|
27
26
|
isLiteralPattern,
|
|
28
27
|
normalizeGlob,
|
|
@@ -394,17 +393,15 @@ async function runRuntimeCommand(
|
|
|
394
393
|
// vx's own `sh`, resolved on its PATH before the probe's: Bun.spawn looks
|
|
395
394
|
// a bare name up on the child's PATH, which leads with the project's
|
|
396
395
|
// `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
|
|
397
|
-
|
|
398
|
-
process.platform === 'win32' ? shellArgv(command) : [executablePath('sh'), '-c', command]
|
|
399
|
-
proc = Bun.spawn(argv, {
|
|
396
|
+
proc = Bun.spawn(shellArgv(command), {
|
|
400
397
|
cwd,
|
|
401
398
|
env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
|
|
402
399
|
stdin: 'ignore',
|
|
403
400
|
stdout: 'pipe',
|
|
404
401
|
stderr: 'pipe',
|
|
405
402
|
// Its own group, so the probe's whole tree can be taken down with vx
|
|
406
|
-
// (`killProbesOnExit`).
|
|
407
|
-
detached:
|
|
403
|
+
// (`killProbesOnExit`).
|
|
404
|
+
detached: true,
|
|
408
405
|
})
|
|
409
406
|
} catch (err) {
|
|
410
407
|
// The probe runs through `sh -c` like a task: a box without sh names
|
package/src/cli/help.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
61
61
|
' foo, @scope/*, ./packages/foo, foo..., ...foo,',
|
|
62
62
|
' foo^..., !foo, [<since>]',
|
|
63
63
|
' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
|
|
64
|
-
' affectedBase, else origin/HEAD, else
|
|
64
|
+
' affectedBase, else origin/HEAD, else main or',
|
|
65
|
+
' master, else HEAD~1.',
|
|
65
66
|
" pkg#task Run a specific project's task directly.",
|
|
66
67
|
'',
|
|
67
68
|
'Execution (for run):',
|
package/src/cli/init.ts
CHANGED
|
@@ -193,7 +193,7 @@ async function adopt(
|
|
|
193
193
|
? ''
|
|
194
194
|
: `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
|
|
195
195
|
process.stdout.write(
|
|
196
|
-
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate
|
|
196
|
+
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until bunx @vzn/vx-migrate writes native config; nothing else written.\n` +
|
|
197
197
|
`${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
|
|
198
198
|
)
|
|
199
199
|
return 0
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -21,11 +21,10 @@ const REPO = 'vznjs/vx'
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* A Bun standalone-binary path lives under the bunfs virtual root.
|
|
24
|
-
* Marker
|
|
25
|
-
* `B:\~BUN\...` or `B:/~BUN/...` (windows).
|
|
24
|
+
* Marker: `/$bunfs/...`.
|
|
26
25
|
*/
|
|
27
26
|
export function isBunfsPath(p: string): boolean {
|
|
28
|
-
return p.startsWith('/$bunfs')
|
|
27
|
+
return p.startsWith('/$bunfs')
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/**
|
package/src/exec/env.ts
CHANGED
|
@@ -32,22 +32,6 @@ export const ESSENTIAL_ENV: readonly string[] = [
|
|
|
32
32
|
'NO_COLOR',
|
|
33
33
|
'CI',
|
|
34
34
|
'NODE_OPTIONS',
|
|
35
|
-
'SYSTEMROOT',
|
|
36
|
-
'APPDATA',
|
|
37
|
-
'LOCALAPPDATA',
|
|
38
|
-
'PROGRAMDATA',
|
|
39
|
-
'PROGRAMFILES',
|
|
40
|
-
'PROGRAMFILES(X86)',
|
|
41
|
-
'COMSPEC',
|
|
42
|
-
'PATHEXT',
|
|
43
|
-
// Read on Windows by os.homedir(), npm and git, which break without them.
|
|
44
|
-
'SYSTEMDRIVE',
|
|
45
|
-
'WINDIR',
|
|
46
|
-
'USERPROFILE',
|
|
47
|
-
'HOMEDRIVE',
|
|
48
|
-
'HOMEPATH',
|
|
49
|
-
'NUMBER_OF_PROCESSORS',
|
|
50
|
-
'PROCESSOR_ARCHITECTURE',
|
|
51
35
|
]
|
|
52
36
|
|
|
53
37
|
/**
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -15,25 +15,6 @@ import { executablePath, procfsIsOwn } from '../util/index.js'
|
|
|
15
15
|
|
|
16
16
|
export type Child = ReturnType<typeof Bun.spawn>
|
|
17
17
|
|
|
18
|
-
const WIN32 = process.platform === 'win32'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Windows has no process groups (`kill(-pid)` is ESRCH, which read as
|
|
22
|
-
* "gone" and killed nothing) and no polite signal a console task hears
|
|
23
|
-
* from another process, so every stop is `taskkill /T /F`: the child and
|
|
24
|
-
* every descendant it still parents. A grandchild whose parent already
|
|
25
|
-
* exited is out of the tree's reach (docs/design/windows-2026-09.md).
|
|
26
|
-
*/
|
|
27
|
-
function killTreeWin32(child: Child): void {
|
|
28
|
-
try {
|
|
29
|
-
Bun.spawn(['taskkill', '/T', '/F', '/PID', String(child.pid)], {
|
|
30
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
31
|
-
})
|
|
32
|
-
} catch {
|
|
33
|
-
child.kill()
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
18
|
/**
|
|
38
19
|
* A child whose polite signals go down a pipe instead of to its group: a
|
|
39
20
|
* Linux sandboxed task. The group there is bwrap's, whose monitor dies of
|
|
@@ -97,8 +78,6 @@ const GUARD_SCRIPT = [
|
|
|
97
78
|
function startGuard(): void {
|
|
98
79
|
if (guardFd !== undefined) return
|
|
99
80
|
guardFd = null
|
|
100
|
-
// The guard is an `sh` script over process groups; Windows has neither.
|
|
101
|
-
if (process.platform === 'win32') return
|
|
102
81
|
try {
|
|
103
82
|
const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
|
|
104
83
|
argv0: 'vx-group-guard',
|
|
@@ -227,7 +206,6 @@ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL')
|
|
|
227
206
|
// A pid of 0 would name OUR group (kill(0)): a child that never
|
|
228
207
|
// spawned has nothing to kill.
|
|
229
208
|
if (!(child.pid > 0) || goneGroups.has(child)) return
|
|
230
|
-
if (WIN32) return killTreeWin32(child)
|
|
231
209
|
const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
|
|
232
210
|
if (fd !== undefined) {
|
|
233
211
|
try {
|
|
@@ -297,8 +275,6 @@ export async function untilGroupsGone(
|
|
|
297
275
|
*/
|
|
298
276
|
function groupAlive(child: Child): boolean {
|
|
299
277
|
if (!(child.pid > 0) || goneGroups.has(child)) return false
|
|
300
|
-
// No groups: the tree kill is `taskkill /T`, and the leader is what is left to ask.
|
|
301
|
-
if (WIN32) return child.exitCode === null && child.signalCode === null
|
|
302
278
|
try {
|
|
303
279
|
process.kill(-child.pid, 0)
|
|
304
280
|
} catch (err) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// plugin claims the task, so it sits at the TAIL of every executor list
|
|
5
5
|
// (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
|
|
6
6
|
// absence is "here".
|
|
7
|
-
import { runCommand, signalExitCode } from './runner.js'
|
|
7
|
+
import { runCommand, signalExitCode, stopSignal } from './runner.js'
|
|
8
8
|
import { runSandboxed } from './sandbox-runtime.js'
|
|
9
9
|
import type { TaskExecutor } from './executor.js'
|
|
10
10
|
|
|
@@ -29,8 +29,7 @@ export function localExecutor(): TaskExecutor {
|
|
|
29
29
|
// runs after the teardown swept the run's children, and held the run
|
|
30
30
|
// to the signal's bound.
|
|
31
31
|
if (req.signal?.aborted === true) {
|
|
32
|
-
const
|
|
33
|
-
const signal = reason === 'SIGINT' || reason === 'SIGHUP' ? reason : 'SIGTERM'
|
|
32
|
+
const signal = stopSignal(req.signal.reason)
|
|
34
33
|
const exitCode = signalExitCode(signal)
|
|
35
34
|
return { exitCode, durationMs: 0, stdout: '', stderr: '', signal, violations: [] }
|
|
36
35
|
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
-
import { existsSync
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
8
|
import { constants as osConstants } from 'node:os'
|
|
9
9
|
import {
|
|
10
10
|
shellArgv,
|
|
@@ -25,9 +25,8 @@ import {
|
|
|
25
25
|
untilGroupsGone,
|
|
26
26
|
} from './kill-tree.js'
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
|
|
28
|
+
/** `sh` names itself in its errors. */
|
|
29
|
+
const SH_ARGV0 = { argv0: 'sh' }
|
|
31
30
|
|
|
32
31
|
export interface RunResult {
|
|
33
32
|
exitCode: number
|
|
@@ -224,8 +223,6 @@ const SHELL_BUILTINS = new Set([
|
|
|
224
223
|
* the shell.
|
|
225
224
|
*/
|
|
226
225
|
export function execWrap(command: string): string {
|
|
227
|
-
// Bun's shell, the task shell on Windows, has no `exec`.
|
|
228
|
-
if (WIN32) return command
|
|
229
226
|
const first = execWord(command)
|
|
230
227
|
return first === undefined ? command : `exec ${command}`
|
|
231
228
|
}
|
|
@@ -238,9 +235,23 @@ export function execWrap(command: string): string {
|
|
|
238
235
|
*/
|
|
239
236
|
export function execWord(command: string): string | undefined {
|
|
240
237
|
if (SHELL_CONTROL.test(command)) return undefined
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
238
|
+
// The word as the shell reads it: quotes group and are removed, so
|
|
239
|
+
// `"./my tool.sh"` names one file (the verdict looked for `"./my`).
|
|
240
|
+
const raw = command.trimStart()
|
|
241
|
+
let first = ''
|
|
242
|
+
let quote = ''
|
|
243
|
+
let i = 0
|
|
244
|
+
for (; i < raw.length; i++) {
|
|
245
|
+
const c = raw[i]!
|
|
246
|
+
if (quote !== '') {
|
|
247
|
+
if (c === quote) quote = ''
|
|
248
|
+
else first += c
|
|
249
|
+
} else if (c === "'" || c === '"') quote = c
|
|
250
|
+
else if (/\s/.test(c)) break
|
|
251
|
+
else first += c
|
|
252
|
+
}
|
|
253
|
+
if (quote !== '' || first === '' || raw.slice(0, i).includes('=')) return undefined
|
|
254
|
+
return SHELL_BUILTINS.has(first) ? undefined : first
|
|
244
255
|
}
|
|
245
256
|
|
|
246
257
|
/**
|
|
@@ -254,6 +265,16 @@ export function signalExitCode(signal: string): number {
|
|
|
254
265
|
return num === undefined ? 130 : 128 + num
|
|
255
266
|
}
|
|
256
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The signal a run's stop sends a task, from its abort reason: a task
|
|
270
|
+
* stopped before its spawn exits as if that signal had killed it. As
|
|
271
|
+
* `forwardedSignal` (orchestrator/signals.ts): a hang-up forwards SIGTERM
|
|
272
|
+
* (to many servers SIGHUP means "reload"), so it reads as SIGTERM here.
|
|
273
|
+
*/
|
|
274
|
+
export function stopSignal(reason: unknown): 'SIGINT' | 'SIGTERM' {
|
|
275
|
+
return reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
|
|
276
|
+
}
|
|
277
|
+
|
|
257
278
|
/**
|
|
258
279
|
* The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
|
|
259
280
|
* by the platform's numbering; undefined for a plain exit. The shell
|
|
@@ -484,9 +505,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
484
505
|
],
|
|
485
506
|
// Its own session and process group, so a kill reaches what it
|
|
486
507
|
// forked (kill-tree.ts). stdin is a pipe, so a background group
|
|
487
|
-
// never stops on a terminal read.
|
|
488
|
-
|
|
489
|
-
detached: !WIN32,
|
|
508
|
+
// never stops on a terminal read.
|
|
509
|
+
detached: true,
|
|
490
510
|
},
|
|
491
511
|
),
|
|
492
512
|
)
|
|
@@ -695,9 +715,8 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
695
715
|
stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
|
|
696
716
|
// Its own session and process group, so a kill reaches what it
|
|
697
717
|
// forked (kill-tree.ts). stdin is ignored, so a background group
|
|
698
|
-
// never stops on a terminal read.
|
|
699
|
-
|
|
700
|
-
detached: !WIN32,
|
|
718
|
+
// never stops on a terminal read.
|
|
719
|
+
detached: true,
|
|
701
720
|
}),
|
|
702
721
|
)
|
|
703
722
|
} catch (err) {
|
|
@@ -750,18 +769,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
750
769
|
* `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
|
|
751
770
|
* 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
|
|
752
771
|
* Read after the child exits so it covers the whole task's span (the mark
|
|
753
|
-
* is monotonic). Linux reads
|
|
754
|
-
* bound in hand.
|
|
772
|
+
* is monotonic). Linux reads its own peak (`getrusage`); elsewhere the
|
|
773
|
+
* current RSS is the bound in hand.
|
|
755
774
|
*/
|
|
756
775
|
export function ownRssHighWater(): number {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// /proc unreadable: fall through to the current RSS.
|
|
763
|
-
}
|
|
764
|
-
}
|
|
776
|
+
// getrusage's own peak, not `/proc/self/status`'s `VmHWM`: one syscall
|
|
777
|
+
// where generating and parsing the status file was ~20 µs a task alone
|
|
778
|
+
// and ~70 ms of a 1,000-task cold run. It is the same mark, raised only
|
|
779
|
+
// by the image this one exec'd from (a shell), within the floor's slack.
|
|
780
|
+
if (process.platform === 'linux') return peakRssBytes(process.resourceUsage().maxRSS)
|
|
765
781
|
return process.memoryUsage.rss()
|
|
766
782
|
}
|
|
767
783
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isMountableLiteral,
|
|
11
11
|
localBindingOn,
|
|
12
12
|
MOUNT_WILDCARDS,
|
|
13
|
+
toRealPath,
|
|
13
14
|
unique,
|
|
14
15
|
} from './sandbox-paths.js'
|
|
15
16
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
@@ -27,27 +28,50 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
|
27
28
|
* is "Device or resource busy"; binding `/w/dist` instead succeeds.
|
|
28
29
|
*
|
|
29
30
|
* So on Linux a file-shaped grant is widened to its directory. That IS a
|
|
30
|
-
* widening — the task may write its siblings
|
|
31
|
+
* widening — the task may write its siblings, and read them unrefused
|
|
32
|
+
* (`widenedEntries` has those reads reported) — and it is the narrowest
|
|
31
33
|
* grant the mechanism can express: the alternative is a declared output
|
|
32
34
|
* the task cannot produce. macOS needs none of this (seatbelt matches
|
|
33
35
|
* paths, it does not mount), so the grant stays exact there.
|
|
34
36
|
*/
|
|
35
37
|
export function bindableWrites(paths: readonly string[]): string[] {
|
|
36
38
|
if (process.platform !== 'linux') return [...paths]
|
|
37
|
-
return unique(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bindOf(p: string): string {
|
|
43
|
+
if (!isMountableLiteral(p)) return p
|
|
44
|
+
try {
|
|
45
|
+
if (statSync(p).isDirectory()) return p
|
|
46
|
+
} catch {
|
|
47
|
+
// Does not exist yet: `prepareOutputsForBind` creates a file for a
|
|
48
|
+
// file-shaped grant, so treat it as one.
|
|
49
|
+
}
|
|
50
|
+
return path.dirname(p)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What each directory `bindableWrites` widened a file grant to held when
|
|
55
|
+
* the task started, by real path. A read there is never refused, so the
|
|
56
|
+
* strace pass reports one of these no grant covers: a task granted
|
|
57
|
+
* `write: ['out.txt']` read the whole project unreported, and a cached run
|
|
58
|
+
* replayed an undeclared file's old bytes. What the task creates there is
|
|
59
|
+
* its own, not an input, so only these entries count. Empty off Linux.
|
|
60
|
+
*/
|
|
61
|
+
export function widenedEntries(paths: readonly string[]): Map<string, Set<string>> {
|
|
62
|
+
const out = new Map<string, Set<string>>()
|
|
63
|
+
if (process.platform !== 'linux') return out
|
|
64
|
+
for (const p of paths) {
|
|
65
|
+
if (holdsGlobChar(p) || bindOf(p) === p) continue
|
|
66
|
+
const dir = toRealPath(path.dirname(p))
|
|
67
|
+
if (out.has(dir)) continue
|
|
68
|
+
try {
|
|
69
|
+
out.set(dir, new Set(readdirSync(dir).map((e) => path.join(dir, e))))
|
|
70
|
+
} catch {
|
|
71
|
+
// Gone or unreadable: nothing there to read.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
/**
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
shellQuote,
|
|
49
49
|
withForwardArgs,
|
|
50
50
|
signalExitCode,
|
|
51
|
+
stopSignal,
|
|
51
52
|
spawnFailureText,
|
|
52
53
|
streamToString,
|
|
53
54
|
resourceUsageToCpuRss,
|
|
@@ -63,7 +64,13 @@ import {
|
|
|
63
64
|
UserError,
|
|
64
65
|
xxh3hex,
|
|
65
66
|
} from '../util/index.js'
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
bindableReads,
|
|
69
|
+
bindableWrites,
|
|
70
|
+
buildCustomConfig,
|
|
71
|
+
scratchWrites,
|
|
72
|
+
widenedEntries,
|
|
73
|
+
} from './sandbox-binds.js'
|
|
67
74
|
import {
|
|
68
75
|
atOrUnder,
|
|
69
76
|
isMountableLiteral,
|
|
@@ -550,6 +557,9 @@ export async function initSandbox(opts?: {
|
|
|
550
557
|
/** Whether any task of the run grants `gitConfig`: SRT reads it run-wide, so it is set per wrap (B-41). */
|
|
551
558
|
gitConfig?: boolean
|
|
552
559
|
}): Promise<void> {
|
|
560
|
+
// This run owns the session now; a reset an earlier run deferred to its
|
|
561
|
+
// last server would tear it down under this run's tasks (M-25).
|
|
562
|
+
resetDeferred = false
|
|
553
563
|
// A reset a server's exit started unawaited: a watch cycle stops its
|
|
554
564
|
// server and starts its run at once, and an init under that reset
|
|
555
565
|
// found SRT up, hot-reloaded it, and had it torn down after (item 884).
|
|
@@ -947,6 +957,8 @@ export async function wrapSandboxedCommand(
|
|
|
947
957
|
server?: boolean
|
|
948
958
|
/** Trace the command's `openat` calls to descriptor TRACE_FD (Linux; `wantsStraceDetection`). */
|
|
949
959
|
trace?: 'plain' | 'seccomp'
|
|
960
|
+
/** Trace with `-y`, each descriptor's path printed: a read under a widened grant is judged. */
|
|
961
|
+
tracePaths?: boolean
|
|
950
962
|
},
|
|
951
963
|
): Promise<{
|
|
952
964
|
wrapped: string
|
|
@@ -1004,7 +1016,7 @@ export async function wrapSandboxedCommand(
|
|
|
1004
1016
|
const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
|
|
1005
1017
|
const grouped =
|
|
1006
1018
|
process.platform === 'linux'
|
|
1007
|
-
? ownGroupCommand(tag, inTmp, args.trace)
|
|
1019
|
+
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1008
1020
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1009
1021
|
const inner = [
|
|
1010
1022
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
@@ -1082,6 +1094,7 @@ function ownGroupCommand(
|
|
|
1082
1094
|
tag: string,
|
|
1083
1095
|
userCommand: string,
|
|
1084
1096
|
trace?: 'plain' | 'seccomp',
|
|
1097
|
+
tracePaths = false,
|
|
1085
1098
|
): { command: string; forwards: boolean; traced: boolean } {
|
|
1086
1099
|
// `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
|
|
1087
1100
|
// bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
|
|
@@ -1131,6 +1144,10 @@ function ownGroupCommand(
|
|
|
1131
1144
|
'-DD',
|
|
1132
1145
|
'-f',
|
|
1133
1146
|
...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
1147
|
+
// A read through a directory's descriptor (`find`, `grep -r`) names
|
|
1148
|
+
// only the entry; `-y` prints the path it opened. 40% slower on 2,000
|
|
1149
|
+
// opens, so only where a widened grant's reads are judged.
|
|
1150
|
+
...(tracePaths ? ['-y'] : []),
|
|
1134
1151
|
'-qq',
|
|
1135
1152
|
'-e',
|
|
1136
1153
|
// A process's cwd moves on `chdir` and starts as its parent's at the
|
|
@@ -1477,9 +1494,31 @@ async function runSandboxedOnce(
|
|
|
1477
1494
|
// kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
|
|
1478
1495
|
// one gets the slow form rather than no detection.
|
|
1479
1496
|
const useStrace = await wantsStraceDetection()
|
|
1497
|
+
// Before the spawn: what the task creates under a widened grant is its own.
|
|
1498
|
+
const widened = useStrace ? widenedEntries(args.config.allowWrite) : undefined
|
|
1480
1499
|
const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
|
|
1481
|
-
await wrapSandboxedCommand({
|
|
1500
|
+
await wrapSandboxedCommand({
|
|
1501
|
+
...args,
|
|
1502
|
+
...(useStrace ? { trace: useStrace } : {}),
|
|
1503
|
+
...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
|
|
1504
|
+
})
|
|
1482
1505
|
const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
|
|
1506
|
+
// A stop that landed during the awaits above leaves nothing to kill yet:
|
|
1507
|
+
// spawned now, the task ran after the teardown swept the run's children.
|
|
1508
|
+
if (args.signal?.aborted === true) {
|
|
1509
|
+
releaseBridges(tag)
|
|
1510
|
+
takeRecords()
|
|
1511
|
+
const signal = stopSignal(args.signal.reason)
|
|
1512
|
+
return {
|
|
1513
|
+
exitCode: signalExitCode(signal),
|
|
1514
|
+
durationMs: Date.now() - start,
|
|
1515
|
+
stdout: '',
|
|
1516
|
+
stderr: '',
|
|
1517
|
+
signal,
|
|
1518
|
+
violations: [],
|
|
1519
|
+
tracerFailed: false,
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1483
1522
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
1484
1523
|
// in the shared temp dir a concurrent task read this log, every path
|
|
1485
1524
|
// this task opened (L-25).
|
|
@@ -1628,7 +1667,7 @@ async function runSandboxedOnce(
|
|
|
1628
1667
|
process.platform === 'linux'
|
|
1629
1668
|
? [
|
|
1630
1669
|
...(straceLog
|
|
1631
|
-
? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
|
|
1670
|
+
? await parseStraceViolations(straceLog, args, baselines, widened).catch(() => [])
|
|
1632
1671
|
: []),
|
|
1633
1672
|
...refusedWrites(
|
|
1634
1673
|
// Keyed by what SRT wrapped, the in-sandbox group wrapper
|
|
@@ -50,6 +50,25 @@ const STRACE_RESUMED_RE = new RegExp(
|
|
|
50
50
|
)
|
|
51
51
|
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
52
52
|
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
53
|
+
/**
|
|
54
|
+
* An `openat` that succeeded, and not for writing alone: a read. Placed by
|
|
55
|
+
* the path `-y` prints for the descriptor it returned; without one, only
|
|
56
|
+
* from the cwd (`AT_FDCWD`) or by an absolute path.
|
|
57
|
+
*/
|
|
58
|
+
const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
|
|
59
|
+
const OPEN_READ_RE = new RegExp(
|
|
60
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
|
|
61
|
+
)
|
|
62
|
+
const OPEN_READ_UNFINISHED_RE = new RegExp(
|
|
63
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
|
|
64
|
+
)
|
|
65
|
+
const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
|
|
66
|
+
|
|
67
|
+
/** Where a successful read opened: `-y`'s path, else the call's own when placeable. */
|
|
68
|
+
function readPath(dirfd: string, rawPath: string, opened: string | undefined): string | undefined {
|
|
69
|
+
if (opened !== undefined) return cStringPath(opened)
|
|
70
|
+
return dirfd === 'AT_FDCWD' || rawPath.startsWith('/') ? rawPath : undefined
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
const C_ESCAPES: Record<string, number> = { n: 10, t: 9, r: 13, v: 11, f: 12, a: 7, b: 8 }
|
|
55
74
|
|
|
@@ -89,6 +108,8 @@ export interface DeniedCall {
|
|
|
89
108
|
syscall: string
|
|
90
109
|
rawPath: string
|
|
91
110
|
errno: string
|
|
111
|
+
/** A read that SUCCEEDED (`errno` empty): asked for by `deniedCalls`' `reads`. */
|
|
112
|
+
read?: true
|
|
92
113
|
/**
|
|
93
114
|
* The directory a relative `rawPath` was opened from, when the trace
|
|
94
115
|
* shows the process (or one it was forked from) changed into one; absent
|
|
@@ -132,8 +153,11 @@ type Op = { chdir: string } | { lost: true } | { call: number }
|
|
|
132
153
|
* detector, and a synthetic trace pins the split-line shapes deterministically
|
|
133
154
|
* where an end-to-end run only produces them when strace happens to interleave.
|
|
134
155
|
*/
|
|
135
|
-
export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
136
|
-
const pending = new Map<
|
|
156
|
+
export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
|
|
157
|
+
const pending = new Map<
|
|
158
|
+
string,
|
|
159
|
+
{ syscall: string; rawPath: string; read?: true; dirfd?: string }
|
|
160
|
+
>()
|
|
137
161
|
const out: DeniedCall[] = []
|
|
138
162
|
const ops = new Map<string, Op[]>()
|
|
139
163
|
// A clone with CLONE_FS (every thread) shares its creator's cwd, so a
|
|
@@ -162,6 +186,15 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
162
186
|
const opener =
|
|
163
187
|
line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
|
|
164
188
|
if (!opener) {
|
|
189
|
+
if (reads && line.includes('openat(')) {
|
|
190
|
+
const m = OPEN_READ_RE.exec(line)
|
|
191
|
+
if (m !== null) {
|
|
192
|
+
const at = readPath(m[2]!, cStringPath(m[3]!), m[4])
|
|
193
|
+
if (at !== undefined)
|
|
194
|
+
denied(m[1]!, { syscall: 'openat', rawPath: at, errno: '', read: true })
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
165
198
|
if (
|
|
166
199
|
cwd !== undefined &&
|
|
167
200
|
(line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
|
|
@@ -180,7 +213,12 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
180
213
|
unfinished[2] !== undefined &&
|
|
181
214
|
unfinished[3] !== undefined
|
|
182
215
|
) {
|
|
183
|
-
pending.set(unfinished[1], {
|
|
216
|
+
pending.set(unfinished[1], {
|
|
217
|
+
syscall: unfinished[2],
|
|
218
|
+
rawPath: cStringPath(unfinished[3]),
|
|
219
|
+
...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
|
|
220
|
+
...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
|
|
221
|
+
})
|
|
184
222
|
continue
|
|
185
223
|
}
|
|
186
224
|
const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
|
|
@@ -190,8 +228,14 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
190
228
|
const resumed = STRACE_RESUMED_RE.exec(line)
|
|
191
229
|
// Only a resume that carries a DENIAL is a violation; a successful
|
|
192
230
|
// resume just retires the pending entry.
|
|
231
|
+
const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
|
|
193
232
|
if (held !== undefined && resumed?.[3] !== undefined) {
|
|
194
|
-
denied(resumedOk[1], {
|
|
233
|
+
denied(resumedOk[1], { syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
|
|
234
|
+
} else if (held !== undefined && fd !== null) {
|
|
235
|
+
const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
|
|
236
|
+
if (at !== undefined) {
|
|
237
|
+
denied(resumedOk[1], { syscall: held.syscall, rawPath: at, errno: '', read: true })
|
|
238
|
+
}
|
|
195
239
|
}
|
|
196
240
|
continue
|
|
197
241
|
}
|
|
@@ -264,6 +308,8 @@ export async function parseStraceViolations(
|
|
|
264
308
|
logPath: string,
|
|
265
309
|
args: SandboxedRunArgs,
|
|
266
310
|
baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
|
|
311
|
+
/** `widenedEntries` taken as the task started: no entry, no read parse. */
|
|
312
|
+
widened: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
267
313
|
): Promise<SandboxViolation[]> {
|
|
268
314
|
const text = await Bun.file(logPath).text()
|
|
269
315
|
if (text.length === 0) return []
|
|
@@ -281,29 +327,60 @@ export async function parseStraceViolations(
|
|
|
281
327
|
),
|
|
282
328
|
)
|
|
283
329
|
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
330
|
+
// A read under a widened write grant's directory is never refused, so it
|
|
331
|
+
// is reported when it succeeds: an entry that was there at the start and
|
|
332
|
+
// no grant covers is an input the key never saw, and so is the
|
|
333
|
+
// directory's listing while it holds one. The granted files and what the
|
|
334
|
+
// task made itself stay readable.
|
|
335
|
+
const written = new Set(args.config.allowWrite.map((p) => toRealPath(absolutize(p))))
|
|
336
|
+
const undeclared = (abs: string): boolean => {
|
|
337
|
+
for (const [dir, entries] of widened) {
|
|
338
|
+
if (abs === dir)
|
|
339
|
+
return [...entries].some((e) => !isUnderAny(e, written) && !isUnderAny(e, allowAbs))
|
|
340
|
+
if (!atOrUnder(abs, dir)) continue
|
|
341
|
+
const top = path.join(dir, path.relative(dir, abs).split(path.sep)[0]!)
|
|
342
|
+
return entries.has(top) && !isUnderAny(abs, written)
|
|
343
|
+
}
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
284
346
|
|
|
285
347
|
const seen = new Set<string>()
|
|
286
348
|
const out: SandboxViolation[] = []
|
|
287
|
-
for (const { syscall, rawPath, errno, dir } of deniedCalls(
|
|
349
|
+
for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
|
|
350
|
+
text,
|
|
351
|
+
baselines.cwd,
|
|
352
|
+
widened.size > 0,
|
|
353
|
+
)) {
|
|
288
354
|
const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
|
|
289
355
|
// Only report paths under the workspace-root deny anchor — system
|
|
290
356
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
291
357
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
292
358
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
293
359
|
if (isUnderAny(abs, allowAbs)) continue
|
|
360
|
+
if (read === true && !undeclared(abs)) continue
|
|
294
361
|
const key = `${syscall}|${abs}`
|
|
295
362
|
if (seen.has(key)) continue
|
|
296
363
|
seen.add(key)
|
|
297
|
-
out.push(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
364
|
+
out.push(
|
|
365
|
+
read === true
|
|
366
|
+
? {
|
|
367
|
+
line: `${syscall}(${rawPath}) read under a write grant's directory, granted no read [${abs}]`,
|
|
368
|
+
timestamp: new Date(),
|
|
369
|
+
target: abs,
|
|
370
|
+
path: abs,
|
|
371
|
+
ignorable: ['read'],
|
|
372
|
+
}
|
|
373
|
+
: {
|
|
374
|
+
line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
|
|
375
|
+
timestamp: new Date(),
|
|
376
|
+
target: abs,
|
|
377
|
+
path: abs,
|
|
378
|
+
// The trace is `-e trace=openat`, and an openat is a read or a
|
|
379
|
+
// write depending on flags the trace does not carry — so either
|
|
380
|
+
// list can silence it.
|
|
381
|
+
ignorable: ['read', 'write'],
|
|
382
|
+
},
|
|
383
|
+
)
|
|
307
384
|
}
|
|
308
385
|
return out
|
|
309
386
|
}
|
|
@@ -52,7 +52,12 @@ export interface EventBus {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function createEventBus(): EventBus {
|
|
55
|
-
|
|
55
|
+
// Replaced, never mutated: an emit walks the list it started with, so a
|
|
56
|
+
// subscriber that unsubscribes itself (an embedder's `off()` on
|
|
57
|
+
// `run:end`) no longer shifts the next one past the event — the terminal
|
|
58
|
+
// renderer after it missed `run:end` (C-77). A subscriber added during
|
|
59
|
+
// an emit hears the next event, not this one.
|
|
60
|
+
let subscribers: readonly RunEventSubscriber[] = []
|
|
56
61
|
return {
|
|
57
62
|
emit(event) {
|
|
58
63
|
// Synchronous, in-subscription-order fan-out. Ordering is part of
|
|
@@ -79,10 +84,10 @@ export function createEventBus(): EventBus {
|
|
|
79
84
|
}
|
|
80
85
|
},
|
|
81
86
|
subscribe(subscriber) {
|
|
82
|
-
subscribers
|
|
87
|
+
subscribers = [...subscribers, subscriber]
|
|
83
88
|
return () => {
|
|
84
89
|
const i = subscribers.indexOf(subscriber)
|
|
85
|
-
if (i >= 0) subscribers.
|
|
90
|
+
if (i >= 0) subscribers = subscribers.toSpliced(i, 1)
|
|
86
91
|
}
|
|
87
92
|
},
|
|
88
93
|
}
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from '../exec/index.js'
|
|
38
38
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
39
39
|
import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
|
|
40
|
-
import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
|
|
40
|
+
import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
|
|
41
41
|
import { executorLabel, nameExecutorFailure } from './plugin-host.js'
|
|
42
42
|
import {
|
|
43
43
|
mayWriteFingerprint,
|
|
@@ -354,6 +354,22 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
354
354
|
persistentOpts.timeoutMs = effectiveTimeout
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
// A stop that landed during the awaits above (the key, the sandbox's
|
|
358
|
+
// arming, request and wrap) leaves nothing to kill yet: spawned now, the
|
|
359
|
+
// server came up after the teardown and held the run's exit 7 s.
|
|
360
|
+
if (args.stopSignal?.aborted === true) {
|
|
361
|
+
if (bridgeTag !== undefined) releaseBridges(bridgeTag)
|
|
362
|
+
await placeholderSweeper(placeholders)()
|
|
363
|
+
return {
|
|
364
|
+
node,
|
|
365
|
+
status: 'aborted',
|
|
366
|
+
// The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
|
|
367
|
+
exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
|
|
368
|
+
durationMs: 0,
|
|
369
|
+
wallclockStartNs,
|
|
370
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
357
373
|
const spawn = runPersistent(persistentOpts)
|
|
358
374
|
// The host side of a port bridge lives exactly as long as the server:
|
|
359
375
|
// released on the child's exit, whether the run tore it down or it died.
|
|
@@ -493,11 +509,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
493
509
|
// The local executor keeps its own timer (it signals the process group).
|
|
494
510
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
495
511
|
let timeoutFired = false
|
|
512
|
+
// The attempt's listener on the run's stop signal, taken off once the
|
|
513
|
+
// attempt settles. Left on, every task of the run added one: the list
|
|
514
|
+
// grew to the task count, and each add scans it for a duplicate, ~100 ms
|
|
515
|
+
// of a 1,000-task cold run.
|
|
516
|
+
let unlistenStop: (() => void) | undefined
|
|
496
517
|
function requestSignal(): AbortSignal {
|
|
497
518
|
const stop = new AbortController()
|
|
498
519
|
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
520
|
+
unlistenStop?.()
|
|
521
|
+
unlistenStop = undefined
|
|
499
522
|
if (args.stopSignal?.aborted === true) abort()
|
|
500
|
-
else args.stopSignal
|
|
523
|
+
else if (args.stopSignal !== undefined) {
|
|
524
|
+
const run = args.stopSignal
|
|
525
|
+
run.addEventListener('abort', abort, { once: true })
|
|
526
|
+
unlistenStop = () => run.removeEventListener('abort', abort)
|
|
527
|
+
}
|
|
501
528
|
if (effectiveTimeout !== undefined) {
|
|
502
529
|
clearTimeout(timeoutTimer)
|
|
503
530
|
timeoutFired = false
|
|
@@ -801,6 +828,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
801
828
|
})
|
|
802
829
|
.finally(() => {
|
|
803
830
|
clearTimeout(timeoutTimer)
|
|
831
|
+
unlistenStop?.()
|
|
832
|
+
unlistenStop = undefined
|
|
804
833
|
flushMasked()
|
|
805
834
|
})
|
|
806
835
|
endExec()
|
|
@@ -240,7 +240,7 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
240
240
|
await mkdir(staging)
|
|
241
241
|
try {
|
|
242
242
|
await writeFile(path.join(staging, entry), '')
|
|
243
|
-
await
|
|
243
|
+
await rename(staging, lockDir)
|
|
244
244
|
return entry
|
|
245
245
|
} catch (err) {
|
|
246
246
|
await rm(staging, { recursive: true, force: true })
|
|
@@ -250,36 +250,6 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
const WIN32 = process.platform === 'win32'
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Windows renames no directory onto another, even an empty one: EPERM
|
|
257
|
-
* where POSIX gives ENOTEMPTY, which read as a refusal and ran every
|
|
258
|
-
* contender unlocked. A name that exists is taken; an empty one (a
|
|
259
|
-
* holder's leave that lost its rmdir) is removed and the rename tried
|
|
260
|
-
* once more, as POSIX's rename onto it would replace it.
|
|
261
|
-
*/
|
|
262
|
-
async function renameWin32(staging: string, lockDir: string): Promise<void> {
|
|
263
|
-
try {
|
|
264
|
-
await rename(staging, lockDir)
|
|
265
|
-
return
|
|
266
|
-
} catch (err) {
|
|
267
|
-
const code = (err as NodeJS.ErrnoException).code
|
|
268
|
-
if (code !== 'EPERM' && code !== 'EACCES') throw err
|
|
269
|
-
const empty = await rmdir(lockDir).then(
|
|
270
|
-
() => true,
|
|
271
|
-
() => false,
|
|
272
|
-
)
|
|
273
|
-
if (!empty) throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
|
|
274
|
-
}
|
|
275
|
-
await rename(staging, lockDir).catch((err: NodeJS.ErrnoException) => {
|
|
276
|
-
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
|
277
|
-
throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
|
|
278
|
-
}
|
|
279
|
-
throw err
|
|
280
|
-
})
|
|
281
|
-
}
|
|
282
|
-
|
|
283
253
|
/**
|
|
284
254
|
* Unlink one taking's entry, then the directory if that left it empty.
|
|
285
255
|
* Gone already is not an error (another waiter reclaimed it first); a
|
|
@@ -193,7 +193,7 @@ export async function sandboxRequestFor(
|
|
|
193
193
|
// character class, so vx's own workspace wall matched nothing (B-65).
|
|
194
194
|
// A `*` or `?` is one too, and its grants matched the project's siblings.
|
|
195
195
|
const home = toRealPath(node.projectDir)
|
|
196
|
-
if (
|
|
196
|
+
if (/[[\]*?]/.test(home)) {
|
|
197
197
|
throw new UserError(
|
|
198
198
|
`exec.sandbox: ${home} holds [, ], * or ?, which the sandbox runtime reads as a ` +
|
|
199
199
|
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
package/src/util/real-path.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A path's canonical spelling
|
|
5
|
-
*
|
|
6
|
-
* and every other reader say `runneradmin`, so one file had two
|
|
7
|
-
* spellings and a key or a comparison that folds the path split on
|
|
8
|
-
* them (O-16). The native call asks the OS for the final path.
|
|
4
|
+
* A path's canonical spelling: the OS's own answer for the final path,
|
|
5
|
+
* so a key or a comparison that folds the path sees one spelling (O-16).
|
|
9
6
|
*/
|
|
10
7
|
export function realPath(p: string): string {
|
|
11
8
|
return realpathSync.native(p)
|
package/src/util/which.ts
CHANGED
|
@@ -48,13 +48,8 @@ export function executablePath(name: string): string {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* The argv that runs `script` in the task shell: `sh -c
|
|
52
|
-
* macOS, and Bun's own shell (`bun exec`) on Windows, which ships no `sh`
|
|
53
|
-
* (docs/design/windows-2026-09.md § Decisions 1). Bun's shell reads the
|
|
54
|
-
* same `&&`, `|`, `$(…)` and `VAR=x cmd` syntax that `bun run` uses there.
|
|
51
|
+
* The argv that runs `script` in the task shell: `sh -c`.
|
|
55
52
|
*/
|
|
56
53
|
export function shellArgv(script: string): string[] {
|
|
57
|
-
return
|
|
58
|
-
? [process.execPath, 'exec', script]
|
|
59
|
-
: [executablePath('sh'), '-c', script]
|
|
54
|
+
return [executablePath('sh'), '-c', script]
|
|
60
55
|
}
|
|
@@ -545,6 +545,21 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
545
545
|
return h.toString(16).padStart(16, '0')
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
/** `configEvalKeyFromClosure` over identities already in hand; null when one is missing. */
|
|
549
|
+
export function configEvalKeyFromIdentities(a: {
|
|
550
|
+
closure: readonly string[]
|
|
551
|
+
identities: ReadonlyMap<string, string>
|
|
552
|
+
workspaceFingerprint: string
|
|
553
|
+
}): string | null {
|
|
554
|
+
let h = keySeed(a.workspaceFingerprint)
|
|
555
|
+
for (const file of a.closure) {
|
|
556
|
+
const id = a.identities.get(file)
|
|
557
|
+
if (id === undefined) return null
|
|
558
|
+
h = xxh3(`${file}\0${id}`, h)
|
|
559
|
+
}
|
|
560
|
+
return h.toString(16).padStart(16, '0')
|
|
561
|
+
}
|
|
562
|
+
|
|
548
563
|
/**
|
|
549
564
|
* Every file a config imports by relative specifier, transitively, outside
|
|
550
565
|
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
@@ -343,6 +343,16 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
343
343
|
return false
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The hooks a package's build hides in when it has no `build` script:
|
|
348
|
+
* react-navigation's twelve packages build in `prepack: bob build`, their
|
|
349
|
+
* root's `build` (`lerna run prepack`) runs the members and is left out,
|
|
350
|
+
* and the repo mapped with no build at all.
|
|
351
|
+
*/
|
|
352
|
+
const LIFECYCLE_BUILD_HOOKS = ['prepack', 'prepublishOnly', 'prepublish', 'prepare']
|
|
353
|
+
const BUILDER =
|
|
354
|
+
/(?:^|[\s;&|(])(?:bob build|tsc|tsup|tsdown|rollup|vite build|babel|unbuild|esbuild|webpack|microbundle|pkgroll|bunchee|preconstruct build)(?:\s|$)/
|
|
355
|
+
|
|
346
356
|
/** The workspace flags that move a package manager off the package it runs in. */
|
|
347
357
|
const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
|
|
348
358
|
const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
|
|
@@ -481,6 +491,7 @@ export function migrateScripts(
|
|
|
481
491
|
// and maps.
|
|
482
492
|
const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
|
|
483
493
|
const notes: string[] = []
|
|
494
|
+
const lifecycleBuilds: [string, string, string][] = []
|
|
484
495
|
// The rest check the whole repo (`lint: oxlint .`, `test: vitest`):
|
|
485
496
|
// `vx run lint` found no project in remix, wagmi or element-plus. Such a
|
|
486
497
|
// script maps onto the root, when the root has a name (vx skips a
|
|
@@ -585,6 +596,12 @@ export function migrateScripts(
|
|
|
585
596
|
return why === null ? [] : [[n, why] as const]
|
|
586
597
|
})
|
|
587
598
|
const names = runnable.filter((n) => taskNameProblem(n) === null)
|
|
599
|
+
if (meta !== rootMeta && !('build' in scripts)) {
|
|
600
|
+
const hook = LIFECYCLE_BUILD_HOOKS.find(
|
|
601
|
+
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h] as string),
|
|
602
|
+
)
|
|
603
|
+
if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
|
|
604
|
+
}
|
|
588
605
|
if (runnable.length === 0) continue
|
|
589
606
|
const hasBuild = names.includes('build')
|
|
590
607
|
const has = (n: string): boolean => names.includes(n)
|
|
@@ -681,6 +698,13 @@ export function migrateScripts(
|
|
|
681
698
|
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
682
699
|
}
|
|
683
700
|
}
|
|
701
|
+
if (lifecycleBuilds.length > 0) {
|
|
702
|
+
const [name, hook, command] = lifecycleBuilds[0]!
|
|
703
|
+
const more = lifecycleBuilds.length - 1
|
|
704
|
+
notes.push(
|
|
705
|
+
`${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
684
708
|
breakBuildCycles(projects, metas)
|
|
685
709
|
pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
|
|
686
710
|
return {
|
|
@@ -402,10 +402,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
402
402
|
for (const f of replaced) report.push(` ${relPosix(root, f)}`)
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
|
-
const firstTask =
|
|
406
|
-
plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
|
|
407
|
-
plan.projects[0]?.tasks[0]?.name ??
|
|
408
|
-
'build'
|
|
405
|
+
const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
|
|
409
406
|
// A run of tasks none of which caches is never a hit, and a first try
|
|
410
407
|
// that runs twice to see the cache work saw it run twice.
|
|
411
408
|
if (!cached && [...todos.keys()].some(isCacheTodo)) {
|
|
@@ -442,6 +439,25 @@ function inGitWorkTree(root: string): boolean {
|
|
|
442
439
|
}
|
|
443
440
|
}
|
|
444
441
|
|
|
442
|
+
/** Names that change the repo or the world rather than check it. */
|
|
443
|
+
const NOT_A_TRY = /^(clean|reset|nuke|release|publish|deploy|version|format|fix)(?:$|[:\-_.])/
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The task the `next:` line runs: `build`, else the first task that
|
|
447
|
+
* neither serves nor changes anything, else the first. react-navigation, whose
|
|
448
|
+
* packages build in `prepack`, was told `vx run clean --all` (D-105).
|
|
449
|
+
*/
|
|
450
|
+
function nextTask(tasks: readonly GeneratedTask[]): string {
|
|
451
|
+
const names = tasks.filter((t) => t.task !== null).map((t) => t.name)
|
|
452
|
+
const fit = tasks.find(
|
|
453
|
+
(t) =>
|
|
454
|
+
t.task !== null &&
|
|
455
|
+
!NOT_A_TRY.test(t.name) &&
|
|
456
|
+
(t.task['exec'] as { persistent?: unknown } | undefined)?.persistent === undefined,
|
|
457
|
+
)
|
|
458
|
+
return names.includes('build') ? 'build' : (fit?.name ?? names[0] ?? 'build')
|
|
459
|
+
}
|
|
460
|
+
|
|
445
461
|
/**
|
|
446
462
|
* How the user runs vx, for the report's `next:` line. After `npx vx init`
|
|
447
463
|
* or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
|
|
@@ -8,6 +8,7 @@ import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
|
|
|
8
8
|
import {
|
|
9
9
|
configEvalKey,
|
|
10
10
|
configEvalKeyFromClosure,
|
|
11
|
+
configEvalKeyFromIdentities,
|
|
11
12
|
configImports,
|
|
12
13
|
type ConfigEvalStore,
|
|
13
14
|
} from './config-cache.js'
|
|
@@ -297,7 +298,7 @@ export interface LoadProjectConfigOptions {
|
|
|
297
298
|
* order given, so a failure names the first broken file the way a
|
|
298
299
|
* one-by-one load did). A single-path load is the one-element case.
|
|
299
300
|
*/
|
|
300
|
-
const LOAD_WIDTH =
|
|
301
|
+
const LOAD_WIDTH = 128
|
|
301
302
|
|
|
302
303
|
export async function loadProjectConfigs(
|
|
303
304
|
configPaths: readonly string[],
|
|
@@ -323,49 +324,69 @@ export async function loadProjectConfigs(
|
|
|
323
324
|
// the map; a file the batch could not stat has no identity, which makes
|
|
324
325
|
// that config's fast key miss and sends it down the slow path exactly as
|
|
325
326
|
// a throwing per-file `hashFile` did.
|
|
326
|
-
let
|
|
327
|
+
let identities: Map<string, string> | undefined
|
|
327
328
|
if (closures.size > 0 && store?.hashFiles !== undefined) {
|
|
328
329
|
const files = new Set<string>()
|
|
329
330
|
for (const closure of closures.values()) for (const f of closure) files.add(f)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
331
|
+
identities = await store.hashFiles([...files])
|
|
332
|
+
}
|
|
333
|
+
// With the batch in hand a fast key is synchronous: an async key per
|
|
334
|
+
// config, awaiting identities already in the map, was ~5 ms of a
|
|
335
|
+
// 1,000-config warm load. Only a config off the fast path is awaited.
|
|
336
|
+
const fastKeyOf = (configPath: string): string | null => {
|
|
337
|
+
const closure = closures.get(configPath)
|
|
338
|
+
if (closure === undefined || evalCache === undefined || identities === undefined) return null
|
|
339
|
+
return configEvalKeyFromIdentities({
|
|
340
|
+
closure,
|
|
341
|
+
identities,
|
|
342
|
+
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
const fastHashFile = identities === undefined ? hashFile : undefined
|
|
346
|
+
interface Prepared {
|
|
347
|
+
configPath: string
|
|
348
|
+
bytes: Uint8Array | null
|
|
349
|
+
cacheKey: string | null
|
|
350
|
+
indexed: boolean
|
|
351
|
+
closure?: string[] | undefined
|
|
337
352
|
}
|
|
338
353
|
const prepared = await Promise.all(
|
|
339
|
-
configPaths.map(
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
hashFile: fastHashFile,
|
|
345
|
-
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
346
|
-
})
|
|
347
|
-
if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
|
|
348
|
-
}
|
|
349
|
-
const bytes = await Bun.file(configPath).bytes()
|
|
350
|
-
const keyed =
|
|
351
|
-
evalCache === undefined
|
|
352
|
-
? null
|
|
353
|
-
: await configEvalKey({
|
|
354
|
-
configPath,
|
|
355
|
-
bytes,
|
|
356
|
-
workspaceRoot: evalCache.workspaceRoot,
|
|
357
|
-
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
358
|
-
...slowKeyHash,
|
|
359
|
-
})
|
|
360
|
-
return {
|
|
361
|
-
configPath,
|
|
362
|
-
bytes,
|
|
363
|
-
cacheKey: keyed?.key ?? null,
|
|
364
|
-
indexed: false,
|
|
365
|
-
closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
|
|
366
|
-
}
|
|
354
|
+
configPaths.map((configPath): Prepared | Promise<Prepared> => {
|
|
355
|
+
const fastKey = fastKeyOf(configPath)
|
|
356
|
+
return fastKey !== null
|
|
357
|
+
? { configPath, bytes: null, cacheKey: fastKey, indexed: true }
|
|
358
|
+
: slowPrepared(configPath)
|
|
367
359
|
}),
|
|
368
360
|
)
|
|
361
|
+
async function slowPrepared(configPath: string): Promise<Prepared> {
|
|
362
|
+
const closure = closures.get(configPath)
|
|
363
|
+
if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
|
|
364
|
+
const fastKey = await configEvalKeyFromClosure({
|
|
365
|
+
closure,
|
|
366
|
+
hashFile: fastHashFile,
|
|
367
|
+
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
368
|
+
})
|
|
369
|
+
if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
|
|
370
|
+
}
|
|
371
|
+
const bytes = await Bun.file(configPath).bytes()
|
|
372
|
+
const keyed =
|
|
373
|
+
evalCache === undefined
|
|
374
|
+
? null
|
|
375
|
+
: await configEvalKey({
|
|
376
|
+
configPath,
|
|
377
|
+
bytes,
|
|
378
|
+
workspaceRoot: evalCache.workspaceRoot,
|
|
379
|
+
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
380
|
+
...slowKeyHash,
|
|
381
|
+
})
|
|
382
|
+
return {
|
|
383
|
+
configPath,
|
|
384
|
+
bytes,
|
|
385
|
+
cacheKey: keyed?.key ?? null,
|
|
386
|
+
indexed: false,
|
|
387
|
+
closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
369
390
|
let hits = new Map<string, string>()
|
|
370
391
|
if (evalCache !== undefined) {
|
|
371
392
|
const keys = prepared.map((p) => p.cacheKey).filter((k): k is string => k !== null)
|
|
@@ -462,7 +483,9 @@ export async function loadProjectConfigs(
|
|
|
462
483
|
try {
|
|
463
484
|
// Loaded LOAD_WIDTH at a time: one import after another put 1,000 cold
|
|
464
485
|
// configs at ~160 ms of imports where 64 at once take ~60 (D-68), and
|
|
465
|
-
//
|
|
486
|
+
// 128 took `load configs` from 432 to 411 ms more; the width bounds the
|
|
487
|
+
// files a module load may hold open, so it stays under macOS's default
|
|
488
|
+
// 256-descriptor limit. A failure
|
|
466
489
|
// stops nothing (the rest are evaluated and stored for the next
|
|
467
490
|
// attempt); the error thrown is the first in order, as the serial
|
|
468
491
|
// loop's was.
|