@vzn/vx 0.0.362 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.362",
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.362",
25
- "@vzn/vx-linux-arm64": "0.0.362",
26
- "@vzn/vx-darwin-x64": "0.0.362",
27
- "@vzn/vx-darwin-arm64": "0.0.362"
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/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)) wrapped = `exec ${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)
@@ -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 { TaskNode } from './task-graph.js'
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
- serverDied(d)
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(serverDied) ??
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 }),
@@ -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).
@@ -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
- command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
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
- baseDenyRead: process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot],
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 { maskedCommand, maskedEmitter, MASKED, secretMask, type SecretMask } from './secret-mask.js'
58
+ export {
59
+ maskedCommand,
60
+ maskedEmitter,
61
+ maskedLine,
62
+ MASKED,
63
+ secretMask,
64
+ type SecretMask,
65
+ } from './secret-mask.js'
@@ -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 (args.since.length === 0 || args.since.startsWith('-')) {
136
+ if (since.length === 0 || since.startsWith('-')) {
133
137
  throw new UserError(
134
- `git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
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 = args.since.indexOf('..')
144
+ const range = since.indexOf('..')
141
145
  if (range >= 0) {
142
146
  throw new UserError(
143
- `git ref "${args.since}" is a range: ranges are not supported — pass the base alone ` +
144
- `("${args.since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
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, args.since)
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, args.since)
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