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.
- package/dist/assets/index-BftlaHAF.css +1 -0
- package/dist/assets/index-DuR3H-Hf.js +278 -0
- package/dist/assets/{index-C-SoIGdn.js → index-kW5L1QeO.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +121 -0
- package/dist-server/container-mcp.js +4 -1
- package/dist-server/index.js +17996 -15876
- package/dist-server/openmausbot.js +752 -245
- package/dist-server/pair-cli.js +752 -245
- package/dist-server/prepare-cloudflared.js +10 -2
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/attachments.js +140 -16
- package/dist-server/server/browser-engine-release.js +10 -8
- package/dist-server/server/browser-engine.js +226 -26
- package/dist-server/server/browser-live.js +567 -0
- package/dist-server/server/browser-proxy.js +129 -0
- package/dist-server/server/browser-runtime.js +370 -0
- package/dist-server/server/claude-accounts.js +103 -0
- package/dist-server/server/config.js +31 -20
- package/dist-server/server/custom-domain.js +196 -0
- package/dist-server/server/drivers/claude.js +77 -19
- package/dist-server/server/drivers/codex-device-auth.js +234 -0
- package/dist-server/server/drivers/codex.js +10 -0
- package/dist-server/server/harness/bus.js +9 -4
- package/dist-server/server/harness/registry.js +15 -0
- package/dist-server/server/index.js +446 -91
- package/dist-server/server/provider-auth-sessions.js +118 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/sse-fanout.js +53 -0
- package/dist-server/src/lib/composer-attachments.js +23 -6
- package/dist-server/vps-container-mcp.js +4 -1
- package/package.json +1 -1
- package/dist/assets/index-BuntoOSX.js +0 -264
- 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
|
|
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(
|
|
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
|
|
594
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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:
|
|
1310
|
+
env: environment("claude-haiku-4-5"),
|
|
1253
1311
|
});
|
|
1254
1312
|
let stdout = "";
|
|
1255
1313
|
let stderr = "";
|