@vzn/vx 0.0.636 → 0.0.637
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 +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/orchestrator/run.ts
CHANGED
|
@@ -3,12 +3,7 @@
|
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import {
|
|
7
|
-
inputStabilityEnv,
|
|
8
|
-
loadWorkspace,
|
|
9
|
-
unreachedHint,
|
|
10
|
-
unreachedPackages,
|
|
11
|
-
} from '../workspace/index.js'
|
|
6
|
+
import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
|
|
12
7
|
import { realpathSync } from 'node:fs'
|
|
13
8
|
import path from 'node:path'
|
|
14
9
|
import {
|
|
@@ -42,8 +37,6 @@ import {
|
|
|
42
37
|
stageTimes,
|
|
43
38
|
killGraceMs,
|
|
44
39
|
MAX_TIMEOUT_MS,
|
|
45
|
-
parseDuration,
|
|
46
|
-
parseSize,
|
|
47
40
|
printTimings,
|
|
48
41
|
ulid,
|
|
49
42
|
isLiteralPattern,
|
|
@@ -54,10 +47,11 @@ import {
|
|
|
54
47
|
teardownTimeoutMs,
|
|
55
48
|
secretMask,
|
|
56
49
|
isFsRefusal,
|
|
50
|
+
isUserError,
|
|
57
51
|
} from '../util/index.js'
|
|
58
52
|
import { keyedProjects } from './keyed-projects.js'
|
|
59
53
|
import { isDefaultBuild } from './projects.js'
|
|
60
|
-
import { prepareSandbox } from './sandbox-request.js'
|
|
54
|
+
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
61
55
|
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
62
56
|
import { admitTasks, taintTracker } from './admission.js'
|
|
63
57
|
import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
@@ -67,6 +61,7 @@ import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './
|
|
|
67
61
|
import { installPlugins, PluginSetupError } from './plugin.js'
|
|
68
62
|
import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
69
63
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
64
|
+
import { claimOutput, guardedOutput } from './output-host.js'
|
|
70
65
|
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
71
66
|
import type { RunContextRecord } from './telemetry.js'
|
|
72
67
|
import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
@@ -126,10 +121,13 @@ import {
|
|
|
126
121
|
type RunSummaryJson,
|
|
127
122
|
} from './run-artifacts.js'
|
|
128
123
|
import { createSaveLane } from './save-lane.js'
|
|
124
|
+
import { commandWindows } from './command-windows.js'
|
|
129
125
|
import { formatOutcomeTable, formatRunSummary } from './summary.js'
|
|
130
126
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
131
127
|
import { createMissExplainer } from './miss-reason.js'
|
|
132
128
|
import type { HeldServer, RunOptions, RunSummary } from './options.js'
|
|
129
|
+
import { withRunSettings } from './run-settings.js'
|
|
130
|
+
import { resolveSetting } from '../settings.js'
|
|
133
131
|
|
|
134
132
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
135
133
|
// is one `preProbed.set` away from leaking a hit across cycles.
|
|
@@ -140,28 +138,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
|
|
|
140
138
|
groupKeys: new Map(),
|
|
141
139
|
})
|
|
142
140
|
|
|
143
|
-
/**
|
|
144
|
-
* Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
|
|
145
|
-
* timeout default. A missing/empty/non-positive-integer value yields
|
|
146
|
-
* `undefined` (ignored), so a typo never silently disables a task's own
|
|
147
|
-
* `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
|
|
148
|
-
*
|
|
149
|
-
* Exported for `tests/options-resolve.test.ts`, which pins every accepted
|
|
150
|
-
* and ignored form of this rung; it has no other caller outside this file.
|
|
151
|
-
*/
|
|
152
|
-
export function readTaskTimeoutEnv(): number | undefined {
|
|
153
|
-
const raw = process.env['VX_TASK_TIMEOUT']
|
|
154
|
-
if (raw === undefined || raw === '') return undefined
|
|
155
|
-
const n = Number(raw)
|
|
156
|
-
if (!Number.isInteger(n) || n <= 0) return undefined
|
|
157
|
-
// CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
|
|
158
|
-
// contract is already "unusable value → fall back", so it never throws. But
|
|
159
|
-
// falling back is wrong here: someone who typed a huge number wants a long
|
|
160
|
-
// timeout, and the largest expressible one IS long (~24.8 days). Handing it
|
|
161
|
-
// to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
|
|
162
|
-
return Math.min(n, MAX_TIMEOUT_MS)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
141
|
/**
|
|
166
142
|
* Compact the 4-axis cache policy into the `invocations.cache_policy`
|
|
167
143
|
* column string. Each enabled axis contributes its flag; a fully-on
|
|
@@ -182,7 +158,7 @@ function compactCachePolicy(p: CachePolicy): string {
|
|
|
182
158
|
* local cache writes NOWHERE, yet the write axis reads as on. Normalising
|
|
183
159
|
* here — the ONE place both `run()` and `planRun()` derive it from — is
|
|
184
160
|
* what keeps `--dry` describing the run you are about to get: reading the
|
|
185
|
-
* raw request made the plan label `--cache=local:,remote:rw` "cache miss —
|
|
161
|
+
* raw request made the plan label `--cache-policy=local:,remote:rw` "cache miss —
|
|
186
162
|
* would exec" (a result that will be stored) for a run in which caching is
|
|
187
163
|
* entirely off.
|
|
188
164
|
*/
|
|
@@ -201,7 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
|
|
|
201
177
|
* ingests hits into local for execute-task's lazy probe. The gate asks
|
|
202
178
|
* the layer (`hasRemote`), not `instanceof LayeredCache` — a
|
|
203
179
|
* third-party remote layer stalls on the classify just as hard.
|
|
204
|
-
* - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
|
|
180
|
+
* - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
|
|
205
181
|
* run reads nothing locally → nothing to restore → skip);
|
|
206
182
|
* - there is at least one task.
|
|
207
183
|
* A flat graph has no ordering to bypass, but the classify pass is worth
|
|
@@ -294,6 +270,12 @@ function refuseRunOptions(options: RunOptions): void {
|
|
|
294
270
|
oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
|
|
295
271
|
oneOf('download', options.download, ['all', 'toplevel', 'none'])
|
|
296
272
|
oneOf('flow', options.flow, ['focused', 'broad'])
|
|
273
|
+
oneOf('schedule', options.schedule, [
|
|
274
|
+
'most-work',
|
|
275
|
+
'critical-path',
|
|
276
|
+
'direct-dependents',
|
|
277
|
+
'ready-order',
|
|
278
|
+
])
|
|
297
279
|
const exclude = options.excludeDependencies
|
|
298
280
|
if (exclude !== undefined && exclude !== 'all' && !names(exclude))
|
|
299
281
|
refuse('excludeDependencies', exclude, "'all' or an array of task names")
|
|
@@ -304,11 +286,15 @@ function refuseRunOptions(options: RunOptions): void {
|
|
|
304
286
|
refuse('concurrency', concurrency, 'a positive integer')
|
|
305
287
|
if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
|
|
306
288
|
refuse('retries', retries, 'a non-negative integer')
|
|
307
|
-
|
|
308
|
-
timeout
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
289
|
+
for (const [name, ms] of [
|
|
290
|
+
['timeout', timeout],
|
|
291
|
+
['killGrace', options.killGrace],
|
|
292
|
+
['teardownTimeout', options.teardownTimeout],
|
|
293
|
+
['readyNotice', options.readyNotice],
|
|
294
|
+
] as const) {
|
|
295
|
+
if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
|
|
296
|
+
refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
|
|
297
|
+
}
|
|
312
298
|
}
|
|
313
299
|
|
|
314
300
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
@@ -337,6 +323,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
337
323
|
coalesce: true,
|
|
338
324
|
...(options.tty === true ? { tty: true } : {}),
|
|
339
325
|
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
326
|
+
...(options.footer === false ? { footer: false } : {}),
|
|
340
327
|
},
|
|
341
328
|
)
|
|
342
329
|
: null
|
|
@@ -349,16 +336,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
349
336
|
// Tasks run with colour forced (exec/env.ts); where the terminal prints
|
|
350
337
|
// plain, their escapes are stripped. A custom logger gets the task's
|
|
351
338
|
// bytes as written, as the cache holds them: it formats its own output.
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
)
|
|
339
|
+
const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
|
|
340
|
+
let unsubscribeTerminal = bus.subscribe(render)
|
|
355
341
|
const detach = (): void => {
|
|
356
342
|
unsubscribeTerminal()
|
|
357
343
|
terminal?.settle()
|
|
358
344
|
}
|
|
345
|
+
// A plugin's `output` renders the run from its start; core's renderer
|
|
346
|
+
// stays the fallback. Only core's own renderer is replaceable: an
|
|
347
|
+
// embedder's `log` is never consulted away.
|
|
348
|
+
const takeOutput =
|
|
349
|
+
terminal === null
|
|
350
|
+
? undefined
|
|
351
|
+
: (claimed: { logger: Logger; owner: string }): void => {
|
|
352
|
+
terminal.settle()
|
|
353
|
+
unsubscribeTerminal()
|
|
354
|
+
unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
|
|
355
|
+
}
|
|
359
356
|
let held = false
|
|
360
357
|
try {
|
|
361
|
-
const summary = await runOnBus(options, bus, colors, terminal)
|
|
358
|
+
const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
|
|
362
359
|
if (summary.persistent === undefined) return summary
|
|
363
360
|
// Servers handed back still running still write: `vx watch dev` printed
|
|
364
361
|
// none of its server's log while it idled, the renderer gone with this
|
|
@@ -382,6 +379,7 @@ async function runOnBus(
|
|
|
382
379
|
bus: EventBus,
|
|
383
380
|
colors: ColorSupport,
|
|
384
381
|
terminal: DefaultLogger | null,
|
|
382
|
+
takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
|
|
385
383
|
): Promise<RunSummary> {
|
|
386
384
|
const log = busLogger(bus)
|
|
387
385
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
@@ -398,6 +396,7 @@ async function runOnBus(
|
|
|
398
396
|
}
|
|
399
397
|
|
|
400
398
|
const prepared = await prepareRun(options, log)
|
|
399
|
+
options = withRunSettings(options, prepared.workspaceConfig)
|
|
401
400
|
mark('plugin stages')
|
|
402
401
|
// Every exit from here tears the plugins down: their capability
|
|
403
402
|
// factories ran in prepareRun, and teardown is what releases what those
|
|
@@ -499,14 +498,13 @@ async function runOnBus(
|
|
|
499
498
|
cacheDir,
|
|
500
499
|
cache,
|
|
501
500
|
nodes,
|
|
502
|
-
workspaceFingerprint,
|
|
503
501
|
fingerprintWatch,
|
|
504
502
|
nestedDirsByProject,
|
|
505
503
|
gitFilesCache,
|
|
506
504
|
hashCache,
|
|
507
505
|
workspaceProjectCount,
|
|
508
506
|
} = prepared
|
|
509
|
-
const concurrency = options.concurrency ??
|
|
507
|
+
const concurrency = options.concurrency ?? machineParallelism()
|
|
510
508
|
// An earlier cycle's servers: the ones this graph starts again unchanged
|
|
511
509
|
// stay up; the others go now, before anything binds their ports. A stop
|
|
512
510
|
// that lands meanwhile ends the run here, having run nothing: an aborted
|
|
@@ -598,7 +596,7 @@ async function runOnBus(
|
|
|
598
596
|
}
|
|
599
597
|
}
|
|
600
598
|
|
|
601
|
-
//
|
|
599
|
+
// `cache.download` (default `all`) decides ONCE per task, here, whether a
|
|
602
600
|
// remote execution's outputs come home.
|
|
603
601
|
const downloadPolicy = options.download ?? 'all'
|
|
604
602
|
const localPlaced = locallyPlaced(placements, nodes.keys())
|
|
@@ -609,20 +607,20 @@ async function runOnBus(
|
|
|
609
607
|
remoteOnly: placements.remoteOnly,
|
|
610
608
|
})
|
|
611
609
|
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
// → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
|
|
615
|
-
// ignored (a bad timeout must not silently disable a task's own limit).
|
|
616
|
-
const taskTimeoutDefault =
|
|
617
|
-
options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
|
|
610
|
+
// `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
|
|
611
|
+
const taskTimeoutDefault = options.timeout
|
|
618
612
|
// Read once, before any task: a bad value is refused here, not mid-run.
|
|
619
613
|
const inputStability = {
|
|
620
614
|
run: inputStabilityEnv(),
|
|
621
|
-
...(workspaceConfig?.inputStability !== undefined
|
|
622
|
-
? { workspace: workspaceConfig.inputStability }
|
|
615
|
+
...(workspaceConfig?.cache?.inputStability !== undefined
|
|
616
|
+
? { workspace: workspaceConfig.cache.inputStability }
|
|
623
617
|
: {}),
|
|
624
618
|
}
|
|
625
619
|
|
|
620
|
+
const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
|
|
621
|
+
| boolean
|
|
622
|
+
| undefined
|
|
623
|
+
|
|
626
624
|
// Run-scoped registries of live subprocesses:
|
|
627
625
|
// - `liveChildren`: in-flight children. The runner adds/removes
|
|
628
626
|
// each child around its spawn (persistent children stay until
|
|
@@ -710,7 +708,7 @@ async function runOnBus(
|
|
|
710
708
|
// bumps are flushed, so a throw between opening the cache and the
|
|
711
709
|
// normal close leaked the SQLite handle (it matters in a long-lived
|
|
712
710
|
// host) AND lost this run's touch record, after which an LRU
|
|
713
|
-
// `vx cache
|
|
711
|
+
// `vx cache clean` can evict entries the run just hit. Once-only
|
|
714
712
|
// because the normal path closes before the persistent-task wait —
|
|
715
713
|
// holding the handle open for a dev server's whole lifetime would be
|
|
716
714
|
// worse — and because close() re-runs its retention DELETEs.
|
|
@@ -751,7 +749,7 @@ async function runOnBus(
|
|
|
751
749
|
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
|
|
752
750
|
if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
|
|
753
751
|
log.status(
|
|
754
|
-
'vx: --cache named the remote cache, but no cache plugin in vx
|
|
752
|
+
'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
|
|
755
753
|
)
|
|
756
754
|
}
|
|
757
755
|
const deferredOutputs = new DeferredOutputs({
|
|
@@ -768,7 +766,11 @@ async function runOnBus(
|
|
|
768
766
|
// fails a run). `dirty` reuses the `git status --porcelain` the
|
|
769
767
|
// GitFilesCache populate already ran for input enumeration — no
|
|
770
768
|
// second status spawn.
|
|
771
|
-
|
|
769
|
+
// `run.branch` names the branch over git's (`prepared.branch`).
|
|
770
|
+
const gitContext = {
|
|
771
|
+
...captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty),
|
|
772
|
+
branch: prepared.branch(),
|
|
773
|
+
}
|
|
772
774
|
const ciContext = detectCi(process.env)
|
|
773
775
|
const hostContext = captureHostContext()
|
|
774
776
|
|
|
@@ -784,7 +786,7 @@ async function runOnBus(
|
|
|
784
786
|
let runContextRecord: RunContextRecord | undefined
|
|
785
787
|
// Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
|
|
786
788
|
// any plugin at all: a cache-only plugin has no telemetry hook to
|
|
787
|
-
// consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
|
|
789
|
+
// consult, so paying for the record (2 git spawns + a `.vx/cache/workspace-id`
|
|
788
790
|
// write on a remote-less repo) buys nothing. A plugin that has the hook
|
|
789
791
|
// but declines still pays — its answer is only knowable by asking.
|
|
790
792
|
const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
|
|
@@ -869,6 +871,13 @@ async function runOnBus(
|
|
|
869
871
|
log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
870
872
|
)
|
|
871
873
|
const deferredSaves = new Map<string, Promise<SaveFacts>>()
|
|
874
|
+
const windows = commandWindows()
|
|
875
|
+
// A server held from an earlier run (`vx watch`) runs through this one
|
|
876
|
+
// too, so it may write a mutable input while it is described.
|
|
877
|
+
for (const id of heldBefore.kept.keys()) {
|
|
878
|
+
const node = nodes.get(id)!
|
|
879
|
+
windows.open(id, node.projectDir, commandWriteReach(node, workspaceRoot))
|
|
880
|
+
}
|
|
872
881
|
|
|
873
882
|
// Focused flow: a requested GROUP has no output of its own, so
|
|
874
883
|
// surface the same-project, non-group tasks it chains (one level)
|
|
@@ -907,6 +916,20 @@ async function runOnBus(
|
|
|
907
916
|
workspaceProjectCount,
|
|
908
917
|
}
|
|
909
918
|
|
|
919
|
+
// Asked only when a plugin declares `output`: a plain run pays nothing.
|
|
920
|
+
if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
|
|
921
|
+
const json = options.json === true
|
|
922
|
+
const claimed = await claimOutput(prepared.plugins, {
|
|
923
|
+
workspaceRoot,
|
|
924
|
+
cacheDir,
|
|
925
|
+
warn: (m: string) => log.status(m),
|
|
926
|
+
isTTY: (json ? process.stderr : process.stdout).isTTY === true,
|
|
927
|
+
ci: ciContext.ci,
|
|
928
|
+
json,
|
|
929
|
+
})
|
|
930
|
+
if (claimed !== undefined) takeOutput(claimed)
|
|
931
|
+
}
|
|
932
|
+
|
|
910
933
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
911
934
|
// (TTY-only); custom loggers may ignore them.
|
|
912
935
|
log.runStart?.({
|
|
@@ -921,7 +944,11 @@ async function runOnBus(
|
|
|
921
944
|
terminal?.forecast(async () => {
|
|
922
945
|
const ids: string[] = []
|
|
923
946
|
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
924
|
-
const p50s = await new LocalHistoryProvider(
|
|
947
|
+
const p50s = await new LocalHistoryProvider(
|
|
948
|
+
prepared.localCache.dbHandle(),
|
|
949
|
+
undefined,
|
|
950
|
+
prepared.branch(),
|
|
951
|
+
).p50sFor(ids)
|
|
925
952
|
return createForecast(nodes, p50s, concurrency)
|
|
926
953
|
})
|
|
927
954
|
|
|
@@ -938,7 +965,7 @@ async function runOnBus(
|
|
|
938
965
|
nodes,
|
|
939
966
|
cache,
|
|
940
967
|
workspaceRoot,
|
|
941
|
-
|
|
968
|
+
claimedFiles: fingerprintWatch.names,
|
|
942
969
|
forwardArgs: options.forwardArgs,
|
|
943
970
|
nestedDirsByProject,
|
|
944
971
|
gitFilesCache,
|
|
@@ -970,7 +997,7 @@ async function runOnBus(
|
|
|
970
997
|
nodes,
|
|
971
998
|
cache,
|
|
972
999
|
workspaceRoot,
|
|
973
|
-
|
|
1000
|
+
claimedFiles: fingerprintWatch.names,
|
|
974
1001
|
forwardArgs: options.forwardArgs,
|
|
975
1002
|
nestedDirsByProject,
|
|
976
1003
|
gitFilesCache,
|
|
@@ -1043,7 +1070,6 @@ async function runOnBus(
|
|
|
1043
1070
|
node,
|
|
1044
1071
|
upstream,
|
|
1045
1072
|
workspaceRoot,
|
|
1046
|
-
workspaceFingerprint,
|
|
1047
1073
|
cache,
|
|
1048
1074
|
cachePolicy: policy,
|
|
1049
1075
|
forwardArgs: options.forwardArgs,
|
|
@@ -1063,12 +1089,15 @@ async function runOnBus(
|
|
|
1063
1089
|
outputDirSnapshots,
|
|
1064
1090
|
deferSave: saveLane.defer,
|
|
1065
1091
|
deferredSaves,
|
|
1092
|
+
commandWindows: windows,
|
|
1066
1093
|
stopSignal: stopRun.signal,
|
|
1067
1094
|
failFast: failFast.signal,
|
|
1068
1095
|
}
|
|
1069
1096
|
if (options.retries !== undefined) a.retries = options.retries
|
|
1070
1097
|
if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
|
|
1098
|
+
if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
|
|
1071
1099
|
a.inputStability = inputStability
|
|
1100
|
+
if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
|
|
1072
1101
|
if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
|
|
1073
1102
|
if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
|
|
1074
1103
|
if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
|
|
@@ -1090,7 +1119,6 @@ async function runOnBus(
|
|
|
1090
1119
|
shortCircuit,
|
|
1091
1120
|
hashArgs: {
|
|
1092
1121
|
workspaceRoot,
|
|
1093
|
-
workspaceFingerprint,
|
|
1094
1122
|
cache,
|
|
1095
1123
|
forwardArgs: options.forwardArgs,
|
|
1096
1124
|
nestedDirsByProject,
|
|
@@ -1148,6 +1176,7 @@ async function runOnBus(
|
|
|
1148
1176
|
wallclockEndNs: at,
|
|
1149
1177
|
}
|
|
1150
1178
|
}
|
|
1179
|
+
let lockBroken: boolean | undefined
|
|
1151
1180
|
const outcomes = await runGraph({
|
|
1152
1181
|
nodes,
|
|
1153
1182
|
concurrency,
|
|
@@ -1169,7 +1198,15 @@ async function runOnBus(
|
|
|
1169
1198
|
log.taskComplete(o.node, o)
|
|
1170
1199
|
narrowDemand(o.node.id)
|
|
1171
1200
|
},
|
|
1172
|
-
|
|
1201
|
+
// A crash a hand-broken lock entry caused is named as that; asked
|
|
1202
|
+
// only here, so a run that does not crash pays nothing.
|
|
1203
|
+
onError: (node, line, err) =>
|
|
1204
|
+
log.taskStderr(
|
|
1205
|
+
node,
|
|
1206
|
+
!isUserError(err) && !isFsRefusal(err) && (lockBroken ??= lockEntryBroken(workspaceRoot))
|
|
1207
|
+
? `[vx] ${node.id}: ${LOCK_INVALID}\n`
|
|
1208
|
+
: line,
|
|
1209
|
+
),
|
|
1173
1210
|
execute: (node, upstream) => {
|
|
1174
1211
|
const kept = keepServer(node)
|
|
1175
1212
|
return kept !== undefined
|
|
@@ -1195,9 +1232,7 @@ async function runOnBus(
|
|
|
1195
1232
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1196
1233
|
...(priorities.size > 0 ? { priorities } : {}),
|
|
1197
1234
|
// The baseline under them; `most-work` when the workspace names none.
|
|
1198
|
-
...(
|
|
1199
|
-
? { strategy: prepared.workspaceConfig.schedule }
|
|
1200
|
-
: {}),
|
|
1235
|
+
...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
|
|
1201
1236
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
1202
1237
|
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
1203
1238
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
@@ -1289,7 +1324,7 @@ async function runOnBus(
|
|
|
1289
1324
|
const stillDeferred = deferredOutputs.pending()
|
|
1290
1325
|
if (stillDeferred.length > 0) {
|
|
1291
1326
|
log.status(
|
|
1292
|
-
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
|
|
1327
|
+
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
|
|
1293
1328
|
)
|
|
1294
1329
|
}
|
|
1295
1330
|
|
|
@@ -1519,7 +1554,8 @@ async function runOnBus(
|
|
|
1519
1554
|
if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
|
|
1520
1555
|
const above = options.beforeFooter?.(list, ok)
|
|
1521
1556
|
if (above) log.status(above.replace(/\n$/, ''))
|
|
1522
|
-
|
|
1557
|
+
if (options.footer !== false)
|
|
1558
|
+
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1523
1559
|
|
|
1524
1560
|
// Edge case the summary already reported: the user requested a
|
|
1525
1561
|
// persistent task (dev server / watcher). The run is "done" in every
|
|
@@ -1665,18 +1701,16 @@ async function runOnBus(
|
|
|
1665
1701
|
}
|
|
1666
1702
|
|
|
1667
1703
|
/**
|
|
1668
|
-
*
|
|
1704
|
+
* `cache.retention`, after every save and upload of this run
|
|
1669
1705
|
* has landed (so nothing this run wrote is mid-flight) and before the cache
|
|
1670
1706
|
* closes. Housekeeping, not the run's work: silent, and a failure is never
|
|
1671
1707
|
* a failed run. Declared nowhere → one property read.
|
|
1672
1708
|
*/
|
|
1673
1709
|
async function applyCacheRetention(prepared: PreparedRun): Promise<void> {
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
|
|
1679
|
-
const maxBytes = retention.maxSize === undefined ? undefined : parseSize(retention.maxSize)!
|
|
1710
|
+
const ws = prepared.workspaceConfig
|
|
1711
|
+
const maxAgeMs = resolveSetting('cache.retention.olderThan', ws).value as number | undefined
|
|
1712
|
+
const maxBytes = resolveSetting('cache.retention.maxSize', ws).value as number | undefined
|
|
1713
|
+
if (maxAgeMs === undefined && maxBytes === undefined) return
|
|
1680
1714
|
try {
|
|
1681
1715
|
await prepared.localCache.evictIfDue({
|
|
1682
1716
|
...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
|
|
@@ -1721,6 +1755,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1721
1755
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
1722
1756
|
const reasons = new Map<string, AffectedReason>()
|
|
1723
1757
|
const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
|
|
1758
|
+
options = withRunSettings(options, prepared.workspaceConfig)
|
|
1724
1759
|
try {
|
|
1725
1760
|
if (prepared.unresolvedTasks.length > 0) {
|
|
1726
1761
|
return {
|
|
@@ -1748,7 +1783,6 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1748
1783
|
const planned = await plan({
|
|
1749
1784
|
nodes: prepared.nodes,
|
|
1750
1785
|
workspaceRoot: prepared.workspaceRoot,
|
|
1751
|
-
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
1752
1786
|
cache: prepared.cache,
|
|
1753
1787
|
cachePolicy: policy,
|
|
1754
1788
|
forwardArgs: options.forwardArgs,
|
|
@@ -1760,7 +1794,11 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1760
1794
|
// per-task p50s + a predicted wall-clock. `--dry` is an explicit
|
|
1761
1795
|
// inspection command, so the history read's cost is fine here even
|
|
1762
1796
|
// though it stays OFF the default run path.
|
|
1763
|
-
history: new LocalHistoryProvider(
|
|
1797
|
+
history: new LocalHistoryProvider(
|
|
1798
|
+
prepared.localCache.dbHandle(),
|
|
1799
|
+
undefined,
|
|
1800
|
+
prepared.branch(),
|
|
1801
|
+
),
|
|
1764
1802
|
// Placement, by the same rules the run applies. Only worth showing
|
|
1765
1803
|
// when there is a choice to show — with one declared executor every
|
|
1766
1804
|
// line would carry the same label. Resolving the executors here is
|
|
@@ -1810,7 +1848,7 @@ async function planRestorable(
|
|
|
1810
1848
|
nodes,
|
|
1811
1849
|
cache: prepared.cache,
|
|
1812
1850
|
workspaceRoot: prepared.workspaceRoot,
|
|
1813
|
-
|
|
1851
|
+
claimedFiles: prepared.fingerprintWatch.names,
|
|
1814
1852
|
forwardArgs,
|
|
1815
1853
|
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1816
1854
|
gitFilesCache: prepared.gitFilesCache,
|
|
@@ -1828,25 +1866,13 @@ function elsewhereHint(names: readonly string[]): string {
|
|
|
1828
1866
|
}
|
|
1829
1867
|
|
|
1830
1868
|
/**
|
|
1831
|
-
* The first-run case, told apart from a typo: no
|
|
1869
|
+
* The first-run case, told apart from a typo: no project in the workspace
|
|
1832
1870
|
* has a `vx.config.*` at all, so no name could have resolved. Reached only
|
|
1833
|
-
* on the error path.
|
|
1834
|
-
* told that instead — `vx init` would find no package to write for.
|
|
1871
|
+
* on the error path.
|
|
1835
1872
|
*/
|
|
1836
|
-
async function initHint(prepared: {
|
|
1837
|
-
anyProjectConfig: boolean
|
|
1838
|
-
workspaceProjectCount: number
|
|
1839
|
-
workspaceRoot: string
|
|
1840
|
-
}): Promise<string> {
|
|
1873
|
+
async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
|
|
1841
1874
|
if (prepared.anyProjectConfig) return ''
|
|
1842
|
-
|
|
1843
|
-
return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
|
|
1844
|
-
}
|
|
1845
|
-
// Single-project mode with packages the root's missing `workspaces`
|
|
1846
|
-
// never reaches: `vx init` would find no scripts either (item 248).
|
|
1847
|
-
const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
|
|
1848
|
-
if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
|
|
1849
|
-
return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1875
|
+
return ' No directory holds a vx.config — run `vx init` to write them, then `vx lock`.'
|
|
1850
1876
|
}
|
|
1851
1877
|
|
|
1852
1878
|
/**
|
|
@@ -1892,7 +1918,7 @@ function didYouMean(
|
|
|
1892
1918
|
// `//` is two edits from any two-letter name: it hinted `ui#build` (X-13).
|
|
1893
1919
|
if (proj === '//') {
|
|
1894
1920
|
lists.add(
|
|
1895
|
-
|
|
1921
|
+
'`//` is the workspace root, which is no project here: add a vx.config there with that task and run `vx lock`.',
|
|
1896
1922
|
)
|
|
1897
1923
|
continue
|
|
1898
1924
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The `exec.sandbox.allow` entries that would have granted what a failed
|
|
2
|
-
// task was denied, so the report names the line to add (CLI, JSON
|
|
2
|
+
// task was denied, so the report names the line to add (CLI, JSON)
|
|
3
3
|
// rather than leaving the user to turn absolute paths into grants. Built on
|
|
4
4
|
// the failure path only: a passing run never calls it.
|
|
5
5
|
|
|
@@ -9,26 +9,22 @@ import path from 'node:path'
|
|
|
9
9
|
import type { SandboxViolation } from '../exec/index.js'
|
|
10
10
|
import type { TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isLiteralPattern } from '../util/index.js'
|
|
12
|
+
import { CREDENTIAL_STORES } from './sandbox-request.js'
|
|
12
13
|
|
|
13
14
|
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
14
15
|
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
15
|
-
type Kind = Exclude<keyof SandboxFix, 'withheld'>
|
|
16
|
-
|
|
17
|
-
// Where credentials live. A denial here is never turned into a grant: an
|
|
18
|
-
// agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
|
|
19
|
-
// get its grant suggested and applied (Security, 2026-10-12).
|
|
20
|
-
const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
|
|
21
|
-
const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
|
|
16
|
+
type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
|
|
22
17
|
|
|
18
|
+
// A denial in a credential store is never turned into a grant: an agent
|
|
19
|
+
// applies `sandboxFix` as given, so a dependency probing ~/.ssh would get
|
|
20
|
+
// its grant suggested and applied (Security, 2026-10-12). The stores are
|
|
21
|
+
// the ones the sandbox denies: a hand copy here missed half of them.
|
|
23
22
|
function isCredential(abs: string): boolean {
|
|
24
23
|
const base = path.basename(abs)
|
|
25
24
|
if (base === '.env' || base.startsWith('.env.')) return true
|
|
26
25
|
const rel = path.relative(os.homedir(), abs)
|
|
27
26
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
28
|
-
return (
|
|
29
|
-
CREDENTIAL_FILES.includes(rel) ||
|
|
30
|
-
CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
31
|
-
)
|
|
27
|
+
return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
/**
|
|
@@ -63,6 +59,16 @@ function readsDir(g: string, top: string): boolean {
|
|
|
63
59
|
return first === top || !isLiteralPattern(first)
|
|
64
60
|
}
|
|
65
61
|
|
|
62
|
+
const inProject = (grant: string): boolean => !grant.startsWith('~/') && !path.isAbsolute(grant)
|
|
63
|
+
|
|
64
|
+
/** Whether a project-relative path is one the task's input globs match. */
|
|
65
|
+
function keyedBy(globs: readonly string[]): (rel: string) => boolean {
|
|
66
|
+
const strip = (g: string) => g.replace(/^\.\//, '')
|
|
67
|
+
const take = globs.filter((g) => !g.startsWith('!')).map((g) => new Bun.Glob(strip(g)))
|
|
68
|
+
const drop = globs.filter((g) => g.startsWith('!')).map((g) => new Bun.Glob(strip(g.slice(1))))
|
|
69
|
+
return (rel) => take.some((g) => g.match(rel)) && !drop.some((g) => g.match(rel))
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
function kindOf(v: SandboxViolation): Kind | undefined {
|
|
67
73
|
const can = v.ignorable ?? []
|
|
68
74
|
if (can.length === 1) return can[0]
|
|
@@ -78,16 +84,22 @@ export function sandboxFix(
|
|
|
78
84
|
violations: readonly SandboxViolation[],
|
|
79
85
|
projectDir: string,
|
|
80
86
|
mask: (text: string) => string = (t) => t,
|
|
81
|
-
/**
|
|
82
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The task's `cache.inputs.files`, undefined when it does not cache: a
|
|
89
|
+
* write under one is granted exactly, and a read of the project no glob
|
|
90
|
+
* matches is named for the key too.
|
|
91
|
+
*/
|
|
92
|
+
inputs?: readonly string[],
|
|
83
93
|
): SandboxFix | undefined {
|
|
84
|
-
const fix: Record<Kind | 'withheld', Set<string>> = {
|
|
94
|
+
const fix: Record<Kind | 'withheld' | 'inputs', Set<string>> = {
|
|
85
95
|
read: new Set(),
|
|
86
96
|
write: new Set(),
|
|
87
97
|
network: new Set(),
|
|
88
98
|
systemInfo: new Set(),
|
|
89
99
|
withheld: new Set(),
|
|
100
|
+
inputs: new Set(),
|
|
90
101
|
}
|
|
102
|
+
const keyed = inputs === undefined ? undefined : keyedBy(inputs)
|
|
91
103
|
for (const v of violations) {
|
|
92
104
|
if (v.hint === true || v.target === undefined) continue
|
|
93
105
|
const kind = kindOf(v)
|
|
@@ -96,16 +108,19 @@ export function sandboxFix(
|
|
|
96
108
|
else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
|
|
97
109
|
else if (v.path === undefined) continue
|
|
98
110
|
else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
|
|
99
|
-
else if (kind === 'read')
|
|
100
|
-
|
|
111
|
+
else if (kind === 'read') {
|
|
112
|
+
const grant = grantPath(v.path, projectDir)
|
|
113
|
+
fix.read.add(grant)
|
|
114
|
+
if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
|
|
115
|
+
} else {
|
|
101
116
|
const isDir =
|
|
102
117
|
v.line.startsWith('mkdir') ||
|
|
103
118
|
lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
104
|
-
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
|
|
119
|
+
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs ?? []))
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
const out: SandboxFix = {}
|
|
108
|
-
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
|
|
123
|
+
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld', 'inputs'] as const)
|
|
109
124
|
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
110
125
|
return Object.keys(out).length > 0 ? out : undefined
|
|
111
126
|
}
|
|
@@ -114,7 +129,11 @@ export function sandboxFix(
|
|
|
114
129
|
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
115
130
|
* or undefined when every denial was a withheld credential path.
|
|
116
131
|
*/
|
|
117
|
-
export function sandboxFixLine({
|
|
132
|
+
export function sandboxFixLine({
|
|
133
|
+
withheld: _,
|
|
134
|
+
inputs: __,
|
|
135
|
+
...fix
|
|
136
|
+
}: SandboxFix): string | undefined {
|
|
118
137
|
if (Object.keys(fix).length === 0) return undefined
|
|
119
138
|
const parts = Object.entries(fix).map(
|
|
120
139
|
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|