@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.
@@ -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
- const home = env.CODEX_HOME || path.join(homedir(), ".codex");
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
- if (truthy(source.CLAUDE_CODE_USE_BEDROCK))
539
+ const routing = opts.routing ?? source;
540
+ if (truthy(routing.CLAUDE_CODE_USE_BEDROCK))
539
541
  prefixes.push(...BEDROCK_PREFIXES);
540
- if (truthy(source.CLAUDE_CODE_USE_VERTEX))
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
- { path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
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
  }
@@ -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
- /** Environment variables for Source resolution auth lookup */
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>;
@@ -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. */
@@ -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 = resolveOnPath(this.agent.command, process.env);
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. */
@@ -30,7 +30,8 @@ function truthy(v) {
30
30
  */
31
31
  export function detectClaudeCodeLogin(opts = {}) {
32
32
  const env = opts.env ?? process.env;
33
- const home = opts.home ?? os.homedir();
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). Applies auth precedence
196
- * via {@link resolveAuthEnv} on the full process env (it reads
197
- * `SWENY_AUTH`). When env scoping is on (default in CI, see
198
- * {@link resolveEnvScope}) the result is narrowed to the allowlist plus
199
- * `extraVars` (the node's declared skill env vars) and the operator
200
- * passthrough list, and the withheld names (never values) are reported once
201
- * per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
202
- * Either way, skill credentials are then dropped ({@link finishAgentEnv}):
203
- * only the names the node granted with `agent_env` reach the agent.
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(process.env));
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). Applies auth precedence
291
- * via {@link resolveAuthEnv} on the full process env (it reads
292
- * `SWENY_AUTH`). When env scoping is on (default in CI, see
293
- * {@link resolveEnvScope}) the result is narrowed to the allowlist plus
294
- * `extraVars` (the node's declared skill env vars) and the operator
295
- * passthrough list, and the withheld names (never values) are reported once
296
- * per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
297
- * Either way, skill credentials are then dropped ({@link finishAgentEnv}):
298
- * only the names the node granted with `agent_env` reach the agent.
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 = authed;
307
+ let env = full;
305
308
  if (resolveEnvScope(process.env, this.envScope, this.logger)) {
306
- const scoped = scopeAgentEnv(authed, { extraVars: access?.envVars ?? [], passthrough, logger: this.logger });
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,
@@ -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. Skill credentials are dropped either way
167
- * ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
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. */
@@ -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(process.env));
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
- execFile(command, [...args, "--version"], { timeout: 30_000 }, (err, stdout) => {
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. Skill credentials are dropped either way
374
- * ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
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 full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
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 = process.env.OPENAI_BASE_URL;
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.codexCommand.command, args: argsFor(withSchema), env, cwd: this.cwd },
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.codexCommand.command, args, env: this.buildEnv(), cwd: this.cwd },
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,
@@ -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. Skill credentials are dropped
192
- * too ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
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. */
@@ -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
- execFile(command, [...args, "--version"], { timeout: 30_000 }, (err, stdout, stderr) => {
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(process.env, model, this.provider);
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. Skill credentials are dropped
402
- * too ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
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.piCommand.command, args, env, cwd: this.cwd },
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.piCommand.command,
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">, opts: {
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 = (opts.credentialPaths ?? CREDENTIAL_PATHS)
127
- .map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
128
- .filter((p) => exists(p));
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.198",
3
+ "version": "0.1.199",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"