@vzn/vx 0.0.86 → 0.0.88

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.86",
3
+ "version": "0.0.88",
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.86",
21
- "@vzn/vx-linux-arm64": "0.0.86",
22
- "@vzn/vx-darwin-x64": "0.0.86",
23
- "@vzn/vx-darwin-arm64": "0.0.86"
20
+ "@vzn/vx-linux-x64": "0.0.88",
21
+ "@vzn/vx-linux-arm64": "0.0.88",
22
+ "@vzn/vx-darwin-x64": "0.0.88",
23
+ "@vzn/vx-darwin-arm64": "0.0.88"
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
@@ -142,9 +142,11 @@ export interface ExecuteRequest {
142
142
  /**
143
143
  * Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
144
144
  * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
145
- * work and returns; core waits for `execute`, and a child an executor
146
- * spawned is not one core can reach (H-10, H-12). A non-zero exit after
147
- * the timeout's abort is recorded `timedOut`.
145
+ * work and returns: a child an executor spawned is not one core can
146
+ * reach (H-10, H-12). A non-zero exit after the timeout's abort is
147
+ * recorded `timedOut`. Core waits the kill grace (`VX_KILL_GRACE_MS`,
148
+ * 2 s) after the abort, then settles the attempt without `execute` and
149
+ * abandons it (H-14).
148
150
  */
149
151
  readonly signal?: AbortSignal
150
152
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
package/src/exec/index.ts CHANGED
@@ -30,7 +30,7 @@ export {
30
30
  export { type DeniedCall } from './sandbox-violations.js'
31
31
  export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
32
32
  export { bindableWrites, punchWalls } from './sandbox-binds.js'
33
- export { localExecutor } from './local-executor.js'
33
+ export { isLocalExecutor, localExecutor } from './local-executor.js'
34
34
  export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
35
35
  export {
36
36
  assertExecuteResult,
@@ -8,9 +8,20 @@ import { runCommand } from './runner.js'
8
8
  import { runSandboxed } from './sandbox-runtime.js'
9
9
  import type { TaskExecutor } from './executor.js'
10
10
 
11
+ const locals = new WeakSet<TaskExecutor>()
12
+
13
+ /**
14
+ * Whether `executor` is core's own. By identity: a plugin may name its
15
+ * executor 'local', and core bounds a plugin's `execute` after an abort,
16
+ * which the local one (it SIGKILLs its own group) never needs.
17
+ */
18
+ export function isLocalExecutor(executor: TaskExecutor): boolean {
19
+ return locals.has(executor)
20
+ }
21
+
11
22
  /** The local executor. Accepts every task. */
12
23
  export function localExecutor(): TaskExecutor {
13
- return {
24
+ const executor: TaskExecutor = {
14
25
  name: 'local',
15
26
  async execute(req) {
16
27
  const common = {
@@ -38,4 +49,6 @@ export function localExecutor(): TaskExecutor {
38
49
  })
39
50
  },
40
51
  }
52
+ locals.add(executor)
53
+ return executor
41
54
  }
@@ -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
@@ -22,6 +22,7 @@ import {
22
22
  releaseBridges,
23
23
  wrapSandboxedCommand,
24
24
  signalExitCode,
25
+ isLocalExecutor,
25
26
  type CaptureConfig,
26
27
  type ExecuteRequest,
27
28
  assertExecuteResult,
@@ -32,7 +33,9 @@ import {
32
33
  PersistentReadyError,
33
34
  } from '../exec/index.js'
34
35
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
35
- import { relPosix, span } from '../util/index.js'
36
+ import { killGraceMs, relPosix, span } from '../util/index.js'
37
+ import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
38
+ import { executorLabel } from './plugin-host.js'
36
39
  import {
37
40
  mayWriteFingerprint,
38
41
  type Placeholder,
@@ -493,6 +496,49 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
493
496
  }
494
497
  return stop.signal
495
498
  }
499
+ // An executor that ignores `signal` held its task, and so the run, past
500
+ // the stop and past `exec.timeout` (H-14). Once the signal aborts it has
501
+ // the kill grace the local executor gives a process group; then core
502
+ // settles the attempt without it and says so. Its work is abandoned: core
503
+ // cannot reach what it started.
504
+ function boundAfterAbort(
505
+ running: Promise<unknown>,
506
+ signal: AbortSignal | undefined,
507
+ ): Promise<unknown> {
508
+ if (signal === undefined || isLocalExecutor(args.executor)) return running
509
+ const started = Date.now()
510
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
511
+ let onAbort: (() => void) | undefined
512
+ const abandoned = new Promise<ExecuteResult>((resolve) => {
513
+ onAbort = (): void => {
514
+ const graceMs = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS)
515
+ graceTimer = setTimeout(() => {
516
+ const why = timeoutFired ? `timeout (${effectiveTimeout}ms)` : 'stop'
517
+ log.taskStderr(
518
+ node,
519
+ `vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
520
+ )
521
+ const reason: unknown = signal.reason
522
+ resolve({
523
+ exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
524
+ durationMs: Date.now() - started,
525
+ stdout: '',
526
+ stderr: '',
527
+ violations: [],
528
+ ...(timeoutFired ? { timedOut: true } : {}),
529
+ })
530
+ }, graceMs)
531
+ }
532
+ if (signal.aborted) onAbort()
533
+ else signal.addEventListener('abort', onAbort, { once: true })
534
+ })
535
+ // The abandoned call may still settle, or reject, later: nobody awaits it.
536
+ running.catch(() => {})
537
+ return Promise.race([running, abandoned]).finally(() => {
538
+ clearTimeout(graceTimer)
539
+ if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)
540
+ })
541
+ }
496
542
 
497
543
  // When the task started, as a ns offset from run start — captured for
498
544
  // EVERY outcome (hits included) so the run-detail timeline reflects when
@@ -722,8 +768,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
722
768
  // no other trace. Rethrown unchanged — the scheduler still classifies it,
723
769
  // and still prints it plainly for a UserError.
724
770
  const endExec = span('miss: execute')
725
- let res = await args.executor
726
- .execute(req)
771
+ let res = await boundAfterAbort(args.executor.execute(req), req.signal)
727
772
  .then((r: unknown) => {
728
773
  assertExecuteResult(args.executor.name, node.id, r)
729
774
  return r
@@ -199,7 +199,6 @@ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: stri
199
199
  if (prefixes.length === 0) return out
200
200
  const reaches = (dir: string): boolean =>
201
201
  dir === '' ||
202
- dir === '.' ||
203
202
  prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
204
203
  const dependants = new Map<string, string[]>()
205
204
  for (const node of nodes.values()) {
@@ -241,58 +241,24 @@ export function getInvocation(db: Database, runId: string): InvocationDetail | n
241
241
 
242
242
  export interface ListInvocationsArgs {
243
243
  limit?: number
244
- branch?: string
245
- ci?: boolean
246
- tagKey?: string
247
- tagValue?: string
248
244
  }
249
245
 
250
246
  /**
251
- * List `vx run` invocations newest-first from the `invocations` header table
252
- * with optional branch / ci / tag filters. Reading the dedicated header table
253
- * (vs the old `GROUP BY run_id` over `runs`) is lossless — git/ci/tag context
254
- * survives. Accepts a bare `number` for the limit (back-compat with the old
255
- * `listInvocations(db, 50)` signature).
247
+ * List `vx run` invocations newest-first from the `invocations` header table.
248
+ * Reading the dedicated header table (vs the old `GROUP BY run_id` over
249
+ * `runs`) is lossless — git/ci/tag context survives.
256
250
  */
257
- export function listInvocations(
258
- db: Database,
259
- args: ListInvocationsArgs | number = {},
260
- ): InvocationDetail[] {
261
- const opts: ListInvocationsArgs = typeof args === 'number' ? { limit: args } : args
262
- const limit = clampInt(opts.limit ?? 50, 1, 500)
263
- const where: string[] = []
264
- const params: (string | number)[] = []
265
- if (opts.branch !== undefined) {
266
- where.push('branch = ?')
267
- params.push(opts.branch)
268
- }
269
- if (opts.ci !== undefined) {
270
- where.push('ci = ?')
271
- params.push(opts.ci ? 1 : 0)
272
- }
273
- if (opts.tagKey !== undefined && opts.tagValue !== undefined) {
274
- // The tags column is a JSON object {"k":"v"}; a LIKE over the serialized
275
- // pair is adequate at this table's scale (see the design doc).
276
- where.push('tags LIKE ?')
277
- params.push(`%${jsonPairFragment(opts.tagKey, opts.tagValue)}%`)
278
- }
279
- const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
251
+ export function listInvocations(db: Database, args: ListInvocationsArgs = {}): InvocationDetail[] {
252
+ const limit = clampInt(args.limit ?? 50, 1, 500)
280
253
  const rows = db
281
254
  .query(
282
- `SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
255
+ `SELECT ${INVOCATION_COLUMNS} FROM invocations
283
256
  ORDER BY rowid DESC LIMIT ?`,
284
257
  )
285
- .all(...params, limit) as InvocationRawRow[]
258
+ .all(limit) as InvocationRawRow[]
286
259
  return rows.map(mapInvocation)
287
260
  }
288
261
 
289
- /** The `"key":"value"` fragment as it appears in `JSON.stringify({k:v})`. */
290
- function jsonPairFragment(key: string, value: string): string {
291
- const k = JSON.stringify(key).slice(1, -1)
292
- const v = JSON.stringify(value).slice(1, -1)
293
- return `"${k}":"${v}"`
294
- }
295
-
296
262
  export interface RunDetail {
297
263
  runId: string
298
264
  startedAt: number