@robmclarty/volley 0.10.0 → 0.10.1

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/dist/index.d.ts CHANGED
@@ -486,10 +486,11 @@ export declare function create_renderer(options: RendererOptions): Renderer;
486
486
  //#endregion
487
487
  //#region src/phases.d.ts
488
488
  /** How the check runs when the caller sets it, as `volley acp` does. Left
489
- * out, the check inherits volley's environment and runs unasked, as on the
490
- * CLI. */
489
+ * out, the check runs unasked with volley's environment less the seats' keys,
490
+ * as on the CLI. */
491
491
  type CheckGate = {
492
- /** The whole environment the check runs with, in place of volley's own. */
492
+ /** The whole environment the check runs with, in place of volley's own less
493
+ * the seats' keys. */
493
494
  env?: Readonly<Record<string, string>>;
494
495
  /** Asked on the check's card before each check, the way a model call asks
495
496
  * before a gated tool runs. Anything short of an allow keeps the check from
package/dist/index.js CHANGED
@@ -39,6 +39,231 @@ function error_kind(err) {
39
39
  return null;
40
40
  }
41
41
  //#endregion
42
+ //#region src/engine.ts
43
+ /**
44
+ * The ONLY module that calls fascicle's `create_engine`. Every
45
+ * other module receives an `Engine`-shaped value, which keeps the engine
46
+ * mockable in tests and confines provider knowledge to one seam.
47
+ */
48
+ /** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
49
+ * The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
50
+ * `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
51
+ * and 404s. */
52
+ const DEFAULT_OLLAMA_URL = "http://localhost:11434";
53
+ const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
54
+ /** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
55
+ const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
56
+ /** The variable an `openrouter` seat's API key is read from. */
57
+ const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
58
+ /** The variable a `claude_cli` seat's API key is read from, which the Claude
59
+ * CLI also reads on its own. */
60
+ const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
61
+ /** Every variable a seat reads a credential from. Every process volley starts
62
+ * runs without them (the check, the builder's commands, and volley's own git
63
+ * and checkride calls, which run hooks and filters the builder can write), so
64
+ * nothing the builder writes can read the keys its seats spend. */
65
+ const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
66
+ /** The environment every process volley starts runs with: `env`, `PATH`
67
+ * included, less every variable a seat reads a credential from. */
68
+ function command_env(env) {
69
+ const stripped = new Set(SEAT_CREDENTIAL_VARS);
70
+ return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
71
+ }
72
+ /** Loopback authorities the container→host crossing rewrites. A base URL
73
+ * pointing at one of these is local to wherever volley runs; contained, that is
74
+ * *inside* the sandbox container, where the host LLM daemon is unreachable
75
+ * except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
76
+ * IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
77
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
78
+ "localhost",
79
+ "127.0.0.1",
80
+ "[::1]",
81
+ "0.0.0.0"
82
+ ]);
83
+ /** Cross a loopback base URL to the host LLM endpoint. When volley
84
+ * runs inside its sandbox container the local model daemon is on the *host*, so
85
+ * the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
86
+ * (the host-gateway allowlist target; see `sandbox_invocation` in
87
+ * `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
88
+ * loopback host for the gateway, so an unchanged `http://localhost:11434` still
89
+ * reaches the host from in-container. A non-loopback URL (a real remote daemon)
90
+ * is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
91
+ * path) is a no-op — so the default and all-Claude paths are unaffected. */
92
+ function cross_to_host_gateway(base_url, env) {
93
+ const host = env["VOLLEY_MODEL_HOST"];
94
+ if (host === void 0 || host === "") return base_url;
95
+ let parsed;
96
+ try {
97
+ parsed = new URL(base_url);
98
+ } catch {
99
+ return base_url;
100
+ }
101
+ if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
102
+ parsed.hostname = host;
103
+ return parsed.toString().replace(/\/$/, "");
104
+ }
105
+ function strip_trailing_slashes(url) {
106
+ return url.replace(/\/+$/, "");
107
+ }
108
+ const SEAT_URL_SOURCES = {
109
+ ollama: {
110
+ env: "VOLLEY_OLLAMA_URL",
111
+ default_url: DEFAULT_OLLAMA_URL,
112
+ normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
113
+ },
114
+ lmstudio: {
115
+ env: "VOLLEY_LMSTUDIO_URL",
116
+ default_url: DEFAULT_LMSTUDIO_URL,
117
+ normalize: strip_trailing_slashes
118
+ },
119
+ openrouter: {
120
+ env: "VOLLEY_OPENROUTER_URL",
121
+ default_url: DEFAULT_OPENROUTER_URL,
122
+ normalize: strip_trailing_slashes
123
+ }
124
+ };
125
+ /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
126
+ * so get their endpoint probed by `--dry-run`. */
127
+ const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
128
+ /** Whether a seat on `provider` reaches its model at a base URL volley
129
+ * configures, and so takes a URL of its own. */
130
+ function takes_seat_url(provider) {
131
+ return SEAT_URL_PROVIDERS.includes(provider);
132
+ }
133
+ /** An absent, empty, or all-whitespace value counts as unset. */
134
+ function present(value) {
135
+ const trimmed = value?.trim() ?? "";
136
+ return trimmed === "" ? null : trimmed;
137
+ }
138
+ /** The base URL a seat's model calls go to: the seat's own URL, else the
139
+ * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
140
+ * `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
141
+ * other seat's URL. The result is normalized, then a loopback authority is
142
+ * crossed to the host LLM endpoint when volley runs contained. Both seats can
143
+ * be loopback URLs on different ports, and each crosses on its own port. */
144
+ function seat_base_url(provider, seat_url, env) {
145
+ const source = SEAT_URL_SOURCES[provider];
146
+ const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
147
+ return cross_to_host_gateway(source.normalize(raw), env);
148
+ }
149
+ /** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
150
+ * or blank key is a config error naming the variable, which `resolve_config`
151
+ * raises before any work, because fascicle's own "requires a non-empty
152
+ * api_key" doesn't say where the key comes from. */
153
+ function openrouter_api_key(env) {
154
+ const key = present(env[OPENROUTER_KEY_VAR]);
155
+ if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
156
+ return key;
157
+ }
158
+ function load_pricing_overrides(path) {
159
+ if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
160
+ try {
161
+ return JSON.parse(readFileSync(path, "utf8"));
162
+ } catch (err) {
163
+ throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ }
166
+ /** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
167
+ * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
168
+ * fascicle only when the provider actually runs. The entry is complete, because
169
+ * `Engine.with_providers` replaces a provider's whole entry: whatever this
170
+ * returns is all a seat's derived engine knows about the provider, so an
171
+ * `openrouter` entry carries its key and its transport along with its URL.
172
+ *
173
+ * `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
174
+ * so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
175
+ * traffic to volley.
176
+ *
177
+ * For the local providers, native transport is the deferred future bridge, not
178
+ * adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
179
+ * selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
180
+ * so no field is needed today. Flipping later is a one-line change — add
181
+ * `transport: 'native'` to the ollama/lmstudio entry returned below, which both
182
+ * seats' engines then share. No URL re-pointing is required: `seat_base_url`
183
+ * already yields the daemon-root URL the native `/api/chat` transport wants
184
+ * (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
185
+ * base-URL fix produces.
186
+ * Kept as a proven-once-then-reverted option so the transport stays a clean
187
+ * second variable in the model-vs-transport comparison. Flipping it today would
188
+ * also lose the reasoning: fascicle's native Ollama adapter drops
189
+ * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
190
+ * thinking seat's reasoning would vanish from the terminal and the trajectory.
191
+ *
192
+ * Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
193
+ * when volley runs contained, so an in-container model client reaches the
194
+ * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
195
+ * override is unset and this is a no-op. */
196
+ function seat_provider_config(provider, seat_url, env) {
197
+ const base_url = seat_base_url(provider, seat_url, env);
198
+ switch (provider) {
199
+ case "ollama": return { ollama: { base_url } };
200
+ case "lmstudio": return { lmstudio: { base_url } };
201
+ case "openrouter": return { openrouter: {
202
+ api_key: openrouter_api_key(env),
203
+ base_url,
204
+ transport: "native",
205
+ x_title: "volley"
206
+ } };
207
+ }
208
+ }
209
+ /** Per-run engine. `claude_cli` is always configured with `options.workspace`
210
+ * (the run's build root) as its session cwd; any URL-taking provider selected
211
+ * by the builder or critic role is wired in alongside it, at that seat's URL,
212
+ * so one engine drives both roles. An engine holds one URL per provider, so on
213
+ * a provider both seats share it carries the builder's; `create_seat_engines`
214
+ * gives the critic its own engine when its URL differs. Binary, auth mode,
215
+ * fallback provider URLs, and pricing come from the environment. */
216
+ function create_volley_engine(options) {
217
+ const env = options.env ?? process.env;
218
+ const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
219
+ if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
220
+ const binary = env["VOLLEY_CLAUDE_BIN"];
221
+ const pricing_path = env["VOLLEY_PRICING_PATH"];
222
+ const providers = { claude_cli: {
223
+ default_cwd: options.workspace,
224
+ setting_sources: ["project", "local"],
225
+ auth_mode,
226
+ ...binary !== void 0 && binary.length > 0 ? { binary } : {},
227
+ ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
228
+ } };
229
+ if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
230
+ if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
231
+ return create_engine({
232
+ providers,
233
+ ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
234
+ });
235
+ }
236
+ /** One engine per seat, shared unless it can't be. fascicle fixes one base URL
237
+ * per provider when it builds an engine, so two seats on the same provider at
238
+ * different URLs need two engines: the critic's is derived from the builder's
239
+ * with `with_providers`, which keeps claude_cli, auth, and pricing and
240
+ * overrides only the shared provider's entry. Every other pairing, including a
241
+ * run that sets no seat URL at all, gets the one engine a run has always had. */
242
+ async function create_seat_engines(options) {
243
+ const env = options.env ?? process.env;
244
+ const builder = create_volley_engine(options);
245
+ const provider = options.critic_provider;
246
+ if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
247
+ builder,
248
+ critic: builder
249
+ };
250
+ try {
251
+ return {
252
+ builder,
253
+ critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
254
+ };
255
+ } catch (err) {
256
+ await builder.dispose();
257
+ throw err;
258
+ }
259
+ }
260
+ /** Dispose each distinct seat engine once. Both disposals start together, so
261
+ * one that fails doesn't keep the other from running. */
262
+ async function dispose_seat_engines(engines) {
263
+ const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
264
+ await Promise.all(distinct.map((engine) => engine.dispose()));
265
+ }
266
+ //#endregion
42
267
  //#region src/changes.ts
43
268
  /**
44
269
  * What the builder changed, and whether it touched the gate.
@@ -111,6 +336,7 @@ function git$1(root, args) {
111
336
  try {
112
337
  const result = spawnSync("git", args, {
113
338
  cwd: root,
339
+ env: command_env(process.env),
114
340
  encoding: "utf8",
115
341
  maxBuffer: 33554432
116
342
  });
@@ -348,220 +574,6 @@ function detect_containment(env) {
348
574
  return value === "1" || value === "true";
349
575
  }
350
576
  //#endregion
351
- //#region src/engine.ts
352
- /**
353
- * The ONLY module that calls fascicle's `create_engine`. Every
354
- * other module receives an `Engine`-shaped value, which keeps the engine
355
- * mockable in tests and confines provider knowledge to one seam.
356
- */
357
- /** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
358
- * The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
359
- * `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
360
- * and 404s. */
361
- const DEFAULT_OLLAMA_URL = "http://localhost:11434";
362
- const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
363
- /** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
364
- const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
365
- /** The variable an `openrouter` seat's API key is read from. */
366
- const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
367
- /** The variable a `claude_cli` seat's API key is read from, which the Claude
368
- * CLI also reads on its own. */
369
- const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
370
- /** Loopback authorities the container→host crossing rewrites. A base URL
371
- * pointing at one of these is local to wherever volley runs; contained, that is
372
- * *inside* the sandbox container, where the host LLM daemon is unreachable
373
- * except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
374
- * IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
375
- const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
376
- "localhost",
377
- "127.0.0.1",
378
- "[::1]",
379
- "0.0.0.0"
380
- ]);
381
- /** Cross a loopback base URL to the host LLM endpoint. When volley
382
- * runs inside its sandbox container the local model daemon is on the *host*, so
383
- * the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
384
- * (the host-gateway allowlist target; see `sandbox_invocation` in
385
- * `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
386
- * loopback host for the gateway, so an unchanged `http://localhost:11434` still
387
- * reaches the host from in-container. A non-loopback URL (a real remote daemon)
388
- * is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
389
- * path) is a no-op — so the default and all-Claude paths are unaffected. */
390
- function cross_to_host_gateway(base_url, env) {
391
- const host = env["VOLLEY_MODEL_HOST"];
392
- if (host === void 0 || host === "") return base_url;
393
- let parsed;
394
- try {
395
- parsed = new URL(base_url);
396
- } catch {
397
- return base_url;
398
- }
399
- if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
400
- parsed.hostname = host;
401
- return parsed.toString().replace(/\/$/, "");
402
- }
403
- function strip_trailing_slashes(url) {
404
- return url.replace(/\/+$/, "");
405
- }
406
- const SEAT_URL_SOURCES = {
407
- ollama: {
408
- env: "VOLLEY_OLLAMA_URL",
409
- default_url: DEFAULT_OLLAMA_URL,
410
- normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
411
- },
412
- lmstudio: {
413
- env: "VOLLEY_LMSTUDIO_URL",
414
- default_url: DEFAULT_LMSTUDIO_URL,
415
- normalize: strip_trailing_slashes
416
- },
417
- openrouter: {
418
- env: "VOLLEY_OPENROUTER_URL",
419
- default_url: DEFAULT_OPENROUTER_URL,
420
- normalize: strip_trailing_slashes
421
- }
422
- };
423
- /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
424
- * so get their endpoint probed by `--dry-run`. */
425
- const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
426
- /** Whether a seat on `provider` reaches its model at a base URL volley
427
- * configures, and so takes a URL of its own. */
428
- function takes_seat_url(provider) {
429
- return SEAT_URL_PROVIDERS.includes(provider);
430
- }
431
- /** An absent, empty, or all-whitespace value counts as unset. */
432
- function present(value) {
433
- const trimmed = value?.trim() ?? "";
434
- return trimmed === "" ? null : trimmed;
435
- }
436
- /** The base URL a seat's model calls go to: the seat's own URL, else the
437
- * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
438
- * `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
439
- * other seat's URL. The result is normalized, then a loopback authority is
440
- * crossed to the host LLM endpoint when volley runs contained. Both seats can
441
- * be loopback URLs on different ports, and each crosses on its own port. */
442
- function seat_base_url(provider, seat_url, env) {
443
- const source = SEAT_URL_SOURCES[provider];
444
- const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
445
- return cross_to_host_gateway(source.normalize(raw), env);
446
- }
447
- /** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
448
- * or blank key is a config error naming the variable, which `resolve_config`
449
- * raises before any work, because fascicle's own "requires a non-empty
450
- * api_key" doesn't say where the key comes from. */
451
- function openrouter_api_key(env) {
452
- const key = present(env[OPENROUTER_KEY_VAR]);
453
- if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
454
- return key;
455
- }
456
- function load_pricing_overrides(path) {
457
- if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
458
- try {
459
- return JSON.parse(readFileSync(path, "utf8"));
460
- } catch (err) {
461
- throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
462
- }
463
- }
464
- /** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
465
- * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
466
- * fascicle only when the provider actually runs. The entry is complete, because
467
- * `Engine.with_providers` replaces a provider's whole entry: whatever this
468
- * returns is all a seat's derived engine knows about the provider, so an
469
- * `openrouter` entry carries its key and its transport along with its URL.
470
- *
471
- * `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
472
- * so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
473
- * traffic to volley.
474
- *
475
- * For the local providers, native transport is the deferred future bridge, not
476
- * adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
477
- * selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
478
- * so no field is needed today. Flipping later is a one-line change — add
479
- * `transport: 'native'` to the ollama/lmstudio entry returned below, which both
480
- * seats' engines then share. No URL re-pointing is required: `seat_base_url`
481
- * already yields the daemon-root URL the native `/api/chat` transport wants
482
- * (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
483
- * base-URL fix produces.
484
- * Kept as a proven-once-then-reverted option so the transport stays a clean
485
- * second variable in the model-vs-transport comparison. Flipping it today would
486
- * also lose the reasoning: fascicle's native Ollama adapter drops
487
- * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
488
- * thinking seat's reasoning would vanish from the terminal and the trajectory.
489
- *
490
- * Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
491
- * when volley runs contained, so an in-container model client reaches the
492
- * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
493
- * override is unset and this is a no-op. */
494
- function seat_provider_config(provider, seat_url, env) {
495
- const base_url = seat_base_url(provider, seat_url, env);
496
- switch (provider) {
497
- case "ollama": return { ollama: { base_url } };
498
- case "lmstudio": return { lmstudio: { base_url } };
499
- case "openrouter": return { openrouter: {
500
- api_key: openrouter_api_key(env),
501
- base_url,
502
- transport: "native",
503
- x_title: "volley"
504
- } };
505
- }
506
- }
507
- /** Per-run engine. `claude_cli` is always configured with `options.workspace`
508
- * (the run's build root) as its session cwd; any URL-taking provider selected
509
- * by the builder or critic role is wired in alongside it, at that seat's URL,
510
- * so one engine drives both roles. An engine holds one URL per provider, so on
511
- * a provider both seats share it carries the builder's; `create_seat_engines`
512
- * gives the critic its own engine when its URL differs. Binary, auth mode,
513
- * fallback provider URLs, and pricing come from the environment. */
514
- function create_volley_engine(options) {
515
- const env = options.env ?? process.env;
516
- const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
517
- if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
518
- const binary = env["VOLLEY_CLAUDE_BIN"];
519
- const pricing_path = env["VOLLEY_PRICING_PATH"];
520
- const providers = { claude_cli: {
521
- default_cwd: options.workspace,
522
- setting_sources: ["project", "local"],
523
- auth_mode,
524
- ...binary !== void 0 && binary.length > 0 ? { binary } : {},
525
- ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
526
- } };
527
- if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
528
- if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
529
- return create_engine({
530
- providers,
531
- ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
532
- });
533
- }
534
- /** One engine per seat, shared unless it can't be. fascicle fixes one base URL
535
- * per provider when it builds an engine, so two seats on the same provider at
536
- * different URLs need two engines: the critic's is derived from the builder's
537
- * with `with_providers`, which keeps claude_cli, auth, and pricing and
538
- * overrides only the shared provider's entry. Every other pairing, including a
539
- * run that sets no seat URL at all, gets the one engine a run has always had. */
540
- async function create_seat_engines(options) {
541
- const env = options.env ?? process.env;
542
- const builder = create_volley_engine(options);
543
- const provider = options.critic_provider;
544
- if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
545
- builder,
546
- critic: builder
547
- };
548
- try {
549
- return {
550
- builder,
551
- critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
552
- };
553
- } catch (err) {
554
- await builder.dispose();
555
- throw err;
556
- }
557
- }
558
- /** Dispose each distinct seat engine once. Both disposals start together, so
559
- * one that fails doesn't keep the other from running. */
560
- async function dispose_seat_engines(engines) {
561
- const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
562
- await Promise.all(distinct.map((engine) => engine.dispose()));
563
- }
564
- //#endregion
565
577
  //#region src/trust.ts
566
578
  /**
567
579
  * Which run configs a host-side resume may trust. A resume runs what a run's
@@ -768,6 +780,7 @@ function commit_identity(cwd) {
768
780
  key
769
781
  ], {
770
782
  cwd,
783
+ env: command_env(process.env),
771
784
  encoding: "utf8"
772
785
  });
773
786
  return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
@@ -795,7 +808,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
795
808
  "add",
796
809
  "-A",
797
810
  ...pathspec
798
- ], { cwd: root });
811
+ ], {
812
+ cwd: root,
813
+ env: command_env(process.env)
814
+ });
799
815
  if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
800
816
  const commit = spawnSync("git", [
801
817
  ...commit_identity(root),
@@ -803,7 +819,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
803
819
  "-m",
804
820
  message,
805
821
  "--no-verify"
806
- ], { cwd: root });
822
+ ], {
823
+ cwd: root,
824
+ env: command_env(process.env)
825
+ });
807
826
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
808
827
  const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
809
828
  if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
@@ -827,6 +846,7 @@ function integrate_worktree(workspace, branch, message) {
827
846
  branch
828
847
  ], {
829
848
  cwd: workspace,
849
+ env: command_env(process.env),
830
850
  encoding: "utf8"
831
851
  });
832
852
  if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
@@ -839,6 +859,7 @@ function integrate_worktree(workspace, branch, message) {
839
859
  "--no-verify"
840
860
  ], {
841
861
  cwd: workspace,
862
+ env: command_env(process.env),
842
863
  encoding: "utf8"
843
864
  });
844
865
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
@@ -1719,14 +1740,17 @@ async function run_fetch(raw, ctx, config) {
1719
1740
  * containment this runs *inside* volley's hardened container against the
1720
1741
  * bind-mounted worktree — the container is the isolation boundary, no
1721
1742
  * `docker exec` hop — and it is likewise the executor on the
1722
- * `--allow-unsandboxed-builder` host escape hatch. `status` is null
1723
- * when a signal (the timeout SIGKILL) killed the process.
1743
+ * `--allow-unsandboxed-builder` host escape hatch. A command runs with
1744
+ * volley's environment less the seats' keys, so nothing the builder runs can
1745
+ * read the key its seat spends. `status` is null when a signal (the timeout
1746
+ * SIGKILL) killed the process.
1724
1747
  */
1725
1748
  function host_bash_executor(cwd) {
1726
1749
  return (command, options) => {
1727
1750
  const result = spawnSync(command, {
1728
1751
  shell: true,
1729
1752
  cwd,
1753
+ env: command_env(process.env),
1730
1754
  encoding: "utf8",
1731
1755
  timeout: options.timeout_ms,
1732
1756
  killSignal: "SIGKILL",
@@ -1864,7 +1888,7 @@ function spawn_capture(cmd, args, options) {
1864
1888
  return new Promise((resolve_promise, reject) => {
1865
1889
  const proc = spawn(cmd, args, {
1866
1890
  cwd: options.cwd,
1867
- ...options.env !== void 0 ? { env: options.env } : {},
1891
+ env: options.env ?? command_env(process.env),
1868
1892
  detached: process.platform !== "win32",
1869
1893
  stdio: [
1870
1894
  "ignore",
@@ -2292,6 +2316,7 @@ function report_worktree_fate(config, sink) {
2292
2316
  function git(cwd, args) {
2293
2317
  const result = spawnSync("git", args, {
2294
2318
  cwd,
2319
+ env: command_env(process.env),
2295
2320
  encoding: "utf8"
2296
2321
  });
2297
2322
  if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
@@ -2318,6 +2343,7 @@ function git_quiet(cwd, args) {
2318
2343
  try {
2319
2344
  const result = spawnSync("git", args, {
2320
2345
  cwd,
2346
+ env: command_env(process.env),
2321
2347
  encoding: "utf8"
2322
2348
  });
2323
2349
  return {
@@ -3528,13 +3554,14 @@ function report_changes(config, renderer, changes) {
3528
3554
  renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
3529
3555
  }
3530
3556
  /** Run the configured check, checkride or a command, with the environment
3531
- * the caller set for it, else volley's own. */
3557
+ * the caller set for it, else volley's own less the seats' keys, since the
3558
+ * check runs code the builder wrote. */
3532
3559
  async function execute_check(deps, abort) {
3533
3560
  const { config } = deps;
3534
3561
  const root = build_root(config.workspace, config.worktree);
3535
3562
  const options = {
3536
3563
  abort,
3537
- ...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
3564
+ env: deps.check?.env ?? command_env(process.env)
3538
3565
  };
3539
3566
  return config.check_resolved === "checkride" ? run_checkride({
3540
3567
  ...options,