@vzn/vx 0.0.396 → 0.0.398

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.396",
3
+ "version": "0.0.398",
4
4
  "description": "The fastest 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.396",
25
- "@vzn/vx-linux-arm64": "0.0.396",
26
- "@vzn/vx-darwin-x64": "0.0.396",
27
- "@vzn/vx-darwin-arm64": "0.0.396"
24
+ "@vzn/vx-linux-x64": "0.0.398",
25
+ "@vzn/vx-linux-arm64": "0.0.398",
26
+ "@vzn/vx-darwin-x64": "0.0.398",
27
+ "@vzn/vx-darwin-arm64": "0.0.398"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -1145,6 +1145,15 @@ export async function wrapSandboxedCommand(
1145
1145
  wrapped = `exec ${readOnlyMasks(wrapped, scratch)}`
1146
1146
  if (process.platform === 'linux' && !hostHasIpv6())
1147
1147
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
1148
+ const held = portsHeld(ports)
1149
+ if (held.length > 0) {
1150
+ throw new UserError(
1151
+ `sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
1152
+ `${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
1153
+ `cannot be exposed there and a client would reach the other listener; stop what ` +
1154
+ `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1155
+ )
1156
+ }
1148
1157
  if (args.server === true) liveServers.add(tag)
1149
1158
  if (ports.length > 0) {
1150
1159
  spawnHostBridges(ports, tag)
@@ -1402,6 +1411,37 @@ const hostBridges = new Map<
1402
1411
  { ports: readonly number[]; procs: Array<ReturnType<typeof Bun.spawn>> }
1403
1412
  >()
1404
1413
 
1414
+ /**
1415
+ * The ports a host listener already holds where the bridge would bind
1416
+ * (`127.0.0.1`, or every address). The listen wait below reads the same
1417
+ * table and took that listener for the bridge, whose bind had failed: the
1418
+ * task passed and a client of the port reached the other process
1419
+ * (2026-10-03). Linux, own procfs only, as the wait.
1420
+ */
1421
+ function portsHeld(ports: readonly number[]): number[] {
1422
+ if (ports.length === 0 || !procfsIsOwn()) return []
1423
+ const held = new Set<string>()
1424
+ for (const [file, any, loop] of [
1425
+ ['/proc/net/tcp', '00000000', '0100007F'],
1426
+ ['/proc/net/tcp6', '00000000000000000000000000000000', '00000000000000000000000001000000'],
1427
+ ] as const) {
1428
+ let table: string
1429
+ try {
1430
+ table = readFileSync(file, 'utf8')
1431
+ } catch {
1432
+ continue
1433
+ }
1434
+ for (const line of table.split('\n')) {
1435
+ const f = line.trim().split(/\s+/)
1436
+ if (f[3] !== '0A' || f[1] === undefined) continue
1437
+ const [addr, port] = f[1].split(':')
1438
+ // `::1` does not hold 127.0.0.1's port; every address does.
1439
+ if (addr === any || (file === '/proc/net/tcp' && addr === loop)) held.add(port!)
1440
+ }
1441
+ }
1442
+ return ports.filter((p) => held.has(p.toString(16).toUpperCase().padStart(4, '0')))
1443
+ }
1444
+
1405
1445
  /**
1406
1446
  * Wait until each host-side bridge listens on its port, so a server that
1407
1447
  * says it is ready inside the sandbox is reachable on the host: the socat
@@ -1409,7 +1449,8 @@ const hostBridges = new Map<
1409
1449
  * right after its ready line under I/O load (M-22). Read off
1410
1450
  * /proc/net/tcp (127.0.0.1, state 0A); skipped where /proc is not this
1411
1451
  * process's (its net table could be another namespace's), ended early by
1412
- * a bridge that exited (a port already taken), and bounded at 5 s.
1452
+ * a bridge that exited, and bounded at 5 s. A port another listener holds
1453
+ * reads as listening here, so the wrap refuses it first (`portsHeld`).
1413
1454
  */
1414
1455
  async function hostBridgesListen(ports: readonly number[], tag: string): Promise<void> {
1415
1456
  if (!procfsIsOwn()) return
@@ -61,6 +61,17 @@ export const WATCHED_BUILTIN_NAMES = [
61
61
  // A global one config set reached every config loaded after it in the
62
62
  // process: `--all` read it, `--filter` of the reader alone did not (D-122).
63
63
  'globalThis',
64
+ // What the check itself and vx's matching read through: a replaced
65
+ // `Reflect.ownKeys` blinded the check, and `RegExp.prototype.test` or
66
+ // `Date.now` ran vx on a config's choice (D-124).
67
+ 'Reflect',
68
+ 'Object',
69
+ 'Array',
70
+ 'RegExp.prototype',
71
+ 'Function.prototype',
72
+ 'Date',
73
+ 'Date.prototype',
74
+ 'Number.prototype',
64
75
  ] as const
65
76
 
66
77
  const WORKER_SRC = `
@@ -109,10 +120,16 @@ self.onmessage = async (e) => {
109
120
  const watched = e.data.blame
110
121
  ? ${JSON.stringify(WATCHED_BUILTIN_NAMES)}.map((n) => [n, n.split('.').reduce((o, k) => o[k], globalThis)])
111
122
  : null
112
- const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
123
+ // Taken before the import: a config can replace them (D-124).
124
+ const RO = Reflect.ownKeys
125
+ const GD = Object.getOwnPropertyDescriptor
126
+ const own = (o) => new Map(RO(o).map((k) => [k, GD(o, k)]))
113
127
  const before = watched?.map(([, o]) => own(o))
114
128
  const envBefore = watched ? { ...live } : null
115
129
  const cwdBefore = watched ? globalThis.process.cwd() : null
130
+ // The umask is the process's, not the worker's: a config's umask(0o777)
131
+ // here left every file vx wrote after it 000 (D-125). Put back below.
132
+ const umaskIn = globalThis.process.umask()
116
133
  const same = (a, b) =>
117
134
  a !== undefined && b !== undefined && Object.is(a.value, b.value) && a.get === b.get && a.set === b.set &&
118
135
  a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
@@ -127,8 +144,14 @@ self.onmessage = async (e) => {
127
144
  for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
128
145
  if (envBefore[k] !== live[k]) out.push('process.env.' + k)
129
146
  if (globalThis.process.cwd() !== cwdBefore) out.push('process.cwd (a chdir)')
147
+ if (globalThis.process.umask() !== umaskIn) out.push('process.umask')
130
148
  return out
131
149
  }
150
+ const umaskBack = () => {
151
+ if (globalThis.process.umask() === umaskIn) return false
152
+ globalThis.process.umask(umaskIn)
153
+ return true
154
+ }
132
155
  try {
133
156
  const ns = await import(path)
134
157
  // Awaited as the in-process load's async return flattens it: a Promise
@@ -144,6 +167,7 @@ self.onmessage = async (e) => {
144
167
  json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
145
168
  fn: typeof mod === 'function',
146
169
  changed: changed(),
170
+ umask: umaskBack(),
147
171
  })
148
172
  } catch (err) {
149
173
  postMessage({
@@ -153,6 +177,7 @@ self.onmessage = async (e) => {
153
177
  message: err?.message ?? String(err),
154
178
  stack: err?.stack ?? null,
155
179
  changed: changed(),
180
+ umask: umaskBack(),
156
181
  position:
157
182
  err?.position && typeof err.position === 'object'
158
183
  ? { file: err.position.file, line: err.position.line, column: err.position.column }
@@ -164,6 +189,9 @@ self.onmessage = async (e) => {
164
189
 
165
190
  const FUNCTION_EXPORT = (): void => {}
166
191
 
192
+ /** A repeat load moved the process's umask; the loader names the config (D-125). */
193
+ export class UmaskChanged extends Error {}
194
+
167
195
  const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
168
196
 
169
197
  interface WorkerReply {
@@ -172,6 +200,8 @@ interface WorkerReply {
172
200
  json: string | null
173
201
  /** The default export is a function, which JSON cannot carry back. */
174
202
  fn?: boolean
203
+ /** The evaluation moved the process's umask; the worker put it back. */
204
+ umask?: boolean
175
205
  nonJson: NonJsonValue[]
176
206
  name: string
177
207
  message: string
@@ -333,6 +363,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
333
363
  timer.unref?.()
334
364
  w.postMessage({ id, path: abs, env: { ...process.env } })
335
365
  })
366
+ if (reply.umask === true) throw new UmaskChanged()
336
367
  const [nonJson] = reply.nonJson
337
368
  if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
338
369
  // A function stands in for the one the config exported, so the caller's
@@ -9,6 +9,7 @@ import {
9
9
  CONFIG_EXIT,
10
10
  evalBudgetMs,
11
11
  evaluateConfigFresh,
12
+ UmaskChanged,
12
13
  WATCHED_BUILTIN_NAMES,
13
14
  } from './config-eval.js'
14
15
  import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
@@ -448,9 +449,10 @@ export async function loadProjectConfigs(
448
449
  // Taken only when a config is evaluated in this process: reading every
449
450
  // descriptor of `Bun` makes Bun build its lazy members (`bun:sql`,
450
451
  // `node:stream`), ~7 ms of a two-config warm run where every load hit.
451
- let builtins: BuiltinSnapshot = []
452
+ let builtins: BuiltinSnapshot | undefined
452
453
  let env: Readonly<Record<string, string | undefined>> = {}
453
454
  let cwd = ''
455
+ let umask = -1
454
456
  // More than one evaluation in flight: a change seen after one load may
455
457
  // be another's.
456
458
  let overlapping = false
@@ -489,6 +491,7 @@ export async function loadProjectConfigs(
489
491
  if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
490
492
  const mod = repeat
491
493
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
494
+ if (err instanceof UmaskChanged) throw builtinsChanged(['process.umask'], configPath)
492
495
  throw configLoadError(err, configPath, 'Project') ?? err
493
496
  })
494
497
  : await loadDefaultExport(configPath, 'Project', bytes!)
@@ -496,7 +499,12 @@ export async function loadProjectConfigs(
496
499
  // turned the JSON-data walk's own check into "a cyclic reference".
497
500
  const changed = repeat
498
501
  ? []
499
- : [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
502
+ : [
503
+ ...restoreBuiltins(builtins),
504
+ ...restoreEnv(env),
505
+ ...restoreCwd(cwd),
506
+ ...restoreUmask(umask),
507
+ ]
500
508
  // Loads overlap, so another config's change can surface after this
501
509
  // one: named here, the refusal blamed the wrong file (D-119). The round
502
510
  // finds the one that made it.
@@ -549,6 +557,7 @@ export async function loadProjectConfigs(
549
557
  builtins = builtinSnapshot()
550
558
  env = { ...process.env }
551
559
  cwd = process.cwd()
560
+ umask = currentUmask()
552
561
  }
553
562
  overlapping = misses.length > 1
554
563
  let next = 0
@@ -569,7 +578,12 @@ export async function loadProjectConfigs(
569
578
  }
570
579
  const changed =
571
580
  misses.length > 0
572
- ? [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
581
+ ? [
582
+ ...restoreBuiltins(builtins),
583
+ ...restoreEnv(env),
584
+ ...restoreCwd(cwd),
585
+ ...restoreUmask(umask),
586
+ ]
573
587
  : []
574
588
  if (first?.failed instanceof ChangedInRound) {
575
589
  for (const i of misses) {
@@ -618,20 +632,51 @@ const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILT
618
632
  ] as const,
619
633
  )
620
634
 
635
+ // The check reads through these, taken when a round's snapshot is, before
636
+ // any of its configs runs: a config that set `Reflect.ownKeys = () => []`
637
+ // blinded it, and its `Object.prototype.exec` ran in another project's
638
+ // task (D-124). Each round keeps its own, so an overlapping round cannot
639
+ // hand it a replaced one. The compare and put-back use indexed loops only
640
+ // (a replaced `Array.prototype.forEach` skipped them).
641
+ interface Primitives {
642
+ ownKeys: typeof Reflect.ownKeys
643
+ descriptorOf: typeof Object.getOwnPropertyDescriptor
644
+ defineOwn: typeof Object.defineProperty
645
+ deleteOwn: typeof Reflect.deleteProperty
646
+ hasOwn: typeof Object.hasOwn
647
+ same: typeof Object.is
648
+ keyName: typeof String
649
+ }
650
+
621
651
  interface OwnProperties {
622
652
  keys: PropertyKey[]
623
653
  descriptors: PropertyDescriptor[]
624
- byKey: ReadonlyMap<PropertyKey, PropertyDescriptor>
625
654
  }
626
655
 
627
- type BuiltinSnapshot = readonly OwnProperties[]
656
+ interface BuiltinSnapshot {
657
+ readonly prims: Primitives
658
+ readonly props: readonly OwnProperties[]
659
+ }
628
660
 
629
661
  function builtinSnapshot(): BuiltinSnapshot {
630
- return WATCHED_BUILTINS.map(([, proto]) => {
631
- const keys = Reflect.ownKeys(proto)
632
- const descriptors = keys.map((k) => Object.getOwnPropertyDescriptor(proto, k)!)
633
- return { keys, descriptors, byKey: new Map(keys.map((k, j) => [k, descriptors[j]!])) }
634
- })
662
+ const prims: Primitives = {
663
+ ownKeys: Reflect.ownKeys,
664
+ descriptorOf: Object.getOwnPropertyDescriptor,
665
+ defineOwn: Object.defineProperty,
666
+ deleteOwn: Reflect.deleteProperty,
667
+ hasOwn: Object.hasOwn,
668
+ same: Object.is,
669
+ keyName: String,
670
+ }
671
+ const props: OwnProperties[] = []
672
+ for (let i = 0; i < WATCHED_BUILTINS.length; i++) {
673
+ const proto = WATCHED_BUILTINS[i]![1]
674
+ const keys = prims.ownKeys(proto)
675
+ const descriptors: PropertyDescriptor[] = []
676
+ for (let j = 0; j < keys.length; j++) descriptors[j] = prims.descriptorOf(proto, keys[j]!)!
677
+ props[i] = { keys, descriptors }
678
+ }
679
+ return { prims, props }
635
680
  }
636
681
 
637
682
  /**
@@ -641,13 +686,17 @@ function builtinSnapshot(): BuiltinSnapshot {
641
686
  * evaluated config: the full check was ~0.1 ms a config, ~100 ms of a
642
687
  * 1,000-config cold load.
643
688
  */
644
- function unchanged(proto: object, keys: readonly PropertyKey[], was: OwnProperties): boolean {
689
+ function unchanged(
690
+ p: Primitives,
691
+ proto: object,
692
+ keys: readonly PropertyKey[],
693
+ was: OwnProperties,
694
+ ): boolean {
645
695
  if (keys.length !== was.keys.length) return false
646
696
  for (let j = 0; j < keys.length; j++) {
647
697
  const key = keys[j]!
648
698
  if (key !== was.keys[j]) return false
649
- if (!sameDescriptor(was.descriptors[j]!, Object.getOwnPropertyDescriptor(proto, key)!))
650
- return false
699
+ if (!sameDescriptor(p, was.descriptors[j]!, p.descriptorOf(proto, key)!)) return false
651
700
  }
652
701
  return true
653
702
  }
@@ -667,6 +716,9 @@ function builtinsChanged(changed: readonly string[], configPath?: string): UserE
667
716
  (changed.some((c) => c.startsWith('process.cwd'))
668
717
  ? '; a task runs in its project directory, and `cd <dir> && …` in `exec.command` moves it'
669
718
  : '') +
719
+ (changed.some((c) => c.startsWith('process.umask'))
720
+ ? '; a task sets its own with `umask <mode> && …` in `exec.command`'
721
+ : '') +
670
722
  (changed.some((c) => c.startsWith('globalThis.'))
671
723
  ? '; a constant configs share goes in a module each one imports'
672
724
  : '')
@@ -676,27 +728,38 @@ function builtinsChanged(changed: readonly string[], configPath?: string): UserE
676
728
  }
677
729
 
678
730
  /** Puts back what changed since `before`, naming each property it put back. */
679
- function restoreBuiltins(before: BuiltinSnapshot): string[] {
731
+ function restoreBuiltins(before: BuiltinSnapshot | undefined): string[] {
680
732
  const changed: string[] = []
681
- WATCHED_BUILTINS.forEach(([name, proto], i) => {
682
- const keys = Reflect.ownKeys(proto)
683
- if (unchanged(proto, keys, before[i]!)) return
684
- const was = before[i]!.byKey
685
- for (const key of keys) {
686
- const prior = was.get(key)
687
- const now = Object.getOwnPropertyDescriptor(proto, key)!
688
- if (prior !== undefined && sameDescriptor(prior, now)) continue
689
- changed.push(`${name}.${String(key)}`)
690
- if (prior === undefined) Reflect.deleteProperty(proto, key)
691
- else Object.defineProperty(proto, key, prior)
692
- }
693
- for (const [key, prior] of was) {
694
- if (!Object.hasOwn(proto, key)) {
695
- changed.push(`${name}.${String(key)}`)
696
- Object.defineProperty(proto, key, prior)
733
+ if (before === undefined) return changed
734
+ const p = before.prims
735
+ for (let i = 0; i < WATCHED_BUILTINS.length; i++) {
736
+ const name = WATCHED_BUILTINS[i]![0]
737
+ const proto = WATCHED_BUILTINS[i]![1]
738
+ const was = before.props[i]!
739
+ const keys = p.ownKeys(proto)
740
+ if (unchanged(p, proto, keys, was)) continue
741
+ // Slow path, a change only: a linear lookup keeps it off `Map`.
742
+ for (let j = 0; j < keys.length; j++) {
743
+ const key = keys[j]!
744
+ let prior: PropertyDescriptor | undefined
745
+ for (let k = 0; k < was.keys.length; k++) {
746
+ if (was.keys[k] === key) {
747
+ prior = was.descriptors[k]
748
+ break
749
+ }
697
750
  }
751
+ if (prior !== undefined && sameDescriptor(p, prior, p.descriptorOf(proto, key)!)) continue
752
+ changed[changed.length] = name + '.' + p.keyName(key)
753
+ if (prior === undefined) p.deleteOwn(proto, key)
754
+ else p.defineOwn(proto, key, prior)
698
755
  }
699
- })
756
+ for (let k = 0; k < was.keys.length; k++) {
757
+ const key = was.keys[k]!
758
+ if (p.hasOwn(proto, key)) continue
759
+ changed[changed.length] = name + '.' + p.keyName(key)
760
+ p.defineOwn(proto, key, was.descriptors[k]!)
761
+ }
762
+ }
700
763
  return changed
701
764
  }
702
765
 
@@ -739,10 +802,30 @@ function restoreCwd(before: string): string[] {
739
802
  return ['process.cwd (a chdir)']
740
803
  }
741
804
 
742
- function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
805
+ /**
806
+ * A config's `process.umask()` set the mode of every file vx and its tasks
807
+ * wrote after it: a cache artifact and a task's outputs landed `000`, which
808
+ * a user other than root could not read back (D-125). Put back, and named.
809
+ */
810
+ function currentUmask(): number {
811
+ // Through globalThis, as restoreCwd: the playground's shim has no umask.
812
+ const proc = globalThis.process as { umask?: (mask?: number) => number }
813
+ if (typeof proc.umask !== 'function') return -1
814
+ // No argument reads it: setting 0 and back would open a window where a
815
+ // file written by an overlapping write landed world-writable.
816
+ return proc.umask()
817
+ }
818
+
819
+ function restoreUmask(before: number): string[] {
820
+ if (before === -1 || currentUmask() === before) return []
821
+ ;(globalThis.process as { umask: (mask: number) => number }).umask(before)
822
+ return ['process.umask']
823
+ }
824
+
825
+ function sameDescriptor(p: Primitives, a: PropertyDescriptor, b: PropertyDescriptor): boolean {
743
826
  return (
744
827
  // `globalThis.NaN` is watched (D-122), and NaN !== NaN.
745
- Object.is(a.value, b.value) &&
828
+ p.same(a.value, b.value) &&
746
829
  a.get === b.get &&
747
830
  a.set === b.set &&
748
831
  a.writable === b.writable &&