openmausbot 0.1.63 → 0.1.66

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 (34) hide show
  1. package/dist/assets/index-BftlaHAF.css +1 -0
  2. package/dist/assets/index-DuR3H-Hf.js +278 -0
  3. package/dist/assets/{index-C-SoIGdn.js → index-kW5L1QeO.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/browser-proxy.js +121 -0
  6. package/dist-server/container-mcp.js +4 -1
  7. package/dist-server/index.js +17996 -15876
  8. package/dist-server/openmausbot.js +752 -245
  9. package/dist-server/pair-cli.js +752 -245
  10. package/dist-server/prepare-cloudflared.js +10 -2
  11. package/dist-server/proxy-paths.js +1 -0
  12. package/dist-server/server/attachments.js +140 -16
  13. package/dist-server/server/browser-engine-release.js +10 -8
  14. package/dist-server/server/browser-engine.js +226 -26
  15. package/dist-server/server/browser-live.js +567 -0
  16. package/dist-server/server/browser-proxy.js +129 -0
  17. package/dist-server/server/browser-runtime.js +370 -0
  18. package/dist-server/server/claude-accounts.js +103 -0
  19. package/dist-server/server/config.js +31 -20
  20. package/dist-server/server/custom-domain.js +196 -0
  21. package/dist-server/server/drivers/claude.js +77 -19
  22. package/dist-server/server/drivers/codex-device-auth.js +234 -0
  23. package/dist-server/server/drivers/codex.js +10 -0
  24. package/dist-server/server/harness/bus.js +9 -4
  25. package/dist-server/server/harness/registry.js +15 -0
  26. package/dist-server/server/index.js +446 -91
  27. package/dist-server/server/provider-auth-sessions.js +118 -0
  28. package/dist-server/server/proxy-paths.js +1 -0
  29. package/dist-server/server/sse-fanout.js +53 -0
  30. package/dist-server/src/lib/composer-attachments.js +23 -6
  31. package/dist-server/vps-container-mcp.js +4 -1
  32. package/package.json +1 -1
  33. package/dist/assets/index-BuntoOSX.js +0 -264
  34. package/dist/assets/index-oPMv6a_k.css +0 -1
@@ -216,6 +216,8 @@ const defaultModelSelectionSchema = z.object({
216
216
  effort: z.enum(EFFORT_LEVELS).optional(),
217
217
  });
218
218
  const appConfigSchema = z.object({
219
+ /** Verified by the dedicated domain endpoint, never a generic config patch. */
220
+ customDomain: z.string().optional(),
219
221
  defaultModelSelection: defaultModelSelectionSchema.optional(),
220
222
  /** CLI-only launch preferences. Never enable remote access implicitly. */
221
223
  cliStartup: z.object({
@@ -261,7 +263,7 @@ const appConfigSchema = z.object({
261
263
  const storedAppConfigSchema = appConfigSchema.extend({
262
264
  browserProfiles: storedBrowserProfilesSchema.optional(),
263
265
  });
264
- const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true });
266
+ const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true, customDomain: true });
265
267
  const jsonObjectSchema = z.record(z.string(), z.json());
266
268
  /** Resolve a canonical profile record to its exact durable Electron
267
269
  * partition identity. Callers must never substitute the display/API id. */
@@ -511,7 +513,7 @@ export const PROVIDER_CREDENTIAL_ENV = [
511
513
  ];
512
514
  /** Merge a partial config into ~/.openmausbot/config.json (secrets never
513
515
  * echoed back — callers report configured-or-not booleans only). */
514
- export function saveConfig(patch) {
516
+ export function saveConfig(patch, options = {}) {
515
517
  const p = join(DATA_DIR, "config.json");
516
518
  let disk = {};
517
519
  try {
@@ -543,6 +545,8 @@ export function saveConfig(patch) {
543
545
  // scalar, not a section: the merge loop above only walks objects
544
546
  if (checkedPatch.language !== undefined)
545
547
  disk.language = checkedPatch.language;
548
+ if (checkedPatch.customDomain !== undefined)
549
+ disk.customDomain = checkedPatch.customDomain;
546
550
  // A selection is replaced as one value, so changing engines also clears
547
551
  // an effort level omitted from the new selection.
548
552
  if (checkedPatch.defaultModelSelection !== undefined) {
@@ -573,10 +577,17 @@ export function saveConfig(patch) {
573
577
  }
574
578
  if (checkedPatch.instances) {
575
579
  const currentInstances = jsonObjectSchema.safeParse(disk.instances);
576
- const diskInstances = currentInstances.success ? currentInstances.data : {};
580
+ const storedInstances = currentInstances.success ? currentInstances.data : {};
581
+ const diskInstances = options.replaceInstances ? {} : storedInstances;
577
582
  for (const [instanceId, entry] of Object.entries(checkedPatch.instances)) {
578
- const current = jsonObjectSchema.safeParse(diskInstances[instanceId]);
583
+ const current = jsonObjectSchema.safeParse(storedInstances[instanceId]);
579
584
  const merged = current.success ? { ...current.data } : {};
585
+ // Replacement clears omitted known settings, but retained shadow
586
+ // entries keep fields understood only by a newer app or driver.
587
+ if (options.replaceInstances) {
588
+ for (const key of Object.keys(instanceConfigSchema.shape))
589
+ delete merged[key];
590
+ }
580
591
  Object.assign(merged, entry);
581
592
  diskInstances[instanceId] = merged;
582
593
  }
@@ -590,13 +601,13 @@ export function saveConfig(patch) {
590
601
  * entry rides driver.defaultConfig(). Returns false for unknown instances
591
602
  * when the fleet is explicitly configured. The returned map must stay
592
603
  * PERSISTABLE: instanceConfigs() injects credential env into consuming
593
- * drivers' entries for the live fleet, so those injected keys are stripped
594
- * back out before the map is returned — otherwise saving an override would
604
+ * drivers' entries for the live fleet, so only their originally configured
605
+ * environment is retained — otherwise saving an override would
595
606
  * copy xai/box/opencodeGo secrets into the instances section of
596
607
  * config.json. */
597
608
  export function withInstanceCli(cfg, instanceId, cli) {
598
609
  const next = structuredClone(cfg);
599
- const map = instanceConfigs(next);
610
+ const map = persistableInstanceConfigs(next);
600
611
  // hasOwn, not truthiness: map is a plain object literal, so
601
612
  // map["__proto__"] resolves to Object.prototype — truthy — and the
602
613
  // assignment below would poison EVERY object in the process (instanceId
@@ -616,22 +627,22 @@ export function withInstanceCli(cfg, instanceId, cli) {
616
627
  delete rest.cli;
617
628
  entry.config = Object.keys(rest).length ? rest : undefined;
618
629
  }
619
- for (const e of Object.values(map)) {
620
- if (!e.environment)
621
- continue;
622
- const injected = injectedEnvironment(next, e.driver);
623
- for (const [k, v] of Object.entries(e.environment)) {
624
- if (injected.get(k) === v)
625
- delete e.environment[k];
626
- }
627
- if (!Object.keys(e.environment).length)
628
- delete e.environment;
629
- }
630
630
  next.instances = map;
631
631
  return { ok: true, config: next };
632
632
  }
633
- /** The credential env instanceConfigs() injects for one driver — shared with
634
- * withInstanceCli() so the inject rule and the strip rule cannot drift apart.
633
+ /** Materialize defaults without copying injected workspace secrets to disk. */
634
+ export function persistableInstanceConfigs(cfg) {
635
+ const map = instanceConfigs(cfg);
636
+ for (const [id, entry] of Object.entries(map)) {
637
+ const environment = cfg.instances?.[id]?.environment;
638
+ if (environment)
639
+ entry.environment = { ...environment };
640
+ else
641
+ delete entry.environment;
642
+ }
643
+ return map;
644
+ }
645
+ /** The credential env instanceConfigs() injects for one driver at runtime.
635
646
  * Each secret goes only to the driver that actually reads it: the API-key
636
647
  * Grok driver reads XAI_API_KEY, the Computer driver reads BOX_TOKEN, and
637
648
  * OpenCode reads OPENCODE_API_KEY. Every other engine brings its own
@@ -0,0 +1,196 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lookup as dnsLookup } from "node:dns/promises";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { BlockList, isIP } from "node:net";
5
+ import { networkInterfaces } from "node:os";
6
+ const CHECK_TIMEOUT_MS = 8_000;
7
+ const MAX_RESPONSE_BYTES = 16_384;
8
+ const DESCRIPTOR_PATH = "/.well-known/openmausbot/environment";
9
+ export const CUSTOM_DOMAIN_CHALLENGE_PATH = "/.well-known/openmausbot/domain-check/";
10
+ export class CustomDomainError extends Error {
11
+ status;
12
+ constructor(message, status = 400) {
13
+ super(message);
14
+ this.name = "CustomDomainError";
15
+ this.status = status;
16
+ }
17
+ }
18
+ /** A public domain on standard HTTPS, never an invitation or arbitrary URL. */
19
+ export function normalizeCustomDomain(raw) {
20
+ const value = raw.trim();
21
+ // Reject characters URL parsing otherwise removes or interprets as secrets.
22
+ // eslint-disable-next-line no-control-regex
23
+ if (!value || value.length > 512 || /[\s\u0000-\u001f\u007f\\?#@%]/.test(value)) {
24
+ throw new CustomDomainError("Enter a domain such as bots.yourcompany.com, without a path, password, or pairing code.");
25
+ }
26
+ try {
27
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
28
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
29
+ if (url.protocol !== "https:" || url.port || url.pathname !== "/" || url.username || url.password
30
+ || isIP(host) || host.startsWith("[") || host.length > 253 || !host.includes(".")
31
+ || !host.split(".").every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part))
32
+ || /(?:^|\.)(?:localhost|local|internal|invalid|test)$/.test(host))
33
+ throw new Error("invalid origin");
34
+ return `https://${host}`;
35
+ }
36
+ catch {
37
+ throw new CustomDomainError("Use a public domain with HTTPS on port 443, without a path or pairing code.");
38
+ }
39
+ }
40
+ const excludedAddresses = new BlockList();
41
+ for (const [address, prefix] of [
42
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
43
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
44
+ ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
45
+ ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
46
+ ])
47
+ excludedAddresses.addSubnet(address, prefix, "ipv4");
48
+ for (const [address, prefix] of [
49
+ ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20],
50
+ ])
51
+ excludedAddresses.addSubnet(address, prefix, "ipv6");
52
+ const globalIpv6 = new BlockList();
53
+ globalIpv6.addSubnet("2000::", 3, "ipv6");
54
+ /** Exclude private, mapped/NAT64, multicast, documentation, and reserved ranges. */
55
+ export function isPublicDomainAddress(address) {
56
+ const family = isIP(address);
57
+ if (family === 4)
58
+ return !excludedAddresses.check(address, "ipv4");
59
+ return family === 6 && globalIpv6.check(address, "ipv6") && !excludedAddresses.check(address, "ipv6");
60
+ }
61
+ /** Local interfaces only: a tunnel/CDN address or an external IP-echo service
62
+ * does not identify the server where the customer's HTTPS proxy runs. */
63
+ export function customDomainIpv4(interfaces = networkInterfaces(), configured = process.env.OMB_PUBLIC_IPV4) {
64
+ if (configured?.trim()) {
65
+ const address = configured.trim();
66
+ return isIP(address) === 4 && isPublicDomainAddress(address) ? address : null;
67
+ }
68
+ const addresses = new Set(Object.values(interfaces).flatMap((entries) => entries ?? [])
69
+ .filter((entry) => !entry.internal && entry.family === "IPv4" && isPublicDomainAddress(entry.address))
70
+ .map((entry) => entry.address));
71
+ // Containers/NAT and multi-address hosts need an explicit operator choice.
72
+ return addresses.size === 1 ? [...addresses][0] : null;
73
+ }
74
+ function withAbort(promise, signal) {
75
+ return new Promise((resolve, reject) => {
76
+ const abort = () => reject(new CustomDomainError("The domain check timed out. Check DNS, HTTPS, and your reverse proxy, then try again."));
77
+ if (signal.aborted) {
78
+ abort();
79
+ return;
80
+ }
81
+ signal.addEventListener("abort", abort, { once: true });
82
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
83
+ });
84
+ }
85
+ function requestJson(url, address, signal) {
86
+ return new Promise((resolve, reject) => {
87
+ // Pin the validated DNS result for both requests. TLS/SNI and Host still use
88
+ // the original domain, so a DNS rebind cannot reach a private IP afterwards.
89
+ const lookup = (_hostname, options, callback) => {
90
+ if (options.all)
91
+ callback(null, [address]);
92
+ else
93
+ callback(null, address.address, address.family);
94
+ };
95
+ const request = httpsRequest(url, {
96
+ method: "GET", agent: false, lookup, signal, rejectUnauthorized: true,
97
+ headers: { Accept: "application/json", "User-Agent": "OpenMausBot-domain-check" },
98
+ }, (response) => {
99
+ // Node's HTTPS client does not follow Location redirects.
100
+ if (response.statusCode !== 200) {
101
+ response.destroy();
102
+ reject(new CustomDomainError("The domain did not return the OpenMausBot verification page. Check your reverse proxy; redirects are not supported."));
103
+ return;
104
+ }
105
+ const chunks = [];
106
+ let size = 0;
107
+ response.on("error", reject);
108
+ response.on("data", (chunk) => {
109
+ size += chunk.length;
110
+ if (size > MAX_RESPONSE_BYTES) {
111
+ response.destroy();
112
+ reject(new CustomDomainError("The domain returned an unexpected response. Check that it points to OpenMausBot."));
113
+ return;
114
+ }
115
+ chunks.push(chunk);
116
+ });
117
+ response.on("end", () => {
118
+ try {
119
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
120
+ }
121
+ catch {
122
+ reject(new CustomDomainError("The domain did not return OpenMausBot data. Check that it points to this server."));
123
+ }
124
+ });
125
+ });
126
+ request.on("error", reject);
127
+ request.end();
128
+ });
129
+ }
130
+ /** Verification only: callers persist the returned origin after success. The
131
+ * public challenge endpoint reveals no account credentials and grants no access. */
132
+ export function createCustomDomainVerifier(dependencies) {
133
+ const lookup = dependencies.lookup ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
134
+ const request = dependencies.request ?? requestJson;
135
+ let inFlight = false;
136
+ let active = null;
137
+ return {
138
+ challenge(token) {
139
+ if (!active || Date.now() >= active.expiresAt || token !== active.token)
140
+ return null;
141
+ return { environmentId: dependencies.environmentId, proof: active.proof };
142
+ },
143
+ async verify(raw) {
144
+ const origin = normalizeCustomDomain(raw);
145
+ if (inFlight)
146
+ throw new CustomDomainError("A domain check is already running. Wait for it to finish, then try again.", 409);
147
+ inFlight = true;
148
+ const controller = new AbortController();
149
+ const timeoutMs = dependencies.timeoutMs ?? CHECK_TIMEOUT_MS;
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
151
+ try {
152
+ let addresses;
153
+ try {
154
+ addresses = await withAbort(lookup(new URL(origin).hostname), controller.signal);
155
+ }
156
+ catch (error) {
157
+ if (error instanceof CustomDomainError)
158
+ throw error;
159
+ throw new CustomDomainError("The domain could not be found in DNS. Add its A or AAAA record and allow time for it to become available.");
160
+ }
161
+ if (!addresses.length || addresses.some((entry) => !isPublicDomainAddress(entry.address)
162
+ || isIP(entry.address) !== entry.family)) {
163
+ throw new CustomDomainError("The domain must resolve only to public internet addresses. Local, private, and reserved addresses cannot be connected here.");
164
+ }
165
+ // Prefer IPv4 where both exist; use the exact same address for proof.
166
+ const address = addresses.find((entry) => entry.family === 4) ?? addresses[0];
167
+ const descriptor = await withAbort(request(new URL(DESCRIPTOR_PATH, origin), address, controller.signal), controller.signal);
168
+ if (!descriptor || typeof descriptor !== "object"
169
+ || Reflect.get(descriptor, "environmentId") !== dependencies.environmentId) {
170
+ throw new CustomDomainError("This domain does not point to this OpenMausBot server. Check its DNS and reverse proxy configuration.");
171
+ }
172
+ active = {
173
+ token: randomBytes(32).toString("hex"), proof: randomBytes(32).toString("hex"),
174
+ expiresAt: Date.now() + timeoutMs,
175
+ };
176
+ const proof = await withAbort(request(new URL(`${CUSTOM_DOMAIN_CHALLENGE_PATH}${active.token}`, origin), address, controller.signal), controller.signal);
177
+ if (!proof || typeof proof !== "object" || Reflect.get(proof, "environmentId") !== dependencies.environmentId
178
+ || Reflect.get(proof, "proof") !== active.proof) {
179
+ throw new CustomDomainError("The domain could not prove it reaches this server. Forward all OpenMausBot routes through your reverse proxy and try again.");
180
+ }
181
+ return { origin, verifiedAt: new Date().toISOString() };
182
+ }
183
+ catch (error) {
184
+ if (error instanceof CustomDomainError)
185
+ throw error;
186
+ throw new CustomDomainError("A secure connection to the domain could not be verified. Check its HTTPS certificate, firewall, and reverse proxy.");
187
+ }
188
+ finally {
189
+ clearTimeout(timer);
190
+ controller.abort();
191
+ active = null;
192
+ inFlight = false;
193
+ }
194
+ },
195
+ };
196
+ }
@@ -12,7 +12,7 @@ import { createHash, randomBytes } from "node:crypto";
12
12
  import { chmodSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
13
13
  import { createServer as createNetServer } from "node:net";
14
14
  import { homedir, tmpdir } from "node:os";
15
- import { join, dirname } from "node:path";
15
+ import { join, dirname, isAbsolute, normalize } from "node:path";
16
16
  import { DATA_DIR, stripWorkspaceCredentialEnv } from "../config.js";
17
17
  import { augmentedPath } from "../env-path.js";
18
18
  import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
@@ -29,19 +29,61 @@ import { SPAWNED_PROXIES } from "../proxy-paths.js";
29
29
  * backends over time. Presence checks also accept stale credentials. The CLI's
30
30
  * own machine-readable auth command is the source of truth for every backend.
31
31
  */
32
- export function claudeSignedIn(cli, env, run = execCli) {
32
+ function claudeAuthStatus(cli, env, run = execCli) {
33
33
  return new Promise((resolve) => {
34
- run(cli, ["auth", "status", "--json"], { timeout: 8000, env }, (_error, stdout) => {
34
+ run(cli, ["auth", "status", "--json"], { timeout: 8000, maxBuffer: 65_536, env }, (_error, stdout) => {
35
35
  try {
36
36
  const status = JSON.parse(stdout);
37
- resolve(typeof status === "object" && status !== null && "loggedIn" in status && status.loggedIn === true);
37
+ if (!status || typeof status !== "object" || !("loggedIn" in status) || status.loggedIn !== true) {
38
+ return resolve({ authenticated: false });
39
+ }
40
+ // Only display identity fields, never the CLI's full auth response.
41
+ const identity = status;
42
+ const boundedText = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value)
43
+ ? value.trim() : undefined;
44
+ const candidateEmail = boundedText(identity.email, 254);
45
+ const email = candidateEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidateEmail) ? candidateEmail : undefined;
46
+ const organization = boundedText(identity.orgName, 160);
47
+ resolve({
48
+ authenticated: true,
49
+ ...(email || organization ? { account: { ...(email ? { email } : {}), ...(organization ? { organization } : {}) } } : {}),
50
+ });
38
51
  }
39
52
  catch {
40
- resolve(false);
53
+ resolve({ authenticated: false });
41
54
  }
42
55
  });
43
56
  });
44
57
  }
58
+ export async function claudeSignedIn(cli, env, run = execCli) {
59
+ return (await claudeAuthStatus(cli, env, run)).authenticated;
60
+ }
61
+ /** Parent-session credentials/routing that a named account must not inherit.
62
+ * Shared with terminal sign-in instructions so login and turns select alike. */
63
+ export const CLAUDE_ACCOUNT_ENV_KEYS = [
64
+ "CLAUDE_CODE_OAUTH_TOKEN",
65
+ "CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
66
+ "CLAUDE_CODE_OAUTH_SCOPES",
67
+ "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
68
+ "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
69
+ "ANTHROPIC_API_KEY",
70
+ "ANTHROPIC_AUTH_TOKEN",
71
+ "ANTHROPIC_BASE_URL",
72
+ "ANTHROPIC_CUSTOM_HEADERS",
73
+ "CLAUDE_CODE_USE_BEDROCK",
74
+ "CLAUDE_CODE_USE_VERTEX",
75
+ "CLAUDE_CODE_USE_FOUNDRY",
76
+ ];
77
+ /** Resolve the CLI's config location without changing HOME or Keychain. */
78
+ export function resolveClaudeConfigDir(configDir, env = process.env) {
79
+ const home = env.HOME || env.USERPROFILE || homedir();
80
+ const configured = configDir?.trim() || env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
81
+ const expanded = configured === "~" ? home : configured.startsWith("~/") ? join(home, configured.slice(2)) : configured;
82
+ if (!isAbsolute(expanded) || /[\p{Cc}\p{Cf}]/u.test(expanded)) {
83
+ throw new Error("claude: configDir must be an absolute path or start with ~/");
84
+ }
85
+ return normalize(expanded);
86
+ }
45
87
  /** Whether a stream frame is the CLI reporting that it has no login.
46
88
  *
47
89
  * The CLI flags its own api-error frames (`error`, `is_api_error_message`);
@@ -63,8 +105,21 @@ export function claudeAuthFailure(frame, text) {
63
105
  * Keeping the probe and turn environments identical prevents setup from
64
106
  * claiming an API-key login that the turn itself would deliberately remove.
65
107
  */
66
- function claudeEnvironment(model, source = process.env) {
108
+ function claudeEnvironment(model, source = process.env, configDir, instanceEnvironment = {}) {
67
109
  const env = { ...source, PATH: augmentedPath(), NPM_CONFIG_LOGLEVEL: "error" };
110
+ if (configDir?.trim()) {
111
+ env.CLAUDE_CONFIG_DIR = resolveClaudeConfigDir(configDir, env);
112
+ for (const key of CLAUDE_ACCOUNT_ENV_KEYS) {
113
+ // Explicit custom endpoint settings still work; subscription OAuth
114
+ // always belongs to this account's CLI-managed login, never its parent.
115
+ if (key.startsWith("CLAUDE_CODE_OAUTH_") || key.endsWith("_FILE_DESCRIPTOR") || !Object.hasOwn(instanceEnvironment, key)) {
116
+ delete env[key];
117
+ }
118
+ }
119
+ }
120
+ else if (env.CLAUDE_CONFIG_DIR) {
121
+ env.CLAUDE_CONFIG_DIR = resolveClaudeConfigDir(undefined, env);
122
+ }
68
123
  delete env.CLAUDECODE;
69
124
  delete env.CLAUDE_CODE_ENTRYPOINT;
70
125
  // The harness process may hold workspace credentials (xai/box/voice keys,
@@ -97,11 +152,6 @@ async function resolveClaudeTurnModel(model, env) {
97
152
  }
98
153
  return resolveInjectId(model, await probeLocalInjects(env)) ?? model;
99
154
  }
100
- function claudeConfigDir(env) {
101
- if (env.CLAUDE_CONFIG_DIR)
102
- return env.CLAUDE_CONFIG_DIR;
103
- return join(env.HOME || env.USERPROFILE || homedir(), ".claude");
104
- }
105
155
  function extrasFromUnknown(value) {
106
156
  if (!Array.isArray(value))
107
157
  return [];
@@ -127,7 +177,7 @@ function extrasFromUnknown(value) {
127
177
  export function readClaudeModelCatalog(env = process.env) {
128
178
  let settings = {};
129
179
  try {
130
- settings = JSON.parse(readFileSync(join(claudeConfigDir(env), "settings.json"), "utf8"));
180
+ settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
131
181
  }
132
182
  catch {
133
183
  return STATIC_CLAUDE_MODELS;
@@ -446,8 +496,14 @@ function decodeConfig(raw) {
446
496
  }
447
497
  const tools = decodeToolList(o.tools, "tools");
448
498
  const disallowedTools = decodeToolList(o.disallowedTools, "disallowedTools");
499
+ if (o.configDir !== undefined && typeof o.configDir !== "string")
500
+ throw new Error("claude: configDir must be a string");
501
+ const configDir = typeof o.configDir === "string" ? o.configDir.trim() : undefined;
502
+ if (configDir)
503
+ resolveClaudeConfigDir(configDir);
449
504
  return {
450
505
  cli: typeof o.cli === "string" ? o.cli : "claude",
506
+ ...(configDir ? { configDir } : {}),
451
507
  permissionMode: mode ?? "acceptEdits",
452
508
  ...(tools !== undefined ? { tools } : {}),
453
509
  ...(disallowedTools !== undefined ? { disallowedTools } : {}),
@@ -523,7 +579,8 @@ export const ClaudeDriver = {
523
579
  defaultConfig: () => decodeConfig({}),
524
580
  async create(input) {
525
581
  const { instanceId, config } = input;
526
- const catalogEnv = { ...process.env, ...input.environment };
582
+ const environment = (model) => claudeEnvironment(model, { ...process.env, ...input.environment }, config.configDir, input.environment);
583
+ const catalogEnv = environment();
527
584
  let models = STATIC_CLAUDE_MODELS;
528
585
  const refreshModels = async () => {
529
586
  try {
@@ -659,7 +716,7 @@ export const ClaudeDriver = {
659
716
  if (config.disallowedTools?.length) {
660
717
  args.push("--disallowedTools", config.disallowedTools.join(","));
661
718
  }
662
- const turnEnvironment = { ...process.env, ...input.environment };
719
+ const turnEnvironment = environment();
663
720
  const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
664
721
  const injected = applyClaudeInject({ ...turnEnvironment }, turnModel);
665
722
  if (injected.model)
@@ -762,7 +819,7 @@ export const ClaudeDriver = {
762
819
  args.push("--mcp-config", mcpConfigPath);
763
820
  args.push("--allowedTools", allowed.join(","));
764
821
  }
765
- const env = claudeEnvironment(turnModel, turnEnvironment);
822
+ const env = environment(turnModel);
766
823
  // Our approvals and browser credentials expire at the user-turn
767
824
  // boundary. Native background workers cannot outlive that boundary;
768
825
  // parallel bot work must use the harness's durable delegate_bot path.
@@ -779,6 +836,7 @@ export const ClaudeDriver = {
779
836
  cwd,
780
837
  model: injected.model ?? null,
781
838
  base: env.ANTHROPIC_BASE_URL ?? null,
839
+ configDir: env.CLAUDE_CONFIG_DIR ?? null,
782
840
  });
783
841
  // Reuse the live process when it is idle, unchanged, and is the session
784
842
  // the harness wants resumed. Anything else: close it and spawn fresh
@@ -1230,17 +1288,17 @@ export const ClaudeDriver = {
1230
1288
  return writeUser(s, threadId, claudeUserMessage(text, undefined));
1231
1289
  };
1232
1290
  const snapshot = async () => {
1233
- const env = claudeEnvironment(undefined, { ...process.env, ...input.environment });
1291
+ const env = environment();
1234
1292
  const version = await new Promise((resolve) => {
1235
1293
  execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim()));
1236
1294
  });
1237
1295
  if (!version)
1238
1296
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1239
- const authenticated = await claudeSignedIn(config.cli, env);
1297
+ const auth = await claudeAuthStatus(config.cli, env);
1240
1298
  // claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
1241
1299
  // CLI's own login (Pro/Max): the cost it reports is what the call
1242
1300
  // WOULD bill, not a charge
1243
- return { state: "available", version, authenticated, billing: "subscription" };
1301
+ return { state: "available", version, ...auth, billing: "subscription" };
1244
1302
  };
1245
1303
  /** One-shot Claude call with the prompt on stdin, never argv. Approval
1246
1304
  * summaries can contain paths, commands, or secrets, so the generic
@@ -1249,7 +1307,7 @@ export const ClaudeDriver = {
1249
1307
  const generateReview = (prompt, signal) => new Promise((resolve, reject) => {
1250
1308
  const child = spawnCli(config.cli, ["-p", "--model", "claude-haiku-4-5", "--output-format", "text"], {
1251
1309
  stdio: ["pipe", "pipe", "pipe"],
1252
- env: claudeEnvironment("claude-haiku-4-5", { ...process.env, ...input.environment }),
1310
+ env: environment("claude-haiku-4-5"),
1253
1311
  });
1254
1312
  let stdout = "";
1255
1313
  let stderr = "";