@vzn/vx 0.0.362 → 0.0.364
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/bin.ts +10 -3
- package/src/cache/tar-stream.ts +40 -3
- 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 +5 -1
- package/src/exec/sandbox-violations.ts +29 -0
- package/src/graph/scheduler.ts +16 -3
- package/src/orchestrator/events.ts +5 -2
- package/src/orchestrator/execute-task.ts +3 -1
- package/src/orchestrator/plugin.ts +3 -0
- package/src/orchestrator/run.ts +5 -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/config-schema.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.364",
|
|
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.364",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.364",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.364",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.364"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/bin.ts
CHANGED
|
@@ -9,10 +9,14 @@ async function main(): Promise<void> {
|
|
|
9
9
|
// `vx --version` needs one leaf module; the dispatcher and the util
|
|
10
10
|
// barrel it pulled in were 20 ms of its 28 (2026-10-01).
|
|
11
11
|
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
|
|
12
|
-
process.stdout
|
|
12
|
+
// `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
|
|
13
|
+
// ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
|
|
14
|
+
// failure of the line.
|
|
15
|
+
await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}\n`).catch(() => {})
|
|
13
16
|
settled = true
|
|
14
17
|
return
|
|
15
18
|
}
|
|
19
|
+
listenForReadersGone()
|
|
16
20
|
const { registerCoreAlias, run } = await import('./cli/index.js')
|
|
17
21
|
// Every `import … from '@vzn/vx'` this process evaluates — a plugin
|
|
18
22
|
// package, a workspace or project config — resolves to THIS core, not to
|
|
@@ -86,8 +90,11 @@ process.on('beforeExit', () => {
|
|
|
86
90
|
// and exits with its own verdict; what was going to the reader goes
|
|
87
91
|
// nowhere. Same for stderr (`2>&1 | head`). Here and not in the logger:
|
|
88
92
|
// an embedder's streams are the embedder's.
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
// Set up past `--version`, which never touches the Node streams.
|
|
94
|
+
function listenForReadersGone(): void {
|
|
95
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
96
|
+
stream.on('error', () => {})
|
|
97
|
+
}
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
void main()
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -53,6 +53,8 @@ function octal(h: Uint8Array, off: number, len: number): number {
|
|
|
53
53
|
for (let i = off + 1; i < off + len; i++) n = n * 256 + h[i]!
|
|
54
54
|
return n
|
|
55
55
|
}
|
|
56
|
+
const plain = plainOctal(h, off, len)
|
|
57
|
+
if (plain !== null) return plain
|
|
56
58
|
const s = field(h, off, len).trim()
|
|
57
59
|
if (s === '') return 0
|
|
58
60
|
// Every digit, not the longest parseable prefix: `parseInt` would read
|
|
@@ -61,10 +63,33 @@ function octal(h: Uint8Array, off: number, len: number): number {
|
|
|
61
63
|
return parseInt(s, 8)
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The field every writer emits — octal digits between ASCII spaces, ended
|
|
68
|
+
* by a NUL or the field's end — read off the bytes; null for anything
|
|
69
|
+
* else, which `octal` reads the slow way. Three fields a header through a
|
|
70
|
+
* decoder and a regex were most of a small artifact's read (2026-10-02).
|
|
71
|
+
*/
|
|
72
|
+
function plainOctal(h: Uint8Array, off: number, len: number): number | null {
|
|
73
|
+
const end = off + len
|
|
74
|
+
let i = off
|
|
75
|
+
while (i < end && h[i] === 0x20) i++
|
|
76
|
+
let n = 0
|
|
77
|
+
while (i < end && h[i]! >= 0x30 && h[i]! <= 0x37) n = n * 8 + (h[i++]! - 0x30)
|
|
78
|
+
while (i < end && h[i] === 0x20) i++
|
|
79
|
+
return i === end || h[i] === 0 ? n : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isZeroBlock(h: Uint8Array): boolean {
|
|
83
|
+
for (let i = 0; i < BLOCK; i++) if (h[i] !== 0) return false
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function checksumOk(h: Uint8Array): boolean {
|
|
65
88
|
const stored = octal(h, 148, 8)
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
// The checksum field counts as eight spaces.
|
|
90
|
+
let sum = 8 * 32
|
|
91
|
+
for (let i = 0; i < 148; i++) sum += h[i]!
|
|
92
|
+
for (let i = 156; i < BLOCK; i++) sum += h[i]!
|
|
68
93
|
return sum === stored
|
|
69
94
|
}
|
|
70
95
|
|
|
@@ -107,6 +132,18 @@ class Source {
|
|
|
107
132
|
|
|
108
133
|
/** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
|
|
109
134
|
async exact(n: number, what: string): Promise<Uint8Array | null> {
|
|
135
|
+
// Within one chunk, a view: the copy below was a third of a small
|
|
136
|
+
// artifact's read. Nothing writes to what this returns.
|
|
137
|
+
const c0 = this.chunks[0]
|
|
138
|
+
if (c0 !== undefined && c0.byteLength - this.head >= n) {
|
|
139
|
+
const view = c0.subarray(this.head, this.head + n)
|
|
140
|
+
this.head += n
|
|
141
|
+
if (this.head === c0.byteLength) {
|
|
142
|
+
this.chunks.shift()
|
|
143
|
+
this.head = 0
|
|
144
|
+
}
|
|
145
|
+
return view
|
|
146
|
+
}
|
|
110
147
|
const out = new Uint8Array(n)
|
|
111
148
|
let got = 0
|
|
112
149
|
while (got < n) {
|
|
@@ -163,7 +200,7 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
|
|
|
163
200
|
if (zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
|
|
164
201
|
return
|
|
165
202
|
}
|
|
166
|
-
if (h
|
|
203
|
+
if (isZeroBlock(h)) {
|
|
167
204
|
zeroBlocks++
|
|
168
205
|
if (zeroBlocks >= 2) return
|
|
169
206
|
continue
|
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'
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
} from './sandbox-deny-scan.js'
|
|
89
90
|
import {
|
|
90
91
|
parseStraceViolations,
|
|
92
|
+
refusedConnections,
|
|
91
93
|
refusedWrites,
|
|
92
94
|
refusedWritesOutside,
|
|
93
95
|
reportableViolations,
|
|
@@ -1065,7 +1067,8 @@ export async function wrapSandboxedCommand(
|
|
|
1065
1067
|
// sandboxed server and all it forked outlived vx (turborepo#9666). Now
|
|
1066
1068
|
// the namespace goes with vx, a `setsid` daemon inside included, a
|
|
1067
1069
|
// traced one-shot task too: its strace runs inside (B-11).
|
|
1068
|
-
if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped))
|
|
1070
|
+
if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped))
|
|
1071
|
+
wrapped = `exec ${readOnlyMasks(wrapped, scratch)}`
|
|
1069
1072
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1070
1073
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
1071
1074
|
if (args.server === true) liveServers.add(tag)
|
|
@@ -1730,6 +1733,7 @@ async function runSandboxedOnce(
|
|
|
1730
1733
|
bindableWrites(args.config.allowWrite),
|
|
1731
1734
|
scratch,
|
|
1732
1735
|
),
|
|
1736
|
+
...refusedConnections(records.map((v) => v.line)),
|
|
1733
1737
|
]
|
|
1734
1738
|
: []
|
|
1735
1739
|
if (straceLog) {
|
|
@@ -419,6 +419,8 @@ function matchesIgnore(
|
|
|
419
419
|
* crossing, and the task can grant it.
|
|
420
420
|
*/
|
|
421
421
|
function describeMacViolation(line: string): Partial<SandboxViolation> {
|
|
422
|
+
const proxy = PROXY_DENY_RE.exec(line)
|
|
423
|
+
if (proxy !== null) return { target: proxy[1]!, ignorable: ['network'] }
|
|
422
424
|
const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
|
|
423
425
|
if (m === null) return {}
|
|
424
426
|
const [op, target] = [m[1]!, m[2]!]
|
|
@@ -589,6 +591,28 @@ function filterIgnored(
|
|
|
589
591
|
return violations.filter((v) => !matchesIgnore(v, ignore))
|
|
590
592
|
}
|
|
591
593
|
|
|
594
|
+
/**
|
|
595
|
+
* SRT's filtering proxy records a connection it refused as `deny
|
|
596
|
+
* network-outbound <host>:<port> (<reason>)`, on both platforms: the one
|
|
597
|
+
* component that knows the host. Read as its own shape, so `ignore.network`
|
|
598
|
+
* silences it by host; the seatbelt pattern above wants `deny(<n>)`.
|
|
599
|
+
*/
|
|
600
|
+
const PROXY_DENY_RE = /^deny network-outbound (\S+) \([^)]*\)$/
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Linux: the connections the proxy refused, from the store records. Only
|
|
604
|
+
* the write observer's records were read there, so a task denied a host
|
|
605
|
+
* failed with its own `403` and no report, and one that survived the
|
|
606
|
+
* refusal passed, where macOS reports the same record (2026-10-02).
|
|
607
|
+
*/
|
|
608
|
+
export function refusedConnections(records: readonly string[]): SandboxViolation[] {
|
|
609
|
+
return [...new Set(records.filter((r) => PROXY_DENY_RE.test(r)))].map((line) => ({
|
|
610
|
+
line,
|
|
611
|
+
timestamp: new Date(),
|
|
612
|
+
...describeMacViolation(line),
|
|
613
|
+
}))
|
|
614
|
+
}
|
|
615
|
+
|
|
592
616
|
/**
|
|
593
617
|
* The writes SRT's Linux observer saw that this task's binds do not cover.
|
|
594
618
|
* The observer reports every write-intent syscall as `deny <syscall>
|
|
@@ -612,6 +636,11 @@ export function refusedWrites(
|
|
|
612
636
|
const m = /^deny (\S+) (\/.*)$/.exec(record)
|
|
613
637
|
if (m === null) continue
|
|
614
638
|
const [, syscall, raw] = m as unknown as [string, string, string]
|
|
639
|
+
// A descriptor or pseudo-file is the task's, and resolving it here
|
|
640
|
+
// reads vx's own: strace's log is `/dev/fd/5`, and a vx started with
|
|
641
|
+
// `5>out.log` in the project reported a write to `out.log` and failed
|
|
642
|
+
// a clean task (2026-10-02).
|
|
643
|
+
if (/^\/(?:dev|proc)\//.test(raw)) continue
|
|
615
644
|
const abs = toRealPath(raw)
|
|
616
645
|
if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
|
|
617
646
|
const key = `${syscall}|${abs}`
|
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
|
|
@@ -572,6 +572,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
572
572
|
// could fail anyway.
|
|
573
573
|
const aborted = (): boolean => options.signal?.aborted === true
|
|
574
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
|
+
}
|
|
575
588
|
const servers =
|
|
576
589
|
options.serverDied === undefined
|
|
577
590
|
? []
|
|
@@ -600,7 +613,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
600
613
|
u?.status === 'skipped' ||
|
|
601
614
|
u?.status === 'aborted' ||
|
|
602
615
|
blockedRestores.has(d) ||
|
|
603
|
-
|
|
616
|
+
deadServerVia(d) !== undefined
|
|
604
617
|
)
|
|
605
618
|
})
|
|
606
619
|
}
|
|
@@ -671,7 +684,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
671
684
|
const viaRestore = node.deps.find((d) => blockedRestores.has(d))
|
|
672
685
|
const blockedBy =
|
|
673
686
|
blocker?.node.id ??
|
|
674
|
-
node.deps.find(
|
|
687
|
+
node.deps.map(deadServerVia).find((d) => d !== undefined) ??
|
|
675
688
|
viaSkip?.blockedBy ??
|
|
676
689
|
(viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
|
|
677
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 }),
|
|
@@ -915,7 +915,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
915
915
|
signal: res.signal,
|
|
916
916
|
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
917
917
|
})
|
|
918
|
-
|
|
918
|
+
// The line quotes the command's first word, which a config may have
|
|
919
|
+
// built from a secret: masked as the task's own output is (L-37).
|
|
920
|
+
if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
|
|
919
921
|
// A committed file another task's clean removed, still gone: vx
|
|
920
922
|
// cleans outputs before a run where Turbo does not, so a reader with
|
|
921
923
|
// no edge to the producer fails naming only the file (A-48).
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -777,6 +777,36 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
777
777
|
'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
|
|
778
778
|
},
|
|
779
779
|
],
|
|
780
|
+
// wireit's `files` / `output` and include-style spellings written into
|
|
781
|
+
// a cache block, refused with no word on where they go (D-118).
|
|
782
|
+
[
|
|
783
|
+
CACHE_FIELDS,
|
|
784
|
+
{
|
|
785
|
+
files: '`inputs.files`',
|
|
786
|
+
output: '`outputs.files`',
|
|
787
|
+
env: '`inputs.env`',
|
|
788
|
+
dependencies: "the task's `dependsOn` (an upstream's key folds into this one)",
|
|
789
|
+
enabled: 'no field: a task with a `cache` block caches, one without runs every time',
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
[
|
|
793
|
+
CACHE_INPUT_FIELDS,
|
|
794
|
+
{
|
|
795
|
+
globs: '`files`',
|
|
796
|
+
include: '`files`',
|
|
797
|
+
patterns: '`files`',
|
|
798
|
+
exclude: "`files` with a `!` entry (`'!**/*.test.ts'`)",
|
|
799
|
+
ignore: "`files` with a `!` entry (`'!**/*.test.ts'`)",
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
[
|
|
803
|
+
CACHE_OUTPUT_FIELDS,
|
|
804
|
+
{
|
|
805
|
+
globs: '`files`',
|
|
806
|
+
include: '`files`',
|
|
807
|
+
exclude: "`files` with a `!` entry (`'!dist/cache/**'`)",
|
|
808
|
+
},
|
|
809
|
+
],
|
|
780
810
|
// A package's turbo.json and Nx's project.json keys, written into a
|
|
781
811
|
// project's vx.config (D-56). `tags` had read as a typo of `tasks`.
|
|
782
812
|
[
|