@cabane/companion 0.6.215 → 0.6.217

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.
@@ -0,0 +1,653 @@
1
+ // src/credential-broker/broker.ts
2
+ import { randomBytes } from "crypto";
3
+ import { createServer } from "http";
4
+ import { Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
6
+
7
+ // src/credential-broker/policy.ts
8
+ function createsOrRevealsCredential(origin, path) {
9
+ if (origin === "https://api.anthropic.com")
10
+ return /^\/api\/oauth\/claude_cli\/create_api_key(?:\/|$)/.test(path) || /^\/(?:v1\/)?oauth\/token(?:\/|$)/.test(path);
11
+ if (origin === "https://chatgpt.com")
12
+ return /^\/api\/auth\/session(?:\/|$)/.test(path) || /^\/(?:v1\/)?oauth\/token(?:\/|$)/.test(path);
13
+ if (origin === "https://github.com")
14
+ return /^\/login\/(?:oauth\/access_token|device\/code)(?:\/|$)/.test(path);
15
+ path = path.replace(/^\/repositories\/[^/]+(?=\/|$)/, "/repos/_/_");
16
+ return /^\/user\/keys(?:\/|$)/.test(path) || /^\/repos\/[^/]+\/[^/]+\/keys(?:\/|$)/.test(path) || /^\/authorizations(?:\/|$)/.test(path) || /^\/app-manifests\/[^/]+\/conversions(?:\/|$)/.test(path) || /^\/app\/installations\/[^/]+\/access_tokens(?:\/|$)/.test(path) || /^\/applications\/[^/]+\/token(?:\/|$)/.test(path) || /^\/(?:repos\/[^/]+\/[^/]+|orgs\/[^/]+|enterprises\/[^/]+)\/actions\/runners\/(?:registration-token|remove-token|generate-jitconfig)(?:\/|$)/.test(
17
+ path
18
+ );
19
+ }
20
+ function brokerRoute(method, target) {
21
+ if (method === "CONNECT") return null;
22
+ if (target.startsWith("http://github.com/"))
23
+ target = "/git/" + target.slice("http://github.com/".length);
24
+ if (!target.startsWith("/") || target.startsWith("//") || /[\\#]/.test(target) || [...target].some((char) => char.charCodeAt(0) <= 32))
25
+ return null;
26
+ const path = target.split("?")[0];
27
+ let decoded;
28
+ try {
29
+ decoded = decodeURIComponent(path);
30
+ } catch {
31
+ return null;
32
+ }
33
+ if (/[\\%;]/.test(decoded) || /%2f/i.test(path) || decoded.split("/").some((part) => part === "." || part === "..") || [...decoded].some((char) => char.charCodeAt(0) <= 32))
34
+ return null;
35
+ const url = new URL(target, "http://broker.invalid");
36
+ if (path === "/codex/refresh" && method === "POST" && !url.search)
37
+ return { origin: "https://chatgpt.com", path, service: "codex", refresh: true };
38
+ const destinations = [
39
+ ["/claude", "https://api.anthropic.com", "claude"],
40
+ ["/codex", "https://chatgpt.com", "codex"],
41
+ ["/github", "https://api.github.com", "github"],
42
+ ["/git", "https://github.com", "github"]
43
+ ];
44
+ for (const [prefix, origin, service] of destinations) {
45
+ if (!path.startsWith(prefix + "/")) continue;
46
+ const upstreamPath = path.slice(prefix.length);
47
+ const canonical = decoded.slice(prefix.length).replace(/\/{2,}/g, "/").toLowerCase();
48
+ if (createsOrRevealsCredential(origin, canonical)) return null;
49
+ const operation = upstreamPath.endsWith("/info/refs") ? url.searchParams.get("service") : upstreamPath.split("/").at(-1);
50
+ const git = origin === "https://github.com" && (operation === "git-upload-pack" || operation === "git-receive-pack");
51
+ return {
52
+ origin,
53
+ path: upstreamPath + url.search,
54
+ service,
55
+ ...git ? { git: operation === "git-upload-pack" ? "upload" : "receive" } : {},
56
+ ...origin === "https://chatgpt.com" && upstreamPath === "/backend-api/ps/mcp" && ["GET", "POST", "DELETE"].includes(method) ? { identityOnly: true } : {}
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // src/credential-broker/response-filter.ts
63
+ import { Transform } from "stream";
64
+ function credentialEchoes(secrets) {
65
+ return [
66
+ ...new Set(
67
+ secrets.flatMap((secret) => [secret, `Bearer ${secret}`, `x-access-token:${secret}`]).flatMap((value) => [
68
+ value,
69
+ encodeURIComponent(value),
70
+ Buffer.from(value).toString("base64"),
71
+ Buffer.from(value).toString("base64url"),
72
+ JSON.stringify(value).slice(1, -1)
73
+ ])
74
+ )
75
+ ].filter(Boolean);
76
+ }
77
+ function secretResponseFilter(secrets) {
78
+ const needles = credentialEchoes(secrets).map((value) => Buffer.from(value));
79
+ const keep = Math.max(1, ...needles.map((needle) => needle.length)) - 1;
80
+ let pending = Buffer.alloc(0);
81
+ return new Transform({
82
+ transform(chunk, _encoding, done) {
83
+ const bytes = Buffer.concat([pending, chunk]);
84
+ if (needles.some((needle) => bytes.includes(needle))) {
85
+ done(new Error("credential_echo"));
86
+ return;
87
+ }
88
+ const length = Math.max(0, bytes.length - keep);
89
+ if (length) this.push(bytes.subarray(0, length));
90
+ pending = bytes.subarray(length);
91
+ done();
92
+ },
93
+ flush(done) {
94
+ if (pending.length) this.push(pending);
95
+ done();
96
+ }
97
+ });
98
+ }
99
+
100
+ // src/credential-broker/broker.ts
101
+ var REQUEST_HEADERS = [
102
+ "accept",
103
+ "content-type",
104
+ "content-encoding",
105
+ "user-agent",
106
+ "anthropic-version",
107
+ "anthropic-beta",
108
+ "x-app",
109
+ "originator",
110
+ "version",
111
+ "session_id",
112
+ "conversation_id",
113
+ "openai-beta",
114
+ "git-protocol",
115
+ "mcp-protocol-version",
116
+ "mcp-session-id"
117
+ ];
118
+ var RESPONSE_HEADERS = [
119
+ "link",
120
+ "content-type",
121
+ "retry-after",
122
+ "x-request-id",
123
+ "request-id",
124
+ "x-ratelimit-limit",
125
+ "x-ratelimit-remaining",
126
+ "x-ratelimit-reset",
127
+ "mcp-session-id"
128
+ ];
129
+ async function readBody(req, limit = 64 * 1024 * 1024) {
130
+ const chunks = [];
131
+ let length = 0;
132
+ for await (const chunk of req) {
133
+ length += chunk.length;
134
+ if (length > limit) throw new Error("body_limit");
135
+ chunks.push(chunk);
136
+ }
137
+ return Buffer.concat(chunks);
138
+ }
139
+ function requestToken(req) {
140
+ const auth = req.headers.authorization;
141
+ const apiKey = req.headers["x-api-key"];
142
+ const names = req.rawHeaders.filter((_, index) => index % 2 === 0).map((name) => name.toLowerCase());
143
+ if (names.filter((name) => ["authorization", "x-api-key"].includes(name)).length > 1 || req.headers["proxy-authorization"] !== void 0)
144
+ throw new Error("conflicting_credentials");
145
+ if (apiKey !== void 0) return typeof apiKey === "string" ? apiKey : null;
146
+ if (auth === void 0) return null;
147
+ const bearer = auth.match(/^(?:Bearer|token) ([A-Za-z0-9_.-]+)$/i);
148
+ if (bearer) return bearer[1];
149
+ const basic = auth.match(/^Basic ([A-Za-z0-9+/=]+)$/i);
150
+ if (basic) {
151
+ const value = Buffer.from(basic[1], "base64").toString("utf8");
152
+ const parts = value.split(":");
153
+ if (parts.length === 2 && ["x-access-token", "oauth2"].includes(parts[0])) return parts[1];
154
+ }
155
+ throw new Error("invalid_credentials");
156
+ }
157
+ function refuse(res, route, body, status = 403) {
158
+ if (res.headersSent) {
159
+ res.destroy();
160
+ return;
161
+ }
162
+ if (route?.git === "receive") {
163
+ const capabilities = body.subarray(0, 1048576).toString().split("\0")[1]?.split("\n")[0]?.split(" ") ?? [];
164
+ const sideband = capabilities.includes("side-band-64k") || capabilities.includes("side-band");
165
+ const line = `${sideband ? "" : ""}ERR credential broker refused this operation
166
+ `;
167
+ res.writeHead(route.path.endsWith("/git-receive-pack") ? 200 : status, {
168
+ "content-type": "application/x-git-receive-pack-result"
169
+ });
170
+ res.end((Buffer.byteLength(line) + 4).toString(16).padStart(4, "0") + line + "0000");
171
+ } else {
172
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
173
+ res.end('{"error":"credential_broker_refused"}\n');
174
+ }
175
+ }
176
+ function createCredentialBroker(options) {
177
+ const now = options.now ?? Date.now;
178
+ const upstream = options.fetch ?? fetch;
179
+ const grants = /* @__PURE__ */ new Set();
180
+ const byToken = /* @__PURE__ */ new Map();
181
+ function revoke(grant) {
182
+ grant.revoked = true;
183
+ for (const alias of grant.aliases) byToken.delete(alias);
184
+ for (const active of grant.active) active.abort();
185
+ grants.delete(grant);
186
+ }
187
+ async function handle(req, res, githubSocket = false) {
188
+ const route = brokerRoute(req.method ?? "", (githubSocket ? "/github" : "") + (req.url ?? ""));
189
+ let grant;
190
+ let abort;
191
+ let upstreamStarted = false;
192
+ let requestBody = Buffer.alloc(0);
193
+ try {
194
+ for (const item of grants) if (item.expiresAt <= now()) revoke(item);
195
+ if (!route || githubSocket && req.headers.host !== "api.github.com") throw new Error();
196
+ const identity = await options.resolveIdentity(req.socket);
197
+ if (!identity) throw new Error();
198
+ let token2 = requestToken(req);
199
+ if (route.git && token2 === null) {
200
+ res.writeHead(401, {
201
+ "www-authenticate": 'Basic realm="credential-broker"',
202
+ "cache-control": "no-store"
203
+ });
204
+ res.end();
205
+ return;
206
+ }
207
+ let body;
208
+ if (route.refresh) {
209
+ body = await readBody(req, 16384);
210
+ const input = JSON.parse(body.toString());
211
+ if (input.grant_type !== "refresh_token" || typeof input.refresh_token !== "string" || token2 !== null && token2 !== input.refresh_token)
212
+ throw new Error();
213
+ token2 = input.refresh_token;
214
+ }
215
+ if (token2 !== null) grant = byToken.get(token2);
216
+ else if (route.identityOnly) {
217
+ const matches = [...grants].filter(
218
+ (item) => item.identity === identity && !item.revoked && item.expiresAt > now() && item.credentials[route.service]
219
+ );
220
+ if (matches.length === 1) grant = matches[0];
221
+ }
222
+ if (!grant || grant.revoked || grant.identity !== identity || grant.expiresAt <= now())
223
+ throw new Error();
224
+ const credential = grant.credentials[route.service];
225
+ if (!credential) throw new Error();
226
+ if (grant.active.size >= (grant.concurrentRequests ?? 16)) throw new Error();
227
+ if (now() - grant.windowStart >= 6e4) {
228
+ grant.windowStart = now();
229
+ grant.requests = 0;
230
+ }
231
+ if (++grant.requests > (grant.requestsPerMinute ?? 120)) throw new Error();
232
+ abort = new AbortController();
233
+ grant.active.add(abort);
234
+ const signal = AbortSignal.any([
235
+ abort.signal,
236
+ AbortSignal.timeout(Math.max(1, Math.min(6e5, grant.expiresAt - now())))
237
+ ]);
238
+ res.once("close", () => abort?.abort());
239
+ body ??= await readBody(req);
240
+ requestBody = body;
241
+ if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
242
+ throw new Error();
243
+ let account = await credential.current();
244
+ if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
245
+ throw new Error();
246
+ if (route.refresh) {
247
+ if (account.mode !== "codex-oauth") throw new Error();
248
+ const access = placeholderJwt(
249
+ grant,
250
+ account.accountId,
251
+ account.expiresAt ?? now() + 36e5
252
+ );
253
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
254
+ res.end(
255
+ JSON.stringify({
256
+ access_token: access,
257
+ refresh_token: grant.token,
258
+ id_token: access,
259
+ token_type: "Bearer",
260
+ expires_in: Math.max(1, Math.floor(((account.expiresAt ?? now()) - now()) / 1e3))
261
+ })
262
+ );
263
+ options.audit?.("allowed");
264
+ return;
265
+ }
266
+ const headers = new Headers();
267
+ for (const name of REQUEST_HEADERS) {
268
+ const value = req.headers[name];
269
+ if (typeof value === "string") headers.set(name, value);
270
+ }
271
+ headers.set("accept-encoding", "identity");
272
+ headers.set(
273
+ "authorization",
274
+ route.git ? `Basic ${Buffer.from(`x-access-token:${account.accessToken}`).toString("base64")}` : `Bearer ${account.accessToken}`
275
+ );
276
+ if (route.service === "codex" && account.accountId)
277
+ headers.set("chatgpt-account-id", account.accountId);
278
+ const forward = () => {
279
+ upstreamStarted = true;
280
+ return upstream(route.origin + route.path, {
281
+ method: req.method,
282
+ headers,
283
+ redirect: "error",
284
+ signal,
285
+ ...body.length ? { body: new Uint8Array(body) } : {}
286
+ });
287
+ };
288
+ let response = await forward();
289
+ if (response.status === 401 && account.mode.endsWith("-oauth")) {
290
+ await response.body?.cancel();
291
+ if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
292
+ throw new Error();
293
+ account = await credential.current(account.accessToken);
294
+ if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
295
+ throw new Error();
296
+ headers.set("authorization", `Bearer ${account.accessToken}`);
297
+ response = await forward();
298
+ }
299
+ if (response.status >= 300 && response.status < 400 || response.headers.has("content-encoding") && response.headers.get("content-encoding") !== "identity") {
300
+ await response.body?.cancel();
301
+ throw new Error();
302
+ }
303
+ if (grant.revoked || grant.expiresAt <= now() || signal.aborted) {
304
+ await response.body?.cancel();
305
+ throw new Error();
306
+ }
307
+ const forbidden = credential.secrets();
308
+ const echoes = credentialEchoes(forbidden);
309
+ for (const value of response.headers.values()) {
310
+ if (echoes.some((secret) => value.includes(secret))) {
311
+ await response.body?.cancel();
312
+ throw new Error();
313
+ }
314
+ }
315
+ for (const name of RESPONSE_HEADERS) {
316
+ const value = response.headers.get(name);
317
+ if (value) res.setHeader(name, value);
318
+ }
319
+ res.setHeader("cache-control", "no-store");
320
+ res.statusCode = response.status;
321
+ if (response.body) {
322
+ await pipeline(
323
+ Readable.fromWeb(response.body),
324
+ secretResponseFilter(forbidden),
325
+ res,
326
+ { signal }
327
+ );
328
+ } else res.end();
329
+ options.audit?.("allowed");
330
+ } catch {
331
+ options.audit?.(upstreamStarted ? "upstream_failed" : "refused");
332
+ refuse(res, route, requestBody);
333
+ } finally {
334
+ if (grant && abort) grant.active.delete(abort);
335
+ }
336
+ }
337
+ function placeholderJwt(grant, accountId, expiresAt) {
338
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
339
+ const token2 = `${encode({ alg: "none", typ: "JWT" })}.${encode({
340
+ exp: Math.floor(expiresAt / 1e3),
341
+ sub: grant.token,
342
+ "https://api.openai.com/auth": { chatgpt_account_id: accountId ?? "broker" }
343
+ })}.${grant.token}`;
344
+ grant.aliases.add(token2);
345
+ byToken.set(token2, grant);
346
+ return token2;
347
+ }
348
+ return {
349
+ server: createServer((req, res) => {
350
+ void handle(req, res);
351
+ }),
352
+ // gh speaks HTTP to its configured Unix socket with api.github.com as Host.
353
+ // The adapter MUST still authenticate the peer; the socket path is no proof.
354
+ githubServer: createServer((req, res) => {
355
+ void handle(req, res, true);
356
+ }),
357
+ grant(input) {
358
+ for (const item of grants) if (item.expiresAt <= now()) revoke(item);
359
+ if (grants.size >= 1e4) throw new Error("grant_limit");
360
+ if (!input.identity || input.expiresAt <= now()) throw new Error("invalid_grant");
361
+ if (input.credentials.claude && !["claude-setup", "claude-oauth"].includes(input.credentials.claude.mode) || input.credentials.codex && input.credentials.codex.mode !== "codex-oauth" || input.credentials.github && input.credentials.github.mode !== "github")
362
+ throw new Error("invalid_grant");
363
+ const grant = {
364
+ ...input,
365
+ credentials: { ...input.credentials },
366
+ token: `cbg_${randomBytes(32).toString("hex")}`,
367
+ aliases: /* @__PURE__ */ new Set(),
368
+ revoked: false,
369
+ active: /* @__PURE__ */ new Set(),
370
+ windowStart: now(),
371
+ requests: 0
372
+ };
373
+ grant.aliases.add(grant.token);
374
+ grants.add(grant);
375
+ byToken.set(grant.token, grant);
376
+ return {
377
+ token: grant.token,
378
+ revoke: () => revoke(grant),
379
+ async codexAuth() {
380
+ if (grant.revoked || grant.expiresAt <= now() || !grant.credentials.codex)
381
+ throw new Error("invalid_grant");
382
+ const account = await grant.credentials.codex.current();
383
+ if (account.mode !== "codex-oauth" || grant.revoked) throw new Error("invalid_grant");
384
+ const token2 = placeholderJwt(grant, account.accountId, account.expiresAt ?? now());
385
+ return {
386
+ auth_mode: "chatgpt",
387
+ OPENAI_API_KEY: null,
388
+ tokens: {
389
+ access_token: token2,
390
+ refresh_token: grant.token,
391
+ id_token: token2,
392
+ account_id: account.accountId ?? "broker"
393
+ },
394
+ last_refresh: new Date(now()).toISOString()
395
+ };
396
+ }
397
+ };
398
+ },
399
+ revokeIdentity(identity) {
400
+ for (const grant of grants) if (grant.identity === identity) revoke(grant);
401
+ },
402
+ revokeAll() {
403
+ for (const grant of grants) revoke(grant);
404
+ }
405
+ };
406
+ }
407
+
408
+ // src/credential-broker/credentials.ts
409
+ import { randomUUID } from "crypto";
410
+ import { constants } from "fs";
411
+ import { open, rename, unlink } from "fs/promises";
412
+ import { dirname } from "path";
413
+ import { z } from "zod";
414
+ var token = z.string().min(8).max(32768).regex(/^[\x21-\x7e]+$/);
415
+ var accountSchema = z.object({
416
+ mode: z.enum(["claude-setup", "claude-oauth", "codex-oauth", "github"]),
417
+ accessToken: token,
418
+ refreshToken: token.optional(),
419
+ idToken: token.optional(),
420
+ expiresAt: z.number().finite().optional(),
421
+ accountId: z.string().min(1).optional()
422
+ }).strict().superRefine((value, ctx) => {
423
+ if (value.mode.endsWith("-oauth") && (!value.refreshToken || value.expiresAt === void 0)) {
424
+ ctx.addIssue({ code: "custom", message: "OAuth requires renewal state" });
425
+ }
426
+ });
427
+ var BrokerError = class extends Error {
428
+ constructor(code) {
429
+ super(code);
430
+ this.code = code;
431
+ }
432
+ code;
433
+ };
434
+ function fileCredentialStore(path) {
435
+ return {
436
+ async read() {
437
+ let file;
438
+ try {
439
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
440
+ const stat = await file.stat();
441
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 63) !== 0 || stat.uid !== process.getuid?.() || stat.size > 131072)
442
+ throw new Error();
443
+ return accountSchema.parse(JSON.parse(await file.readFile("utf8")));
444
+ } catch {
445
+ throw new BrokerError("credential_unavailable");
446
+ } finally {
447
+ await file?.close();
448
+ }
449
+ },
450
+ async write(account) {
451
+ const tmp = `${path}.${randomUUID()}.tmp`;
452
+ try {
453
+ const validated = accountSchema.parse(account);
454
+ const file = await open(tmp, "wx", 384);
455
+ try {
456
+ await file.writeFile(JSON.stringify(validated) + "\n");
457
+ await file.sync();
458
+ } finally {
459
+ await file.close();
460
+ }
461
+ await rename(tmp, path);
462
+ const dir = await open(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
463
+ try {
464
+ await dir.sync();
465
+ } finally {
466
+ await dir.close();
467
+ }
468
+ } catch {
469
+ throw new BrokerError("persistence_failed");
470
+ } finally {
471
+ await unlink(tmp).catch(() => {
472
+ });
473
+ }
474
+ }
475
+ };
476
+ }
477
+ var renewalSchema = z.object({
478
+ access_token: token,
479
+ refresh_token: token.optional(),
480
+ id_token: token.optional(),
481
+ expires_in: z.number().positive().finite().max(31536e3).optional()
482
+ });
483
+ var OAUTH = {
484
+ "claude-oauth": {
485
+ url: "https://platform.claude.com/v1/oauth/token",
486
+ clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
487
+ },
488
+ "codex-oauth": {
489
+ url: "https://auth.openai.com/oauth/token",
490
+ clientId: "app_EMoamEEZ73f0CkXaXp7hrann"
491
+ }
492
+ };
493
+ async function openBrokerCredentials(store, ports = {}) {
494
+ let account;
495
+ try {
496
+ account = accountSchema.parse(await store.read());
497
+ } catch {
498
+ throw new BrokerError("credential_unavailable");
499
+ }
500
+ const fetchUpstream = ports.fetch ?? fetch;
501
+ const now = ports.now ?? Date.now;
502
+ let pending;
503
+ let poisoned = false;
504
+ const secrets = /* @__PURE__ */ new Set();
505
+ function remember(value) {
506
+ for (const secret of [value.accessToken, value.refreshToken, value.idToken]) {
507
+ if (secret) secrets.add(secret);
508
+ }
509
+ }
510
+ remember(account);
511
+ async function renew() {
512
+ const mode = account.mode;
513
+ if (mode !== "claude-oauth" && mode !== "codex-oauth") return account;
514
+ try {
515
+ const oauth = OAUTH[mode];
516
+ const response = await fetchUpstream(oauth.url, {
517
+ method: "POST",
518
+ redirect: "error",
519
+ signal: AbortSignal.timeout(3e4),
520
+ headers: { "content-type": "application/json", accept: "application/json" },
521
+ body: JSON.stringify({
522
+ grant_type: "refresh_token",
523
+ client_id: oauth.clientId,
524
+ refresh_token: account.refreshToken
525
+ })
526
+ });
527
+ if (!response.ok) {
528
+ await response.body?.cancel();
529
+ throw new Error();
530
+ }
531
+ const reader = response.body?.getReader();
532
+ if (!reader) throw new Error();
533
+ const chunks = [];
534
+ let size = 0;
535
+ try {
536
+ while (true) {
537
+ const chunk = await reader.read();
538
+ if (chunk.done) break;
539
+ size += chunk.value.length;
540
+ if (size > 131072) throw new Error();
541
+ chunks.push(chunk.value);
542
+ }
543
+ } finally {
544
+ await reader.cancel().catch(() => {
545
+ });
546
+ }
547
+ const renewed = renewalSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
548
+ let lifetime = renewed.expires_in;
549
+ if (lifetime === void 0 && mode === "codex-oauth") {
550
+ const payload = JSON.parse(
551
+ Buffer.from(renewed.access_token.split(".")[1] ?? "", "base64url").toString()
552
+ );
553
+ if (typeof payload.exp === "number") lifetime = payload.exp - now() / 1e3;
554
+ }
555
+ if (!lifetime || !Number.isFinite(lifetime) || lifetime <= 0) throw new Error();
556
+ const next = accountSchema.parse({
557
+ ...account,
558
+ accessToken: renewed.access_token,
559
+ refreshToken: renewed.refresh_token ?? account.refreshToken,
560
+ idToken: renewed.id_token ?? account.idToken,
561
+ expiresAt: now() + lifetime * 1e3
562
+ });
563
+ remember(next);
564
+ try {
565
+ await store.write(next);
566
+ } catch {
567
+ poisoned = true;
568
+ throw new BrokerError("persistence_failed");
569
+ }
570
+ account = next;
571
+ return account;
572
+ } catch (error) {
573
+ if (error instanceof BrokerError) throw error;
574
+ throw new BrokerError("refresh_failed");
575
+ }
576
+ }
577
+ return {
578
+ mode: account.mode,
579
+ // A snapshot used only by the response filter, inside the trusted process.
580
+ secrets: () => [...secrets],
581
+ async current(rejectedAccessToken) {
582
+ if (poisoned) throw new BrokerError("persistence_failed");
583
+ if (pending) return pending;
584
+ if (!account.mode.endsWith("-oauth") || rejectedAccessToken !== void 0 && rejectedAccessToken !== account.accessToken || rejectedAccessToken === void 0 && (account.expiresAt ?? 0) > now() + 6e4) {
585
+ return account;
586
+ }
587
+ pending = renew();
588
+ try {
589
+ return await pending;
590
+ } finally {
591
+ pending = void 0;
592
+ }
593
+ }
594
+ };
595
+ }
596
+
597
+ // src/credential-broker/client-config.ts
598
+ import { parse, stringify } from "smol-toml";
599
+ function brokerClientConfiguration(input) {
600
+ const base = new URL(input.baseUrl);
601
+ if (base.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(base.hostname) || base.username || base.password || base.pathname !== "/" || base.search || base.hash || !/^cbg_[a-f0-9]{64}$/.test(input.placeholder) || input.githubSocket != null && (!input.githubSocket.startsWith("/") || [...input.githubSocket].some((char) => char.charCodeAt(0) < 32)))
602
+ throw new Error("invalid_broker_configuration");
603
+ const origin = base.origin;
604
+ const config = parse(input.codexConfig);
605
+ const providers = config.model_providers;
606
+ if (providers !== void 0 && (!providers || typeof providers !== "object" || Array.isArray(providers) || providers instanceof Date)) {
607
+ throw new Error("invalid_broker_configuration");
608
+ }
609
+ config.model_provider = "credential_broker";
610
+ config.chatgpt_base_url = `${origin}/codex/backend-api/`;
611
+ config.model_providers = {
612
+ ...providers,
613
+ credential_broker: {
614
+ name: "Credential broker",
615
+ base_url: `${origin}/codex/backend-api/codex`,
616
+ wire_api: "responses",
617
+ requires_openai_auth: true,
618
+ supports_websockets: false
619
+ }
620
+ };
621
+ const git = new URL("http://github.com/");
622
+ git.username = "x-access-token";
623
+ git.password = input.placeholder;
624
+ const githubEnv = input.githubSocket == null ? {} : {
625
+ GH_TOKEN: input.placeholder
626
+ };
627
+ return {
628
+ env: {
629
+ ANTHROPIC_BASE_URL: `${origin}/claude`,
630
+ CLAUDE_CODE_OAUTH_TOKEN: input.placeholder,
631
+ CODEX_REFRESH_TOKEN_URL_OVERRIDE: `${origin}/codex/refresh`,
632
+ ...githubEnv
633
+ },
634
+ codexConfig: stringify(config),
635
+ // Include from the sandbox's global config. The stored remote stays HTTPS.
636
+ // No generic forward proxy, CONNECT tunnel or arbitrary host is enabled.
637
+ gitConfig: input.githubSocket == null ? "" : `[url ${JSON.stringify(git.href)}]
638
+ insteadOf = https://github.com/
639
+ [http "http://github.com/"]
640
+ proxy = ${JSON.stringify(origin)}
641
+ followRedirects = false
642
+ `,
643
+ // YAML accepts a JSON string scalar, including quotes/backslashes in paths.
644
+ ghConfig: input.githubSocket == null ? "" : `http_unix_socket: ${JSON.stringify(input.githubSocket)}
645
+ `
646
+ };
647
+ }
648
+ export {
649
+ brokerClientConfiguration,
650
+ createCredentialBroker,
651
+ fileCredentialStore,
652
+ openBrokerCredentials
653
+ };
package/dist/launch.js CHANGED
@@ -145,9 +145,9 @@ var companionConfigSchema = z2.object({
145
145
  // silent roster: an agent nobody listed gets no hook and no error, and starts
146
146
  // in the operator's `$HOME`.
147
147
  prepareHook: prepareHookSchema.optional(),
148
- // `dashboardPort` and `autoOpen` are IGNORED by the CLI (no port, no browser)
149
- // but stay in the schema so an existing file still parses; the Electron shell
150
- // reads them. `logLevel` is pino's level, live-editable.
148
+ // The CLI ignores both dashboard settings. Desktop uses `dashboardPort`;
149
+ // `autoOpen` remains parseable for older config files but no longer controls
150
+ // startup. `logLevel` is pino's level, live-editable.
151
151
  dashboardPort: z2.number().int().min(1).max(65535).optional(),
152
152
  autoOpen: z2.boolean().optional(),
153
153
  logLevel: z2.enum(["warn", "info", "debug"]).optional(),
@@ -185,7 +185,10 @@ var companionConfigSchema = z2.object({
185
185
  // credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
186
186
  // what a wrapper can rely on.
187
187
  runner: z2.object({
188
- command: z2.array(z2.string().min(1)).min(1)
188
+ command: z2.array(z2.string().min(1)).min(1),
189
+ // The external wrapper supplies only broker placeholders and removes
190
+ // credential mounts. Do not hand provider logins to that runner on stdin.
191
+ brokeredCredentials: z2.boolean().optional()
189
192
  }).strict().optional()
190
193
  });
191
194
  var legacyOfferSchema = z2.object({