@sweny-ai/core 0.1.198 → 0.1.200

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.
Files changed (44) hide show
  1. package/dist/agent-env.d.ts +94 -2
  2. package/dist/agent-env.js +144 -7
  3. package/dist/budget.d.ts +77 -11
  4. package/dist/budget.js +132 -16
  5. package/dist/cli/renderer.js +5 -0
  6. package/dist/cli/run-history.d.ts +12 -0
  7. package/dist/cli/run-history.js +16 -0
  8. package/dist/cli/run-output.d.ts +16 -1
  9. package/dist/cli/run-output.js +46 -4
  10. package/dist/cli/runs.js +38 -1
  11. package/dist/cli/ticket.js +7 -2
  12. package/dist/eval/index.d.ts +10 -0
  13. package/dist/eval/index.js +27 -6
  14. package/dist/eval/judge.d.ts +3 -0
  15. package/dist/eval/judge.js +12 -2
  16. package/dist/executor.d.ts +5 -1
  17. package/dist/executor.js +276 -31
  18. package/dist/harness/acp.d.ts +5 -2
  19. package/dist/harness/acp.js +11 -23
  20. package/dist/harness/auth.js +2 -1
  21. package/dist/harness/claude-code.d.ts +18 -13
  22. package/dist/harness/claude-code.js +34 -15
  23. package/dist/harness/codex.d.ts +22 -5
  24. package/dist/harness/codex.js +67 -11
  25. package/dist/harness/compat.js +1 -0
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +1 -1
  28. package/dist/harness/pi.d.ts +11 -5
  29. package/dist/harness/pi.js +27 -9
  30. package/dist/harness/prompts.d.ts +11 -1
  31. package/dist/harness/prompts.js +21 -2
  32. package/dist/harness/sandbox-wrapper.d.ts +13 -2
  33. package/dist/harness/sandbox-wrapper.js +29 -4
  34. package/dist/harness/types.d.ts +9 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/journal.d.ts +48 -10
  38. package/dist/journal.js +164 -16
  39. package/dist/safe-outputs.d.ts +9 -0
  40. package/dist/safe-outputs.js +41 -26
  41. package/dist/skills/linear.js +3 -1
  42. package/dist/studio.js +2 -1
  43. package/dist/types.d.ts +34 -1
  44. package/package.json +1 -1
@@ -8,8 +8,8 @@
8
8
  * This is the ONLY supported LLM backend. The whole point of sweny
9
9
  * is to use headless Claude Code - never the raw Anthropic API.
10
10
  */
11
- import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
12
- import { type SandboxMode } from "../agent-env.js";
11
+ import type { Claude, Tool, ToolContext, NodeUsage, Logger, McpServerConfig } from "../types.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. */
@@ -247,6 +250,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
247
250
  timeoutMs?: number;
248
251
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
249
252
  signal?: AbortSignal;
253
+ onUsage?: (usage: NodeUsage) => void;
250
254
  }): Promise<string | null>;
251
255
  /** Free-text completion. Delegates to core {@link coreAsk} over {@link complete}. */
252
256
  ask(opts: {
@@ -257,6 +261,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
257
261
  timeoutMs?: number;
258
262
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
259
263
  signal?: AbortSignal;
264
+ onUsage?: (usage: NodeUsage) => void;
260
265
  }): Promise<string>;
261
266
  }
262
267
  /** @deprecated Use {@link ClaudeCodeHarnessOptions}. */
@@ -12,11 +12,11 @@ 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";
19
- import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
19
+ import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt, reportCompletionUsage, } from "./prompts.js";
20
20
  import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
21
21
  import { startToolBridge } from "./tool-bridge/server.js";
22
22
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.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,
@@ -816,6 +832,9 @@ export class ClaudeCodeHarness {
816
832
  if (message.type === "result") {
817
833
  const resultMsg = message;
818
834
  sawResult = true;
835
+ // A completion that failed was still billed: report before the outcome.
836
+ if (req.onUsage)
837
+ reportCompletionUsage(req.onUsage, extractUsage(resultMsg), this.logger);
819
838
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
820
839
  response = resultMsg.result;
821
840
  }
@@ -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,11 +28,11 @@ 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";
35
- import { buildNodePrompt } from "./prompts.js";
35
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
36
36
  import { makeAbort } from "./abort.js";
37
37
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
38
38
  import { startToolBridge } from "./tool-bridge/server.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,13 +1048,15 @@ 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,
1001
1055
  // Classification calls use no tools; any tool call is over budget.
1002
1056
  maxToolCalls: 0,
1003
1057
  });
1058
+ // A completion that failed was still billed: report before the outcome.
1059
+ reportCompletionUsage(req.onUsage, out.usage, this.logger);
1004
1060
  if (out.spawnError)
1005
1061
  return failClosed(`codex could not start: ${out.spawnError}`);
1006
1062
  if (out.abortReason === "timeout")
@@ -35,6 +35,7 @@ export function claudeCompat(claude) {
35
35
  model: req.model,
36
36
  timeoutMs: req.timeoutMs,
37
37
  signal: req.signal,
38
+ ...(req.onUsage ? { onUsage: req.onUsage } : {}),
38
39
  });
39
40
  },
40
41
  };
@@ -10,7 +10,7 @@ export { claudeCompat, asClaude } from "./compat.js";
10
10
  export type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, } from "./types.js";
11
11
  export { policyGate, budgetGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES, } from "./policy.js";
12
12
  export type { HarnessPolicyMode } from "./policy.js";
13
- export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
13
+ export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt, reportCompletionUsage } from "./prompts.js";
14
14
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
15
15
  export type { ClaudeCodeHarnessOptions } from "./claude-code.js";
16
16
  export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
@@ -9,7 +9,7 @@ import { PiHarness } from "./pi.js";
9
9
  import { parseAcpAgent, unsupportedAgentError } from "./agents.js";
10
10
  export { claudeCompat, asClaude } from "./compat.js";
11
11
  export { policyGate, budgetGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES, } from "./policy.js";
12
- export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
12
+ export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt, reportCompletionUsage } from "./prompts.js";
13
13
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
14
14
  export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
15
15
  export { AcpHarness, ACP_CAPABILITIES, ACP_PROTOCOL_VERSION } from "./acp.js";
@@ -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,17 +34,19 @@ 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";
41
- import { buildNodePrompt } from "./prompts.js";
41
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
42
42
  import { makeAbort } from "./abort.js";
43
43
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
44
44
  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,
@@ -990,6 +1006,8 @@ export class PiHarness {
990
1006
  // Classification calls use no tools; any tool call is over budget.
991
1007
  maxToolCalls: 0,
992
1008
  });
1009
+ // A completion that failed was still billed: report before the outcome.
1010
+ reportCompletionUsage(req.onUsage, out.usage, this.logger);
993
1011
  if (out.spawnError)
994
1012
  return failClosed(`pi could not start: ${out.spawnError}`);
995
1013
  if (out.abortReason === "timeout")