@vzn/vx 0.0.361 → 0.0.363
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/cache.ts +20 -2
- package/src/cli/lock.ts +44 -1
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/select.ts +2 -2
- package/src/exec/sandbox-binds.ts +37 -0
- package/src/exec/sandbox-runtime.ts +3 -1
- package/src/graph/scheduler.ts +36 -2
- package/src/orchestrator/events.ts +5 -2
- package/src/orchestrator/execute-task.ts +9 -1
- package/src/orchestrator/plugin.ts +3 -0
- package/src/orchestrator/run.ts +11 -1
- package/src/orchestrator/sandbox-request.ts +46 -1
- package/src/util/index.ts +8 -1
- package/src/util/secret-mask.ts +5 -0
- package/src/workspace/affected.ts +11 -7
- package/src/workspace/migrate-scripts.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.363",
|
|
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.363",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.363",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.363",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.363"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -1590,8 +1590,24 @@ export class Cache implements CacheLayer {
|
|
|
1590
1590
|
// whole. A commit that fails after the rename takes the artifact back
|
|
1591
1591
|
// out: the old rows then name no artifact, which a probe reads as a
|
|
1592
1592
|
// miss.
|
|
1593
|
+
//
|
|
1594
|
+
// A previous artifact is moved aside first, never renamed over: ext4
|
|
1595
|
+
// flushes the incoming file's delayed blocks when a rename replaces a
|
|
1596
|
+
// file, 0.55 ms against 0.04 on the main thread with the write lock
|
|
1597
|
+
// held (a `--force` run, 2026-10-02). A reader probing between the two
|
|
1598
|
+
// renames finds no artifact, a miss (`ArtifactVanishedError`); one
|
|
1599
|
+
// holding the old file still reads it whole. The aside name is a temp
|
|
1600
|
+
// name, so a crash before its unlink leaves an orphan the sweep takes.
|
|
1601
|
+
const aside = this.tempPath(hash)
|
|
1602
|
+
let displaced = false
|
|
1593
1603
|
let renamed = false
|
|
1594
1604
|
const tx = this.db.transaction(() => {
|
|
1605
|
+
try {
|
|
1606
|
+
renameSync(finalPath, aside)
|
|
1607
|
+
displaced = true
|
|
1608
|
+
} catch (err) {
|
|
1609
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
1610
|
+
}
|
|
1595
1611
|
renameSync(tmpPath, finalPath)
|
|
1596
1612
|
renamed = true
|
|
1597
1613
|
insertEntry.run(
|
|
@@ -1628,11 +1644,13 @@ export class Cache implements CacheLayer {
|
|
|
1628
1644
|
try {
|
|
1629
1645
|
tx.immediate()
|
|
1630
1646
|
} catch (err) {
|
|
1647
|
+
endTx()
|
|
1631
1648
|
await unlink(renamed ? finalPath : tmpPath).catch(() => undefined)
|
|
1649
|
+
if (displaced) await unlink(aside).catch(() => undefined)
|
|
1632
1650
|
throw err
|
|
1633
|
-
} finally {
|
|
1634
|
-
endTx()
|
|
1635
1651
|
}
|
|
1652
|
+
endTx()
|
|
1653
|
+
if (displaced) await unlink(aside).catch(() => undefined)
|
|
1636
1654
|
}
|
|
1637
1655
|
|
|
1638
1656
|
/** Apply the deferred accessed_at bumps in one statement. */
|
package/src/cli/lock.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
6
|
import { flagHint, refusedWord, seeHelp } from './help.js'
|
|
7
|
-
import { relPosix, xxh3hex } from '../util/index.js'
|
|
7
|
+
import { relPosix, secretMask, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
10
10
|
type LoadReads,
|
|
@@ -86,6 +86,15 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
86
86
|
const projects: Record<string, LockfileEntry> = {}
|
|
87
87
|
// `listProjects` sorts by name — stable lockfile diffs for free.
|
|
88
88
|
for (let i = 0; i < metas.length; i++) projects[metas[i]!.name] = entries[i]!
|
|
89
|
+
const leaks = secretsIn(projects)
|
|
90
|
+
if (leaks.length > 0) {
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
|
|
93
|
+
leaks.map((l) => ` ${l}\n`).join('') +
|
|
94
|
+
`let the shell expand it ($API_TOKEN in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
|
|
95
|
+
)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
89
98
|
const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
|
|
90
99
|
await writeLockfile(root, lock)
|
|
91
100
|
const n = metas.length
|
|
@@ -95,6 +104,40 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
95
104
|
return 0
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Each place an evaluated config holds the value of a secret in this
|
|
109
|
+
* environment, by the rule output masking uses (a secret-named variable,
|
|
110
|
+
* or one a task lists in `exec.env.secret`). The lock is committed, so
|
|
111
|
+
* such a value would be published, and masking it would freeze a `***`
|
|
112
|
+
* that `--frozen` runs (L-42).
|
|
113
|
+
*/
|
|
114
|
+
function secretsIn(projects: Record<string, LockfileEntry>): string[] {
|
|
115
|
+
const named = new Set<string>()
|
|
116
|
+
for (const entry of Object.values(projects)) {
|
|
117
|
+
for (const task of Object.values(entry.config.tasks ?? {}))
|
|
118
|
+
for (const n of task?.exec?.env?.secret ?? []) named.add(n)
|
|
119
|
+
}
|
|
120
|
+
const secrets: [value: string, name: string][] = []
|
|
121
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
122
|
+
if (value !== undefined && secretMask([{ [name]: value }], named.has(name) ? [name] : []))
|
|
123
|
+
secrets.push([value, name])
|
|
124
|
+
}
|
|
125
|
+
if (secrets.length === 0) return []
|
|
126
|
+
const found: string[] = []
|
|
127
|
+
const walk = (project: string, at: string, v: unknown): void => {
|
|
128
|
+
if (typeof v === 'string') {
|
|
129
|
+
for (const [value, name] of secrets)
|
|
130
|
+
if (v.includes(value)) found.push(`${project}: ${at} holds $${name}`)
|
|
131
|
+
} else if (Array.isArray(v)) {
|
|
132
|
+
v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
|
|
133
|
+
} else if (v !== null && typeof v === 'object') {
|
|
134
|
+
for (const [k, x] of Object.entries(v)) walk(project, at === '' ? k : `${at}.${k}`, x)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [project, entry] of Object.entries(projects)) walk(project, '', entry.config)
|
|
138
|
+
return found
|
|
139
|
+
}
|
|
140
|
+
|
|
98
141
|
/**
|
|
99
142
|
* Audit the lock. Two layers, strictly stronger than what runs do:
|
|
100
143
|
* 1. The same hash check runs perform (file bytes vs lock hash).
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
11
11
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
12
12
|
import { loadCliWorkspace } from './workspace-config.js'
|
|
13
|
-
import { machineParallelism } from '../util/index.js'
|
|
13
|
+
import { machineParallelism, maskedLine } from '../util/index.js'
|
|
14
14
|
|
|
15
15
|
export interface ResolvedPluginCommand {
|
|
16
16
|
plugin: VxPlugin
|
|
@@ -92,7 +92,7 @@ export async function resolvePluginCommand(
|
|
|
92
92
|
workspaceRoot: ws.workspaceRoot,
|
|
93
93
|
cacheDir: ws.cacheDir,
|
|
94
94
|
concurrency: ws.concurrency,
|
|
95
|
-
warn: (m) => process.stderr.write(`${m}\n`),
|
|
95
|
+
warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
|
|
96
96
|
},
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import type { ProjectConfig } from '../config.js'
|
|
26
26
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
27
27
|
import { parseDependencySpec } from '../graph/index.js'
|
|
28
|
-
import { nearest, UserError } from '../util/index.js'
|
|
28
|
+
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
29
29
|
import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
|
|
30
30
|
import {
|
|
31
31
|
type CliLoadOptions,
|
|
@@ -104,7 +104,7 @@ async function workspaceFingerprintClaims(
|
|
|
104
104
|
const ctx = {
|
|
105
105
|
workspaceRoot: root,
|
|
106
106
|
cacheDir: load.cacheDir ?? ws.cacheDir,
|
|
107
|
-
warn: (m: string) => process.stderr.write(`${m}\n`),
|
|
107
|
+
warn: (m: string) => process.stderr.write(`${maskedLine(m)}\n`),
|
|
108
108
|
projects: projects.map((p) => ({ name: p.name, dir: p.dir })),
|
|
109
109
|
}
|
|
110
110
|
return {
|
|
@@ -333,3 +333,40 @@ export function buildCustomConfig(
|
|
|
333
333
|
}
|
|
334
334
|
return custom
|
|
335
335
|
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Linux: SRT's bwrap line with each `--tmpfs` it lays over a read-denied
|
|
339
|
+
* directory (the workspace root, a sibling's tree, the cwd with no read
|
|
340
|
+
* grant) remounted read-only once the mounts beneath it are made. bwrap's
|
|
341
|
+
* tmpfs is writable, so a write there succeeded and vanished with the
|
|
342
|
+
* sandbox: a task that wrote `../../dist` went green with its output gone,
|
|
343
|
+
* where seatbelt refuses the same write. Now it is `EROFS` on both, and a
|
|
344
|
+
* failed task's hint names the path. The mask a `scratch` glob's writes
|
|
345
|
+
* land in stays writable: that is the grant (`scratchWrites`). Placed
|
|
346
|
+
* before the `--` that ends bwrap's options; the words are SRT's own
|
|
347
|
+
* quoting, copied as they are.
|
|
348
|
+
*/
|
|
349
|
+
export function readOnlyMasks(wrapped: string, scratch: readonly string[] = []): string {
|
|
350
|
+
const masks: string[] = []
|
|
351
|
+
let prev = ''
|
|
352
|
+
for (const m of wrapped.matchAll(/(?:'[^']*'|"[^"]*"|[^\s'"])+/g)) {
|
|
353
|
+
if (m[0] === '--') {
|
|
354
|
+
const held = scratch.map((g) => {
|
|
355
|
+
const dir = toRealPath(path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS) + 1)))
|
|
356
|
+
const holding = masks.filter((w) => atOrUnder(dir, unquoted(w)))
|
|
357
|
+
return holding.sort((a, b) => unquoted(b).length - unquoted(a).length)[0]
|
|
358
|
+
})
|
|
359
|
+
const ro = masks.filter((w) => !held.includes(w))
|
|
360
|
+
if (ro.length === 0) return wrapped
|
|
361
|
+
const words = ro.map((w) => `--remount-ro ${w} `).join('')
|
|
362
|
+
return `${wrapped.slice(0, m.index)}${words}${wrapped.slice(m.index)}`
|
|
363
|
+
}
|
|
364
|
+
if (prev === '--tmpfs') masks.push(m[0])
|
|
365
|
+
prev = m[0]
|
|
366
|
+
}
|
|
367
|
+
return wrapped
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A word as SRT's `quote` spells it, read back: single-quoted runs and `"'"`. */
|
|
371
|
+
const unquoted = (word: string): string =>
|
|
372
|
+
word.replace(/'([^']*)'|"([^"]*)"/g, (_, a: string | undefined, b: string | undefined) => a ?? b!)
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
bindableReads,
|
|
71
71
|
bindableWrites,
|
|
72
72
|
buildCustomConfig,
|
|
73
|
+
readOnlyMasks,
|
|
73
74
|
scratchWrites,
|
|
74
75
|
widenedEntries,
|
|
75
76
|
} from './sandbox-binds.js'
|
|
@@ -1065,7 +1066,8 @@ export async function wrapSandboxedCommand(
|
|
|
1065
1066
|
// sandboxed server and all it forked outlived vx (turborepo#9666). Now
|
|
1066
1067
|
// the namespace goes with vx, a `setsid` daemon inside included, a
|
|
1067
1068
|
// traced one-shot task too: its strace runs inside (B-11).
|
|
1068
|
-
if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped))
|
|
1069
|
+
if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped))
|
|
1070
|
+
wrapped = `exec ${readOnlyMasks(wrapped, scratch)}`
|
|
1069
1071
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1070
1072
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
1071
1073
|
if (args.server === true) liveServers.add(tag)
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
OUT_OF_FDS_HINT,
|
|
7
7
|
} from '../util/index.js'
|
|
8
8
|
import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
|
|
9
|
-
import type
|
|
9
|
+
import { isGroupTask, type TaskNode } from './task-graph.js'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Thrown by `execute` for a restore-tier task that turned out to have
|
|
@@ -181,6 +181,14 @@ export interface ScheduleOptions {
|
|
|
181
181
|
* be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
|
|
182
182
|
*/
|
|
183
183
|
continueMode?: ContinueMode
|
|
184
|
+
/**
|
|
185
|
+
* A failure the graph's outcomes do not hold yet: a persistent task
|
|
186
|
+
* that died after it became ready. Its outcome said `success` when it
|
|
187
|
+
* was ready, so this is how a dependant not yet started learns of it:
|
|
188
|
+
* under 'deps-ok' it is skipped as below a failed task, and under
|
|
189
|
+
* 'never' it stops dispatch.
|
|
190
|
+
*/
|
|
191
|
+
serverDied?: (id: string) => boolean
|
|
184
192
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
185
193
|
onStart?: (node: TaskNode) => void
|
|
186
194
|
onFinish?: (outcome: TaskOutcome) => void
|
|
@@ -563,8 +571,32 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
563
571
|
// they're dep-independent, so they typically restore before a dep
|
|
564
572
|
// could fail anyway.
|
|
565
573
|
const aborted = (): boolean => options.signal?.aborted === true
|
|
574
|
+
const serverDied = options.serverDied ?? ((): boolean => false)
|
|
575
|
+
// The dead server a dependency stands for: itself, or one a group
|
|
576
|
+
// reaches. A group is a name for its deps, and it finished the moment
|
|
577
|
+
// the server was ready, so a task behind it ran against the dead one.
|
|
578
|
+
const deadServerVia = (id: string): string | undefined => {
|
|
579
|
+
if (serverDied(id)) return id
|
|
580
|
+
const n = nodes.get(id)
|
|
581
|
+
if (n === undefined || !isGroupTask(n)) return undefined
|
|
582
|
+
for (const d of n.deps) {
|
|
583
|
+
const dead = deadServerVia(d)
|
|
584
|
+
if (dead !== undefined) return dead
|
|
585
|
+
}
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
const servers =
|
|
589
|
+
options.serverDied === undefined
|
|
590
|
+
? []
|
|
591
|
+
: [...nodes.values()]
|
|
592
|
+
.filter((n) => n.config.exec?.persistent !== undefined)
|
|
593
|
+
.map((n) => n.id)
|
|
566
594
|
const willSkip = (id: string): boolean => {
|
|
567
595
|
if (failFastTripped || aborted()) return true
|
|
596
|
+
if (continueMode === 'never' && servers.some(serverDied)) {
|
|
597
|
+
failFastTripped = true
|
|
598
|
+
return true
|
|
599
|
+
}
|
|
568
600
|
if (inRestoreTier(id)) return false
|
|
569
601
|
if (continueMode === 'always') return false
|
|
570
602
|
const node = nodes.get(id) as TaskNode
|
|
@@ -580,7 +612,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
580
612
|
u?.status === 'failed' ||
|
|
581
613
|
u?.status === 'skipped' ||
|
|
582
614
|
u?.status === 'aborted' ||
|
|
583
|
-
blockedRestores.has(d)
|
|
615
|
+
blockedRestores.has(d) ||
|
|
616
|
+
deadServerVia(d) !== undefined
|
|
584
617
|
)
|
|
585
618
|
})
|
|
586
619
|
}
|
|
@@ -651,6 +684,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
651
684
|
const viaRestore = node.deps.find((d) => blockedRestores.has(d))
|
|
652
685
|
const blockedBy =
|
|
653
686
|
blocker?.node.id ??
|
|
687
|
+
node.deps.map(deadServerVia).find((d) => d !== undefined) ??
|
|
654
688
|
viaSkip?.blockedBy ??
|
|
655
689
|
(viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
|
|
656
690
|
finishOne(id, {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { exitSignal } from '../exec/index.js'
|
|
10
10
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isGroupTask } from '../graph/index.js'
|
|
12
|
-
import { maskedCommand } from '../util/index.js'
|
|
12
|
+
import { maskedCommand, secretMask } from '../util/index.js'
|
|
13
13
|
import type { Logger } from './logger.js'
|
|
14
14
|
import type { RunContext } from './summary.js'
|
|
15
15
|
|
|
@@ -103,8 +103,11 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
103
103
|
// run() reaches runEnd on its success path, in its finally and from a
|
|
104
104
|
// signal; a plugin's onRunEnd hears the first only.
|
|
105
105
|
let ended = false
|
|
106
|
+
// A status line carries plugin warnings, and a remote layer's warning
|
|
107
|
+
// quotes the server's reply, which may echo what it was sent (L-40).
|
|
108
|
+
const secrets = secretMask([process.env])
|
|
106
109
|
return {
|
|
107
|
-
status: (line) => bus.emit({ kind: 'run:status', line }),
|
|
110
|
+
status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
|
|
108
111
|
taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
|
|
109
112
|
taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
|
|
110
113
|
taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
|
|
@@ -821,7 +821,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
821
821
|
})
|
|
822
822
|
.catch(async (raw: unknown) => {
|
|
823
823
|
const err = nameExecutorFailure(args.executor, raw)
|
|
824
|
-
|
|
824
|
+
// A remote executor's message carries the server's own text, which
|
|
825
|
+
// may echo the env it was sent: masked here, where it is printed,
|
|
826
|
+
// and on the error the scheduler prints with its cause (L-39).
|
|
827
|
+
if (secrets !== null) {
|
|
828
|
+
for (const e of [err, err instanceof Error ? err.cause : undefined])
|
|
829
|
+
if (e instanceof Error) e.message = secrets.mask(e.message)
|
|
830
|
+
}
|
|
831
|
+
const message =
|
|
832
|
+
err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
|
|
825
833
|
log.taskStderr(node, `${message}\n`)
|
|
826
834
|
await sweepPlaceholders(placeholders)
|
|
827
835
|
throw err
|
|
@@ -358,6 +358,8 @@ export interface PluginContext {
|
|
|
358
358
|
readonly workspaceRoot: string
|
|
359
359
|
/** Where vx's cache lives — read-only as far as the plugin is concerned. */
|
|
360
360
|
readonly cacheDir: string
|
|
361
|
+
/** Funnel warnings into the run:status channel, as every hook's context does. */
|
|
362
|
+
warn(message: string): void
|
|
361
363
|
/**
|
|
362
364
|
* The run event bus. A plugin can subscribe directly if its needs exceed
|
|
363
365
|
* the hooks; the subscription ends with the run, as a hook's does.
|
|
@@ -539,6 +541,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
539
541
|
const ctx: PluginContext = {
|
|
540
542
|
workspaceRoot,
|
|
541
543
|
cacheDir,
|
|
544
|
+
warn,
|
|
542
545
|
// A subscription made past the hooks leaves with the run like a
|
|
543
546
|
// hook's does: the bus can outlive the run (`RunOptions.bus`), and
|
|
544
547
|
// one left behind hears every later run on it (item 635).
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -693,7 +693,11 @@ async function runOnBus(
|
|
|
693
693
|
vxVersion: VERSION,
|
|
694
694
|
// An embedder's `command` is redacted as the argv is: it passed a
|
|
695
695
|
// token after `--` to every sink verbatim (C-67).
|
|
696
|
-
|
|
696
|
+
// Masked as the stored line is: `--tag key=$DEPLOY_KEY` sits before
|
|
697
|
+
// the `--` the count covers (L-44).
|
|
698
|
+
command: maskInvocation(
|
|
699
|
+
invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
700
|
+
),
|
|
697
701
|
requestedTasks: [...options.tasks],
|
|
698
702
|
cachePolicy: compactCachePolicy(policy),
|
|
699
703
|
concurrency,
|
|
@@ -922,6 +926,12 @@ async function runOnBus(
|
|
|
922
926
|
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
923
927
|
...(admit !== undefined ? { admit } : {}),
|
|
924
928
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
929
|
+
// A server that ended on its own is failed (the end of the run says
|
|
930
|
+
// so); what has not yet started hears it before it dispatches (C-88).
|
|
931
|
+
serverDied: (id) => {
|
|
932
|
+
const child = persistentRegistry.get(id)
|
|
933
|
+
return child !== undefined && hasEnded(child) && child.exitCode !== 0
|
|
934
|
+
},
|
|
925
935
|
signal: stopRun.signal,
|
|
926
936
|
onStart: (node) => {
|
|
927
937
|
log.taskStart?.(node)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (bwrap cannot bind a path that does not exist). Shared by the cached path
|
|
4
4
|
// (through the executor) and the persistent path (spawned in execute-task).
|
|
5
5
|
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
6
7
|
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
7
8
|
import { homedir } from 'node:os'
|
|
8
9
|
import path from 'node:path'
|
|
@@ -254,7 +255,13 @@ export async function sandboxRequestFor(
|
|
|
254
255
|
// 0.0.76). A grant naming a wall is not strictly outside it and wins.
|
|
255
256
|
// Without it a root task's `read: ['.']` read its nested projects under
|
|
256
257
|
// seatbelt (B-4).
|
|
257
|
-
|
|
258
|
+
// The host's credential stores are denied too: the rest of home stays
|
|
259
|
+
// readable (tools need `~/.cache`), but a dependency the task runs
|
|
260
|
+
// could copy a key into an output the cache shares (L-41).
|
|
261
|
+
baseDenyRead: [
|
|
262
|
+
...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
|
|
263
|
+
...credentialStores(workspaceRoot),
|
|
264
|
+
],
|
|
258
265
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
259
266
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
260
267
|
// walk `bun build --compile` makes from `/` down to its cwd lists
|
|
@@ -555,6 +562,44 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
|
|
|
555
562
|
)
|
|
556
563
|
}
|
|
557
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Where tools keep credentials under the user's home. A sandboxed task
|
|
567
|
+
* reads none of them unless its `allow.read` names one (a publish task's
|
|
568
|
+
* `~/.npmrc`).
|
|
569
|
+
*/
|
|
570
|
+
const CREDENTIAL_STORES = [
|
|
571
|
+
'.ssh',
|
|
572
|
+
'.gnupg',
|
|
573
|
+
'.aws',
|
|
574
|
+
'.azure',
|
|
575
|
+
'.kube',
|
|
576
|
+
'.config/gcloud',
|
|
577
|
+
'.config/gh',
|
|
578
|
+
'.docker/config.json',
|
|
579
|
+
'.netrc',
|
|
580
|
+
'.git-credentials',
|
|
581
|
+
'.npmrc',
|
|
582
|
+
'.yarnrc.yml',
|
|
583
|
+
'.pypirc',
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
/** The stores present on this host, learned once per home. */
|
|
587
|
+
let presentStores: { home: string; paths: string[] } | undefined
|
|
588
|
+
|
|
589
|
+
/** The credential stores to deny, less any that holds the workspace. */
|
|
590
|
+
function credentialStores(workspaceRoot: string): string[] {
|
|
591
|
+
const home = homedir()
|
|
592
|
+
if (presentStores?.home !== home) {
|
|
593
|
+
presentStores = {
|
|
594
|
+
home,
|
|
595
|
+
paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return presentStores.paths.filter(
|
|
599
|
+
(p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
558
603
|
/** `~/x` against the user's home; anything else unchanged. */
|
|
559
604
|
function expandHome(p: string): string {
|
|
560
605
|
return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
|
package/src/util/index.ts
CHANGED
|
@@ -55,4 +55,11 @@ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.
|
|
|
55
55
|
export { executablePath, shellArgv } from './which.js'
|
|
56
56
|
export { procfsIsOwn } from './procfs.js'
|
|
57
57
|
export { realPath } from './real-path.js'
|
|
58
|
-
export {
|
|
58
|
+
export {
|
|
59
|
+
maskedCommand,
|
|
60
|
+
maskedEmitter,
|
|
61
|
+
maskedLine,
|
|
62
|
+
MASKED,
|
|
63
|
+
secretMask,
|
|
64
|
+
type SecretMask,
|
|
65
|
+
} from './secret-mask.js'
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -124,6 +124,11 @@ export function maskedCommand(command: string, env?: TaskEnvSecrets): string {
|
|
|
124
124
|
return secretMask([process.env, env?.define], env?.secret)?.mask(command) ?? command
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** A line vx prints for no one task (a plugin's warning): this process's secrets masked. */
|
|
128
|
+
export function maskedLine(line: string): string {
|
|
129
|
+
return secretMask([process.env])?.mask(line) ?? line
|
|
130
|
+
}
|
|
131
|
+
|
|
127
132
|
/** What of a task's `exec.env` names its secrets. */
|
|
128
133
|
export interface TaskEnvSecrets {
|
|
129
134
|
readonly define?: Readonly<Record<string, string>>
|
|
@@ -123,28 +123,32 @@ export interface FingerprintClaims {
|
|
|
123
123
|
* or `<since>` doesn't resolve to a commit.
|
|
124
124
|
*/
|
|
125
125
|
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
126
|
+
// Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
|
|
127
|
+
// vx already diffs from the merge base (three dots' meaning), and the
|
|
128
|
+
// working tree it diffs to holds HEAD (D-117). Any other range is refused.
|
|
129
|
+
const since = /^(.+)\.\.\.HEAD$/.exec(args.since)?.[1] ?? args.since
|
|
126
130
|
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
127
131
|
// is opaque — but an option-like value is not: `--output=<path>` is a
|
|
128
132
|
// real `git diff` option and an arbitrary file write. This is a security
|
|
129
133
|
// boundary, so it is a check that knows it is one, before any spawn, and
|
|
130
134
|
// every git call below also ends its options (`--end-of-options`) so a
|
|
131
135
|
// second caller cannot lose the guard by accident.
|
|
132
|
-
if (
|
|
136
|
+
if (since.length === 0 || since.startsWith('-')) {
|
|
133
137
|
throw new UserError(
|
|
134
|
-
`git ref "${
|
|
138
|
+
`git ref "${since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
135
139
|
)
|
|
136
140
|
}
|
|
137
141
|
// `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
|
|
138
142
|
// the user read "did not resolve" about refs that both exist. `..` is
|
|
139
143
|
// illegal in a ref name (git-check-ref-format), so this refuses no ref.
|
|
140
|
-
const range =
|
|
144
|
+
const range = since.indexOf('..')
|
|
141
145
|
if (range >= 0) {
|
|
142
146
|
throw new UserError(
|
|
143
|
-
`git ref "${
|
|
144
|
-
`("${
|
|
147
|
+
`git ref "${since}" is a range: ranges are not supported — pass the base alone ` +
|
|
148
|
+
`("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
145
149
|
)
|
|
146
150
|
}
|
|
147
|
-
await verifyRef(args.workspaceRoot,
|
|
151
|
+
await verifyRef(args.workspaceRoot, since)
|
|
148
152
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
149
153
|
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
150
154
|
// file OTHER people changed on the base (over-selection that defeats a
|
|
@@ -152,7 +156,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
152
156
|
// byte-identical content. Turbo and Nx both diff from the merge base;
|
|
153
157
|
// when there is none (unrelated histories, a detached probe) the ref
|
|
154
158
|
// itself is the base, as before.
|
|
155
|
-
const base = await mergeBase(args.workspaceRoot,
|
|
159
|
+
const base = await mergeBase(args.workspaceRoot, since)
|
|
156
160
|
|
|
157
161
|
const [diffed, untracked] = await Promise.all([
|
|
158
162
|
// `--no-renames` is crucial for project-affected detection: with
|
|
@@ -296,7 +296,7 @@ function servesCommand(command: string): boolean {
|
|
|
296
296
|
/**
|
|
297
297
|
* Whether script `name` never exits: by name, as a watcher, as a server, or
|
|
298
298
|
* running such a script of its own package by name (docusaurus's
|
|
299
|
-
* `start:baseUrl`: `cross-env BASE_URL=… pnpm start`).
|
|
299
|
+
* `start:baseUrl`: `cross-env BASE_URL=… pnpm start`) or through a runner.
|
|
300
300
|
*/
|
|
301
301
|
function isPersistent(
|
|
302
302
|
name: string,
|
|
@@ -307,8 +307,13 @@ function isPersistent(
|
|
|
307
307
|
if (typeof own !== 'string' || seen.has(name)) return false
|
|
308
308
|
seen.add(name)
|
|
309
309
|
if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
|
|
310
|
+
// Through a runner too: `concurrently "npm:web" "npm:api"` and `run-p web
|
|
311
|
+
// api` over two servers mapped as one-shot tasks (D-113).
|
|
312
|
+
const text = Object.entries(scripts).filter(
|
|
313
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
314
|
+
)
|
|
310
315
|
return foreground(own).some((segment) =>
|
|
311
|
-
|
|
316
|
+
scriptRefs(segment, text).some((ref) => isPersistent(ref, scripts, seen)),
|
|
312
317
|
)
|
|
313
318
|
}
|
|
314
319
|
|