@peakflo/agent-harness 0.2.10-rc.1 → 0.2.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,68 @@ Optional flags include `--health-port <port>`, `--runtime <desktop|self-hosted>`
52
52
  `--workspace-root <path>`, and `--github-proxy-port <port>`. Run
53
53
  `npx @peakflo/agent-harness@rc --help` for the full list.
54
54
 
55
+ ## Run after you close the terminal
56
+
57
+ The background service supports macOS launchd user agents and Linux systemd
58
+ user units. Windows background service setup is not supported yet.
59
+
60
+ First, run `start` once in a terminal and finish sign-in. The runner saves its
61
+ runner credential in the workspace with file mode 0600. Stop it with Ctrl-C.
62
+ Install the package in a stable location before you install the service. For
63
+ example:
64
+
65
+ ```sh
66
+ npm install -g @peakflo/agent-harness@latest
67
+ pf-agent-harness service install --control-plane https://api.peakflo.ai
68
+ pf-agent-harness service status
69
+ ```
70
+
71
+ For a self-hosted host, add `--runtime self-hosted`. If you used a custom
72
+ `--workspace-root` for the first start, pass that same path to `service install`.
73
+ Other `start` options can also follow `service install`. To enable vendor
74
+ harnesses on this machine, pass `--vendor-harnesses codex,claude-code,cursor` or set
75
+ `PF_VENDOR_HARNESSES` when you install. The service saves that choice as a
76
+ start option. It does not save a token or copy the current environment.
77
+
78
+ ```sh
79
+ pf-agent-harness service repair
80
+ pf-agent-harness service uninstall
81
+ ```
82
+
83
+ `install` checks that the health port is free before it starts a new service.
84
+ Stop a manual runner before you install. `install` updates an existing service.
85
+ `repair` writes the service file again with the current Node.js and harness
86
+ paths, then restarts it. `uninstall` stops and removes the service and its
87
+ settings. It keeps the runner credential so you
88
+ can use the runner again. `status` shows running or stopped and the last exit
89
+ error that the service manager reports.
90
+
91
+ On macOS, the service file is in `~/Library/LaunchAgents`, and logs are in
92
+ `~/.pf-agent-harness/logs`. On Linux, the unit is in
93
+ `~/.config/systemd/user`; read its logs with
94
+ `journalctl --user -u pf-agent-harness.service`. A Linux user service can stop
95
+ when you log out. If it must run after logout, ask your system administrator to
96
+ enable linger with `loginctl enable-linger $USER`. The installer does not run
97
+ that command.
98
+
99
+ On macOS, rotate the two log files in `~/.pf-agent-harness/logs` with your
100
+ normal user log rotation tool. The service does not cap them. Stop the service
101
+ before you remove or truncate them. After an npm or Node.js upgrade, run
102
+ `pf-agent-harness service repair` so the service uses the new paths. If you
103
+ installed the harness from npm's temporary `npx` cache or use an `nvm` Node.js
104
+ path, `service install` shows a warning about this path.
105
+
106
+ New service files start with `--service --stored-credential`. Older service
107
+ files with only `--stored-credential` still work after an upgrade. Run
108
+ `service repair` to update the file. The runner reads its saved credential
109
+ from the workspace. If that credential is missing, expired, or
110
+ rejected, start the runner once in a terminal to sign in again. The service
111
+ file and the service settings contain no enrolment token or runner credential.
112
+ It stops after an invalid option, a refused harness version, an unusable
113
+ credential, or a local port conflict. Linux waits 30 seconds before it retries
114
+ other failures. `service status` shows the last cause. A manual `start` refuses to
115
+ run beside a live service with the same workspace or health port.
116
+
55
117
  ## Harnesses found on your machine
56
118
 
57
119
  On a desktop or self-hosted machine the runner looks for Codex (`codex`),
@@ -60,15 +122,26 @@ Claude Code (`claude`), Cursor (`cursor-agent`), Pi (`pi`) and OpenCode
60
122
  whether it is signed in, and the models it offers. Peakflo shows this list in
61
123
  the agent editor and in the chat details.
62
124
 
63
- **Which ones the runner can run.** Pi always. On a desktop or self-hosted
64
- machine also Codex, Claude Code and Cursor, each for as long as it is
65
- installed and signed in on that machine — the runner uses that sign-in, as you
66
- do when you run the tool yourself. An agent in Peakflo can then be given that
67
- harness and one of its models. OpenCode is listed and cannot be run yet. A
68
- cloud sandbox runs Pi only.
69
-
70
- - These turns use your own Codex, Claude or Cursor account. They do not go
71
- through the Peakflo model gateway and are not metered by Peakflo.
125
+ **Which ones the runner can run.** Pi always. After onboarding 0g adds personal
126
+ tokens, a personal desktop or self-hosted runner can also run Codex, Claude Code
127
+ and Cursor while each tool is installed and signed in on that machine. The
128
+ runner uses that sign-in, as you do when you run the tool yourself. An agent in
129
+ Peakflo can then be given that harness and one of its models. OpenCode is
130
+ listed and cannot be run yet. A cloud sandbox runs Pi only.
131
+
132
+ - These turns use a configured vendor account. Codex links only `auth.json`
133
+ into a private runner folder, so login refresh stays in sync. Codex keyring
134
+ and auto login are not supported in that folder; select file login or an API
135
+ key. Claude Code uses a private config folder. On macOS it keeps access to
136
+ the host login keychain. On Linux it links the owner's credential file into
137
+ that folder and uses the owner file as the secure storage path for refresh.
138
+ API-key turns use a separate folder with no subscription login link. Claude
139
+ Code 2.1.144 or newer is required for this login path.
140
+ Cursor keeps the host home folder on macOS for its login keychain. Its
141
+ other config roots are private, but Cursor can still read owner settings
142
+ under the home folder. On Linux, the private home folder cannot find a
143
+ Cursor subscription login; use an API key for Cursor turns there.
144
+ These turns do not go through the Peakflo model gateway.
72
145
  - When you sign out of a harness, or remove it, the runner stops taking work
73
146
  for it after its next check.
74
147
  - What the runner brings and what it expects, the same split as the 20x app:
@@ -108,6 +181,17 @@ or the content of credential files. Model names that look like an email
108
181
  address or a key are dropped; that check is a safety net, not a guarantee
109
182
  about what a third-party program prints.
110
183
 
184
+ **Enabling vendor turns.** Discovery only reports Codex, Claude Code and
185
+ Cursor. It does not let the runner take turns with them. To enable one or more,
186
+ start the runner with `--vendor-harnesses codex,claude-code,cursor`, or set
187
+ `PF_VENDOR_HARNESSES` to a comma-separated list. The default is empty. The
188
+ control plane also requires a personal runner whose owner is the turn speaker.
189
+ Admin-minted tokens always create shared runners, which cannot take vendor turns
190
+ even when they have an owner and this flag. Vendor turns are unavailable until
191
+ member-minted personal tokens ship in onboarding 0g. After that, each person
192
+ must register their own personal runner with a token they minted. An existing
193
+ shared runner must be replaced and any agent pins must use the new runner ID.
194
+
111
195
  **Turning it off.** Start with `--no-harness-discovery`, or set
112
196
  `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
113
197
  reports no list. Running turns with Pi is not affected.
@@ -126,10 +210,22 @@ cannot be honoured exactly, the runner uses the stricter behaviour.
126
210
  | Don't ask for permission | Runs without asking | Runs without asking | Runs without asking; each request Cursor raises is approved once |
127
211
 
128
212
  Cursor is read-only in the asking modes because it asks only about commands
129
- that are not on the allow-list in its own settings (`~/.cursor/cli-config.json`).
130
- The runner does not read or change that file, so it cannot promise a question
131
- before each action. If Cursor will not accept the read-only mode, the turn is
132
- refused.
213
+ that are not on its own allow-list. Cursor has no supported switch that
214
+ proves all plugins and keychain state are isolated. If Cursor
215
+ will not accept the read-only mode, the turn is refused.
216
+
217
+ Every vendor turn gets only a selected child environment. Repository turns
218
+ get a session GitHub sentinel and local proxy. Other turns get an empty `gh`
219
+ login folder. Git uses no owner global configuration. Claude Code loads no
220
+ user or project settings. Codex receives a private `CODEX_HOME` with login
221
+ data only; owner MCP servers from `config.toml` are not copied. The default
222
+ approval wait is 30 minutes. Set `CODING_AGENT_APPROVAL_TIMEOUT_MINUTES` on
223
+ the runner to change it. An unanswered approval is denied and ends the turn.
224
+ An unanswered agent question also closes after this limit. On desktop and
225
+ self-hosted runners, the vendor CLI uses the same OS account as the owner.
226
+ These environment and config rules stop default credential lookups. They are
227
+ not a hard security boundary outside a sandbox: a process with that account's
228
+ filesystem and keychain access can still read owner data directly.
133
229
 
134
230
  The runner never answers a request with "always allow", and does not offer
135
231
  that answer to the person approving: Codex and Cursor would save it as a
@@ -28,11 +28,10 @@ exports.AcpAdapter = exports.ACP_SESSION_START_TIMEOUT_MS = void 0;
28
28
  const child_process_1 = require("child_process");
29
29
  const crypto_1 = require("crypto");
30
30
  const child_stream_guards_1 = require("../runtime/child-stream-guards");
31
- const fs_1 = require("fs");
32
- const os_1 = require("os");
33
31
  const path_1 = require("path");
34
32
  const util_1 = require("util");
35
33
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
34
+ const vendor_environment_1 = require("./vendor-environment");
36
35
  const cursor_acp_1 = require("./cursor-acp");
37
36
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
38
37
  /**
@@ -183,7 +182,8 @@ class AcpAdapter {
183
182
  env.CODEX_API_KEY = key;
184
183
  }
185
184
  env.NO_BROWSER = '1';
186
- env.CODEX_HOME = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'codex-session-'));
185
+ env.CODEX_HOME = (0, vendor_environment_1.codexVendorConfigDir)(config, true);
186
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CODEX_HOME);
187
187
  console.log(`[AcpAdapter/codex] Auth: API key (authMethod=${config.authMethod ?? 'legacy'}, isolated CODEX_HOME)`);
188
188
  return true;
189
189
  }
@@ -191,19 +191,22 @@ class AcpAdapter {
191
191
  // hijack auth away from the ChatGPT subscription in the default CODEX_HOME.
192
192
  delete env.OPENAI_API_KEY;
193
193
  delete env.CODEX_API_KEY;
194
- // Pin CODEX_HOME to the same dir the `codex` CLI uses in a terminal, so
195
- // codex-acp reads the exact subscription login the user already has. We only
196
- // set it when not already provided (a user who runs `codex` with a custom
197
- // CODEX_HOME will have it in their shell env, which we inherit and preserve).
198
- if (!env.CODEX_HOME) {
199
- env.CODEX_HOME = (0, path_1.join)((0, os_1.homedir)(), '.codex');
200
- }
194
+ // Copy only the subscription login. Do not load the owner's config.toml,
195
+ // which can name MCP servers and rules outside this turn's lease.
196
+ env.CODEX_HOME = (0, vendor_environment_1.codexVendorConfigDir)(config, false);
197
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CODEX_HOME);
201
198
  console.log(`[AcpAdapter/codex] Auth: ChatGPT subscription / Codex CLI login (authMethod=${config.authMethod ?? 'legacy'}, CODEX_HOME=${env.CODEX_HOME})`);
202
199
  return false;
203
200
  }
204
201
  configureCursorAuthEnv(env, config) {
205
202
  if (this.agentType !== 'cursor')
206
203
  return;
204
+ // Keep configuration roots private, but preserve the macOS login keychain.
205
+ // Cursor has no complete switch for owner settings under ~/.cursor.
206
+ (0, vendor_environment_1.isolateVendorHome)(env, (0, vendor_environment_1.vendorConfigDir)('cursor', config, '', []));
207
+ if (process.platform === 'darwin' && process.env.HOME) {
208
+ env.HOME = process.env.HOME;
209
+ }
207
210
  const explicitApiKey = config.apiKeys?.cursor;
208
211
  const useApiKey = config.authMethod === 'api_key'
209
212
  || (config.authMethod !== 'subscription' && !!explicitApiKey);
@@ -276,19 +279,12 @@ class AcpAdapter {
276
279
  const sessionId = config.taskId;
277
280
  console.log(`[AcpAdapter/${this.agentType}] Creating session ${sessionId}`);
278
281
  // Prepare environment with API keys
279
- const env = {
280
- ...process.env,
282
+ const env = (0, vendor_environment_1.vendorEnvironment)(config, {
283
+ ...(this.agentType === 'codex'
284
+ ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY }
285
+ : { CURSOR_API_KEY: process.env.CURSOR_API_KEY }),
281
286
  ...this.agentConfig.env
282
- };
283
- if (config.apiKeys?.anthropic) {
284
- env.ANTHROPIC_API_KEY = config.apiKeys.anthropic;
285
- }
286
- // Inject secret env vars directly into process environment
287
- if (config.secretEnvVars && Object.keys(config.secretEnvVars).length > 0) {
288
- for (const [key, value] of Object.entries(config.secretEnvVars)) {
289
- env[key] = value;
290
- }
291
- }
287
+ });
292
288
  // Decide Codex auth (subscription vs API key) LAST so it is authoritative.
293
289
  // It must run after secret-env injection: a user secret named OPENAI_API_KEY /
294
290
  // CODEX_API_KEY would otherwise be re-added to env after subscription mode
@@ -437,19 +433,12 @@ class AcpAdapter {
437
433
  anthropicKeyLength: config.apiKeys?.anthropic?.length
438
434
  });
439
435
  // Prepare environment with API keys
440
- const env = {
441
- ...process.env,
436
+ const env = (0, vendor_environment_1.vendorEnvironment)(config, {
437
+ ...(this.agentType === 'codex'
438
+ ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY }
439
+ : { CURSOR_API_KEY: process.env.CURSOR_API_KEY }),
442
440
  ...this.agentConfig.env
443
- };
444
- if (config.apiKeys?.anthropic) {
445
- env.ANTHROPIC_API_KEY = config.apiKeys.anthropic;
446
- }
447
- // Inject secret env vars directly into process environment
448
- if (config.secretEnvVars && Object.keys(config.secretEnvVars).length > 0) {
449
- for (const [key, value] of Object.entries(config.secretEnvVars)) {
450
- env[key] = value;
451
- }
452
- }
441
+ });
453
442
  // Decide Codex auth (subscription vs API key) LAST so it is authoritative —
454
443
  // see createSession for why this must run after secret-env injection.
455
444
  const codexUseApiKey = this.configureCodexAuthEnv(env, config);
@@ -1258,10 +1247,9 @@ class AcpAdapter {
1258
1247
  ? oneTimeOptions
1259
1248
  : allApprovalOptions;
1260
1249
  if (session.config.permissionMode === 'allow') {
1261
- // Cursor saves "allow always" as a standing rule in the runner owner's
1262
- // `~/.cursor/cli-config.json` (seen: one bypass turn added `Shell(echo)`
1263
- // and `Shell(cat)`), which would then also apply to turns that are meant
1264
- // to ask. Every request is approved once instead.
1250
+ // Cursor saves "allow always" as a standing rule (seen: one bypass
1251
+ // turn added Shell(echo) and Shell(cat)). That rule could affect later
1252
+ // turns. Every request is approved once instead.
1265
1253
  if (this.agentType === 'cursor') {
1266
1254
  const once = approvalOptions.find((option) => option.optionId === 'allow-once')
1267
1255
  || approvalOptions.find((option) => option.kind === 'allow_once');
@@ -8,15 +8,9 @@
8
8
  * - File-based session persistence (~/.claude/sessions/)
9
9
  * - Different message format (SDKMessage vs OpenCode messages)
10
10
  */
11
- /**
12
- * Extracted verbatim from 20x `src/main/adapters/claude-code-adapter.ts`.
13
- * CHANGED: nothing. The only import it has is `./coding-agent-adapter`, which
14
- * sits in the same directory here too. `@anthropic-ai/claude-agent-sdk` is an
15
- * optional dependency (dynamically imported) and is declared ambiently in
16
- * `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
17
- * Source formatting (no semicolons) preserved.
18
- */
19
11
  import type { CodingAgentAdapter, SessionConfig, SessionStatus, SessionMessage, MessagePart } from './coding-agent-adapter';
12
+ export declare function assertClaudeIsolatedConfigVersion(version: string): void;
13
+ export declare function verifyClaudeIsolatedConfigVersion(path: string): Promise<void>;
20
14
  export declare enum ClaudeSystemSubtype {
21
15
  INIT = "init",
22
16
  TASK_STARTED = "task_started",
@@ -35,6 +29,7 @@ export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
35
29
  private sessions;
36
30
  private sdkLoading;
37
31
  private claudeExecutablePath;
32
+ private verifiedClaudeExecutablePath;
38
33
  /**
39
34
  * Callback set by agent-manager to trigger an immediate poll cycle
40
35
  * when new stream data is buffered. Eliminates the up-to-2-second
@@ -51,12 +46,7 @@ export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
51
46
  * (no shell: true). Resolve claude.cmd → the underlying cli.js path.
52
47
  */
53
48
  private resolveWindowsCmdToJs;
54
- /**
55
- * Build environment variables for Claude process
56
- * Removes CLAUDECODE to prevent nested session errors.
57
- * When secrets are configured, sets SHELL to the secret-shell.sh wrapper
58
- * so every bash command fetches secrets from the broker transparently.
59
- */
49
+ /** Build an isolated Claude process environment with the host keychain login. */
60
50
  private buildClaudeEnvironment;
61
51
  /**
62
52
  * Build PreToolUse hooks for secret injection.
@@ -44,11 +44,50 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.ClaudeCodeAdapter = exports.ClaudeSystemSubtype = void 0;
47
+ exports.assertClaudeIsolatedConfigVersion = assertClaudeIsolatedConfigVersion;
48
+ exports.verifyClaudeIsolatedConfigVersion = verifyClaudeIsolatedConfigVersion;
49
+ /**
50
+ * Extracted verbatim from 20x `src/main/adapters/claude-code-adapter.ts`.
51
+ * CHANGED: nothing. The only import it has is `./coding-agent-adapter`, which
52
+ * sits in the same directory here too. `@anthropic-ai/claude-agent-sdk` is an
53
+ * optional dependency (dynamically imported) and is declared ambiently in
54
+ * `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
55
+ * Source formatting (no semicolons) preserved.
56
+ */
57
+ const node_os_1 = require("node:os");
58
+ const node_path_1 = require("node:path");
47
59
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
48
60
  const permission_mode_1 = require("../runtime/permission-mode");
61
+ const vendor_environment_1 = require("./vendor-environment");
49
62
  let ClaudeAgentSDK = null;
50
63
  /** Module-level cache for the resolved Claude executable path (persists across adapter instances) */
51
64
  let resolvedClaudeExecutablePath = null;
65
+ const MINIMUM_ISOLATED_CONFIG_VERSION = [2, 1, 144];
66
+ function assertClaudeIsolatedConfigVersion(version) {
67
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
68
+ const actual = match?.slice(1, 4).map(Number);
69
+ const firstDifference = actual?.findIndex((part, index) => part !== MINIMUM_ISOLATED_CONFIG_VERSION[index]);
70
+ const tooOld = actual && firstDifference !== undefined && firstDifference >= 0 &&
71
+ actual[firstDifference] < MINIMUM_ISOLATED_CONFIG_VERSION[firstDifference];
72
+ if (!actual || tooOld) {
73
+ throw new Error('Claude Code CLI is too old or its version cannot be checked for isolated config. Upgrade Claude Code to 2.1.144 or newer.');
74
+ }
75
+ }
76
+ async function verifyClaudeIsolatedConfigVersion(path) {
77
+ const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
78
+ const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
79
+ const command = path.endsWith('.js') ? process.execPath : path;
80
+ const args = path.endsWith('.js') ? [path, '--version'] : ['--version'];
81
+ try {
82
+ const { stdout } = await promisify(execFile)(command, args, { timeout: 5_000 });
83
+ assertClaudeIsolatedConfigVersion(stdout.trim());
84
+ }
85
+ catch (error) {
86
+ if (error instanceof Error && error.message.includes('Upgrade Claude Code'))
87
+ throw error;
88
+ throw new Error('Claude Code CLI version could not be checked for isolated config. Upgrade Claude Code to 2.1.144 or newer.');
89
+ }
90
+ }
52
91
  /** Maximum number of messages to keep in the buffer per session */
53
92
  const MAX_MESSAGE_BUFFER_SIZE = 500;
54
93
  var ClaudeSystemSubtype;
@@ -84,6 +123,7 @@ class ClaudeCodeAdapter {
84
123
  sessions = new Map();
85
124
  sdkLoading = null;
86
125
  claudeExecutablePath = null;
126
+ verifiedClaudeExecutablePath = null;
87
127
  /**
88
128
  * Callback set by agent-manager to trigger an immediate poll cycle
89
129
  * when new stream data is buffered. Eliminates the up-to-2-second
@@ -215,16 +255,35 @@ class ClaudeCodeAdapter {
215
255
  // Fall back to original .cmd path
216
256
  return cmdPath;
217
257
  }
218
- /**
219
- * Build environment variables for Claude process
220
- * Removes CLAUDECODE to prevent nested session errors.
221
- * When secrets are configured, sets SHELL to the secret-shell.sh wrapper
222
- * so every bash command fetches secrets from the broker transparently.
223
- */
224
- buildClaudeEnvironment() {
225
- const env = { ...process.env };
226
- // Remove CLAUDECODE to prevent nested session error
227
- delete env.CLAUDECODE;
258
+ /** Build an isolated Claude process environment with the host keychain login. */
259
+ buildClaudeEnvironment(config) {
260
+ const useApiKey = config.authMethod === 'api_key';
261
+ const env = (0, vendor_environment_1.vendorEnvironment)(config, {
262
+ ANTHROPIC_API_KEY: useApiKey ? config.apiKeys?.anthropic ?? process.env.ANTHROPIC_API_KEY : undefined,
263
+ CLAUDE_CODE_OAUTH_TOKEN: useApiKey ? undefined : process.env.CLAUDE_CODE_OAUTH_TOKEN
264
+ });
265
+ if (useApiKey)
266
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
267
+ else
268
+ delete env.ANTHROPIC_API_KEY;
269
+ const ownerClaudeDir = (0, node_path_1.resolve)(process.env.CLAUDE_CONFIG_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), '.claude'));
270
+ env.CLAUDE_CONFIG_DIR = (0, vendor_environment_1.claudeVendorConfigDir)(config);
271
+ // On Linux, read and refresh the owner's credential file at its source.
272
+ // The private config also has a link for CLI paths that use that root.
273
+ // On macOS, an empty value selects the owner's original Keychain item.
274
+ env.CLAUDE_SECURESTORAGE_CONFIG_DIR = useApiKey
275
+ ? env.CLAUDE_CONFIG_DIR
276
+ : process.platform === 'linux' ? ownerClaudeDir : '';
277
+ // macOS needs the real HOME to find the login keychain. Linux can keep
278
+ // HOME private because the credential file is linked into this config.
279
+ if (useApiKey || process.platform === 'linux')
280
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CLAUDE_CONFIG_DIR);
281
+ else {
282
+ if (process.env.HOME)
283
+ env.HOME = process.env.HOME;
284
+ if (process.env.USERPROFILE)
285
+ env.USERPROFILE = process.env.USERPROFILE;
286
+ }
228
287
  return env;
229
288
  }
230
289
  /**
@@ -234,7 +293,7 @@ class ClaudeCodeAdapter {
234
293
  * the output appear in conversation context.
235
294
  */
236
295
  buildSecretHooks(config) {
237
- const secretEnvVars = config.secretEnvVars;
296
+ const secretEnvVars = Object.fromEntries(Object.entries(config.secretEnvVars ?? {}).filter(([key]) => (0, vendor_environment_1.allowedVendorSecretKey)(key)));
238
297
  if (!secretEnvVars || Object.keys(secretEnvVars).length === 0) {
239
298
  return undefined;
240
299
  }
@@ -339,12 +398,12 @@ class ClaudeCodeAdapter {
339
398
  * Cleans the session file by removing messages with empty text blocks
340
399
  * This prevents API errors when resuming sessions
341
400
  */
342
- async cleanSessionFile(sessionId, workspaceDir) {
401
+ async cleanSessionFile(sessionId, config) {
343
402
  try {
344
403
  const { readFileSync, writeFileSync, existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
345
404
  const { join } = await Promise.resolve().then(() => __importStar(require('path')));
346
- const { homedir } = await Promise.resolve().then(() => __importStar(require('os')));
347
- const claudeDir = join(homedir(), '.claude', 'projects');
405
+ const workspaceDir = config.workspaceDir;
406
+ const claudeDir = join((0, vendor_environment_1.claudeVendorConfigDir)(config), 'projects');
348
407
  // Claude Code CLI encodes workspace paths by replacing all non-alphanumeric/non-hyphen chars with '-'
349
408
  const encodedWorkspace = workspaceDir.replace(/[^a-zA-Z0-9-]/g, '-');
350
409
  const sessionFile = join(claudeDir, encodedWorkspace, `${sessionId}.jsonl`);
@@ -400,20 +459,20 @@ class ClaudeCodeAdapter {
400
459
  if (!workspaceDir)
401
460
  return [];
402
461
  try {
403
- return await this.loadSessionHistory(sessionId, workspaceDir);
462
+ return await this.loadSessionHistory(sessionId, config);
404
463
  }
405
464
  catch {
406
465
  return [];
407
466
  }
408
467
  }
409
- async loadSessionHistory(sessionId, workspaceDir) {
468
+ async loadSessionHistory(sessionId, config) {
410
469
  try {
411
470
  const { readFileSync, existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
412
471
  const { join } = await Promise.resolve().then(() => __importStar(require('path')));
413
- const { homedir } = await Promise.resolve().then(() => __importStar(require('os')));
414
- // Session files are stored in: ~/.claude/projects/[encoded-workspace]/[sessionId].jsonl
472
+ const workspaceDir = config.workspaceDir;
473
+ // Session files are stored under the same runner config used by the CLI.
415
474
  // Claude Code CLI encodes workspace paths by replacing all non-alphanumeric/non-hyphen chars with '-'
416
- const claudeDir = join(homedir(), '.claude', 'projects');
475
+ const claudeDir = join((0, vendor_environment_1.claudeVendorConfigDir)(config), 'projects');
417
476
  const encodedWorkspace = workspaceDir.replace(/[^a-zA-Z0-9-]/g, '-');
418
477
  const sessionFile = join(claudeDir, encodedWorkspace, `${sessionId}.jsonl`);
419
478
  console.log(`[ClaudeCodeAdapter] Loading session history from: ${sessionFile}`);
@@ -577,7 +636,7 @@ class ClaudeCodeAdapter {
577
636
  throw new Error('INCOMPATIBLE_SESSION_ID: This session was created with a different coding agent and cannot be resumed with Claude Code.');
578
637
  }
579
638
  // Clean session file to remove empty text blocks before resuming
580
- await this.cleanSessionFile(sessionId, config.workspaceDir);
639
+ await this.cleanSessionFile(sessionId, config);
581
640
  // Create session state (idle until user sends a message)
582
641
  const session = {
583
642
  sessionId,
@@ -600,7 +659,7 @@ class ClaudeCodeAdapter {
600
659
  // The query will be started in sendPrompt with resume option
601
660
  console.log(`[ClaudeCodeAdapter] Session resumed: ${sessionId} (waiting for user prompt)`);
602
661
  // Load conversation history from session file
603
- const messages = await this.loadSessionHistory(sessionId, config.workspaceDir);
662
+ const messages = await this.loadSessionHistory(sessionId, config);
604
663
  console.log(`[ClaudeCodeAdapter] Session loaded with ${messages.length} messages`);
605
664
  return messages;
606
665
  }
@@ -653,6 +712,10 @@ class ClaudeCodeAdapter {
653
712
  }
654
713
  // Find Claude executable
655
714
  const claudePath = await this.findClaudeExecutable();
715
+ if (this.verifiedClaudeExecutablePath !== claudePath) {
716
+ await verifyClaudeIsolatedConfigVersion(claudePath);
717
+ this.verifiedClaudeExecutablePath = claudePath;
718
+ }
656
719
  // Create new abort controller
657
720
  const abortController = new AbortController();
658
721
  session.abortController = abortController;
@@ -663,7 +726,8 @@ class ClaudeCodeAdapter {
663
726
  const options = {
664
727
  cwd: config.workspaceDir,
665
728
  pathToClaudeCodeExecutable: claudePath,
666
- env: this.buildClaudeEnvironment(),
729
+ env: this.buildClaudeEnvironment(config),
730
+ settingSources: [],
667
731
  mcpServers: config.mcpServers,
668
732
  model: config.model,
669
733
  effort,
@@ -53,6 +53,7 @@ exports.CodexAdapter = exports.DEFAULT_CODEX_MODEL = void 0;
53
53
  const child_process_1 = require("child_process");
54
54
  const fs_1 = require("fs");
55
55
  const child_stream_guards_1 = require("../runtime/child-stream-guards");
56
+ const vendor_environment_1 = require("./vendor-environment");
56
57
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
57
58
  exports.DEFAULT_CODEX_MODEL = 'gpt-5.6-sol';
58
59
  class CodexAdapter {
@@ -132,10 +133,20 @@ class CodexAdapter {
132
133
  /**
133
134
  * Build environment variables for Codex process
134
135
  */
135
- buildCodexEnvironment() {
136
- const env = { ...process.env };
137
- // Ensure API key is available
138
- if (!env.OPENAI_API_KEY && !env.CODEX_API_KEY) {
136
+ buildCodexEnvironment(config) {
137
+ const useApiKey = config.authMethod === 'api_key' ||
138
+ (config.authMethod !== 'subscription' && !!config.apiKeys?.openai);
139
+ const env = (0, vendor_environment_1.vendorEnvironment)(config, {
140
+ OPENAI_API_KEY: config.apiKeys?.openai ?? process.env.OPENAI_API_KEY,
141
+ CODEX_API_KEY: config.apiKeys?.openai ?? process.env.CODEX_API_KEY
142
+ });
143
+ if (!useApiKey) {
144
+ delete env.OPENAI_API_KEY;
145
+ delete env.CODEX_API_KEY;
146
+ }
147
+ env.CODEX_HOME = (0, vendor_environment_1.codexVendorConfigDir)(config, useApiKey);
148
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CODEX_HOME);
149
+ if (useApiKey && !env.OPENAI_API_KEY && !env.CODEX_API_KEY) {
139
150
  console.warn('[CodexAdapter] No OPENAI_API_KEY or CODEX_API_KEY found in environment');
140
151
  }
141
152
  return env;
@@ -162,7 +173,7 @@ class CodexAdapter {
162
173
  const codexProcess = (0, child_process_1.spawn)(this.codexExecutablePath, this.buildCodexArgs(config), {
163
174
  cwd: config.workspaceDir,
164
175
  stdio: ['pipe', 'pipe', 'pipe'],
165
- env: this.buildCodexEnvironment(),
176
+ env: this.buildCodexEnvironment(config),
166
177
  ...(needsShell ? { shell: true } : {}),
167
178
  });
168
179
  // Every pipe needs an error listener before the first write. Codex can exit
@@ -235,7 +246,7 @@ class CodexAdapter {
235
246
  const codexProcess = (0, child_process_1.spawn)(this.codexExecutablePath, this.buildCodexArgs(config), {
236
247
  cwd: config.workspaceDir,
237
248
  stdio: ['pipe', 'pipe', 'pipe'],
238
- env: this.buildCodexEnvironment(),
249
+ env: this.buildCodexEnvironment(config),
239
250
  ...(needsShellResume ? { shell: true } : {}),
240
251
  });
241
252
  // Every pipe needs an error listener before the first write. Codex can exit
@@ -54,11 +54,11 @@ exports.codexApprovalPolicy = codexApprovalPolicy;
54
54
  const child_process_1 = require("child_process");
55
55
  const fs_1 = require("fs");
56
56
  const child_stream_guards_1 = require("../runtime/child-stream-guards");
57
- const os_1 = require("os");
58
57
  const path_1 = require("path");
59
58
  const util_1 = require("util");
60
59
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
61
60
  const codex_approval_text_1 = require("./codex-approval-text");
61
+ const vendor_environment_1 = require("./vendor-environment");
62
62
  const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
63
63
  /**
64
64
  * `thread/items/list` returns each item inside an envelope:
@@ -642,10 +642,10 @@ class CodexAppServerAdapter {
642
642
  return await execFileAsync(command, args, options);
643
643
  }
644
644
  buildEnvironment(config) {
645
- const env = { ...process.env };
646
- for (const [key, value] of Object.entries(config.secretEnvVars || {})) {
647
- env[key] = value;
648
- }
645
+ const env = (0, vendor_environment_1.vendorEnvironment)(config, {
646
+ OPENAI_API_KEY: config.apiKeys?.openai ?? process.env.OPENAI_API_KEY,
647
+ CODEX_API_KEY: config.apiKeys?.openai ?? process.env.CODEX_API_KEY
648
+ });
649
649
  const explicitApiKey = config.apiKeys?.openai;
650
650
  const useApiKey = config.authMethod === 'api_key' ? true
651
651
  : config.authMethod === 'subscription' ? false
@@ -657,7 +657,8 @@ class CodexAppServerAdapter {
657
657
  env.CODEX_API_KEY = key;
658
658
  }
659
659
  env.NO_BROWSER = '1';
660
- env.CODEX_HOME = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'codex-app-server-session-'));
660
+ env.CODEX_HOME = (0, vendor_environment_1.codexVendorConfigDir)(config, true);
661
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CODEX_HOME);
661
662
  return {
662
663
  env,
663
664
  usesApiKey: true,
@@ -667,9 +668,8 @@ class CodexAppServerAdapter {
667
668
  if (!useApiKey) {
668
669
  delete env.OPENAI_API_KEY;
669
670
  delete env.CODEX_API_KEY;
670
- if (!env.CODEX_HOME) {
671
- env.CODEX_HOME = (0, path_1.join)((0, os_1.homedir)(), '.codex');
672
- }
671
+ env.CODEX_HOME = (0, vendor_environment_1.codexVendorConfigDir)(config, false);
672
+ (0, vendor_environment_1.isolateVendorHome)(env, env.CODEX_HOME);
673
673
  }
674
674
  return {
675
675
  env,
@@ -40,9 +40,8 @@ export type CursorMode = 'agent' | 'plan' | 'ask';
40
40
  * The Cursor mode a turn must run in.
41
41
  *
42
42
  * FAIL CLOSED. In its `agent` mode Cursor asks only about commands that are
43
- * not on the allow-list in the runner owner's `~/.cursor/cli-config.json`
44
- * (seen: "Not in allowlist: cat, echo"). The runner must not read or change
45
- * that list, so it cannot know which actions Cursor will run unasked, and a
43
+ * not on its own allow-list (seen: "Not in allowlist: cat, echo"). The runner
44
+ * cannot know which actions Cursor will run unasked, and a
46
45
  * permission mode that promises a question before EACH action cannot be
47
46
  * promised for Cursor. Every asking mode therefore runs
48
47
  * Cursor in one of its own read-only modes — stricter than what was asked for,
@@ -112,9 +112,8 @@ function resolveCursorModelValue(model, sessionResult) {
112
112
  * The Cursor mode a turn must run in.
113
113
  *
114
114
  * FAIL CLOSED. In its `agent` mode Cursor asks only about commands that are
115
- * not on the allow-list in the runner owner's `~/.cursor/cli-config.json`
116
- * (seen: "Not in allowlist: cat, echo"). The runner must not read or change
117
- * that list, so it cannot know which actions Cursor will run unasked, and a
115
+ * not on its own allow-list (seen: "Not in allowlist: cat, echo"). The runner
116
+ * cannot know which actions Cursor will run unasked, and a
118
117
  * permission mode that promises a question before EACH action cannot be
119
118
  * promised for Cursor. Every asking mode therefore runs
120
119
  * Cursor in one of its own read-only modes — stricter than what was asked for,