@vzn/vx 0.0.87 → 0.0.89

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.87",
3
+ "version": "0.0.89",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.87",
21
- "@vzn/vx-linux-arm64": "0.0.87",
22
- "@vzn/vx-darwin-x64": "0.0.87",
23
- "@vzn/vx-darwin-arm64": "0.0.87"
20
+ "@vzn/vx-linux-x64": "0.0.89",
21
+ "@vzn/vx-linux-arm64": "0.0.89",
22
+ "@vzn/vx-darwin-x64": "0.0.89",
23
+ "@vzn/vx-darwin-arm64": "0.0.89"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -1127,10 +1127,35 @@ export class Cache implements CacheLayer {
1127
1127
  */
1128
1128
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
1129
1129
  const tmpPath = this.tempPath(hash)
1130
+ // The compressed bytes are bounded too, before the decode's ceiling ever
1131
+ // sees them: a remote body that never ends (a hostile or broken server)
1132
+ // was written to the temp until the disk was full (L-5). No artifact
1133
+ // under the ceiling compresses past zstd's own bound on it.
1134
+ const cap = this.artifactCeiling + (this.artifactCeiling >> 8) + 64 * 1024
1135
+ const past = (): CorruptArtifactError =>
1136
+ new CorruptArtifactError(
1137
+ hash,
1138
+ `remote body runs past ${cap} bytes (the artifact ceiling's bound)`,
1139
+ )
1130
1140
  try {
1131
- // Split only for the typings: Bun.write's Response and Blob overloads
1132
- // do not accept their union.
1133
- await (body instanceof Response ? Bun.write(tmpPath, body) : Bun.write(tmpPath, body))
1141
+ if (body instanceof Blob) {
1142
+ if (body.size > cap) throw past()
1143
+ await Bun.write(tmpPath, body)
1144
+ } else if (Number(body.headers.get('content-length') ?? 0) > cap) {
1145
+ throw past()
1146
+ } else {
1147
+ let n = 0
1148
+ const counted = body.body?.pipeThrough(
1149
+ new TransformStream<Uint8Array, Uint8Array>({
1150
+ transform(chunk, controller) {
1151
+ n += chunk.byteLength
1152
+ if (n > cap) controller.error(past())
1153
+ else controller.enqueue(chunk)
1154
+ },
1155
+ }),
1156
+ )
1157
+ await Bun.write(tmpPath, new Response(counted ?? null))
1158
+ }
1134
1159
  } catch (err) {
1135
1160
  await unlink(tmpPath).catch(() => undefined)
1136
1161
  throw err
package/src/cli/select.ts CHANGED
@@ -353,7 +353,9 @@ export async function pickTask(
353
353
  }
354
354
  }
355
355
  if (entries.length === 0) {
356
- process.stderr.write(`vx run: no tasks declared in any project\n`)
356
+ process.stderr.write(
357
+ 'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts\n',
358
+ )
357
359
  return null
358
360
  }
359
361
  const out = io.output ?? process.stdout
package/src/cli/why.ts CHANGED
@@ -181,7 +181,9 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
181
181
 
182
182
  const why = whyDidThisRerun(db, runId, taskId)
183
183
  if (!why.found) {
184
- throw new UserError(`vx why: run ${runId} has no row for ${taskId}`)
184
+ throw new UserError(
185
+ `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last <runId>\` what one ran`,
186
+ )
185
187
  }
186
188
  const diff = cacheKeyDiff(db, runId, taskId)
187
189
 
@@ -538,6 +538,8 @@ export interface ResolvedSandboxConfig {
538
538
  gitConfig?: boolean
539
539
  weakerWhenNested?: boolean
540
540
  weakerNetworkIsolation?: boolean
541
+ /** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
542
+ wallsReached?: { read: readonly string[]; write: readonly string[] }
541
543
  ignore?: {
542
544
  read?: readonly string[]
543
545
  write?: readonly string[]
@@ -645,6 +647,11 @@ export function resolveSandboxConfig(
645
647
  walls,
646
648
  ),
647
649
  }
650
+ if (process.platform === 'darwin') {
651
+ const read = wallsGlobsReach(r.allowRead, walls)
652
+ const write = wallsGlobsReach(r.allowWrite, walls)
653
+ if (read.length + write.length > 0) r.wallsReached = { read, write }
654
+ }
648
655
  if (a.network !== undefined) r.network = a.network
649
656
  if (cfg.deny?.network !== undefined) r.denyNetwork = cfg.deny.network
650
657
  if (a.systemInfo !== undefined) r.systemInfo = a.systemInfo
@@ -788,7 +795,10 @@ export async function wrapSandboxedCommand(
788
795
  ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
789
796
  let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
790
797
  if (process.platform === 'darwin') {
791
- const rules = macProfileRules(args.config)
798
+ const rules = [
799
+ ...macProfileRules(args.config),
800
+ ...darwinWallRules(args.config, baselines.allowRead),
801
+ ]
792
802
  if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
793
803
  }
794
804
  // Linux: the shell execs bwrap, so bwrap is the spawn itself and its
@@ -1528,7 +1538,54 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
1528
1538
  * that broke it learned so from its OWN tool. Measured, one task per
1529
1539
  * spelling, each writing files it declares:
1530
1540
  *
1531
- * write: ['g/**'] ok — collapsed to the directory
1541
+ * write: ['g/**
1542
+ * The walls a glob grant can reach: each one under (or at) a glob's
1543
+ * literal head. On Linux `expandGrants` drops such a hit before the bind
1544
+ * (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
1545
+ */
1546
+ function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
1547
+ const heads = grants
1548
+ .filter((g) => !isMountableLiteral(g))
1549
+ .map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
1550
+ return walls.filter((w) =>
1551
+ heads.some((h) => w === h || w.startsWith(h === path.sep ? h : h + path.sep)),
1552
+ )
1553
+ }
1554
+
1555
+ /**
1556
+ * macOS: the walls a glob grant reaches, denied at the profile's tail
1557
+ * (SBPL is last-match-wins). SRT re-emits a wall's deny after the allows
1558
+ * only under a LITERAL allow, so a root project's `read: ['**\/*.ts']`
1559
+ * read nested projects' sources its key excludes — item 1010's stale hit,
1560
+ * by glob, on darwin (B-12). A literal grant at or inside the wall, the
1561
+ * task's or a baseline's (a linked dependency), is carved out: on Linux
1562
+ * too it binds. Reads deny the data, not the metadata, as SRT's own wall
1563
+ * denies do: a walk may still stat the directory.
1564
+ */
1565
+ export function darwinWallRules(
1566
+ c: Pick<ResolvedSandboxConfig, 'wallsReached' | 'allowRead' | 'allowWrite'>,
1567
+ baseAllowRead: readonly string[],
1568
+ ): string[] {
1569
+ const rules: string[] = []
1570
+ const deny = (op: string, walls: readonly string[], literals: readonly string[]): void => {
1571
+ for (const w of walls) {
1572
+ const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
1573
+ const kept = literals
1574
+ .filter((l) => isMountableLiteral(l) && (l === w || l.startsWith(w + path.sep)))
1575
+ .map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
1576
+ rules.push(
1577
+ kept.length === 0
1578
+ ? `(deny ${op} ${at})`
1579
+ : `(deny ${op} (require-all ${at} ${kept.join(' ')}))`,
1580
+ )
1581
+ }
1582
+ }
1583
+ deny('file-read-data', c.wallsReached?.read ?? [], [...c.allowRead, ...baseAllowRead])
1584
+ deny('file-write*', c.wallsReached?.write ?? [], c.allowWrite)
1585
+ return rules
1586
+ }
1587
+
1588
+ /**'] ok — collapsed to the directory
1532
1589
  * write: ['g/a.txt'] ok — a literal is widened to its directory
1533
1590
  * write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
1534
1591
  * write: ['g/*.txt'] FAILED, same
@@ -140,13 +140,16 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
140
140
  if (runnable.length === 0) continue
141
141
  const hasBuild = names.includes('build')
142
142
  const has = (n: string): boolean => names.includes(n)
143
+ // Lifecycle hooks are npm's, not tasks anyone runs by name, and a hook
144
+ // of a script that exists rides inside that script's command.
145
+ const isTask = (n: string): boolean => {
146
+ if (!has(n) || LIFECYCLE.test(n)) return false
147
+ const hookOf = /^(pre|post)(.+)$/.exec(n)
148
+ return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
149
+ }
143
150
  const tasks: GeneratedTask[] = []
144
151
  for (const name of names) {
145
- // Lifecycle hooks are npm's, not tasks anyone runs by name.
146
- if (LIFECYCLE.test(name)) continue
147
- // A hook of a script that exists rides inside that script's command.
148
- const hookOf = /^(pre|post)(.+)$/.exec(name)
149
- if (hookOf !== null && has(hookOf[2]!) && !LIFECYCLE.test(hookOf[2]!)) continue
152
+ if (!isTask(name)) continue
150
153
 
151
154
  const todos: string[] = []
152
155
  const own = scripts[name] as string
@@ -155,7 +158,10 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
155
158
  // package manager and never ride inside a task — `pack` stays alone.
156
159
  const hook = (h: string): boolean => has(h) && !LIFECYCLE.test(h)
157
160
  const hooks = [`pre${name}`, `post${name}`].filter(hook)
158
- if (delegate !== null && has(delegate) && delegate !== name && hooks.length === 0) {
161
+ // A group over a script that is no task (`setup: npm run prepare`)
162
+ // named a task nothing defines, and the run `vx init` suggested
163
+ // refused the config (D-12).
164
+ if (delegate !== null && isTask(delegate) && delegate !== name && hooks.length === 0) {
159
165
  // A group: no exec, the graph runs the target. `npm test` calling
160
166
  // `vitest` through `npm run test:unit` is two tasks, not a subprocess.
161
167
  const task: Record<string, unknown> = { dependsOn: [delegate] }