@vzn/vx 0.0.646 → 0.0.649
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/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
readFileSync,
|
|
38
38
|
readlinkSync,
|
|
39
39
|
realpathSync,
|
|
40
|
+
renameSync,
|
|
40
41
|
rmSync,
|
|
41
42
|
statSync,
|
|
42
43
|
unlinkSync,
|
|
@@ -47,11 +48,13 @@ import type { SandboxConfig } from '../config.js'
|
|
|
47
48
|
import {
|
|
48
49
|
armTimeout,
|
|
49
50
|
drainOrAbort,
|
|
51
|
+
lineEnds,
|
|
50
52
|
POST_EXIT_CUT_LINE,
|
|
51
53
|
shellQuote,
|
|
52
54
|
withForwardArgs,
|
|
53
55
|
signalExitCode,
|
|
54
56
|
stopSignal,
|
|
57
|
+
SANDBOXED_TASK,
|
|
55
58
|
spawnFailureText,
|
|
56
59
|
streamToString,
|
|
57
60
|
resourceUsageToCpuRss,
|
|
@@ -62,6 +65,7 @@ import {
|
|
|
62
65
|
import { watchTree } from './proc-sample.js'
|
|
63
66
|
import {
|
|
64
67
|
BUN_GLOB_WILDCARDS,
|
|
68
|
+
ESSENTIAL_ENV,
|
|
65
69
|
executablePath,
|
|
66
70
|
helperEnv,
|
|
67
71
|
taskShell,
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
69
73
|
isTmpdirRefusal,
|
|
70
74
|
procfsIsOwn,
|
|
71
75
|
TMPDIR_HINT,
|
|
76
|
+
ui,
|
|
72
77
|
UserError,
|
|
73
78
|
xxh3hex,
|
|
74
79
|
} from '../util/index.js'
|
|
@@ -206,6 +211,7 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
|
|
|
206
211
|
function applyPolicyHere(): SandboxAvailability {
|
|
207
212
|
const proc = Bun.spawnSync({
|
|
208
213
|
cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
|
|
214
|
+
env: helperEnv(),
|
|
209
215
|
stdout: 'ignore',
|
|
210
216
|
stderr: 'pipe',
|
|
211
217
|
})
|
|
@@ -231,12 +237,25 @@ function applyPolicyHere(): SandboxAvailability {
|
|
|
231
237
|
* the loader).
|
|
232
238
|
*/
|
|
233
239
|
export function unavailableReason(exitCode: number | null, stderr: string): string {
|
|
234
|
-
const hint = stderr.includes('
|
|
235
|
-
?
|
|
236
|
-
|
|
240
|
+
const hint = stderr.includes('capability-restricted')
|
|
241
|
+
? // Ubuntu 25.04+ confines bwrap with this AppArmor profile (2026-10-23).
|
|
242
|
+
" — AppArmor's bwrap-userns-restrict profile strips the capabilities of the seccomp helper's nested user namespace: replace it with a profile for /usr/bin/bwrap that grants `userns,` (https://discourse.ubuntu.com/t/58007)"
|
|
243
|
+
: stderr.includes('uid_map')
|
|
244
|
+
? " — the runtime's seccomp helper cannot create its nested user namespace here (root inside a container, or a kernel that forbids nested user namespaces): run as a non-root user, or set `sandbox.weakerWhenNested: true` on every sandboxed task"
|
|
245
|
+
: ''
|
|
237
246
|
return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/** vx's values of the variables every task gets (`ESSENTIAL_ENV`). */
|
|
250
|
+
function essentialEnv(): Record<string, string> {
|
|
251
|
+
const env: Record<string, string> = {}
|
|
252
|
+
for (const name of ESSENTIAL_ENV) {
|
|
253
|
+
const value = process.env[name]
|
|
254
|
+
if (value !== undefined) env[name] = value
|
|
255
|
+
}
|
|
256
|
+
return env
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
async function trySandboxedTrue(
|
|
241
260
|
SandboxManager: SrtModule['SandboxManager'],
|
|
242
261
|
weakerNested: boolean,
|
|
@@ -249,6 +268,8 @@ async function trySandboxedTrue(
|
|
|
249
268
|
)
|
|
250
269
|
const proc = Bun.spawn([taskShell(), '-c', wrapped], {
|
|
251
270
|
argv0: 'sh',
|
|
271
|
+
// What a task gets of vx's env: the probe is a sandboxed `true`.
|
|
272
|
+
env: essentialEnv(),
|
|
252
273
|
stdout: 'ignore',
|
|
253
274
|
stderr: 'pipe',
|
|
254
275
|
stdin: 'ignore',
|
|
@@ -446,10 +467,41 @@ function trackTaskTmpdir(dir: string): void {
|
|
|
446
467
|
if (tmpdirExitHooked) return
|
|
447
468
|
tmpdirExitHooked = true
|
|
448
469
|
process.on('exit', () => {
|
|
449
|
-
for (const d of liveTaskTmpdirs)
|
|
470
|
+
for (const d of liveTaskTmpdirs) removeTaskTmpdir(d)
|
|
450
471
|
})
|
|
451
472
|
}
|
|
452
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Remove a task's temp directory; never throws. The task may leave it
|
|
476
|
+
* unremovable: a read-only directory in it (EACCES), or a straggler still
|
|
477
|
+
* writing (ENOTEMPTY). Thrown, either failed a task that had passed, as a
|
|
478
|
+
* worker fault under dte. A read-only one is made writable and the remove
|
|
479
|
+
* retried; what still stands is left to the exit hook's last try.
|
|
480
|
+
*/
|
|
481
|
+
function removeTaskTmpdir(dir: string): boolean {
|
|
482
|
+
for (let attempt = 0; ; attempt++) {
|
|
483
|
+
try {
|
|
484
|
+
rmSync(dir, { recursive: true, force: true })
|
|
485
|
+
return true
|
|
486
|
+
} catch {
|
|
487
|
+
if (attempt === 2) return false
|
|
488
|
+
ownerWritable(dir)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Give the owner write and search on every directory under `dir`, which a recursive remove needs. */
|
|
494
|
+
function ownerWritable(dir: string): void {
|
|
495
|
+
try {
|
|
496
|
+
const st = lstatSync(dir)
|
|
497
|
+
if (!st.isDirectory()) return
|
|
498
|
+
if ((st.mode & 0o700) !== 0o700) chmodSync(dir, (st.mode & 0o7777) | 0o700)
|
|
499
|
+
for (const name of readdirSync(dir)) ownerWritable(path.join(dir, name))
|
|
500
|
+
} catch {
|
|
501
|
+
// gone, or not this user's: the retry says
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
453
505
|
/** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
|
|
454
506
|
let srtUp = false
|
|
455
507
|
/**
|
|
@@ -596,6 +648,22 @@ function noScanCommand(): string {
|
|
|
596
648
|
return file
|
|
597
649
|
}
|
|
598
650
|
|
|
651
|
+
/**
|
|
652
|
+
* vx's executables the runtime spawns OUTSIDE the sandbox (the no-op scan,
|
|
653
|
+
* the socat wrapper), as directories every task is walled from, and the
|
|
654
|
+
* files a task still runs (the wrapper is its side of the proxy bridge
|
|
655
|
+
* too), granted back read-only. They live in the OS temp dir, and where
|
|
656
|
+
* that is one every task may write (SRT binds `/tmp/claude` writable into
|
|
657
|
+
* each), a task rewrote the no-op and the next wrap ran it unsandboxed.
|
|
658
|
+
*/
|
|
659
|
+
function hostHelpers(): { dirs: string[]; files: string[] } {
|
|
660
|
+
const files = [...socatWrappers].filter(([bare, w]) => w !== bare).map(([, w]) => w)
|
|
661
|
+
const dirs = [...(noScan === undefined ? [] : [noScan]), ...files].map((f) =>
|
|
662
|
+
toRealPath(path.dirname(f)),
|
|
663
|
+
)
|
|
664
|
+
return { dirs: unique(dirs), files: files.map(toRealPath) }
|
|
665
|
+
}
|
|
666
|
+
|
|
599
667
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
600
668
|
if (process.platform !== 'linux') return {}
|
|
601
669
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -605,13 +673,93 @@ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
|
605
673
|
// SRT's dependency check says so
|
|
606
674
|
}
|
|
607
675
|
try {
|
|
608
|
-
paths.socatPath = executablePath('socat')
|
|
676
|
+
paths.socatPath = envCutSocat(executablePath('socat'))
|
|
609
677
|
} catch {
|
|
610
678
|
// SRT's dependency check says so
|
|
611
679
|
}
|
|
612
680
|
return paths
|
|
613
681
|
}
|
|
614
682
|
|
|
683
|
+
/**
|
|
684
|
+
* socat behind a wrapper that `exec`s it with the essential env only.
|
|
685
|
+
* SRT spawns its host bridge (`socat UNIX-LISTEN:… TCP:localhost:…`) with
|
|
686
|
+
* no `env`, so it held vx's whole environment for the run, readable from
|
|
687
|
+
* its `/proc/<pid>/environ` by any unsandboxed task beside it, the hole
|
|
688
|
+
* `hideEnvFromTasks` closes for vx itself. SRT takes no env for it, and
|
|
689
|
+
* narrowing `process.env` around `initialize` would hand a task spawned
|
|
690
|
+
* meanwhile the narrowed one. The same path runs socat inside the
|
|
691
|
+
* sandbox, so the wrapper keeps `SOCAT_DEFAULT_LISTEN_IP` (`hostHasIpv6`).
|
|
692
|
+
* It lives in this user's own 0700 directory, named by its content, and
|
|
693
|
+
* no exit hook removes it: SRT's dependency check reads the path its live
|
|
694
|
+
* config holds, and a per-process one an emitted `exit` reaped left the
|
|
695
|
+
* next probe "socat not executable" while the process went on (B-93's
|
|
696
|
+
* shape). A host where it cannot run (`/tmp` mounted noexec) keeps the
|
|
697
|
+
* bare socat.
|
|
698
|
+
*/
|
|
699
|
+
const socatWrappers = new Map<string, string>()
|
|
700
|
+
function envCutSocat(socat: string): string {
|
|
701
|
+
const known = socatWrappers.get(socat)
|
|
702
|
+
// One stat per init: a wrapper someone removed is made again.
|
|
703
|
+
if (known !== undefined && (known === socat || existsSync(known))) return known
|
|
704
|
+
let wrapper = socat
|
|
705
|
+
try {
|
|
706
|
+
const keep = [...ESSENTIAL_ENV, 'SOCAT_DEFAULT_LISTEN_IP']
|
|
707
|
+
.map((n) => `\${${n}+"${n}=$${n}"}`)
|
|
708
|
+
.join(' ')
|
|
709
|
+
const body = `#!/bin/sh\nexec ${shellQuote(executablePath('env'))} -i ${keep} ${shellQuote(socat)} "$@"\n`
|
|
710
|
+
const file = ownWrapper(body)
|
|
711
|
+
if (
|
|
712
|
+
Bun.spawnSync([file, '-V'], { env: helperEnv(), stdout: 'ignore', stderr: 'ignore' })
|
|
713
|
+
.exitCode === 0
|
|
714
|
+
) {
|
|
715
|
+
wrapper = file
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
// no private directory or no `env`: the bare socat, as before
|
|
719
|
+
}
|
|
720
|
+
socatWrappers.set(socat, wrapper)
|
|
721
|
+
return wrapper
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* `body` as an executable in `vx-socat-<uid>/` under the OS temp dir,
|
|
726
|
+
* written once (to a temp, then renamed, so a process beside this one
|
|
727
|
+
* never runs half of it) and checked byte for byte before it is used.
|
|
728
|
+
* Anything else at the names (another owner, group or other bits, a
|
|
729
|
+
* link, other bytes) is refused.
|
|
730
|
+
*/
|
|
731
|
+
function ownWrapper(body: string): string {
|
|
732
|
+
const uid = process.getuid!()
|
|
733
|
+
const dir = path.join(os.tmpdir(), `vx-socat-${uid}`)
|
|
734
|
+
try {
|
|
735
|
+
mkdirSync(dir, { mode: 0o700 })
|
|
736
|
+
} catch (err) {
|
|
737
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
738
|
+
}
|
|
739
|
+
const d = lstatSync(dir)
|
|
740
|
+
if (!d.isDirectory() || d.uid !== uid || (d.mode & 0o077) !== 0) {
|
|
741
|
+
throw new Error(`${dir} is not this user's own directory`)
|
|
742
|
+
}
|
|
743
|
+
const file = path.join(dir, `socat-${xxh3hex(body).slice(0, 16)}`)
|
|
744
|
+
const ok = (): boolean => {
|
|
745
|
+
const f = lstatSync(file, { throwIfNoEntry: false })
|
|
746
|
+
return (
|
|
747
|
+
f !== undefined &&
|
|
748
|
+
f.isFile() &&
|
|
749
|
+
f.uid === uid &&
|
|
750
|
+
(f.mode & 0o777) === 0o500 &&
|
|
751
|
+
readFileSync(file, 'utf8') === body
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
if (ok()) return file
|
|
755
|
+
const tmp = `${file}.${process.pid}`
|
|
756
|
+
rmSync(tmp, { force: true })
|
|
757
|
+
writeFileSync(tmp, body, { mode: 0o500, flag: 'wx' })
|
|
758
|
+
renameSync(tmp, file)
|
|
759
|
+
if (!ok()) throw new Error(`${file} is not this user's wrapper`)
|
|
760
|
+
return file
|
|
761
|
+
}
|
|
762
|
+
|
|
615
763
|
/**
|
|
616
764
|
* Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
|
|
617
765
|
* `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
|
|
@@ -1166,6 +1314,12 @@ export interface SandboxViolation {
|
|
|
1166
1314
|
hint?: true
|
|
1167
1315
|
/** Absolute, when `target` is a path at all. */
|
|
1168
1316
|
path?: string
|
|
1317
|
+
/**
|
|
1318
|
+
* Linux: the directory holding a link on the path the task opened, not
|
|
1319
|
+
* in the sandbox, so the link was not there: a grant of `path` alone
|
|
1320
|
+
* leaves it hidden.
|
|
1321
|
+
*/
|
|
1322
|
+
via?: string
|
|
1169
1323
|
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
1170
1324
|
}
|
|
1171
1325
|
|
|
@@ -1280,6 +1434,9 @@ async function wrapIn(
|
|
|
1280
1434
|
baselines.cwd,
|
|
1281
1435
|
)
|
|
1282
1436
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
1437
|
+
const helpers = hostHelpers()
|
|
1438
|
+
customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
|
|
1439
|
+
customConfig!.filesystem!.allowRead!.push(...helpers.files)
|
|
1283
1440
|
if (process.platform === 'linux' && !cwdMounted(baselines.cwd, customConfig!.filesystem!)) {
|
|
1284
1441
|
customConfig!.filesystem!.denyRead!.push(baselines.cwd)
|
|
1285
1442
|
}
|
|
@@ -1324,7 +1481,10 @@ async function wrapIn(
|
|
|
1324
1481
|
process.platform === 'linux'
|
|
1325
1482
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1326
1483
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1484
|
+
// The tag leads: SRT keys a record by the wrap's first 100 characters,
|
|
1485
|
+
// and the proxy wait below is the same 190 for every networked task.
|
|
1327
1486
|
const inner = [
|
|
1487
|
+
process.platform === 'linux' ? `: 'vx-${tag}';` : '',
|
|
1328
1488
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1329
1489
|
process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
|
|
1330
1490
|
grouped.command,
|
|
@@ -1504,7 +1664,10 @@ function ownGroupCommand(
|
|
|
1504
1664
|
* What strace stops on: the reads, the execs and access probes (a refused
|
|
1505
1665
|
* `./gen.sh || fallback` passed unreported), and what moves or makes a
|
|
1506
1666
|
* process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
|
|
1507
|
-
* `git status` 30%, where the execs and probes cost nothing measurable
|
|
1667
|
+
* `git status` 30%, where the execs and probes cost nothing measurable, and
|
|
1668
|
+
* vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
|
|
1669
|
+
* that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
|
|
1670
|
+
* nothing the trace sees.
|
|
1508
1671
|
*/
|
|
1509
1672
|
const TRACED_CALLS =
|
|
1510
1673
|
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
@@ -1604,7 +1767,8 @@ function seatbeltSpells(grant: string, kind: 'read' | 'write', name: boolean): b
|
|
|
1604
1767
|
const at = name ? m.index : m.index + m[0].length - 2
|
|
1605
1768
|
if (!warnedUnspellable.has(`${kind} ${grant}`)) {
|
|
1606
1769
|
warnedUnspellable.add(`${kind} ${grant}`)
|
|
1607
|
-
|
|
1770
|
+
ui.say(
|
|
1771
|
+
'warn',
|
|
1608
1772
|
`[vx] sandbox: the ${kind} grant ${grant} names a literal ${grant[at + (name ? 0 : 1)]}, ` +
|
|
1609
1773
|
`which the macOS sandbox reads as a pattern and cannot spell as a name, so it is not ` +
|
|
1610
1774
|
`granted. Rename it, or grant the directory above it: ` +
|
|
@@ -1832,7 +1996,8 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1832
1996
|
*/
|
|
1833
1997
|
export function releaseBridges(tag: string): void {
|
|
1834
1998
|
const tmp = taskTmpdir(tag)
|
|
1835
|
-
|
|
1999
|
+
// One that will not go stays tracked, for the exit hook.
|
|
2000
|
+
if (liveTaskTmpdirs.has(tmp) && removeTaskTmpdir(tmp)) liveTaskTmpdirs.delete(tmp)
|
|
1836
2001
|
if (liveServers.delete(tag)) {
|
|
1837
2002
|
// A server's wrap stays counted until it stops: one stopped server
|
|
1838
2003
|
// kept every later task's stubs in the workspace until the reset.
|
|
@@ -1879,11 +2044,25 @@ const TRACER_RETRY_LINE =
|
|
|
1879
2044
|
* declared, so a second run redoes, not doubles, it.
|
|
1880
2045
|
*/
|
|
1881
2046
|
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
1882
|
-
|
|
2047
|
+
// Whether the first attempt's output, both streams, ended mid-line: the
|
|
2048
|
+
// notice starts its own line (it read `…/strace: bare[vx] the sandbox's…`).
|
|
2049
|
+
let midLine = false
|
|
2050
|
+
const seen =
|
|
2051
|
+
(on: ((chunk: string) => void) | undefined) =>
|
|
2052
|
+
(chunk: string): void => {
|
|
2053
|
+
if (chunk.length > 0) midLine = !chunk.endsWith('\n')
|
|
2054
|
+
on?.(chunk)
|
|
2055
|
+
}
|
|
2056
|
+
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
|
|
2057
|
+
...args,
|
|
2058
|
+
onStdout: seen(args.onStdout),
|
|
2059
|
+
onStderr: seen(args.onStderr),
|
|
2060
|
+
})
|
|
1883
2061
|
// A stopping run has killed the children it holds; a retry would be one
|
|
1884
2062
|
// spawned after that kill.
|
|
1885
2063
|
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
1886
|
-
|
|
2064
|
+
const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
|
|
2065
|
+
args.onStderr?.(notice)
|
|
1887
2066
|
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
1888
2067
|
// what the first attempt left of it, not a fresh window.
|
|
1889
2068
|
const {
|
|
@@ -1899,7 +2078,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
1899
2078
|
...second,
|
|
1900
2079
|
durationMs: first.durationMs + second.durationMs,
|
|
1901
2080
|
stdout: first.stdout + second.stdout,
|
|
1902
|
-
stderr: first.stderr +
|
|
2081
|
+
stderr: first.stderr + notice + second.stderr,
|
|
1903
2082
|
}
|
|
1904
2083
|
}
|
|
1905
2084
|
|
|
@@ -2052,7 +2231,7 @@ async function runSandboxedOnce(
|
|
|
2052
2231
|
rmSync(straceLog, { force: true })
|
|
2053
2232
|
liveTempFiles.delete(straceLog)
|
|
2054
2233
|
}
|
|
2055
|
-
const stderr = spawnFailureText(err, args.cwd,
|
|
2234
|
+
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
|
|
2056
2235
|
args.onStderr?.(stderr)
|
|
2057
2236
|
releaseBridges(tag)
|
|
2058
2237
|
takeRecords()
|
|
@@ -2083,8 +2262,9 @@ async function runSandboxedOnce(
|
|
|
2083
2262
|
let partial = ''
|
|
2084
2263
|
let straceSpoke = false
|
|
2085
2264
|
const [out, err] = pipes.streams(proc)
|
|
2265
|
+
const ends = lineEnds(args.onStdout, args.onStderr)
|
|
2086
2266
|
const streams = Promise.all([
|
|
2087
|
-
streamToString(out,
|
|
2267
|
+
streamToString(out, ends.onStdout, ac.signal, args.capture?.stdout ?? true),
|
|
2088
2268
|
streamToString(
|
|
2089
2269
|
err,
|
|
2090
2270
|
(chunk) => {
|
|
@@ -2093,7 +2273,7 @@ async function runSandboxedOnce(
|
|
|
2093
2273
|
const lines = (partial + chunk).split('\n')
|
|
2094
2274
|
partial = (lines.pop() ?? '').slice(0, 512)
|
|
2095
2275
|
if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
|
|
2096
|
-
|
|
2276
|
+
ends.onStderr?.(chunk)
|
|
2097
2277
|
},
|
|
2098
2278
|
ac.signal,
|
|
2099
2279
|
args.capture?.stderr ?? true,
|
|
@@ -2110,13 +2290,17 @@ async function runSandboxedOnce(
|
|
|
2110
2290
|
if (timeout.timedOut()) ac.abort()
|
|
2111
2291
|
else cut = await drainOrAbort(streams, ac)
|
|
2112
2292
|
const [stdout, streamed] = await streams
|
|
2113
|
-
|
|
2114
|
-
|
|
2293
|
+
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
2294
|
+
if (cut) args.onStderr?.(cutLine)
|
|
2295
|
+
const stderr = cut ? streamed + cutLine : streamed
|
|
2115
2296
|
args.liveChildren?.delete(proc)
|
|
2116
2297
|
releaseGroup(proc)
|
|
2117
2298
|
closeSignalChannel(proc)
|
|
2118
2299
|
releaseBridges(tag)
|
|
2119
|
-
const
|
|
2300
|
+
const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
2301
|
+
// A timeout is a failure, whatever the child said: one that traps the
|
|
2302
|
+
// TERM and exits 0 reported success to every executor built on this.
|
|
2303
|
+
const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
|
|
2120
2304
|
|
|
2121
2305
|
// macOS: read the violation store keyed by our tagged command.
|
|
2122
2306
|
//
|
|
@@ -2135,8 +2319,8 @@ async function runSandboxedOnce(
|
|
|
2135
2319
|
// DROPPED by the unified log under pressure, not delayed. Reporting on
|
|
2136
2320
|
// macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
|
|
2137
2321
|
// (every observed loss still denied the read and failed the child).
|
|
2138
|
-
// EVERY record SRT's store saw for this command, unfiltered
|
|
2139
|
-
// 2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2322
|
+
// EVERY record SRT's store saw for this command, unfiltered
|
|
2323
|
+
// (2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2140
2324
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
2141
2325
|
// a failure ends up with an empty violations section and no explanation.
|
|
2142
2326
|
const records = takeRecords()
|
|
@@ -2151,7 +2335,7 @@ async function runSandboxedOnce(
|
|
|
2151
2335
|
// below (`refusedWrites`).
|
|
2152
2336
|
const macViolations = process.platform === 'darwin' ? records : []
|
|
2153
2337
|
// No settle window: the store is read once, right after the child exits
|
|
2154
|
-
// (
|
|
2338
|
+
// (2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
|
|
2155
2339
|
// full budget, since a task with nothing to report can only prove that by
|
|
2156
2340
|
// waiting — against 26ms for one that reports something and breaks out
|
|
2157
2341
|
// early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
|
|
@@ -2271,7 +2455,7 @@ async function runSandboxedOnce(
|
|
|
2271
2455
|
stdout,
|
|
2272
2456
|
stderr,
|
|
2273
2457
|
violations,
|
|
2274
|
-
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
2458
|
+
...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
|
|
2275
2459
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
2276
2460
|
...(cut ? { leftRunning: true } : {}),
|
|
2277
2461
|
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
@@ -2854,7 +3038,8 @@ function writeGrantMatchedNothing(grant: string, within: string): void {
|
|
|
2854
3038
|
// As a committed config spells them (B-97): the absolute path held only
|
|
2855
3039
|
// on the machine that printed it, as in `outsideWritesHint`.
|
|
2856
3040
|
const dir = grantSpelled(grantPrefix(grant), within)
|
|
2857
|
-
|
|
3041
|
+
ui.say(
|
|
3042
|
+
'warn',
|
|
2858
3043
|
`[vx] sandbox: the write grant ${jsString(grantSpelled(grant, within))} matches nothing ` +
|
|
2859
3044
|
`yet, and a read grant mounts its directory read-only, so a file the task creates under ` +
|
|
2860
3045
|
`it will fail with "Read-only file system". A bind mount covers what exists when the ` +
|
|
@@ -2890,6 +3075,7 @@ function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string
|
|
|
2890
3075
|
async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2891
3076
|
try {
|
|
2892
3077
|
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
3078
|
+
env: helperEnv(),
|
|
2893
3079
|
stdout: 'pipe',
|
|
2894
3080
|
stderr: 'ignore',
|
|
2895
3081
|
})
|
|
@@ -2963,7 +3149,7 @@ async function traceRefusal(
|
|
|
2963
3149
|
'--',
|
|
2964
3150
|
executablePath('true'),
|
|
2965
3151
|
],
|
|
2966
|
-
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
|
|
3152
|
+
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe', env: helperEnv() },
|
|
2967
3153
|
)
|
|
2968
3154
|
const err = await new Response(p.stderr).text()
|
|
2969
3155
|
const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
|
|
@@ -2976,7 +3162,8 @@ let warnedNoTrace = false
|
|
|
2976
3162
|
function warnUntraced(why: string): void {
|
|
2977
3163
|
if (warnedNoTrace) return
|
|
2978
3164
|
warnedNoTrace = true
|
|
2979
|
-
|
|
3165
|
+
ui.say(
|
|
3166
|
+
'warn',
|
|
2980
3167
|
`[vx] sandbox: ${why}, so sandboxed tasks run without the report of the reads the sandbox denied; ` +
|
|
2981
3168
|
`the sandbox still enforces\n`,
|
|
2982
3169
|
)
|
|
@@ -8,6 +8,7 @@ import { existsSync, lstatSync, statSync } from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
10
10
|
import { bindableReads, bindableWrites } from './sandbox-binds.js'
|
|
11
|
+
import { printable } from '../util/index.js'
|
|
11
12
|
import type {
|
|
12
13
|
ResolvedSandboxConfig,
|
|
13
14
|
SandboxedRunArgs,
|
|
@@ -50,17 +51,30 @@ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
|
|
|
50
51
|
// The rest of the call: an `execve`'s argv strings may hold a `)`
|
|
51
52
|
// (`sh -c "f()"`), so it reads to the result at the line's end.
|
|
52
53
|
const ARGS = '.*'
|
|
54
|
+
// The result, at the line's end: an argv string may hold `) = -1 ENOENT`
|
|
55
|
+
// itself, and a call that succeeded read as denied.
|
|
56
|
+
const FAILED = '\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)(?: \\([^)]*\\))?\\s*$'
|
|
53
57
|
const STRACE_DONE_RE = new RegExp(
|
|
54
|
-
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)
|
|
58
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)${FAILED}`,
|
|
55
59
|
)
|
|
56
60
|
const STRACE_UNFINISHED_RE = new RegExp(
|
|
57
61
|
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}<unfinished`,
|
|
58
62
|
)
|
|
59
|
-
const STRACE_RESUMED_RE = new RegExp(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const STRACE_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?${FAILED}`)
|
|
64
|
+
/**
|
|
65
|
+
* The calls that ask whether a path is there rather than read it. A
|
|
66
|
+
* `chdir` creates nothing: one into a directory the host lacks (`mkdir -p`
|
|
67
|
+
* after its refused mkdir) was judged a write, and the fix named a file.
|
|
68
|
+
*/
|
|
69
|
+
const PROBES = new Set([
|
|
70
|
+
'execve',
|
|
71
|
+
'faccessat2',
|
|
72
|
+
'faccessat',
|
|
73
|
+
'access',
|
|
74
|
+
'statx',
|
|
75
|
+
'newfstatat',
|
|
76
|
+
'chdir',
|
|
77
|
+
])
|
|
64
78
|
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
65
79
|
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
66
80
|
/**
|
|
@@ -331,6 +345,9 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
|
|
|
331
345
|
return out
|
|
332
346
|
}
|
|
333
347
|
|
|
348
|
+
/** Distinct calls `parseStraceViolations` judges between yields to the event loop. */
|
|
349
|
+
const JUDGED_PER_TURN = 500
|
|
350
|
+
|
|
334
351
|
export async function parseStraceViolations(
|
|
335
352
|
logPath: string,
|
|
336
353
|
args: SandboxedRunArgs,
|
|
@@ -386,19 +403,29 @@ export async function parseStraceViolations(
|
|
|
386
403
|
}
|
|
387
404
|
|
|
388
405
|
const seen = new Set<string>()
|
|
406
|
+
const judged = new Set<string>()
|
|
389
407
|
const out: SandboxViolation[] = []
|
|
390
408
|
for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
|
|
391
409
|
text,
|
|
392
410
|
baselines.cwd,
|
|
393
411
|
widened.size > 0,
|
|
394
412
|
)) {
|
|
413
|
+
// A suite's log repeats a call by the thousand, each judged by sync
|
|
414
|
+
// stats: a test shard's 44,788 calls held the event loop 2.9 s, and a
|
|
415
|
+
// DTE worker that quiet for two heartbeats was lost (main, 2026-10-25).
|
|
416
|
+
// Each call is judged once, and the loop yields between batches.
|
|
417
|
+
const call = `${syscall}\0${rawPath}\0${dir}\0${errno}\0${read}`
|
|
418
|
+
if (judged.has(call)) continue
|
|
419
|
+
judged.add(call)
|
|
420
|
+
if (judged.size % JUDGED_PER_TURN === 0) await new Promise((r) => setImmediate(r))
|
|
395
421
|
const lexical = absolutize(rawPath, dir ?? baselines.cwd)
|
|
396
422
|
const abs = toRealPath(lexical)
|
|
397
423
|
// Only report paths under the workspace-root deny anchor — system
|
|
398
424
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
399
425
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
400
426
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
401
|
-
|
|
427
|
+
const via = lexical === abs ? undefined : hiddenLink(lexical, abs, mounted, denyAnchors)
|
|
428
|
+
if (isUnderAny(abs, allowAbs) && via === undefined) continue
|
|
402
429
|
if (underGlob(abs, pendingWrites)) continue
|
|
403
430
|
if (read === true && !undeclared(abs)) continue
|
|
404
431
|
// A hidden path answers ENOENT, as a missing one does: a probe
|
|
@@ -409,17 +436,20 @@ export async function parseStraceViolations(
|
|
|
409
436
|
const key = `${syscall}|${abs}`
|
|
410
437
|
if (seen.has(key)) continue
|
|
411
438
|
seen.add(key)
|
|
439
|
+
// The line is printed verbatim, and the trace's `\033` is decoded: a
|
|
440
|
+
// denied file named `ESC ]0;…` retitled the terminal.
|
|
441
|
+
const [said, real] = [printable(rawPath), printable(abs)]
|
|
412
442
|
out.push(
|
|
413
443
|
read === true
|
|
414
444
|
? {
|
|
415
|
-
line: `${syscall}(${
|
|
445
|
+
line: `${syscall}(${said}) read under a write grant's directory, granted no read [${real}]`,
|
|
416
446
|
timestamp: new Date(),
|
|
417
447
|
target: abs,
|
|
418
448
|
path: abs,
|
|
419
449
|
ignorable: ['read'],
|
|
420
450
|
}
|
|
421
451
|
: {
|
|
422
|
-
line: `${syscall}(${
|
|
452
|
+
line: `${syscall}(${said}) = -1 ${errno} [${real}]`,
|
|
423
453
|
timestamp: new Date(),
|
|
424
454
|
target: abs,
|
|
425
455
|
path: abs,
|
|
@@ -427,6 +457,7 @@ export async function parseStraceViolations(
|
|
|
427
457
|
// does not keep, and `access` may ask either — so either list
|
|
428
458
|
// can silence it.
|
|
429
459
|
ignorable: ['read', 'write'],
|
|
460
|
+
...(via === undefined ? {} : { via }),
|
|
430
461
|
},
|
|
431
462
|
)
|
|
432
463
|
}
|
|
@@ -434,8 +465,10 @@ export async function parseStraceViolations(
|
|
|
434
465
|
}
|
|
435
466
|
|
|
436
467
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
468
|
+
* The directory holding a link on the path the task opened, when that
|
|
469
|
+
* directory is not in the sandbox, so the link is not there either: what
|
|
470
|
+
* a fix must grant beside the target. A granted file the task could not
|
|
471
|
+
* open this way was dropped as granted. A grant is mounted at its real path and
|
|
439
472
|
* bwrap mounts no link, so `read: ['config.json']` over `config.json ->
|
|
440
473
|
* conf/real.json` bound the target and left the name the task opens out;
|
|
441
474
|
* judged by its real path, the ENOENT was granted and dropped, and a tool
|
|
@@ -444,26 +477,25 @@ export async function parseStraceViolations(
|
|
|
444
477
|
* is missing outside the sandbox too, and outside the deny anchor the
|
|
445
478
|
* host is mounted, links and all.
|
|
446
479
|
*/
|
|
447
|
-
function
|
|
480
|
+
function hiddenLink(
|
|
448
481
|
lexical: string,
|
|
449
482
|
real: string,
|
|
450
483
|
mounted: Set<string>,
|
|
451
484
|
anchors: readonly string[],
|
|
452
|
-
):
|
|
453
|
-
if (
|
|
485
|
+
): string | undefined {
|
|
486
|
+
if (!existsSync(real)) return undefined
|
|
454
487
|
let at = lexical
|
|
455
|
-
const links: string[] = []
|
|
456
488
|
for (let up = path.dirname(at); up !== at; at = up, up = path.dirname(at)) {
|
|
457
489
|
try {
|
|
458
|
-
if (lstatSync(at).isSymbolicLink())
|
|
490
|
+
if (!lstatSync(at).isSymbolicLink()) continue
|
|
459
491
|
} catch {
|
|
460
492
|
// Not on the host by this spelling: no link to judge here.
|
|
493
|
+
continue
|
|
461
494
|
}
|
|
495
|
+
const holder = toRealPath(up)
|
|
496
|
+
if (anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)) return holder
|
|
462
497
|
}
|
|
463
|
-
return
|
|
464
|
-
const holder = toRealPath(path.dirname(l))
|
|
465
|
-
return anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)
|
|
466
|
-
})
|
|
498
|
+
return undefined
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
/**
|