@sweny-ai/core 0.1.198 → 0.1.199
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/agent-env.d.ts +94 -2
- package/dist/agent-env.js +144 -7
- package/dist/executor.d.ts +5 -1
- package/dist/harness/acp.d.ts +5 -2
- package/dist/harness/acp.js +7 -22
- package/dist/harness/auth.js +2 -1
- package/dist/harness/claude-code.d.ts +15 -12
- package/dist/harness/claude-code.js +30 -14
- package/dist/harness/codex.d.ts +22 -5
- package/dist/harness/codex.js +64 -10
- package/dist/harness/pi.d.ts +11 -5
- package/dist/harness/pi.js +24 -8
- package/dist/harness/sandbox-wrapper.d.ts +13 -2
- package/dist/harness/sandbox-wrapper.js +29 -4
- package/package.json +1 -1
package/dist/agent-env.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export declare const PI_ENV_PREFIXES: readonly string[];
|
|
|
77
77
|
* name is in {@link PI_AUTH_VARS} (`meta` to `META_API_KEY`).
|
|
78
78
|
*/
|
|
79
79
|
export declare const PI_PROVIDER_VARS: Readonly<Record<string, readonly string[]>>;
|
|
80
|
+
/** pi settings that are not secrets: their presence alone never selects a provider. */
|
|
81
|
+
export declare const PI_NON_SECRET_VARS: ReadonlySet<string>;
|
|
80
82
|
/** The {@link PI_AUTH_VARS} names for one pi provider id (empty for a custom `models.json` provider). */
|
|
81
83
|
export declare function piProviderVars(provider: string): string[];
|
|
82
84
|
export interface PiProviderResolution {
|
|
@@ -115,6 +117,12 @@ export interface BuildAgentEnvOpts {
|
|
|
115
117
|
prefixes?: readonly string[];
|
|
116
118
|
/** Operator passthrough list (`env-passthrough`). `"*"` inherits everything. */
|
|
117
119
|
passthrough?: readonly string[];
|
|
120
|
+
/**
|
|
121
|
+
* Where `CLAUDE_CODE_USE_BEDROCK` / `CLAUDE_CODE_USE_VERTEX` are read when
|
|
122
|
+
* deciding the cloud vars. Default: `source`. A harness `env` overlay passes
|
|
123
|
+
* the overlaid env, so an overlay that turns Bedrock on keeps the `AWS_*` vars.
|
|
124
|
+
*/
|
|
125
|
+
routing?: Record<string, string | undefined>;
|
|
118
126
|
logger?: Pick<Logger, "warn">;
|
|
119
127
|
}
|
|
120
128
|
/**
|
|
@@ -221,7 +229,8 @@ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
|
|
|
221
229
|
* scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
|
|
222
230
|
* askpass programs among them) and every {@link isAgentCredential} name, even
|
|
223
231
|
* one a node granted with `agent_env`, appends
|
|
224
|
-
* {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
|
|
232
|
+
* {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
|
|
233
|
+
* (and drops `GIT_CONFIG_PARAMETERS`, which git would apply after them),
|
|
225
234
|
* sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
|
|
226
235
|
* routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
|
|
227
236
|
* ssh push fails even to a remote with an explicit `pushurl` under
|
|
@@ -306,6 +315,72 @@ export declare function grantedAgentEnv(agentEnv: readonly string[] | undefined,
|
|
|
306
315
|
grant: string[];
|
|
307
316
|
dropped?: string;
|
|
308
317
|
};
|
|
318
|
+
/**
|
|
319
|
+
* The `env` and `envRemove` options every harness takes. They hold per
|
|
320
|
+
* harness instance: every node and every judge and route call (`complete()`)
|
|
321
|
+
* on that instance gets them, so per-run isolation means one harness per
|
|
322
|
+
* `execute()`.
|
|
323
|
+
*/
|
|
324
|
+
export interface HarnessEnvOptions {
|
|
325
|
+
/**
|
|
326
|
+
* Values laid over the agent env after scoping and passthrough, then put
|
|
327
|
+
* through the same credential rule as the inherited env: a skill credential
|
|
328
|
+
* (`GITHUB_TOKEN`, `SLACK_*`, a custom skill's config name) is withheld
|
|
329
|
+
* unless the node grants it with `agent_env`, and the harness's own auth
|
|
330
|
+
* vars always pass. When it names one of the harness's own credentials, the
|
|
331
|
+
* inherited ones are dropped first, so the overlay's login replaces the
|
|
332
|
+
* parent's instead of sitting beside it. In a staged or dry run the push
|
|
333
|
+
* block lands on top: it sets `GIT_ASKPASS`, `GH_CONFIG_DIR`, the ssh
|
|
334
|
+
* command and its git config entries over any overlaid value, drops
|
|
335
|
+
* `GIT_CONFIG_PARAMETERS` and every push token. `SWENY_*` names here only
|
|
336
|
+
* reach the agent; sweny itself reads its settings from `process.env`.
|
|
337
|
+
* Use it to hand a run its own `HOME` or `CLAUDE_CONFIG_DIR` without
|
|
338
|
+
* touching `process.env`, which is never modified. `HOME`,
|
|
339
|
+
* `CLAUDE_CONFIG_DIR` and `CODEX_HOME` must be absolute paths: the sandbox
|
|
340
|
+
* denies the login files under them.
|
|
341
|
+
*/
|
|
342
|
+
env?: Readonly<Record<string, string>>;
|
|
343
|
+
/**
|
|
344
|
+
* Names removed from the agent env after scoping and passthrough, before
|
|
345
|
+
* `env` is applied (e.g. a parent `ANTHROPIC_API_KEY` when the run should
|
|
346
|
+
* use a login under `CLAUDE_CONFIG_DIR`).
|
|
347
|
+
*/
|
|
348
|
+
envRemove?: readonly string[];
|
|
349
|
+
}
|
|
350
|
+
/** A validated, copied {@link HarnessEnvOptions}. */
|
|
351
|
+
export interface AgentEnvOverlay {
|
|
352
|
+
readonly env: Readonly<Record<string, string>>;
|
|
353
|
+
readonly remove: readonly string[];
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The executable a spawn of `command` with this env and cwd would run: a path
|
|
357
|
+
* with a separator resolves against `cwd`, a bare name is looked up on
|
|
358
|
+
* `env.PATH`. Undefined when nothing executable is found. Used so a version
|
|
359
|
+
* check probes the same file the spawn later runs (a harness `env` overlay may
|
|
360
|
+
* set its own `PATH`).
|
|
361
|
+
*/
|
|
362
|
+
export declare function resolveExecutable(command: string, env: Record<string, string | undefined>, cwd?: string): string | undefined;
|
|
363
|
+
/**
|
|
364
|
+
* Validate and copy a harness's `env` / `envRemove` options. Throws on a
|
|
365
|
+
* non-string value or an invalid name, so a bad overlay fails at construction
|
|
366
|
+
* instead of at spawn. Returns undefined when neither option is set. The copy
|
|
367
|
+
* means a later change to the caller's objects has no effect.
|
|
368
|
+
*/
|
|
369
|
+
export declare function agentEnvOverlay(opts: HarnessEnvOptions): AgentEnvOverlay | undefined;
|
|
370
|
+
/**
|
|
371
|
+
* Apply a caller overlay to a scoped agent env, before {@link finishAgentEnv}
|
|
372
|
+
* (which then withholds any overlaid skill credential the node did not grant).
|
|
373
|
+
* Drops the `remove` names, then, when the overlay names any of `credentials`
|
|
374
|
+
* (the harness's own login vars), drops the inherited ones, then lays the
|
|
375
|
+
* overlay values on top. Pure: returns a copy.
|
|
376
|
+
*/
|
|
377
|
+
export declare function applyEnvOverlay(env: Record<string, string>, overlay: AgentEnvOverlay | undefined, credentials: readonly string[]): Record<string, string>;
|
|
378
|
+
/**
|
|
379
|
+
* `process.env` as the agent will see it for auth and command lookups (a
|
|
380
|
+
* harness's `preflight()` probe): the overlay applied by {@link applyEnvOverlay},
|
|
381
|
+
* except `SWENY_*` names, which stay as sweny reads them from `process.env`.
|
|
382
|
+
*/
|
|
383
|
+
export declare function overlaidProcessEnv(overlay: AgentEnvOverlay | undefined, credentials: readonly string[]): Record<string, string>;
|
|
309
384
|
/** What one node's agent call may see: env var names and network hosts. */
|
|
310
385
|
export interface AgentAccess {
|
|
311
386
|
/**
|
|
@@ -428,14 +503,31 @@ export declare function checkSandboxSupport(platform?: NodeJS.Platform, pathEnv?
|
|
|
428
503
|
* - `network.allowedDomains` + `strictAllowlist`: non-listed hosts are denied
|
|
429
504
|
* deterministically rather than prompted (no prompt exists headless).
|
|
430
505
|
* - `credentials`: the agent's own Anthropic credentials are unset for
|
|
431
|
-
* sandboxed commands, and the Claude Code credentials file is unreadable
|
|
506
|
+
* sandboxed commands, and the Claude Code credentials file is unreadable:
|
|
507
|
+
* the host's, and the one the agent env selects ({@link claudeCredentialFiles},
|
|
508
|
+
* `agentEnv` carrying a harness `env` overlay's `HOME` / `CLAUDE_CONFIG_DIR`).
|
|
432
509
|
*/
|
|
433
510
|
export declare function buildSandboxSettings(domains: readonly string[], opts?: {
|
|
434
511
|
failIfUnavailable?: boolean;
|
|
435
512
|
home?: string;
|
|
513
|
+
agentEnv?: Record<string, string | undefined>;
|
|
514
|
+
/** The agent's working directory, for a relative `HOME` / `CLAUDE_CONFIG_DIR`. */
|
|
515
|
+
cwd?: string;
|
|
436
516
|
}): SandboxSettings;
|
|
517
|
+
/**
|
|
518
|
+
* Claude Code login files to deny to sandboxed commands: the host's
|
|
519
|
+
* (`<hostHome>/.claude/.credentials.json`), plus the one `env` selects when it
|
|
520
|
+
* points elsewhere: `$CLAUDE_CONFIG_DIR/.credentials.json`, and
|
|
521
|
+
* `$HOME/.claude/.credentials.json`. A relative value resolves against the
|
|
522
|
+
* agent's `cwd`, where the agent would resolve it. Deduplicated, host file first.
|
|
523
|
+
*/
|
|
524
|
+
export declare function claudeCredentialFiles(env: Record<string, string | undefined>, hostHome: string, cwd?: string): string[];
|
|
437
525
|
export interface ResolveAgentSandboxOpts {
|
|
438
526
|
env: Record<string, string | undefined>;
|
|
527
|
+
/** The agent's env when it differs from `env` (a harness `env` overlay): its login files are denied too. */
|
|
528
|
+
agentEnv?: Record<string, string | undefined>;
|
|
529
|
+
/** The agent's working directory (resolves a relative path in `agentEnv`). */
|
|
530
|
+
cwd?: string;
|
|
439
531
|
/** Explicit mode (client option); falls back to `env.SWENY_SANDBOX`. */
|
|
440
532
|
mode?: SandboxMode;
|
|
441
533
|
/** Explicit extra hosts (client option); falls back to `env.SWENY_SANDBOX_ALLOWED_DOMAINS`. */
|
package/dist/agent-env.js
CHANGED
|
@@ -262,7 +262,7 @@ export const PI_PROVIDER_VARS = {
|
|
|
262
262
|
"xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
|
|
263
263
|
};
|
|
264
264
|
/** pi settings that are not secrets: their presence alone never selects a provider. */
|
|
265
|
-
const PI_NON_SECRET_VARS = new Set([
|
|
265
|
+
export const PI_NON_SECRET_VARS = new Set([
|
|
266
266
|
"AZURE_OPENAI_BASE_URL",
|
|
267
267
|
"AZURE_OPENAI_RESOURCE_NAME",
|
|
268
268
|
"CLOUDFLARE_ACCOUNT_ID",
|
|
@@ -338,7 +338,8 @@ export function resolvePiProvider(env, model, explicit) {
|
|
|
338
338
|
}
|
|
339
339
|
/** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
|
|
340
340
|
export function hasCodexLogin(env = process.env, exists = existsSync) {
|
|
341
|
-
|
|
341
|
+
// The env's HOME, not the process's: a harness `env` overlay may give the run its own.
|
|
342
|
+
const home = env.CODEX_HOME || path.join(env.HOME || homedir(), ".codex");
|
|
342
343
|
return exists(path.join(home, "auth.json"));
|
|
343
344
|
}
|
|
344
345
|
/**
|
|
@@ -535,9 +536,10 @@ export function buildAgentEnv(source, opts = {}) {
|
|
|
535
536
|
const prefixes = [...(opts.prefixes ?? AGENT_ENV_PREFIXES)];
|
|
536
537
|
// Bedrock / Vertex routing is a Claude Code setting; only its default prefixes carry it.
|
|
537
538
|
if (!opts.prefixes) {
|
|
538
|
-
|
|
539
|
+
const routing = opts.routing ?? source;
|
|
540
|
+
if (truthy(routing.CLAUDE_CODE_USE_BEDROCK))
|
|
539
541
|
prefixes.push(...BEDROCK_PREFIXES);
|
|
540
|
-
if (truthy(
|
|
542
|
+
if (truthy(routing.CLAUDE_CODE_USE_VERTEX))
|
|
541
543
|
for (const v of VERTEX_VARS)
|
|
542
544
|
exact.add(v);
|
|
543
545
|
}
|
|
@@ -636,7 +638,8 @@ export function noPushGitConfig(dir) {
|
|
|
636
638
|
* scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
|
|
637
639
|
* askpass programs among them) and every {@link isAgentCredential} name, even
|
|
638
640
|
* one a node granted with `agent_env`, appends
|
|
639
|
-
* {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
|
|
641
|
+
* {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
|
|
642
|
+
* (and drops `GIT_CONFIG_PARAMETERS`, which git would apply after them),
|
|
640
643
|
* sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
|
|
641
644
|
* routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
|
|
642
645
|
* ssh push fails even to a remote with an explicit `pushurl` under
|
|
@@ -662,6 +665,10 @@ export function withPushBlocked(env, enabled) {
|
|
|
662
665
|
// Every skill credential too, even one a node granted with `agent_env`.
|
|
663
666
|
if (PUSH_TOKEN_VARS.includes(k) || isAgentCredential(k))
|
|
664
667
|
continue;
|
|
668
|
+
// git reads GIT_CONFIG_PARAMETERS after the GIT_CONFIG_COUNT entries, so a value there would
|
|
669
|
+
// override the block (push.default, core.hooksPath, credential.helper).
|
|
670
|
+
if (k === "GIT_CONFIG_PARAMETERS")
|
|
671
|
+
continue;
|
|
665
672
|
out[k] = v;
|
|
666
673
|
}
|
|
667
674
|
const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
|
|
@@ -814,6 +821,116 @@ export function grantedAgentEnv(agentEnv, opts) {
|
|
|
814
821
|
return { grant: [], dropped: `agent_env withheld in a staged or dry run: ${names.join(", ")}` };
|
|
815
822
|
return { grant: names };
|
|
816
823
|
}
|
|
824
|
+
/** POSIX leaves env names loosely defined; the rules here: not empty, no `=`, no NUL, no whitespace. */
|
|
825
|
+
function checkEnvName(option, name) {
|
|
826
|
+
if (typeof name !== "string" || name === "" || /[=\0\s]/.test(name)) {
|
|
827
|
+
throw new Error(`${option}: invalid env var name ${JSON.stringify(name)} (must be a non-empty string without "=", NUL or whitespace)`);
|
|
828
|
+
}
|
|
829
|
+
return name;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The executable a spawn of `command` with this env and cwd would run: a path
|
|
833
|
+
* with a separator resolves against `cwd`, a bare name is looked up on
|
|
834
|
+
* `env.PATH`. Undefined when nothing executable is found. Used so a version
|
|
835
|
+
* check probes the same file the spawn later runs (a harness `env` overlay may
|
|
836
|
+
* set its own `PATH`).
|
|
837
|
+
*/
|
|
838
|
+
export function resolveExecutable(command, env, cwd = process.cwd()) {
|
|
839
|
+
const ok = (p) => {
|
|
840
|
+
try {
|
|
841
|
+
accessSync(p, fsConstants.X_OK);
|
|
842
|
+
return statSync(p).isFile();
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
if (command.includes(path.sep) || command.includes("/")) {
|
|
849
|
+
const p = path.resolve(cwd, command);
|
|
850
|
+
return ok(p) ? p : undefined;
|
|
851
|
+
}
|
|
852
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
853
|
+
const p = path.resolve(cwd, dir, command);
|
|
854
|
+
if (ok(p))
|
|
855
|
+
return p;
|
|
856
|
+
}
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
859
|
+
/** Overlay names whose value is a directory the sandbox derives credential-file denies from. */
|
|
860
|
+
const OVERLAY_PATH_VARS = ["HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME"];
|
|
861
|
+
function isPlainObject(v) {
|
|
862
|
+
if (v === null || typeof v !== "object")
|
|
863
|
+
return false;
|
|
864
|
+
const proto = Object.getPrototypeOf(v);
|
|
865
|
+
return proto === Object.prototype || proto === null;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Validate and copy a harness's `env` / `envRemove` options. Throws on a
|
|
869
|
+
* non-string value or an invalid name, so a bad overlay fails at construction
|
|
870
|
+
* instead of at spawn. Returns undefined when neither option is set. The copy
|
|
871
|
+
* means a later change to the caller's objects has no effect.
|
|
872
|
+
*/
|
|
873
|
+
export function agentEnvOverlay(opts) {
|
|
874
|
+
if (opts.env === undefined && opts.envRemove === undefined)
|
|
875
|
+
return undefined;
|
|
876
|
+
const env = {};
|
|
877
|
+
if (opts.env !== undefined) {
|
|
878
|
+
if (!isPlainObject(opts.env)) {
|
|
879
|
+
throw new Error("env: must be a plain object of env var names to string values (not a Map or class instance)");
|
|
880
|
+
}
|
|
881
|
+
for (const [k, v] of Object.entries(opts.env)) {
|
|
882
|
+
checkEnvName("env", k);
|
|
883
|
+
if (typeof v !== "string") {
|
|
884
|
+
throw new Error(`env: value for ${k} must be a string, got ${typeof v}` +
|
|
885
|
+
(v === undefined ? " (leave the key out, or name it in envRemove to unset it)" : ""));
|
|
886
|
+
}
|
|
887
|
+
if (v.includes("\0"))
|
|
888
|
+
throw new Error(`env: value for ${k} contains NUL`);
|
|
889
|
+
if (OVERLAY_PATH_VARS.includes(k) && !path.isAbsolute(v)) {
|
|
890
|
+
throw new Error(`env: ${k} must be an absolute path, got ${JSON.stringify(v)}`);
|
|
891
|
+
}
|
|
892
|
+
env[k] = v;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (opts.envRemove !== undefined && !Array.isArray(opts.envRemove)) {
|
|
896
|
+
throw new Error("envRemove: must be an array of env var names");
|
|
897
|
+
}
|
|
898
|
+
const remove = (opts.envRemove ?? []).map((n) => checkEnvName("envRemove", n));
|
|
899
|
+
return Object.freeze({ env: Object.freeze(env), remove: Object.freeze(remove) });
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Apply a caller overlay to a scoped agent env, before {@link finishAgentEnv}
|
|
903
|
+
* (which then withholds any overlaid skill credential the node did not grant).
|
|
904
|
+
* Drops the `remove` names, then, when the overlay names any of `credentials`
|
|
905
|
+
* (the harness's own login vars), drops the inherited ones, then lays the
|
|
906
|
+
* overlay values on top. Pure: returns a copy.
|
|
907
|
+
*/
|
|
908
|
+
export function applyEnvOverlay(env, overlay, credentials) {
|
|
909
|
+
if (!overlay)
|
|
910
|
+
return env;
|
|
911
|
+
const out = { ...env };
|
|
912
|
+
for (const name of overlay.remove)
|
|
913
|
+
delete out[name];
|
|
914
|
+
if (credentials.some((c) => Object.hasOwn(overlay.env, c)))
|
|
915
|
+
for (const c of credentials)
|
|
916
|
+
delete out[c];
|
|
917
|
+
return Object.assign(out, overlay.env);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* `process.env` as the agent will see it for auth and command lookups (a
|
|
921
|
+
* harness's `preflight()` probe): the overlay applied by {@link applyEnvOverlay},
|
|
922
|
+
* except `SWENY_*` names, which stay as sweny reads them from `process.env`.
|
|
923
|
+
*/
|
|
924
|
+
export function overlaidProcessEnv(overlay, credentials) {
|
|
925
|
+
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
926
|
+
if (!overlay)
|
|
927
|
+
return full;
|
|
928
|
+
const own = (n) => !n.startsWith("SWENY_");
|
|
929
|
+
return applyEnvOverlay(full, {
|
|
930
|
+
env: Object.fromEntries(Object.entries(overlay.env).filter(([k]) => own(k))),
|
|
931
|
+
remove: overlay.remove.filter(own),
|
|
932
|
+
}, credentials);
|
|
933
|
+
}
|
|
817
934
|
/** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
|
|
818
935
|
export function mcpWithheld(readOnly, access) {
|
|
819
936
|
return readOnly || access?.noMcp === true;
|
|
@@ -1031,9 +1148,12 @@ function defaultProbe() {
|
|
|
1031
1148
|
* - `network.allowedDomains` + `strictAllowlist`: non-listed hosts are denied
|
|
1032
1149
|
* deterministically rather than prompted (no prompt exists headless).
|
|
1033
1150
|
* - `credentials`: the agent's own Anthropic credentials are unset for
|
|
1034
|
-
* sandboxed commands, and the Claude Code credentials file is unreadable
|
|
1151
|
+
* sandboxed commands, and the Claude Code credentials file is unreadable:
|
|
1152
|
+
* the host's, and the one the agent env selects ({@link claudeCredentialFiles},
|
|
1153
|
+
* `agentEnv` carrying a harness `env` overlay's `HOME` / `CLAUDE_CONFIG_DIR`).
|
|
1035
1154
|
*/
|
|
1036
1155
|
export function buildSandboxSettings(domains, opts = {}) {
|
|
1156
|
+
const loginFiles = claudeCredentialFiles(opts.agentEnv ?? {}, opts.home ?? homedir(), opts.cwd);
|
|
1037
1157
|
return {
|
|
1038
1158
|
enabled: true,
|
|
1039
1159
|
failIfUnavailable: opts.failIfUnavailable ?? true,
|
|
@@ -1046,7 +1166,7 @@ export function buildSandboxSettings(domains, opts = {}) {
|
|
|
1046
1166
|
credentials: {
|
|
1047
1167
|
envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
|
|
1048
1168
|
files: [
|
|
1049
|
-
|
|
1169
|
+
...loginFiles.map((p) => ({ path: p, mode: "deny" })),
|
|
1050
1170
|
// Run journals and their keys: an agent that could read a key could forge records.
|
|
1051
1171
|
{ path: runStateRoot(), mode: "deny" },
|
|
1052
1172
|
],
|
|
@@ -1055,6 +1175,21 @@ export function buildSandboxSettings(domains, opts = {}) {
|
|
|
1055
1175
|
filesystem: { denyRead: [runStateRoot()], denyWrite: [runStateRoot()] },
|
|
1056
1176
|
};
|
|
1057
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Claude Code login files to deny to sandboxed commands: the host's
|
|
1180
|
+
* (`<hostHome>/.claude/.credentials.json`), plus the one `env` selects when it
|
|
1181
|
+
* points elsewhere: `$CLAUDE_CONFIG_DIR/.credentials.json`, and
|
|
1182
|
+
* `$HOME/.claude/.credentials.json`. A relative value resolves against the
|
|
1183
|
+
* agent's `cwd`, where the agent would resolve it. Deduplicated, host file first.
|
|
1184
|
+
*/
|
|
1185
|
+
export function claudeCredentialFiles(env, hostHome, cwd = process.cwd()) {
|
|
1186
|
+
const files = [path.join(hostHome, ".claude", ".credentials.json")];
|
|
1187
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
1188
|
+
files.push(path.resolve(cwd, env.CLAUDE_CONFIG_DIR, ".credentials.json"));
|
|
1189
|
+
if (env.HOME)
|
|
1190
|
+
files.push(path.resolve(cwd, env.HOME, ".claude", ".credentials.json"));
|
|
1191
|
+
return [...new Set(files)];
|
|
1192
|
+
}
|
|
1058
1193
|
/**
|
|
1059
1194
|
* Resolve the `sandbox` query option for one agent call.
|
|
1060
1195
|
*
|
|
@@ -1091,6 +1226,8 @@ export function resolveAgentSandbox(opts) {
|
|
|
1091
1226
|
settings: buildSandboxSettings([...DEFAULT_SANDBOX_DOMAINS, ...(opts.nodeDomains ?? []), ...extra], {
|
|
1092
1227
|
// Preflight passed: from here the sandbox is enforced or the node fails.
|
|
1093
1228
|
failIfUnavailable: true,
|
|
1229
|
+
agentEnv: opts.agentEnv,
|
|
1230
|
+
cwd: opts.cwd,
|
|
1094
1231
|
}),
|
|
1095
1232
|
};
|
|
1096
1233
|
}
|
package/dist/executor.d.ts
CHANGED
|
@@ -40,7 +40,11 @@ export interface ExecuteOptions {
|
|
|
40
40
|
logger?: Logger;
|
|
41
41
|
/** Working directory for resolving file Sources (default: process.cwd()) */
|
|
42
42
|
cwd?: string;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The env SWEny itself reads for this run: skill config, `context:` Source
|
|
45
|
+
* auth, the branch pusher's token and the run's identity. It never reaches
|
|
46
|
+
* the agent; to change the agent's env, pass `env` / `envRemove` to the harness.
|
|
47
|
+
*/
|
|
44
48
|
env?: NodeJS.ProcessEnv;
|
|
45
49
|
/** Host → env-var-name map for URL Source authentication */
|
|
46
50
|
fetchAuth?: Record<string, string>;
|
package/dist/harness/acp.d.ts
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* - Timeout: `session/cancel`, a grace period, then the process is killed.
|
|
46
46
|
*/
|
|
47
47
|
import type { Logger, McpServerConfig, ToolContext } from "../types.js";
|
|
48
|
-
import { type SandboxMode } from "../agent-env.js";
|
|
48
|
+
import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
|
|
49
49
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
|
|
50
50
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
51
51
|
import { type HarnessPolicyMode } from "./policy.js";
|
|
@@ -53,7 +53,7 @@ import { type SandboxWrapper } from "./sandbox-wrapper.js";
|
|
|
53
53
|
export { ACP_CAPABILITIES };
|
|
54
54
|
/** The ACP major version sweny speaks (`InitializeRequest.protocolVersion`). */
|
|
55
55
|
export declare const ACP_PROTOCOL_VERSION = 1;
|
|
56
|
-
export interface AcpHarnessOptions {
|
|
56
|
+
export interface AcpHarnessOptions extends HarnessEnvOptions {
|
|
57
57
|
/**
|
|
58
58
|
* The agent to run: a command line (`"opencode acp"`, the part after `acp:`)
|
|
59
59
|
* or an explicit `{command, args}`. Never run through a shell.
|
|
@@ -139,6 +139,7 @@ export declare class AcpHarness implements AgentHarness {
|
|
|
139
139
|
private envPassthrough;
|
|
140
140
|
private authVars;
|
|
141
141
|
private envScope;
|
|
142
|
+
private envOverlay;
|
|
142
143
|
private sandboxMode;
|
|
143
144
|
private sandboxWrapper;
|
|
144
145
|
private policyMode;
|
|
@@ -167,6 +168,8 @@ export declare class AcpHarness implements AgentHarness {
|
|
|
167
168
|
* vars the operator named. Skill credentials are dropped either way
|
|
168
169
|
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
169
170
|
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
171
|
+
* The caller's `envRemove` / `env` are applied after scoping
|
|
172
|
+
* ({@link applyEnvOverlay}), before that credential rule.
|
|
170
173
|
*/
|
|
171
174
|
private buildEnv;
|
|
172
175
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
package/dist/harness/acp.js
CHANGED
|
@@ -45,10 +45,9 @@
|
|
|
45
45
|
* - Timeout: `session/cancel`, a grace period, then the process is killed.
|
|
46
46
|
*/
|
|
47
47
|
import { spawn } from "node:child_process";
|
|
48
|
-
import * as fs from "node:fs";
|
|
49
48
|
import * as path from "node:path";
|
|
50
49
|
import { consoleLogger } from "../types.js";
|
|
51
|
-
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
50
|
+
import { agentEnvOverlay, applyEnvOverlay, finishAgentEnv, heldCredentials, overlaidProcessEnv, resolveExecutable, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
52
51
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
53
52
|
import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
|
|
54
53
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -148,25 +147,6 @@ export function toAcpMcpServers(servers, agentHttp) {
|
|
|
148
147
|
}
|
|
149
148
|
return { list, unsupported };
|
|
150
149
|
}
|
|
151
|
-
function resolveOnPath(command, env) {
|
|
152
|
-
const ok = (p) => {
|
|
153
|
-
try {
|
|
154
|
-
fs.accessSync(p, fs.constants.X_OK);
|
|
155
|
-
return fs.statSync(p).isFile();
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
if (command.includes(path.sep) || command.includes("/"))
|
|
162
|
-
return ok(command) ? command : undefined;
|
|
163
|
-
for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
164
|
-
const p = path.join(dir, command);
|
|
165
|
-
if (ok(p))
|
|
166
|
-
return p;
|
|
167
|
-
}
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
150
|
// ─── Process registry (crash cleanup) ────────────────────────────
|
|
171
151
|
const liveChildren = new Set();
|
|
172
152
|
let exitHookInstalled = false;
|
|
@@ -207,6 +187,7 @@ export class AcpHarness {
|
|
|
207
187
|
envPassthrough;
|
|
208
188
|
authVars;
|
|
209
189
|
envScope;
|
|
190
|
+
envOverlay;
|
|
210
191
|
sandboxMode;
|
|
211
192
|
sandboxWrapper;
|
|
212
193
|
policyMode;
|
|
@@ -238,6 +219,7 @@ export class AcpHarness {
|
|
|
238
219
|
this.envPassthrough = opts.envPassthrough;
|
|
239
220
|
this.authVars = opts.authVars ?? [];
|
|
240
221
|
this.envScope = opts.envScope;
|
|
222
|
+
this.envOverlay = agentEnvOverlay(opts);
|
|
241
223
|
this.sandboxMode = opts.sandbox;
|
|
242
224
|
this.sandboxWrapper = opts.sandboxWrapper;
|
|
243
225
|
this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
|
|
@@ -256,7 +238,7 @@ export class AcpHarness {
|
|
|
256
238
|
* first run (`agentInfo.version`).
|
|
257
239
|
*/
|
|
258
240
|
async preflight() {
|
|
259
|
-
const found =
|
|
241
|
+
const found = resolveExecutable(this.agent.command, overlaidProcessEnv(this.envOverlay, this.authVars), this.cwd);
|
|
260
242
|
if (!found) {
|
|
261
243
|
const example = [this.agent.command, ...this.agent.args].join(" ");
|
|
262
244
|
return {
|
|
@@ -271,6 +253,8 @@ export class AcpHarness {
|
|
|
271
253
|
* vars the operator named. Skill credentials are dropped either way
|
|
272
254
|
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
273
255
|
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
256
|
+
* The caller's `envRemove` / `env` are applied after scoping
|
|
257
|
+
* ({@link applyEnvOverlay}), before that credential rule.
|
|
274
258
|
*/
|
|
275
259
|
buildEnv(access) {
|
|
276
260
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
@@ -287,6 +271,7 @@ export class AcpHarness {
|
|
|
287
271
|
reportWithheldEnv(scoped.withheld, this.logger);
|
|
288
272
|
env = scoped.env;
|
|
289
273
|
}
|
|
274
|
+
env = applyEnvOverlay(env, this.envOverlay, this.authVars);
|
|
290
275
|
return finishAgentEnv(env, { access, keep: this.authVars, passthrough, logger: this.logger }).env;
|
|
291
276
|
}
|
|
292
277
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
package/dist/harness/auth.js
CHANGED
|
@@ -30,7 +30,8 @@ function truthy(v) {
|
|
|
30
30
|
*/
|
|
31
31
|
export function detectClaudeCodeLogin(opts = {}) {
|
|
32
32
|
const env = opts.env ?? process.env;
|
|
33
|
-
|
|
33
|
+
// The env's HOME, not the process's: a harness `env` overlay may give the run its own.
|
|
34
|
+
const home = opts.home ?? (env.HOME || os.homedir());
|
|
34
35
|
const platform = opts.platform ?? process.platform;
|
|
35
36
|
const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
36
37
|
try {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* is to use headless Claude Code - never the raw Anthropic API.
|
|
10
10
|
*/
|
|
11
11
|
import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
12
|
-
import { type SandboxMode } from "../agent-env.js";
|
|
12
|
+
import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
|
|
13
13
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, ToolClass } from "./types.js";
|
|
14
14
|
import { type HarnessPolicyMode } from "./policy.js";
|
|
15
15
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
@@ -108,7 +108,7 @@ export interface ResolveAuthEnvOpts {
|
|
|
108
108
|
* Pure: returns a copy, never mutates the input.
|
|
109
109
|
*/
|
|
110
110
|
export declare function resolveAuthEnv(env: Record<string, string>, opts?: ResolveAuthEnvOpts): Record<string, string>;
|
|
111
|
-
export interface ClaudeCodeHarnessOptions {
|
|
111
|
+
export interface ClaudeCodeHarnessOptions extends HarnessEnvOptions {
|
|
112
112
|
/** Model override */
|
|
113
113
|
model?: string;
|
|
114
114
|
/** Max turns for tool use loop (default: 20) */
|
|
@@ -144,7 +144,7 @@ export interface ClaudeCodeHarnessOptions {
|
|
|
144
144
|
sandboxAllowedDomains?: string[];
|
|
145
145
|
/** Sandbox preflight probe (test seam). Returns a reason when the sandbox cannot run. */
|
|
146
146
|
sandboxProbe?: () => string | undefined;
|
|
147
|
-
/** Login probe for `preflight()` (test seam). Default: {@link claudeCodeAuth} over `process.env
|
|
147
|
+
/** Login probe for `preflight()` (test seam). Default: {@link claudeCodeAuth} over `process.env` with `env` / `envRemove` applied. */
|
|
148
148
|
authProbe?: AuthProbe;
|
|
149
149
|
/**
|
|
150
150
|
* Serve skill tools through the SwenyToolBridge (a stdio MCP shim over a
|
|
@@ -184,6 +184,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
184
184
|
private sandboxProbe;
|
|
185
185
|
private sandboxWarned;
|
|
186
186
|
private envScope;
|
|
187
|
+
private envOverlay;
|
|
187
188
|
/** Floor facts from the latest runQuery, read by run() for the receipt. */
|
|
188
189
|
private lastPolicyFacts;
|
|
189
190
|
private toolBridge;
|
|
@@ -192,15 +193,17 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
192
193
|
private policyMode;
|
|
193
194
|
constructor(opts?: ClaudeCodeHarnessOptions);
|
|
194
195
|
/**
|
|
195
|
-
* Build env for the Claude Code subprocess (#360).
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
196
|
+
* Build env for the Claude Code subprocess (#360). When env scoping is on
|
|
197
|
+
* (default in CI, see {@link resolveEnvScope}) the process env is narrowed
|
|
198
|
+
* to the allowlist plus `extraVars` (the node's declared skill env vars)
|
|
199
|
+
* and the operator passthrough list, and the withheld names (never values)
|
|
200
|
+
* are reported once per process ({@link reportWithheldEnv}). When off, the
|
|
201
|
+
* full env passes through as before. Then the caller's `envRemove` / `env`
|
|
202
|
+
* ({@link applyEnvOverlay}; overlaid auth replaces the inherited auth), then
|
|
203
|
+
* auth precedence ({@link resolveAuthEnv}, mode from `process.env.SWENY_AUTH`),
|
|
204
|
+
* then skill credentials are dropped ({@link finishAgentEnv}), overlaid ones
|
|
205
|
+
* included: only the names the node granted with `agent_env` reach the agent.
|
|
206
|
+
* `process.env` is never modified.
|
|
204
207
|
*/
|
|
205
208
|
private buildEnv;
|
|
206
209
|
/** Which harness (and SDK version) produced a result. */
|
|
@@ -12,7 +12,7 @@ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude
|
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { realpathSync } from "node:fs";
|
|
14
14
|
import { consoleLogger } from "../types.js";
|
|
15
|
-
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
|
+
import { AGENT_AUTH_VARS, agentEnvOverlay, applyEnvOverlay, finishAgentEnv, overlaidProcessEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
|
|
16
16
|
import { gitCredentialGap, policyGate } from "./policy.js";
|
|
17
17
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
18
18
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
@@ -262,6 +262,7 @@ export class ClaudeCodeHarness {
|
|
|
262
262
|
sandboxProbe;
|
|
263
263
|
sandboxWarned = false;
|
|
264
264
|
envScope;
|
|
265
|
+
envOverlay;
|
|
265
266
|
/** Floor facts from the latest runQuery, read by run() for the receipt. */
|
|
266
267
|
lastPolicyFacts;
|
|
267
268
|
toolBridge;
|
|
@@ -278,35 +279,47 @@ export class ClaudeCodeHarness {
|
|
|
278
279
|
this.defaultMcpServers = opts.defaultMcpServers ?? {};
|
|
279
280
|
this.envPassthrough = opts.envPassthrough;
|
|
280
281
|
this.envScope = opts.envScope;
|
|
282
|
+
this.envOverlay = agentEnvOverlay(opts);
|
|
281
283
|
this.sandboxMode = opts.sandbox;
|
|
282
284
|
this.sandboxAllowedDomains = opts.sandboxAllowedDomains;
|
|
283
285
|
this.sandboxProbe = opts.sandboxProbe;
|
|
284
286
|
this.toolBridge = opts.toolBridge ?? process.env.SWENY_TOOL_BRIDGE === "1";
|
|
285
287
|
this.toolBridgeShim = opts.toolBridgeShim;
|
|
286
|
-
this.authProbe = opts.authProbe ?? (() => claudeCodeAuth(
|
|
288
|
+
this.authProbe = opts.authProbe ?? (() => claudeCodeAuth(overlaidProcessEnv(this.envOverlay, AGENT_AUTH_VARS)));
|
|
287
289
|
this.policyMode = opts.policy ?? "warn";
|
|
288
290
|
}
|
|
289
291
|
/**
|
|
290
|
-
* Build env for the Claude Code subprocess (#360).
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
292
|
+
* Build env for the Claude Code subprocess (#360). When env scoping is on
|
|
293
|
+
* (default in CI, see {@link resolveEnvScope}) the process env is narrowed
|
|
294
|
+
* to the allowlist plus `extraVars` (the node's declared skill env vars)
|
|
295
|
+
* and the operator passthrough list, and the withheld names (never values)
|
|
296
|
+
* are reported once per process ({@link reportWithheldEnv}). When off, the
|
|
297
|
+
* full env passes through as before. Then the caller's `envRemove` / `env`
|
|
298
|
+
* ({@link applyEnvOverlay}; overlaid auth replaces the inherited auth), then
|
|
299
|
+
* auth precedence ({@link resolveAuthEnv}, mode from `process.env.SWENY_AUTH`),
|
|
300
|
+
* then skill credentials are dropped ({@link finishAgentEnv}), overlaid ones
|
|
301
|
+
* included: only the names the node granted with `agent_env` reach the agent.
|
|
302
|
+
* `process.env` is never modified.
|
|
299
303
|
*/
|
|
300
304
|
buildEnv(access) {
|
|
301
305
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
302
|
-
const authed = resolveAuthEnv(full, { logger: this.logger });
|
|
303
306
|
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
304
|
-
let env =
|
|
307
|
+
let env = full;
|
|
305
308
|
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
306
|
-
const scoped = scopeAgentEnv(
|
|
309
|
+
const scoped = scopeAgentEnv(full, {
|
|
310
|
+
extraVars: access?.envVars ?? [],
|
|
311
|
+
passthrough,
|
|
312
|
+
// An overlaid CLAUDE_CODE_USE_BEDROCK / _VERTEX decides which cloud vars are kept.
|
|
313
|
+
routing: this.envOverlay ? overlaidProcessEnv(this.envOverlay, AGENT_AUTH_VARS) : undefined,
|
|
314
|
+
logger: this.logger,
|
|
315
|
+
});
|
|
307
316
|
reportWithheldEnv(scoped.withheld, this.logger);
|
|
308
317
|
env = scoped.env;
|
|
309
318
|
}
|
|
319
|
+
env = applyEnvOverlay(env, this.envOverlay, AGENT_AUTH_VARS);
|
|
320
|
+
// Scoping keeps every ANTHROPIC_* / CLAUDE_* name, so auth resolves the same after it. The mode
|
|
321
|
+
// comes from process.env: an overlaid SWENY_AUTH reaches the agent, not sweny ("" reads as auto).
|
|
322
|
+
env = resolveAuthEnv(env, { mode: (process.env.SWENY_AUTH ?? ""), logger: this.logger });
|
|
310
323
|
return finishAgentEnv(env, { access, keep: AGENT_AUTH_VARS, passthrough, logger: this.logger }).env;
|
|
311
324
|
}
|
|
312
325
|
/** Which harness (and SDK version) produced a result. */
|
|
@@ -401,6 +414,9 @@ export class ClaudeCodeHarness {
|
|
|
401
414
|
// `strict` fails the node here instead.
|
|
402
415
|
const sandbox = resolveAgentSandbox({
|
|
403
416
|
env: process.env,
|
|
417
|
+
// A `HOME` / `CLAUDE_CONFIG_DIR` overlay selects another login file; deny it too.
|
|
418
|
+
agentEnv: overlaidProcessEnv(this.envOverlay, AGENT_AUTH_VARS),
|
|
419
|
+
cwd: this.cwd,
|
|
404
420
|
mode: opts.sandboxMode ?? this.sandboxMode,
|
|
405
421
|
allowedDomains: this.sandboxAllowedDomains,
|
|
406
422
|
nodeDomains: agentAccess?.domains,
|
package/dist/harness/codex.d.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* by `policyGate`. Nothing is dropped silently.
|
|
25
25
|
*/
|
|
26
26
|
import type { Logger, McpServerConfig, ToolContext } from "../types.js";
|
|
27
|
-
import { type SandboxMode } from "../agent-env.js";
|
|
27
|
+
import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
|
|
28
28
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
|
|
29
29
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
30
30
|
import { type HarnessPolicyMode } from "./policy.js";
|
|
@@ -52,7 +52,7 @@ export declare const CODEX_ISOLATION_FEATURES_OFF: readonly ["apps", "plugins",
|
|
|
52
52
|
* through the sandbox wrapper, plus the host of `OPENAI_BASE_URL` when set.
|
|
53
53
|
*/
|
|
54
54
|
export declare function codexBackendHosts(env?: Record<string, string | undefined>): string[];
|
|
55
|
-
export interface CodexHarnessOptions {
|
|
55
|
+
export interface CodexHarnessOptions extends HarnessEnvOptions {
|
|
56
56
|
/** Model override (`--model`). Free text; sweny has no model opinion. */
|
|
57
57
|
model?: string;
|
|
58
58
|
/** Tool-call budget the watchdog enforces when a node sets no `max_turns` (default: 20). */
|
|
@@ -97,7 +97,7 @@ export interface CodexHarnessOptions {
|
|
|
97
97
|
};
|
|
98
98
|
/** Grace period between SIGTERM and SIGKILL when stopping Codex (default: 2000 ms). */
|
|
99
99
|
killGraceMs?: number;
|
|
100
|
-
/** Login probe for `preflight()` (test seam). Default: {@link codexAuth} over `process.env
|
|
100
|
+
/** Login probe for `preflight()` (test seam). Default: {@link codexAuth} over `process.env` with `env` / `envRemove` applied. */
|
|
101
101
|
authProbe?: AuthProbe;
|
|
102
102
|
}
|
|
103
103
|
/** A TOML value for `-c key=value`, as the official SDK writes it (sdk/typescript/src/exec.ts). */
|
|
@@ -134,6 +134,7 @@ export declare class CodexHarness implements AgentHarness {
|
|
|
134
134
|
private defaultMcpServers;
|
|
135
135
|
private envPassthrough;
|
|
136
136
|
private envScope;
|
|
137
|
+
private envOverlay;
|
|
137
138
|
private sandboxMode;
|
|
138
139
|
private sandboxProbe;
|
|
139
140
|
private sandboxWarned;
|
|
@@ -141,6 +142,7 @@ export declare class CodexHarness implements AgentHarness {
|
|
|
141
142
|
private loginWarned;
|
|
142
143
|
private policyMode;
|
|
143
144
|
private codexCommand;
|
|
145
|
+
private resolvedCommand;
|
|
144
146
|
private toolBridgeShim;
|
|
145
147
|
private killGraceMs;
|
|
146
148
|
private preflightResult;
|
|
@@ -159,12 +161,27 @@ export declare class CodexHarness implements AgentHarness {
|
|
|
159
161
|
ok: false;
|
|
160
162
|
reason: string;
|
|
161
163
|
}>;
|
|
164
|
+
/**
|
|
165
|
+
* `process.env` with the caller's `env` / `envRemove` applied, unscoped: where
|
|
166
|
+
* sweny reads Codex's own settings (`OPENAI_BASE_URL`) and the login probe.
|
|
167
|
+
* The scoped agent env would drop an inherited base URL.
|
|
168
|
+
*/
|
|
169
|
+
private hostEnv;
|
|
170
|
+
/**
|
|
171
|
+
* {@link hostEnv} as the login probe should see it: an inherited
|
|
172
|
+
* OPENAI_API_KEY does not count once `envRemove` names CODEX_API_KEY, since
|
|
173
|
+
* the agent env no longer derives CODEX_API_KEY from it.
|
|
174
|
+
*/
|
|
175
|
+
private loginEnv;
|
|
176
|
+
/** The Codex executable to spawn: the one the version check resolved and ran. */
|
|
177
|
+
private cliPath;
|
|
162
178
|
/** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
|
|
163
179
|
private checkCli;
|
|
164
180
|
/**
|
|
165
181
|
* Env for the Codex process: scoped like Claude Code's, with Codex's auth
|
|
166
|
-
* vars instead of Anthropic's
|
|
167
|
-
* ({@link
|
|
182
|
+
* vars instead of Anthropic's, then the caller's `envRemove` / `env`
|
|
183
|
+
* ({@link applyEnvOverlay}). Skill credentials are dropped either way, overlaid
|
|
184
|
+
* ones included ({@link finishAgentEnv}), unless the node granted them with `agent_env`.
|
|
168
185
|
*/
|
|
169
186
|
private buildEnv;
|
|
170
187
|
/** The node policy after `disallowed_tools` names are translated into Codex classes. */
|
package/dist/harness/codex.js
CHANGED
|
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
|
|
|
28
28
|
import * as os from "node:os";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { consoleLogger } from "../types.js";
|
|
31
|
-
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
31
|
+
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, agentEnvOverlay, applyEnvOverlay, checkSandboxSupport, finishAgentEnv, resolveExecutable, overlaidProcessEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
32
32
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
33
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
34
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -40,6 +40,13 @@ import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandb
|
|
|
40
40
|
import { TOKEN_ENV } from "./tool-bridge/protocol.js";
|
|
41
41
|
import { codexAuth } from "./auth.js";
|
|
42
42
|
export { CODEX_CAPABILITIES };
|
|
43
|
+
/**
|
|
44
|
+
* The credentials an `env` overlay replaces as a set ({@link applyEnvOverlay}):
|
|
45
|
+
* Codex's API key and access token, and `OPENAI_API_KEY`, which stands in for
|
|
46
|
+
* `CODEX_API_KEY` ({@link resolveCodexAuthEnv}). `CODEX_HOME` is a location,
|
|
47
|
+
* not a credential: overlaying it does not drop an inherited key.
|
|
48
|
+
*/
|
|
49
|
+
const CODEX_CREDENTIAL_VARS = ["CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_API_KEY"];
|
|
43
50
|
/** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
|
|
44
51
|
export const MIN_CODEX_VERSION = "0.159.0";
|
|
45
52
|
/**
|
|
@@ -286,6 +293,7 @@ export class CodexHarness {
|
|
|
286
293
|
defaultMcpServers;
|
|
287
294
|
envPassthrough;
|
|
288
295
|
envScope;
|
|
296
|
+
envOverlay;
|
|
289
297
|
sandboxMode;
|
|
290
298
|
sandboxProbe;
|
|
291
299
|
sandboxWarned = false;
|
|
@@ -293,6 +301,7 @@ export class CodexHarness {
|
|
|
293
301
|
loginWarned = false;
|
|
294
302
|
policyMode;
|
|
295
303
|
codexCommand;
|
|
304
|
+
resolvedCommand;
|
|
296
305
|
toolBridgeShim;
|
|
297
306
|
killGraceMs;
|
|
298
307
|
preflightResult;
|
|
@@ -308,6 +317,7 @@ export class CodexHarness {
|
|
|
308
317
|
this.defaultMcpServers = opts.defaultMcpServers ?? {};
|
|
309
318
|
this.envPassthrough = opts.envPassthrough;
|
|
310
319
|
this.envScope = opts.envScope;
|
|
320
|
+
this.envOverlay = agentEnvOverlay(opts);
|
|
311
321
|
this.sandboxMode = opts.sandbox;
|
|
312
322
|
this.sandboxProbe = opts.sandboxProbe;
|
|
313
323
|
this.sandboxWrapper = opts.sandboxWrapper;
|
|
@@ -315,7 +325,7 @@ export class CodexHarness {
|
|
|
315
325
|
this.codexCommand = opts.codexCommand ?? { command: process.env.SWENY_CODEX_PATH || "codex", args: [] };
|
|
316
326
|
this.toolBridgeShim = opts.toolBridgeShim;
|
|
317
327
|
this.killGraceMs = opts.killGraceMs ?? 2000;
|
|
318
|
-
this.authProbe = opts.authProbe ?? (() => codexAuth(
|
|
328
|
+
this.authProbe = opts.authProbe ?? (() => codexAuth(this.loginEnv()));
|
|
319
329
|
}
|
|
320
330
|
info() {
|
|
321
331
|
return { id: this.id, version: this.version };
|
|
@@ -331,11 +341,40 @@ export class CodexHarness {
|
|
|
331
341
|
const auth = this.authProbe();
|
|
332
342
|
return auth.ok ? cli : { ok: false, reason: auth.reason };
|
|
333
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* `process.env` with the caller's `env` / `envRemove` applied, unscoped: where
|
|
346
|
+
* sweny reads Codex's own settings (`OPENAI_BASE_URL`) and the login probe.
|
|
347
|
+
* The scoped agent env would drop an inherited base URL.
|
|
348
|
+
*/
|
|
349
|
+
hostEnv() {
|
|
350
|
+
return overlaidProcessEnv(this.envOverlay, CODEX_CREDENTIAL_VARS);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* {@link hostEnv} as the login probe should see it: an inherited
|
|
354
|
+
* OPENAI_API_KEY does not count once `envRemove` names CODEX_API_KEY, since
|
|
355
|
+
* the agent env no longer derives CODEX_API_KEY from it.
|
|
356
|
+
*/
|
|
357
|
+
loginEnv() {
|
|
358
|
+
const env = this.hostEnv();
|
|
359
|
+
const overlay = this.envOverlay;
|
|
360
|
+
if (overlay?.remove.includes("CODEX_API_KEY") && !Object.hasOwn(overlay.env, "OPENAI_API_KEY")) {
|
|
361
|
+
delete env.OPENAI_API_KEY;
|
|
362
|
+
}
|
|
363
|
+
return env;
|
|
364
|
+
}
|
|
365
|
+
/** The Codex executable to spawn: the one the version check resolved and ran. */
|
|
366
|
+
cliPath() {
|
|
367
|
+
return this.resolvedCommand ?? this.codexCommand.command;
|
|
368
|
+
}
|
|
334
369
|
/** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
|
|
335
370
|
checkCli() {
|
|
336
371
|
this.preflightResult ??= new Promise((resolve) => {
|
|
337
372
|
const { command, args } = this.codexCommand;
|
|
338
|
-
|
|
373
|
+
// Probe the file the spawn will run: resolved on the run's PATH and cwd, then reused at spawn.
|
|
374
|
+
const hostEnv = this.hostEnv();
|
|
375
|
+
this.resolvedCommand = resolveExecutable(command, hostEnv, this.cwd);
|
|
376
|
+
const opts = { timeout: 30_000, cwd: this.cwd, env: hostEnv };
|
|
377
|
+
execFile(this.resolvedCommand ?? command, [...args, "--version"], opts, (err, stdout) => {
|
|
339
378
|
if (err) {
|
|
340
379
|
const code = err.code;
|
|
341
380
|
resolve({
|
|
@@ -370,11 +409,16 @@ export class CodexHarness {
|
|
|
370
409
|
}
|
|
371
410
|
/**
|
|
372
411
|
* Env for the Codex process: scoped like Claude Code's, with Codex's auth
|
|
373
|
-
* vars instead of Anthropic's
|
|
374
|
-
* ({@link
|
|
412
|
+
* vars instead of Anthropic's, then the caller's `envRemove` / `env`
|
|
413
|
+
* ({@link applyEnvOverlay}). Skill credentials are dropped either way, overlaid
|
|
414
|
+
* ones included ({@link finishAgentEnv}), unless the node granted them with `agent_env`.
|
|
375
415
|
*/
|
|
376
416
|
buildEnv(access) {
|
|
377
|
-
const
|
|
417
|
+
const overlay = this.envOverlay;
|
|
418
|
+
const removed = new Set(overlay?.remove ?? []);
|
|
419
|
+
// `envRemove` goes first, before the OPENAI_API_KEY alias, so a removed OPENAI_API_KEY is not
|
|
420
|
+
// copied; a removed CODEX_API_KEY made by the alias is dropped again with the overlay below.
|
|
421
|
+
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null && !removed.has(e[0])));
|
|
378
422
|
const authed = resolveCodexAuthEnv(full);
|
|
379
423
|
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
380
424
|
let env = authed;
|
|
@@ -389,6 +433,16 @@ export class CodexHarness {
|
|
|
389
433
|
reportWithheldEnv(scoped.withheld, this.logger);
|
|
390
434
|
env = scoped.env;
|
|
391
435
|
}
|
|
436
|
+
if (overlay) {
|
|
437
|
+
env = applyEnvOverlay(env, overlay, CODEX_CREDENTIAL_VARS);
|
|
438
|
+
if (CODEX_CREDENTIAL_VARS.some((c) => Object.hasOwn(overlay.env, c))) {
|
|
439
|
+
// An overlaid OPENAI_API_KEY stands in for CODEX_API_KEY like an inherited one, and is then
|
|
440
|
+
// dropped, as scoping drops an inherited one: Codex reads only CODEX_API_KEY.
|
|
441
|
+
if (!env.CODEX_API_KEY && overlay.env.OPENAI_API_KEY)
|
|
442
|
+
env.CODEX_API_KEY = overlay.env.OPENAI_API_KEY;
|
|
443
|
+
delete env.OPENAI_API_KEY;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
392
446
|
return finishAgentEnv(env, { access, keep: CODEX_AUTH_VARS, passthrough, logger: this.logger }).env;
|
|
393
447
|
}
|
|
394
448
|
/** The node policy after `disallowed_tools` names are translated into Codex classes. */
|
|
@@ -466,7 +520,7 @@ export class CodexHarness {
|
|
|
466
520
|
if (opts.sandbox === "workspace-write") {
|
|
467
521
|
args.push(...cfg("sandbox_workspace_write.network_access", opts.network));
|
|
468
522
|
}
|
|
469
|
-
const baseUrl =
|
|
523
|
+
const baseUrl = this.hostEnv().OPENAI_BASE_URL;
|
|
470
524
|
if (baseUrl)
|
|
471
525
|
args.push(...cfg("openai_base_url", baseUrl));
|
|
472
526
|
return args;
|
|
@@ -866,9 +920,9 @@ export class CodexHarness {
|
|
|
866
920
|
const prep = await prepareAgentSpawn({
|
|
867
921
|
caps: this.capabilities,
|
|
868
922
|
policy,
|
|
869
|
-
spawn: { command: this.
|
|
923
|
+
spawn: { command: this.cliPath(), args: argsFor(withSchema), env, cwd: this.cwd },
|
|
870
924
|
wrapper: wrapper ?? null,
|
|
871
|
-
harnessEgress: codexBackendHosts(),
|
|
925
|
+
harnessEgress: codexBackendHosts(this.hostEnv()),
|
|
872
926
|
env: process.env,
|
|
873
927
|
});
|
|
874
928
|
degraded = prep.degraded;
|
|
@@ -994,7 +1048,7 @@ export class CodexHarness {
|
|
|
994
1048
|
"-",
|
|
995
1049
|
];
|
|
996
1050
|
const out = await this.exec({
|
|
997
|
-
spawn: { command: this.
|
|
1051
|
+
spawn: { command: this.cliPath(), args, env: this.buildEnv(), cwd: this.cwd },
|
|
998
1052
|
prompt: req.prompt,
|
|
999
1053
|
timeoutMs: req.timeoutMs,
|
|
1000
1054
|
signal: req.signal,
|
package/dist/harness/pi.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `policyGate`. Nothing is dropped silently.
|
|
30
30
|
*/
|
|
31
31
|
import type { Logger, McpServerConfig, NodeUsage, ToolContext, Tool } from "../types.js";
|
|
32
|
-
import { type SandboxMode } from "../agent-env.js";
|
|
32
|
+
import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
|
|
33
33
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
|
|
34
34
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
35
35
|
import { type HarnessPolicyMode } from "./policy.js";
|
|
@@ -49,7 +49,7 @@ export declare const PI_BRIDGE_SERVER = "sweny-core";
|
|
|
49
49
|
* providers) are added with `SWENY_SANDBOX_ALLOWED_DOMAINS`.
|
|
50
50
|
*/
|
|
51
51
|
export declare function piBackendHosts(env?: Record<string, string | undefined>, model?: string): string[];
|
|
52
|
-
export interface PiHarnessOptions {
|
|
52
|
+
export interface PiHarnessOptions extends HarnessEnvOptions {
|
|
53
53
|
/** Model passed to `--model`. Free text (`provider/id`, `id:thinking`); sweny has no model opinion. */
|
|
54
54
|
model?: string;
|
|
55
55
|
/**
|
|
@@ -160,10 +160,12 @@ export declare class PiHarness implements AgentHarness {
|
|
|
160
160
|
private defaultMcpServers;
|
|
161
161
|
private envPassthrough;
|
|
162
162
|
private envScope;
|
|
163
|
+
private envOverlay;
|
|
163
164
|
private sandboxMode;
|
|
164
165
|
private sandboxWrapper;
|
|
165
166
|
private policyMode;
|
|
166
167
|
private piCommand;
|
|
168
|
+
private resolvedCommand;
|
|
167
169
|
private modelsFile;
|
|
168
170
|
private toolBridgeShim;
|
|
169
171
|
private abortGraceMs;
|
|
@@ -174,6 +176,8 @@ export declare class PiHarness implements AgentHarness {
|
|
|
174
176
|
private version;
|
|
175
177
|
constructor(opts?: PiHarnessOptions);
|
|
176
178
|
info(): HarnessInfo;
|
|
179
|
+
/** The pi executable to spawn: the one the version check resolved and ran. */
|
|
180
|
+
private cliPath;
|
|
177
181
|
/** `pi --version`, checked against {@link MIN_PI_VERSION}. Cached per instance. */
|
|
178
182
|
preflight(): Promise<{
|
|
179
183
|
ok: true;
|
|
@@ -182,14 +186,16 @@ export declare class PiHarness implements AgentHarness {
|
|
|
182
186
|
ok: false;
|
|
183
187
|
reason: string;
|
|
184
188
|
}>;
|
|
185
|
-
/** Which provider's credential this call gets ({@link resolvePiProvider}). */
|
|
189
|
+
/** Which provider's credential this call gets ({@link resolvePiProvider}), over the env with the overlay applied. */
|
|
186
190
|
private resolveProvider;
|
|
187
191
|
/**
|
|
188
192
|
* Env for the pi process: scoped like Claude Code's, with ONE provider's
|
|
189
193
|
* credentials instead of Anthropic's (`providerVars`, from
|
|
190
194
|
* {@link resolvePiProvider}). Every other provider key is dropped, scoped or
|
|
191
|
-
* not, since pi's bash tool inherits this env.
|
|
192
|
-
*
|
|
195
|
+
* not, since pi's bash tool inherits this env. The caller's `envRemove` /
|
|
196
|
+
* `env` are applied after scoping ({@link applyEnvOverlay}), so the same rules
|
|
197
|
+
* hold for overlaid names. Skill credentials are dropped too, overlaid ones
|
|
198
|
+
* included ({@link finishAgentEnv}), unless the node granted them with `agent_env`.
|
|
193
199
|
*/
|
|
194
200
|
private buildEnv;
|
|
195
201
|
/** The node policy after `disallowed_tools` names are translated into pi classes. */
|
package/dist/harness/pi.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
|
|
|
34
34
|
import * as os from "node:os";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { consoleLogger } from "../types.js";
|
|
37
|
-
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
37
|
+
import { PI_AUTH_VARS, PI_ENV_PREFIXES, PI_NON_SECRET_VARS, agentEnvOverlay, applyEnvOverlay, overlaidProcessEnv, resolveExecutable, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
38
38
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
39
39
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
40
40
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -45,6 +45,8 @@ import { startToolBridge } from "./tool-bridge/server.js";
|
|
|
45
45
|
import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
|
|
46
46
|
import { TOKEN_ENV } from "./tool-bridge/protocol.js";
|
|
47
47
|
export { PI_CAPABILITIES };
|
|
48
|
+
/** The provider secrets an `env` overlay replaces as a set ({@link applyEnvOverlay}); region and project settings are not among them. */
|
|
49
|
+
const PI_CREDENTIAL_VARS = PI_AUTH_VARS.filter((v) => !PI_NON_SECRET_VARS.has(v));
|
|
48
50
|
/**
|
|
49
51
|
* Oldest pi this adapter is written against. The docs and source were read at
|
|
50
52
|
* v0.99.2; older releases are unverified, so they are refused.
|
|
@@ -323,10 +325,12 @@ export class PiHarness {
|
|
|
323
325
|
defaultMcpServers;
|
|
324
326
|
envPassthrough;
|
|
325
327
|
envScope;
|
|
328
|
+
envOverlay;
|
|
326
329
|
sandboxMode;
|
|
327
330
|
sandboxWrapper;
|
|
328
331
|
policyMode;
|
|
329
332
|
piCommand;
|
|
333
|
+
resolvedCommand;
|
|
330
334
|
modelsFile;
|
|
331
335
|
toolBridgeShim;
|
|
332
336
|
abortGraceMs;
|
|
@@ -346,6 +350,7 @@ export class PiHarness {
|
|
|
346
350
|
this.defaultMcpServers = opts.defaultMcpServers ?? {};
|
|
347
351
|
this.envPassthrough = opts.envPassthrough;
|
|
348
352
|
this.envScope = opts.envScope;
|
|
353
|
+
this.envOverlay = agentEnvOverlay(opts);
|
|
349
354
|
this.sandboxMode = opts.sandbox;
|
|
350
355
|
this.sandboxWrapper = opts.sandboxWrapper;
|
|
351
356
|
this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
|
|
@@ -359,11 +364,19 @@ export class PiHarness {
|
|
|
359
364
|
info() {
|
|
360
365
|
return { id: this.id, version: this.version };
|
|
361
366
|
}
|
|
367
|
+
/** The pi executable to spawn: the one the version check resolved and ran. */
|
|
368
|
+
cliPath() {
|
|
369
|
+
return this.resolvedCommand ?? this.piCommand.command;
|
|
370
|
+
}
|
|
362
371
|
/** `pi --version`, checked against {@link MIN_PI_VERSION}. Cached per instance. */
|
|
363
372
|
preflight() {
|
|
364
373
|
this.preflightResult ??= new Promise((resolve) => {
|
|
365
374
|
const { command, args } = this.piCommand;
|
|
366
|
-
|
|
375
|
+
// Probe the file the spawn will run: resolved on the run's PATH and cwd, then reused at spawn.
|
|
376
|
+
const hostEnv = overlaidProcessEnv(this.envOverlay, PI_CREDENTIAL_VARS);
|
|
377
|
+
this.resolvedCommand = resolveExecutable(command, hostEnv, this.cwd);
|
|
378
|
+
const opts = { timeout: 30_000, cwd: this.cwd, env: hostEnv };
|
|
379
|
+
execFile(this.resolvedCommand ?? command, [...args, "--version"], opts, (err, stdout, stderr) => {
|
|
367
380
|
if (err) {
|
|
368
381
|
const code = err.code;
|
|
369
382
|
resolve({
|
|
@@ -390,16 +403,18 @@ export class PiHarness {
|
|
|
390
403
|
});
|
|
391
404
|
return this.preflightResult;
|
|
392
405
|
}
|
|
393
|
-
/** Which provider's credential this call gets ({@link resolvePiProvider}). */
|
|
406
|
+
/** Which provider's credential this call gets ({@link resolvePiProvider}), over the env with the overlay applied. */
|
|
394
407
|
resolveProvider(model) {
|
|
395
|
-
return resolvePiProvider(
|
|
408
|
+
return resolvePiProvider(overlaidProcessEnv(this.envOverlay, PI_CREDENTIAL_VARS), model, this.provider);
|
|
396
409
|
}
|
|
397
410
|
/**
|
|
398
411
|
* Env for the pi process: scoped like Claude Code's, with ONE provider's
|
|
399
412
|
* credentials instead of Anthropic's (`providerVars`, from
|
|
400
413
|
* {@link resolvePiProvider}). Every other provider key is dropped, scoped or
|
|
401
|
-
* not, since pi's bash tool inherits this env.
|
|
402
|
-
*
|
|
414
|
+
* not, since pi's bash tool inherits this env. The caller's `envRemove` /
|
|
415
|
+
* `env` are applied after scoping ({@link applyEnvOverlay}), so the same rules
|
|
416
|
+
* hold for overlaid names. Skill credentials are dropped too, overlaid ones
|
|
417
|
+
* included ({@link finishAgentEnv}), unless the node granted them with `agent_env`.
|
|
403
418
|
*/
|
|
404
419
|
buildEnv(access, providerVars) {
|
|
405
420
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
@@ -416,6 +431,7 @@ export class PiHarness {
|
|
|
416
431
|
reportWithheldEnv(scoped.withheld, this.logger);
|
|
417
432
|
env = scoped.env;
|
|
418
433
|
}
|
|
434
|
+
env = applyEnvOverlay(env, this.envOverlay, PI_CREDENTIAL_VARS);
|
|
419
435
|
// Other providers' keys never reach pi, even when scoping is off, passed through or granted.
|
|
420
436
|
const otherProviders = PI_AUTH_VARS.filter((v) => !providerVars.includes(v));
|
|
421
437
|
env = Object.fromEntries(Object.entries(env).filter(([k]) => !otherProviders.includes(k)));
|
|
@@ -860,7 +876,7 @@ export class PiHarness {
|
|
|
860
876
|
const prep = await prepareAgentSpawn({
|
|
861
877
|
caps: this.capabilities,
|
|
862
878
|
policy,
|
|
863
|
-
spawn: { command: this.
|
|
879
|
+
spawn: { command: this.cliPath(), args, env, cwd: this.cwd },
|
|
864
880
|
wrapper: wrapper ?? null,
|
|
865
881
|
harnessEgress,
|
|
866
882
|
env: process.env,
|
|
@@ -979,7 +995,7 @@ export class PiHarness {
|
|
|
979
995
|
const env = { ...this.buildEnv(undefined, provider.vars), PI_CODING_AGENT_DIR: agentDir };
|
|
980
996
|
const out = await this.exec({
|
|
981
997
|
spawn: {
|
|
982
|
-
command: this.
|
|
998
|
+
command: this.cliPath(),
|
|
983
999
|
args: [...this.baseArgs(model, false, provider.explicit ? provider.provider : undefined), "--no-tools"],
|
|
984
1000
|
env,
|
|
985
1001
|
cwd: this.cwd,
|
|
@@ -107,11 +107,22 @@ export interface SrtSettings {
|
|
|
107
107
|
* each with an optional `:port`. Dropping an entry only narrows egress.
|
|
108
108
|
*/
|
|
109
109
|
export declare function isSrtDomainPattern(entry: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Credential files an agent env points at outside `credentialHome`: the
|
|
112
|
+
* {@link CREDENTIAL_PATHS} under its `HOME` when that is another directory (a
|
|
113
|
+
* harness `env` overlay), `$CLAUDE_CONFIG_DIR/.credentials.json` and
|
|
114
|
+
* `$CODEX_HOME/auth.json`. A relative value resolves against the agent's
|
|
115
|
+
* `cwd`, where the agent would resolve it, never sweny's. Absolute paths.
|
|
116
|
+
*/
|
|
117
|
+
export declare function envCredentialPaths(env: Record<string, string> | undefined, credentialHome: string, cwd: string): string[];
|
|
110
118
|
/**
|
|
111
119
|
* The srt settings for one wrapped spawn. Pure apart from `exists`.
|
|
112
|
-
* `credentialHome` is the operator's real HOME (not the scratch HOME)
|
|
120
|
+
* `credentialHome` is the operator's real HOME (not the scratch HOME); the
|
|
121
|
+
* login files `req.env` selects elsewhere are denied too ({@link envCredentialPaths}).
|
|
113
122
|
*/
|
|
114
|
-
export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly" | "denyRead"
|
|
123
|
+
export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly" | "denyRead"> & {
|
|
124
|
+
env?: Record<string, string>;
|
|
125
|
+
}, opts: {
|
|
115
126
|
home: string;
|
|
116
127
|
credentialHome: string;
|
|
117
128
|
exists?: (p: string) => boolean;
|
|
@@ -116,16 +116,41 @@ function real(p) {
|
|
|
116
116
|
return p;
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Credential files an agent env points at outside `credentialHome`: the
|
|
121
|
+
* {@link CREDENTIAL_PATHS} under its `HOME` when that is another directory (a
|
|
122
|
+
* harness `env` overlay), `$CLAUDE_CONFIG_DIR/.credentials.json` and
|
|
123
|
+
* `$CODEX_HOME/auth.json`. A relative value resolves against the agent's
|
|
124
|
+
* `cwd`, where the agent would resolve it, never sweny's. Absolute paths.
|
|
125
|
+
*/
|
|
126
|
+
export function envCredentialPaths(env, credentialHome, cwd) {
|
|
127
|
+
if (!env)
|
|
128
|
+
return [];
|
|
129
|
+
const at = (dir, ...rest) => path.resolve(cwd, dir, ...rest);
|
|
130
|
+
const out = [];
|
|
131
|
+
if (env.HOME && at(env.HOME) !== path.resolve(credentialHome)) {
|
|
132
|
+
out.push(...CREDENTIAL_PATHS.map((rel) => at(env.HOME, rel)));
|
|
133
|
+
}
|
|
134
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
135
|
+
out.push(at(env.CLAUDE_CONFIG_DIR, ".credentials.json"));
|
|
136
|
+
if (env.CODEX_HOME)
|
|
137
|
+
out.push(at(env.CODEX_HOME, "auth.json"));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
119
140
|
/**
|
|
120
141
|
* The srt settings for one wrapped spawn. Pure apart from `exists`.
|
|
121
|
-
* `credentialHome` is the operator's real HOME (not the scratch HOME)
|
|
142
|
+
* `credentialHome` is the operator's real HOME (not the scratch HOME); the
|
|
143
|
+
* login files `req.env` selects elsewhere are denied too ({@link envCredentialPaths}).
|
|
122
144
|
*/
|
|
123
145
|
export function buildSrtSettings(req, opts) {
|
|
124
146
|
const exists = opts.exists ?? existsSync;
|
|
125
147
|
const allowedDomains = [...new Set(req.egress.map((d) => d.trim()).filter(isSrtDomainPattern))];
|
|
126
|
-
const denyRead =
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
const denyRead = [
|
|
149
|
+
...new Set([
|
|
150
|
+
...(opts.credentialPaths ?? CREDENTIAL_PATHS).map((rel) => path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)),
|
|
151
|
+
...envCredentialPaths(req.env, opts.credentialHome, req.cwd),
|
|
152
|
+
]),
|
|
153
|
+
].filter((p) => exists(p));
|
|
129
154
|
// Deny the parent, not a snapshot of sibling homes: later-created runs
|
|
130
155
|
// must stay hidden too. srt allowRead carves out only our own HOME.
|
|
131
156
|
if (opts.isolationRoot)
|