openmausbot 0.1.65 → 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.
@@ -513,7 +513,7 @@ export const PROVIDER_CREDENTIAL_ENV = [
513
513
  ];
514
514
  /** Merge a partial config into ~/.openmausbot/config.json (secrets never
515
515
  * echoed back — callers report configured-or-not booleans only). */
516
- export function saveConfig(patch) {
516
+ export function saveConfig(patch, options = {}) {
517
517
  const p = join(DATA_DIR, "config.json");
518
518
  let disk = {};
519
519
  try {
@@ -577,10 +577,17 @@ export function saveConfig(patch) {
577
577
  }
578
578
  if (checkedPatch.instances) {
579
579
  const currentInstances = jsonObjectSchema.safeParse(disk.instances);
580
- const diskInstances = currentInstances.success ? currentInstances.data : {};
580
+ const storedInstances = currentInstances.success ? currentInstances.data : {};
581
+ const diskInstances = options.replaceInstances ? {} : storedInstances;
581
582
  for (const [instanceId, entry] of Object.entries(checkedPatch.instances)) {
582
- const current = jsonObjectSchema.safeParse(diskInstances[instanceId]);
583
+ const current = jsonObjectSchema.safeParse(storedInstances[instanceId]);
583
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
+ }
584
591
  Object.assign(merged, entry);
585
592
  diskInstances[instanceId] = merged;
586
593
  }
@@ -594,13 +601,13 @@ export function saveConfig(patch) {
594
601
  * entry rides driver.defaultConfig(). Returns false for unknown instances
595
602
  * when the fleet is explicitly configured. The returned map must stay
596
603
  * PERSISTABLE: instanceConfigs() injects credential env into consuming
597
- * drivers' entries for the live fleet, so those injected keys are stripped
598
- * 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
599
606
  * copy xai/box/opencodeGo secrets into the instances section of
600
607
  * config.json. */
601
608
  export function withInstanceCli(cfg, instanceId, cli) {
602
609
  const next = structuredClone(cfg);
603
- const map = instanceConfigs(next);
610
+ const map = persistableInstanceConfigs(next);
604
611
  // hasOwn, not truthiness: map is a plain object literal, so
605
612
  // map["__proto__"] resolves to Object.prototype — truthy — and the
606
613
  // assignment below would poison EVERY object in the process (instanceId
@@ -620,22 +627,22 @@ export function withInstanceCli(cfg, instanceId, cli) {
620
627
  delete rest.cli;
621
628
  entry.config = Object.keys(rest).length ? rest : undefined;
622
629
  }
623
- for (const e of Object.values(map)) {
624
- if (!e.environment)
625
- continue;
626
- const injected = injectedEnvironment(next, e.driver);
627
- for (const [k, v] of Object.entries(e.environment)) {
628
- if (injected.get(k) === v)
629
- delete e.environment[k];
630
- }
631
- if (!Object.keys(e.environment).length)
632
- delete e.environment;
633
- }
634
630
  next.instances = map;
635
631
  return { ok: true, config: next };
636
632
  }
637
- /** The credential env instanceConfigs() injects for one driver — shared with
638
- * 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.
639
646
  * Each secret goes only to the driver that actually reads it: the API-key
640
647
  * Grok driver reads XAI_API_KEY, the Computer driver reads BOX_TOKEN, and
641
648
  * OpenCode reads OPENCODE_API_KEY. Every other engine brings its own
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { lookup as dnsLookup } from "node:dns/promises";
3
3
  import { request as httpsRequest } from "node:https";
4
4
  import { BlockList, isIP } from "node:net";
5
+ import { networkInterfaces } from "node:os";
5
6
  const CHECK_TIMEOUT_MS = 8_000;
6
7
  const MAX_RESPONSE_BYTES = 16_384;
7
8
  const DESCRIPTOR_PATH = "/.well-known/openmausbot/environment";
@@ -57,6 +58,19 @@ export function isPublicDomainAddress(address) {
57
58
  return !excludedAddresses.check(address, "ipv4");
58
59
  return family === 6 && globalIpv6.check(address, "ipv6") && !excludedAddresses.check(address, "ipv6");
59
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
+ }
60
74
  function withAbort(promise, signal) {
61
75
  return new Promise((resolve, reject) => {
62
76
  const abort = () => reject(new CustomDomainError("The domain check timed out. Check DNS, HTTPS, and your reverse proxy, then try again."));
@@ -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 = "";
@@ -12,7 +12,7 @@ import { newId } from "../contracts.js";
12
12
  const INCOMPLETE_LOG_MESSAGE = "Canonical event history is incomplete: OpenMausBot could not write one or more events to disk. Live updates will continue.";
13
13
  export class EventBus {
14
14
  listeners = new Set();
15
- unsubscribes = [];
15
+ unsubscribes = new Map();
16
16
  pendingLogWarnings = new Map();
17
17
  appendLog;
18
18
  constructor(appendLog = appendFileSync) {
@@ -20,6 +20,7 @@ export class EventBus {
20
20
  }
21
21
  attach(instances) {
22
22
  for (const instance of instances) {
23
+ this.detach(instance.instanceId);
23
24
  const unsub = instance.adapter.onEvent((event) => {
24
25
  // hard invariant borrowed from correlateRuntimeEventWithInstance:
25
26
  // an adapter may only emit events for its own driver kind
@@ -29,7 +30,7 @@ export class EventBus {
29
30
  }
30
31
  this.publish({ ...event, providerInstanceId: instance.instanceId });
31
32
  });
32
- this.unsubscribes.push(unsub);
33
+ this.unsubscribes.set(instance.instanceId, unsub);
33
34
  }
34
35
  }
35
36
  publish(event) {
@@ -80,7 +81,11 @@ export class EventBus {
80
81
  return () => this.listeners.delete(listener);
81
82
  }
82
83
  detachAll() {
83
- for (const unsub of this.unsubscribes.splice(0))
84
- unsub();
84
+ for (const id of this.unsubscribes.keys())
85
+ this.detach(id);
86
+ }
87
+ detach(instanceId) {
88
+ this.unsubscribes.get(instanceId)?.();
89
+ this.unsubscribes.delete(instanceId);
85
90
  }
86
91
  }
@@ -35,6 +35,8 @@ export class ProviderRegistry {
35
35
  }
36
36
  async load(configs) {
37
37
  for (const [instanceId, entry] of Object.entries(configs)) {
38
+ // Account edits replace only their own process/session state.
39
+ await this.dispose(instanceId);
38
40
  const driver = this.driversByKind.get(entry.driver);
39
41
  if (!driver) {
40
42
  this.byId.set(instanceId, {
@@ -221,4 +223,10 @@ export class ProviderRegistry {
221
223
  this.byId.clear();
222
224
  this.cliByInstance.clear();
223
225
  }
226
+ async dispose(instanceId) {
227
+ const entry = this.byId.get(instanceId);
228
+ this.byId.delete(instanceId);
229
+ this.cliByInstance.delete(instanceId);
230
+ await entry?.live?.dispose();
231
+ }
224
232
  }