@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/CHANGELOG.md +8 -0
- package/README.md +6 -1
- package/dist/cli.js +289 -263
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +248 -221
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -101,6 +101,256 @@ function step_countdown(max_steps, check) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
//#endregion
|
|
104
|
+
//#region src/types.ts
|
|
105
|
+
function config_error(message) {
|
|
106
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
107
|
+
}
|
|
108
|
+
function check_error(message) {
|
|
109
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
110
|
+
}
|
|
111
|
+
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
112
|
+
function phase_error(phase, iteration, cause) {
|
|
113
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
114
|
+
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
115
|
+
kind: "phase_error",
|
|
116
|
+
phase,
|
|
117
|
+
iteration,
|
|
118
|
+
cause
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function error_kind(err) {
|
|
122
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
123
|
+
const kind = err.kind;
|
|
124
|
+
return typeof kind === "string" ? kind : null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/engine.ts
|
|
130
|
+
/**
|
|
131
|
+
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
132
|
+
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
133
|
+
* mockable in tests and confines provider knowledge to one seam.
|
|
134
|
+
*/
|
|
135
|
+
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
136
|
+
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
137
|
+
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
138
|
+
* and 404s. */
|
|
139
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
140
|
+
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
141
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
142
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
143
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
144
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
145
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
146
|
+
* CLI also reads on its own. */
|
|
147
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
148
|
+
/** Every variable a seat reads a credential from. Every process volley starts
|
|
149
|
+
* runs without them (the check, the builder's commands, and volley's own git
|
|
150
|
+
* and checkride calls, which run hooks and filters the builder can write), so
|
|
151
|
+
* nothing the builder writes can read the keys its seats spend. */
|
|
152
|
+
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
153
|
+
/** The environment every process volley starts runs with: `env`, `PATH`
|
|
154
|
+
* included, less every variable a seat reads a credential from. */
|
|
155
|
+
function command_env(env) {
|
|
156
|
+
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
157
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
158
|
+
}
|
|
159
|
+
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
160
|
+
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
161
|
+
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
162
|
+
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
163
|
+
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
164
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
165
|
+
"localhost",
|
|
166
|
+
"127.0.0.1",
|
|
167
|
+
"[::1]",
|
|
168
|
+
"0.0.0.0"
|
|
169
|
+
]);
|
|
170
|
+
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
171
|
+
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
172
|
+
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
173
|
+
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
174
|
+
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
175
|
+
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
176
|
+
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
177
|
+
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
178
|
+
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
179
|
+
function cross_to_host_gateway(base_url, env) {
|
|
180
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
181
|
+
if (host === void 0 || host === "") return base_url;
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = new URL(base_url);
|
|
185
|
+
} catch {
|
|
186
|
+
return base_url;
|
|
187
|
+
}
|
|
188
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
189
|
+
parsed.hostname = host;
|
|
190
|
+
return parsed.toString().replace(/\/$/, "");
|
|
191
|
+
}
|
|
192
|
+
function strip_trailing_slashes(url) {
|
|
193
|
+
return url.replace(/\/+$/, "");
|
|
194
|
+
}
|
|
195
|
+
const SEAT_URL_SOURCES = {
|
|
196
|
+
ollama: {
|
|
197
|
+
env: "VOLLEY_OLLAMA_URL",
|
|
198
|
+
default_url: DEFAULT_OLLAMA_URL,
|
|
199
|
+
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
200
|
+
},
|
|
201
|
+
lmstudio: {
|
|
202
|
+
env: "VOLLEY_LMSTUDIO_URL",
|
|
203
|
+
default_url: DEFAULT_LMSTUDIO_URL,
|
|
204
|
+
normalize: strip_trailing_slashes
|
|
205
|
+
},
|
|
206
|
+
openrouter: {
|
|
207
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
208
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
209
|
+
normalize: strip_trailing_slashes
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
213
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
214
|
+
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
215
|
+
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
216
|
+
* configures, and so takes a URL of its own. */
|
|
217
|
+
function takes_seat_url(provider) {
|
|
218
|
+
return SEAT_URL_PROVIDERS.includes(provider);
|
|
219
|
+
}
|
|
220
|
+
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
221
|
+
function present(value) {
|
|
222
|
+
const trimmed = value?.trim() ?? "";
|
|
223
|
+
return trimmed === "" ? null : trimmed;
|
|
224
|
+
}
|
|
225
|
+
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
226
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
227
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
228
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
229
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
230
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
231
|
+
function seat_base_url(provider, seat_url, env) {
|
|
232
|
+
const source = SEAT_URL_SOURCES[provider];
|
|
233
|
+
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
234
|
+
return cross_to_host_gateway(source.normalize(raw), env);
|
|
235
|
+
}
|
|
236
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
237
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
238
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
239
|
+
* api_key" doesn't say where the key comes from. */
|
|
240
|
+
function openrouter_api_key(env) {
|
|
241
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
242
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
243
|
+
return key;
|
|
244
|
+
}
|
|
245
|
+
function load_pricing_overrides(path) {
|
|
246
|
+
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
249
|
+
} catch (err) {
|
|
250
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
254
|
+
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
255
|
+
* fascicle only when the provider actually runs. The entry is complete, because
|
|
256
|
+
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
257
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
258
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
259
|
+
*
|
|
260
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
261
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
262
|
+
* traffic to volley.
|
|
263
|
+
*
|
|
264
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
265
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
266
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
267
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
268
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
269
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
270
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
271
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
272
|
+
* base-URL fix produces.
|
|
273
|
+
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
274
|
+
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
275
|
+
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
276
|
+
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
277
|
+
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
278
|
+
*
|
|
279
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
280
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
281
|
+
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
282
|
+
* override is unset and this is a no-op. */
|
|
283
|
+
function seat_provider_config(provider, seat_url, env) {
|
|
284
|
+
const base_url = seat_base_url(provider, seat_url, env);
|
|
285
|
+
switch (provider) {
|
|
286
|
+
case "ollama": return { ollama: { base_url } };
|
|
287
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
288
|
+
case "openrouter": return { openrouter: {
|
|
289
|
+
api_key: openrouter_api_key(env),
|
|
290
|
+
base_url,
|
|
291
|
+
transport: "native",
|
|
292
|
+
x_title: "volley"
|
|
293
|
+
} };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
297
|
+
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
298
|
+
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
299
|
+
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
300
|
+
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
301
|
+
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
302
|
+
* fallback provider URLs, and pricing come from the environment. */
|
|
303
|
+
function create_volley_engine(options) {
|
|
304
|
+
const env = options.env ?? process.env;
|
|
305
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
306
|
+
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}`);
|
|
307
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
308
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
309
|
+
const providers = { claude_cli: {
|
|
310
|
+
default_cwd: options.workspace,
|
|
311
|
+
setting_sources: ["project", "local"],
|
|
312
|
+
auth_mode,
|
|
313
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
314
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
315
|
+
} };
|
|
316
|
+
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
317
|
+
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
318
|
+
return create_engine({
|
|
319
|
+
providers,
|
|
320
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
324
|
+
* per provider when it builds an engine, so two seats on the same provider at
|
|
325
|
+
* different URLs need two engines: the critic's is derived from the builder's
|
|
326
|
+
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
327
|
+
* overrides only the shared provider's entry. Every other pairing, including a
|
|
328
|
+
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
329
|
+
async function create_seat_engines(options) {
|
|
330
|
+
const env = options.env ?? process.env;
|
|
331
|
+
const builder = create_volley_engine(options);
|
|
332
|
+
const provider = options.critic_provider;
|
|
333
|
+
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 {
|
|
334
|
+
builder,
|
|
335
|
+
critic: builder
|
|
336
|
+
};
|
|
337
|
+
try {
|
|
338
|
+
return {
|
|
339
|
+
builder,
|
|
340
|
+
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
341
|
+
};
|
|
342
|
+
} catch (err) {
|
|
343
|
+
await builder.dispose();
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
348
|
+
* one that fails doesn't keep the other from running. */
|
|
349
|
+
async function dispose_seat_engines(engines) {
|
|
350
|
+
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
351
|
+
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
104
354
|
//#region src/workspace_tools.ts
|
|
105
355
|
/**
|
|
106
356
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -564,14 +814,17 @@ async function run_fetch(raw, ctx, config) {
|
|
|
564
814
|
* containment this runs *inside* volley's hardened container against the
|
|
565
815
|
* bind-mounted worktree — the container is the isolation boundary, no
|
|
566
816
|
* `docker exec` hop — and it is likewise the executor on the
|
|
567
|
-
* `--allow-unsandboxed-builder` host escape hatch.
|
|
568
|
-
*
|
|
817
|
+
* `--allow-unsandboxed-builder` host escape hatch. A command runs with
|
|
818
|
+
* volley's environment less the seats' keys, so nothing the builder runs can
|
|
819
|
+
* read the key its seat spends. `status` is null when a signal (the timeout
|
|
820
|
+
* SIGKILL) killed the process.
|
|
569
821
|
*/
|
|
570
822
|
function host_bash_executor(cwd) {
|
|
571
823
|
return (command, options) => {
|
|
572
824
|
const result = spawnSync(command, {
|
|
573
825
|
shell: true,
|
|
574
826
|
cwd,
|
|
827
|
+
env: command_env(process.env),
|
|
575
828
|
encoding: "utf8",
|
|
576
829
|
timeout: options.timeout_ms,
|
|
577
830
|
killSignal: "SIGKILL",
|
|
@@ -673,31 +926,6 @@ function finish_tool() {
|
|
|
673
926
|
};
|
|
674
927
|
}
|
|
675
928
|
//#endregion
|
|
676
|
-
//#region src/types.ts
|
|
677
|
-
function config_error(message) {
|
|
678
|
-
return Object.assign(new Error(message), { kind: "config_error" });
|
|
679
|
-
}
|
|
680
|
-
function check_error(message) {
|
|
681
|
-
return Object.assign(new Error(message), { kind: "check_error" });
|
|
682
|
-
}
|
|
683
|
-
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
684
|
-
function phase_error(phase, iteration, cause) {
|
|
685
|
-
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
686
|
-
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
687
|
-
kind: "phase_error",
|
|
688
|
-
phase,
|
|
689
|
-
iteration,
|
|
690
|
-
cause
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
function error_kind(err) {
|
|
694
|
-
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
695
|
-
const kind = err.kind;
|
|
696
|
-
return typeof kind === "string" ? kind : null;
|
|
697
|
-
}
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
//#endregion
|
|
701
929
|
//#region src/check/checkride.ts
|
|
702
930
|
/**
|
|
703
931
|
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
@@ -734,7 +962,7 @@ function spawn_capture(cmd, args, options) {
|
|
|
734
962
|
return new Promise((resolve_promise, reject) => {
|
|
735
963
|
const proc = spawn(cmd, args, {
|
|
736
964
|
cwd: options.cwd,
|
|
737
|
-
|
|
965
|
+
env: options.env ?? command_env(process.env),
|
|
738
966
|
detached: process.platform !== "win32",
|
|
739
967
|
stdio: [
|
|
740
968
|
"ignore",
|
|
@@ -1015,224 +1243,6 @@ function cost_cap_hit(config, state) {
|
|
|
1015
1243
|
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
1016
1244
|
}
|
|
1017
1245
|
//#endregion
|
|
1018
|
-
//#region src/engine.ts
|
|
1019
|
-
/**
|
|
1020
|
-
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
1021
|
-
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
1022
|
-
* mockable in tests and confines provider knowledge to one seam.
|
|
1023
|
-
*/
|
|
1024
|
-
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
1025
|
-
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
1026
|
-
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
1027
|
-
* and 404s. */
|
|
1028
|
-
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
1029
|
-
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
1030
|
-
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
1031
|
-
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
1032
|
-
/** The variable an `openrouter` seat's API key is read from. */
|
|
1033
|
-
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
1034
|
-
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
1035
|
-
* CLI also reads on its own. */
|
|
1036
|
-
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
1037
|
-
/** Every variable a seat reads a credential from. Under acp, the builder's
|
|
1038
|
-
* commands and the check run without them, so nothing the builder writes can
|
|
1039
|
-
* read the keys its seats spend. */
|
|
1040
|
-
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
1041
|
-
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
1042
|
-
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
1043
|
-
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
1044
|
-
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
1045
|
-
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
1046
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
1047
|
-
"localhost",
|
|
1048
|
-
"127.0.0.1",
|
|
1049
|
-
"[::1]",
|
|
1050
|
-
"0.0.0.0"
|
|
1051
|
-
]);
|
|
1052
|
-
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
1053
|
-
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
1054
|
-
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
1055
|
-
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
1056
|
-
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
1057
|
-
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
1058
|
-
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
1059
|
-
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
1060
|
-
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
1061
|
-
function cross_to_host_gateway(base_url, env) {
|
|
1062
|
-
const host = env["VOLLEY_MODEL_HOST"];
|
|
1063
|
-
if (host === void 0 || host === "") return base_url;
|
|
1064
|
-
let parsed;
|
|
1065
|
-
try {
|
|
1066
|
-
parsed = new URL(base_url);
|
|
1067
|
-
} catch {
|
|
1068
|
-
return base_url;
|
|
1069
|
-
}
|
|
1070
|
-
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
1071
|
-
parsed.hostname = host;
|
|
1072
|
-
return parsed.toString().replace(/\/$/, "");
|
|
1073
|
-
}
|
|
1074
|
-
function strip_trailing_slashes(url) {
|
|
1075
|
-
return url.replace(/\/+$/, "");
|
|
1076
|
-
}
|
|
1077
|
-
const SEAT_URL_SOURCES = {
|
|
1078
|
-
ollama: {
|
|
1079
|
-
env: "VOLLEY_OLLAMA_URL",
|
|
1080
|
-
default_url: DEFAULT_OLLAMA_URL,
|
|
1081
|
-
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
1082
|
-
},
|
|
1083
|
-
lmstudio: {
|
|
1084
|
-
env: "VOLLEY_LMSTUDIO_URL",
|
|
1085
|
-
default_url: DEFAULT_LMSTUDIO_URL,
|
|
1086
|
-
normalize: strip_trailing_slashes
|
|
1087
|
-
},
|
|
1088
|
-
openrouter: {
|
|
1089
|
-
env: "VOLLEY_OPENROUTER_URL",
|
|
1090
|
-
default_url: DEFAULT_OPENROUTER_URL,
|
|
1091
|
-
normalize: strip_trailing_slashes
|
|
1092
|
-
}
|
|
1093
|
-
};
|
|
1094
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
1095
|
-
* so get their endpoint probed by `--dry-run`. */
|
|
1096
|
-
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
1097
|
-
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
1098
|
-
* configures, and so takes a URL of its own. */
|
|
1099
|
-
function takes_seat_url(provider) {
|
|
1100
|
-
return SEAT_URL_PROVIDERS.includes(provider);
|
|
1101
|
-
}
|
|
1102
|
-
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
1103
|
-
function present(value) {
|
|
1104
|
-
const trimmed = value?.trim() ?? "";
|
|
1105
|
-
return trimmed === "" ? null : trimmed;
|
|
1106
|
-
}
|
|
1107
|
-
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
1108
|
-
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
1109
|
-
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
1110
|
-
* other seat's URL. The result is normalized, then a loopback authority is
|
|
1111
|
-
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
1112
|
-
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
1113
|
-
function seat_base_url(provider, seat_url, env) {
|
|
1114
|
-
const source = SEAT_URL_SOURCES[provider];
|
|
1115
|
-
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
1116
|
-
return cross_to_host_gateway(source.normalize(raw), env);
|
|
1117
|
-
}
|
|
1118
|
-
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
1119
|
-
* or blank key is a config error naming the variable, which `resolve_config`
|
|
1120
|
-
* raises before any work, because fascicle's own "requires a non-empty
|
|
1121
|
-
* api_key" doesn't say where the key comes from. */
|
|
1122
|
-
function openrouter_api_key(env) {
|
|
1123
|
-
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
1124
|
-
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
1125
|
-
return key;
|
|
1126
|
-
}
|
|
1127
|
-
function load_pricing_overrides(path) {
|
|
1128
|
-
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
1129
|
-
try {
|
|
1130
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
1136
|
-
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
1137
|
-
* fascicle only when the provider actually runs. The entry is complete, because
|
|
1138
|
-
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
1139
|
-
* returns is all a seat's derived engine knows about the provider, so an
|
|
1140
|
-
* `openrouter` entry carries its key and its transport along with its URL.
|
|
1141
|
-
*
|
|
1142
|
-
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
1143
|
-
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
1144
|
-
* traffic to volley.
|
|
1145
|
-
*
|
|
1146
|
-
* For the local providers, native transport is the deferred future bridge, not
|
|
1147
|
-
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
1148
|
-
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
1149
|
-
* so no field is needed today. Flipping later is a one-line change — add
|
|
1150
|
-
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
1151
|
-
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
1152
|
-
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
1153
|
-
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
1154
|
-
* base-URL fix produces.
|
|
1155
|
-
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
1156
|
-
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
1157
|
-
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
1158
|
-
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
1159
|
-
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
1160
|
-
*
|
|
1161
|
-
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
1162
|
-
* when volley runs contained, so an in-container model client reaches the
|
|
1163
|
-
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
1164
|
-
* override is unset and this is a no-op. */
|
|
1165
|
-
function seat_provider_config(provider, seat_url, env) {
|
|
1166
|
-
const base_url = seat_base_url(provider, seat_url, env);
|
|
1167
|
-
switch (provider) {
|
|
1168
|
-
case "ollama": return { ollama: { base_url } };
|
|
1169
|
-
case "lmstudio": return { lmstudio: { base_url } };
|
|
1170
|
-
case "openrouter": return { openrouter: {
|
|
1171
|
-
api_key: openrouter_api_key(env),
|
|
1172
|
-
base_url,
|
|
1173
|
-
transport: "native",
|
|
1174
|
-
x_title: "volley"
|
|
1175
|
-
} };
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
1179
|
-
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
1180
|
-
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
1181
|
-
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
1182
|
-
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
1183
|
-
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
1184
|
-
* fallback provider URLs, and pricing come from the environment. */
|
|
1185
|
-
function create_volley_engine(options) {
|
|
1186
|
-
const env = options.env ?? process.env;
|
|
1187
|
-
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
1188
|
-
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}`);
|
|
1189
|
-
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
1190
|
-
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
1191
|
-
const providers = { claude_cli: {
|
|
1192
|
-
default_cwd: options.workspace,
|
|
1193
|
-
setting_sources: ["project", "local"],
|
|
1194
|
-
auth_mode,
|
|
1195
|
-
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
1196
|
-
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
1197
|
-
} };
|
|
1198
|
-
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
1199
|
-
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
1200
|
-
return create_engine({
|
|
1201
|
-
providers,
|
|
1202
|
-
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
1203
|
-
});
|
|
1204
|
-
}
|
|
1205
|
-
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
1206
|
-
* per provider when it builds an engine, so two seats on the same provider at
|
|
1207
|
-
* different URLs need two engines: the critic's is derived from the builder's
|
|
1208
|
-
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
1209
|
-
* overrides only the shared provider's entry. Every other pairing, including a
|
|
1210
|
-
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
1211
|
-
async function create_seat_engines(options) {
|
|
1212
|
-
const env = options.env ?? process.env;
|
|
1213
|
-
const builder = create_volley_engine(options);
|
|
1214
|
-
const provider = options.critic_provider;
|
|
1215
|
-
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 {
|
|
1216
|
-
builder,
|
|
1217
|
-
critic: builder
|
|
1218
|
-
};
|
|
1219
|
-
try {
|
|
1220
|
-
return {
|
|
1221
|
-
builder,
|
|
1222
|
-
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
1223
|
-
};
|
|
1224
|
-
} catch (err) {
|
|
1225
|
-
await builder.dispose();
|
|
1226
|
-
throw err;
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
1230
|
-
* one that fails doesn't keep the other from running. */
|
|
1231
|
-
async function dispose_seat_engines(engines) {
|
|
1232
|
-
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
1233
|
-
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
1234
|
-
}
|
|
1235
|
-
//#endregion
|
|
1236
1246
|
//#region src/prewarm.ts
|
|
1237
1247
|
/**
|
|
1238
1248
|
* Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
|
|
@@ -1513,6 +1523,7 @@ function commit_identity(cwd) {
|
|
|
1513
1523
|
key
|
|
1514
1524
|
], {
|
|
1515
1525
|
cwd,
|
|
1526
|
+
env: command_env(process.env),
|
|
1516
1527
|
encoding: "utf8"
|
|
1517
1528
|
});
|
|
1518
1529
|
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
@@ -1540,7 +1551,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1540
1551
|
"add",
|
|
1541
1552
|
"-A",
|
|
1542
1553
|
...pathspec
|
|
1543
|
-
], {
|
|
1554
|
+
], {
|
|
1555
|
+
cwd: root,
|
|
1556
|
+
env: command_env(process.env)
|
|
1557
|
+
});
|
|
1544
1558
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1545
1559
|
const commit = spawnSync("git", [
|
|
1546
1560
|
...commit_identity(root),
|
|
@@ -1548,7 +1562,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1548
1562
|
"-m",
|
|
1549
1563
|
message,
|
|
1550
1564
|
"--no-verify"
|
|
1551
|
-
], {
|
|
1565
|
+
], {
|
|
1566
|
+
cwd: root,
|
|
1567
|
+
env: command_env(process.env)
|
|
1568
|
+
});
|
|
1552
1569
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1553
1570
|
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
1554
1571
|
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
@@ -1572,6 +1589,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1572
1589
|
branch
|
|
1573
1590
|
], {
|
|
1574
1591
|
cwd: workspace,
|
|
1592
|
+
env: command_env(process.env),
|
|
1575
1593
|
encoding: "utf8"
|
|
1576
1594
|
});
|
|
1577
1595
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
@@ -1584,6 +1602,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1584
1602
|
"--no-verify"
|
|
1585
1603
|
], {
|
|
1586
1604
|
cwd: workspace,
|
|
1605
|
+
env: command_env(process.env),
|
|
1587
1606
|
encoding: "utf8"
|
|
1588
1607
|
});
|
|
1589
1608
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
@@ -1693,6 +1712,7 @@ function report_worktree_fate(config, sink) {
|
|
|
1693
1712
|
function git$1(cwd, args) {
|
|
1694
1713
|
const result = spawnSync("git", args, {
|
|
1695
1714
|
cwd,
|
|
1715
|
+
env: command_env(process.env),
|
|
1696
1716
|
encoding: "utf8"
|
|
1697
1717
|
});
|
|
1698
1718
|
if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
@@ -1719,6 +1739,7 @@ function git_quiet(cwd, args) {
|
|
|
1719
1739
|
try {
|
|
1720
1740
|
const result = spawnSync("git", args, {
|
|
1721
1741
|
cwd,
|
|
1742
|
+
env: command_env(process.env),
|
|
1722
1743
|
encoding: "utf8"
|
|
1723
1744
|
});
|
|
1724
1745
|
return {
|
|
@@ -2231,6 +2252,7 @@ function git(root, args) {
|
|
|
2231
2252
|
try {
|
|
2232
2253
|
const result = spawnSync("git", args, {
|
|
2233
2254
|
cwd: root,
|
|
2255
|
+
env: command_env(process.env),
|
|
2234
2256
|
encoding: "utf8",
|
|
2235
2257
|
maxBuffer: 33554432
|
|
2236
2258
|
});
|
|
@@ -3441,13 +3463,14 @@ function report_changes(config, renderer, changes) {
|
|
|
3441
3463
|
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)." : ""));
|
|
3442
3464
|
}
|
|
3443
3465
|
/** Run the configured check, checkride or a command, with the environment
|
|
3444
|
-
* the caller set for it, else volley's own
|
|
3466
|
+
* the caller set for it, else volley's own less the seats' keys, since the
|
|
3467
|
+
* check runs code the builder wrote. */
|
|
3445
3468
|
async function execute_check(deps, abort) {
|
|
3446
3469
|
const { config } = deps;
|
|
3447
3470
|
const root = build_root(config.workspace, config.worktree);
|
|
3448
3471
|
const options = {
|
|
3449
3472
|
abort,
|
|
3450
|
-
|
|
3473
|
+
env: deps.check?.env ?? command_env(process.env)
|
|
3451
3474
|
};
|
|
3452
3475
|
return config.check_resolved === "checkride" ? run_checkride({
|
|
3453
3476
|
...options,
|
|
@@ -3987,13 +4010,6 @@ const CRITIC_READS = /* @__PURE__ */ new Set([
|
|
|
3987
4010
|
"list_files",
|
|
3988
4011
|
"search"
|
|
3989
4012
|
]);
|
|
3990
|
-
/** The environment the builder's commands and the check run with under acp:
|
|
3991
|
-
* the agent's own, `PATH` included, less every variable a seat reads a
|
|
3992
|
-
* credential from. */
|
|
3993
|
-
function acp_command_env(env) {
|
|
3994
|
-
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
3995
|
-
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
3996
|
-
}
|
|
3997
4013
|
/** Whether `tool` asks before a call with `input`. A set without the tool
|
|
3998
4014
|
* asks, so a check never runs unasked by mistake. */
|
|
3999
4015
|
async function asks_before(tool, input) {
|
|
@@ -4008,10 +4024,10 @@ async function asks_before(tool, input) {
|
|
|
4008
4024
|
* the session exactly when the builder's `run_command` would ask before
|
|
4009
4025
|
* running the same command. */
|
|
4010
4026
|
function acp_seat_tools(session, options = {}) {
|
|
4011
|
-
const
|
|
4027
|
+
const env = command_env(options.env ?? process.env);
|
|
4012
4028
|
const tools = workspace_tools(session, {
|
|
4013
4029
|
ignore: [...IGNORED_DIRS],
|
|
4014
|
-
command_env
|
|
4030
|
+
command_env: env
|
|
4015
4031
|
});
|
|
4016
4032
|
const run_command = tools.find((tool) => tool.name === "run_command");
|
|
4017
4033
|
return {
|
|
@@ -4025,7 +4041,7 @@ function acp_seat_tools(session, options = {}) {
|
|
|
4025
4041
|
tool_set: "acp"
|
|
4026
4042
|
},
|
|
4027
4043
|
check: {
|
|
4028
|
-
env
|
|
4044
|
+
env,
|
|
4029
4045
|
on_approval: async (request) => await asks_before(run_command, request.input) ? session.approve(request) : true
|
|
4030
4046
|
}
|
|
4031
4047
|
};
|
|
@@ -5522,7 +5538,10 @@ async function probe_seat_endpoints(config, renderer, env, reachable) {
|
|
|
5522
5538
|
/** Which required executables are not runnable on the current PATH. */
|
|
5523
5539
|
function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
5524
5540
|
return tools.filter((tool) => {
|
|
5525
|
-
const result = spawnSync(tool, ["--version"], {
|
|
5541
|
+
const result = spawnSync(tool, ["--version"], {
|
|
5542
|
+
env: command_env(process.env),
|
|
5543
|
+
stdio: "ignore"
|
|
5544
|
+
});
|
|
5526
5545
|
return result.error !== void 0 || result.status !== 0;
|
|
5527
5546
|
});
|
|
5528
5547
|
}
|
|
@@ -5531,7 +5550,10 @@ function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
|
5531
5550
|
* run's job — worktree.ts). */
|
|
5532
5551
|
function worktree_creatable(workspace) {
|
|
5533
5552
|
const repo = resolve(workspace);
|
|
5534
|
-
const version = spawnSync("git", ["--version"], {
|
|
5553
|
+
const version = spawnSync("git", ["--version"], {
|
|
5554
|
+
env: command_env(process.env),
|
|
5555
|
+
stdio: "ignore"
|
|
5556
|
+
});
|
|
5535
5557
|
if (version.error !== void 0 || version.status !== 0) return {
|
|
5536
5558
|
ok: false,
|
|
5537
5559
|
detail: "git is not available"
|
|
@@ -5545,7 +5567,10 @@ function worktree_creatable(workspace) {
|
|
|
5545
5567
|
repo,
|
|
5546
5568
|
"rev-parse",
|
|
5547
5569
|
"--is-inside-work-tree"
|
|
5548
|
-
], {
|
|
5570
|
+
], {
|
|
5571
|
+
env: command_env(process.env),
|
|
5572
|
+
encoding: "utf8"
|
|
5573
|
+
});
|
|
5549
5574
|
if (inside.status !== 0 || (inside.stdout ?? "").trim() !== "true") return {
|
|
5550
5575
|
ok: false,
|
|
5551
5576
|
detail: `${repo} is not a git work tree`
|
|
@@ -5618,6 +5643,7 @@ function run_checkride_doctor(workspace) {
|
|
|
5618
5643
|
"doctor"
|
|
5619
5644
|
], {
|
|
5620
5645
|
cwd: workspace,
|
|
5646
|
+
env: command_env(process.env),
|
|
5621
5647
|
stdio: [
|
|
5622
5648
|
"ignore",
|
|
5623
5649
|
"ignore",
|