@vzn/vx-reapi 0.0.636 → 0.0.638

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/README.md CHANGED
@@ -12,7 +12,7 @@ npm install -D @vzn/vx @vzn/vx-reapi # or: pnpm add -D -w · yarn add -D (-W o
12
12
  ```
13
13
 
14
14
  ```ts
15
- // vx.workspace.ts
15
+ // .vx/workspace.ts
16
16
  import { defineWorkspace } from '@vzn/vx/config'
17
17
  import { reapi } from '@vzn/vx-reapi'
18
18
 
@@ -22,6 +22,11 @@ export default defineWorkspace({
22
22
  })
23
23
  ```
24
24
 
25
+ Off CI a run reads the remote and does not write it unless its policy names
26
+ the remote. Give the write credential only to the default branch's CI and a
27
+ read-only one to laptops and pull request jobs: the server's token is the
28
+ boundary (`docs/security.md` § Cache poisoning).
29
+
25
30
  `ReapiRemoteCache` is the layer class behind `reapi()`, for a workspace that
26
31
  composes cache layers by hand. The package exports `reapi`, `ReapiPluginOptions`,
27
32
  `ReapiRemoteCache` and its `ReapiOptions`; the wire client, the Merkle
@@ -134,10 +139,11 @@ held to the git blob id the key folded for it. A file edited or removed
134
139
  in between (an edit mid-run under `vx watch`) still runs, but the
135
140
  execution is not recorded under a key that no longer describes it; the
136
141
  record once replayed the edited outputs on every machine after the file
137
- was restored. The project's own `package.json`, which the key folds
138
- whether or not a glob lists it, is always in the input root.
142
+ was restored. The input root holds exactly the files the key folded: core
143
+ keys no package file, so a project's `package.json` is in it only as a
144
+ listed input (`@vzn/vx-lockfile` lists it on every cached task).
139
145
 
140
- This matters most under `--download=none`, where deferral leaves no
146
+ This matters most under `--cache-download=none`, where deferral leaves no
141
147
  local cache entry behind, so vx's own probe misses on every later run
142
148
  and the record is what makes the second run cheap. Records are checked
143
149
  against the CAS first (`FindMissingBlobs`) — the action cache and the
@@ -187,7 +193,9 @@ generated files. Only what a declared glob names (or what sits under a
187
193
  directory it names) is written back; the rest stays as it is on disk.
188
194
  Writing all of it once put the worker's copy of the sources over the
189
195
  user's, so an edit made while the action ran was lost. A directory a
190
- literal glob names is written whole.
196
+ literal glob names is written whole. Where a write really lands, after
197
+ the result's own links are placed, must be declared too: a declared link
198
+ to `.git/hooks` and a declared directory under it wrote a hook.
191
199
 
192
200
  ## The existence probe confirms the artifact, not just the entry
193
201
 
@@ -408,8 +416,7 @@ A cache-only server (bazel-remote advertises `exec_enabled: false`) makes the
408
416
  plugin **decline the executor with a warning** rather than submit work that
409
417
  will never be answered. Only cacheable tasks are eligible — a task with no
410
418
  `cache` block has no described inputs, so a worker would run it against an
411
- empty input root. A cacheable task's root holds its declared input files
412
- and its project's `package.json`.
419
+ empty input root. A cacheable task's root holds its declared input files.
413
420
 
414
421
  Verified end-to-end against a live NativeLink scheduler + worker: input tree
415
422
  uploaded, QUEUED → EXECUTING → COMPLETED streamed, stdout returned inline,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-reapi",
3
- "version": "0.0.636",
3
+ "version": "0.0.638",
4
4
  "license": "MIT",
5
5
  "description": "Bazel Remote Execution API plugin for vx — remote cache (ActionCache + CAS) against NativeLink, BuildBuddy, Buildbarn or bazel-remote.",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "@vzn/vx": "^0.0.636"
42
+ "@vzn/vx": "^0.0.638"
43
43
  },
44
44
  "repository": {
45
45
  "type": "git",
package/src/executor.ts CHANGED
@@ -802,14 +802,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
802
802
  symlinkGrafts.push(...(g.symlinks ?? []))
803
803
  }
804
804
 
805
- // The key folds the project's package.json whether or not a glob
806
- // lists it, so the worker sees it too: a `"type": "module"` changes
807
- // what a bundler emits, and a tree without it ran as another task.
808
805
  const expected = new Map(req.inputs.files.map((f) => [f.path, f.digest]))
809
- const packageJson = projectRel === '' ? 'package.json' : `${projectRel}/package.json`
810
- if (req.inputs.packageJsonDigest !== '' && !expected.has(packageJson)) {
811
- expected.set(packageJson, req.inputs.packageJsonDigest)
812
- }
813
806
  const inputPaths = [...expected.keys(), ...localUpstreamPaths]
814
807
  const tree = await buildInputTree({
815
808
  expected,
@@ -1008,7 +1001,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1008
1001
 
1009
1002
  // A remote-only task's outputs stay remote — materialising node_modules
1010
1003
  // onto the submitter's disk is precisely what `remote: 'only'` forbids.
1011
- // `--download=none` defers the same transfer WITHOUT making it
1004
+ // `--cache-download=none` defers the same transfer WITHOUT making it
1012
1005
  // permanent: the bytes stay in the CAS and core gets a closure to pull
1013
1006
  // them if a locally-placed consumer turns out to need them.
1014
1007
  // Started before the logs are read, not after: a server that keeps
@@ -1418,7 +1411,14 @@ export async function materialiseOutputs(
1418
1411
  const rel = toPosix(path.relative(req.workspaceRoot, abs))
1419
1412
  return declared.includes(rel) || matchers.some((m) => m.match(rel))
1420
1413
  }
1421
- const fence = new Fence(req.workspaceRoot)
1414
+ // A declared output, or a path under one a literal glob names whole.
1415
+ const underDeclared = (rel: string): boolean => {
1416
+ for (let r = rel; r !== '.' && r !== ''; r = path.posix.dirname(r)) {
1417
+ if (declared.includes(r) || matchers.some((m) => m.match(r))) return true
1418
+ }
1419
+ return false
1420
+ }
1421
+ const fence = new Fence(req.workspaceRoot, underDeclared)
1422
1422
  // A file or link no declared glob names is neither fetched nor written:
1423
1423
  // the workspace root was the only fence, so a result (or a record planted
1424
1424
  // in the action cache) could write `.git/hooks/post-checkout` 0755 or
@@ -1435,6 +1435,7 @@ export async function materialiseOutputs(
1435
1435
  const abs = fence.lexical(req.cwd, f.path)
1436
1436
  await fence.dir(path.dirname(abs))
1437
1437
  await makeDir(path.dirname(abs), created)
1438
+ await fence.landing(abs)
1438
1439
  // Inlined only when it has bytes: an ActionResult read back through
1439
1440
  // proto-loader (the execution-record replay) carries `contents` as an
1440
1441
  // EMPTY Buffer on every file, and taking that as inline wrote each
@@ -1506,11 +1507,35 @@ class Fence {
1506
1507
  private readonly checked = new Set<string>()
1507
1508
  private readonly links: { abs: string; target: string }[] = []
1508
1509
  private readonly root: string
1510
+ private readonly realDirs = new Map<string, Promise<string>>()
1509
1511
 
1510
- constructor(root: string) {
1512
+ constructor(
1513
+ root: string,
1514
+ /** A workspace-relative path the task declared as output, or under one. */
1515
+ private readonly declared: (rel: string) => boolean = () => true,
1516
+ ) {
1511
1517
  this.root = path.resolve(root)
1512
1518
  }
1513
1519
 
1520
+ /**
1521
+ * Where a file write really lands, judged after the result's links are
1522
+ * placed: a declared link to `.git/hooks`, then a declared directory
1523
+ * under it, wrote an executable hook (both inside the root, so the
1524
+ * containment checks passed). The landing must itself be declared.
1525
+ */
1526
+ async landing(abs: string): Promise<void> {
1527
+ this.realRoot ??= await realpath(this.root).catch(() => this.root)
1528
+ const dir = path.dirname(abs)
1529
+ let real = this.realDirs.get(dir)
1530
+ if (real === undefined) {
1531
+ real = realpath(dir)
1532
+ this.realDirs.set(dir, real)
1533
+ }
1534
+ const rel = toPosix(path.relative(this.realRoot, path.join(await real, path.basename(abs))))
1535
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel) || !this.declared(rel))
1536
+ throw this.refuse(abs)
1537
+ }
1538
+
1514
1539
  /** `rel` joined under `base`, refused when the join leaves the root. */
1515
1540
  lexical(base: string, rel: string): string {
1516
1541
  // A NUL reached the file system as a raw ERR_INVALID_ARG_VALUE.
@@ -1763,6 +1788,7 @@ async function materialiseTree(
1763
1788
  missing(abs, f.digest.hash, abs)
1764
1789
  return
1765
1790
  }
1791
+ await fence.landing(abs)
1766
1792
  await writeOutput(abs, bytes, created)
1767
1793
  if (f.is_executable) await chmod(abs, 0o755)
1768
1794
  // NodeProperties.unix_mode is authoritative when the server sent it.
package/src/index.ts CHANGED
@@ -12,8 +12,7 @@
12
12
  import { readFileSync } from 'node:fs'
13
13
  import {
14
14
  definePlugin,
15
- LayeredCache,
16
- type CacheLayer,
15
+ type RemoteCacheLayer,
17
16
  type TaskExecutor,
18
17
  type VxPlugin,
19
18
  UserError,
@@ -84,7 +83,7 @@ export interface ReapiPluginOptions extends Partial<
84
83
  }
85
84
 
86
85
  /**
87
- * Declare in `vx.workspace.ts`; the local store is the floor beneath it and is
86
+ * Declare in `.vx/workspace.ts`; the local store is the floor beneath it and is
88
87
  * read first, so the remote is asked only on a local miss:
89
88
  *
90
89
  * ```ts
@@ -257,7 +256,7 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
257
256
  executorClient?.close()
258
257
  executorClient = undefined
259
258
  // The cache layer holds a client too, and `RemoteCacheLayer` has no
260
- // close hook for core to call — `LayeredCache.close()` closes only the
259
+ // close hook for core to call — core closes only its
261
260
  // LOCAL handle. Closing it here is lifecycle hygiene rather than a
262
261
  // measured fix: @grpc/grpc-js pools subchannels per target, so 20
263
262
  // unclosed clients were MEASURED to share one connection. It matters
@@ -266,19 +265,13 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
266
265
  remoteCache?.close()
267
266
  remoteCache = undefined
268
267
  },
269
- cache(ctx): CacheLayer | undefined {
268
+ cache(ctx): RemoteCacheLayer | undefined {
270
269
  const conn = connection(options)
271
270
  if (conn === undefined) return undefined
272
- const remote = new ReapiRemoteCache({ ...conn, onWarn: (m) => ctx.warn(m) })
273
- remoteCache = remote
274
- // Compose over the local handle the host opened: reads try local, then
275
- // remote (hydrating local on a remote hit); writes go local immediately
276
- // and the remote upload drains in the background. All of that is core's
277
- // LayeredCache — this plugin supplies only the wire.
278
- return new LayeredCache(ctx.localCache, remote, {
279
- policy: ctx.policy,
280
- onRemoteError: (err) => ctx.warn(`vx/reapi: ${err.message}`),
281
- })
271
+ // Only the wire: core layers it over the local store (reads try local,
272
+ // then remote, hydrating local on a hit; uploads drain in the background).
273
+ remoteCache = new ReapiRemoteCache({ ...conn, onWarn: (m) => ctx.warn(m) })
274
+ return remoteCache
282
275
  },
283
276
  })
284
277
  }