@omnicross/daemon 0.1.0

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/cli.cjs ADDED
@@ -0,0 +1,4562 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/commands/import-ccr.ts
27
+ var import_node_fs3 = require("fs");
28
+ var import_node_util = require("util");
29
+
30
+ // src/ccr-import.ts
31
+ function parseCcrConfig(raw) {
32
+ if (!raw || typeof raw !== "object") {
33
+ throw new Error("CCR config: top-level value must be an object");
34
+ }
35
+ const obj = raw;
36
+ const Providers = Array.isArray(obj["Providers"]) ? obj["Providers"] : [];
37
+ const Router = obj["Router"] && typeof obj["Router"] === "object" ? obj["Router"] : {};
38
+ return { Providers, Router };
39
+ }
40
+ function inferApiFormat(provider) {
41
+ const hay = `${provider.api_base_url ?? ""} ${provider.name ?? ""}`.toLowerCase();
42
+ if (hay.includes("anthropic") || hay.includes("claude")) {
43
+ return { format: "anthropic", ambiguous: false };
44
+ }
45
+ if (hay.includes("generativelanguage") || hay.includes("gemini") || hay.includes("google")) {
46
+ return { format: "gemini", ambiguous: false };
47
+ }
48
+ if (hay.includes("openai") || hay.includes("/v1") || hay.includes("chat/completions")) {
49
+ return { format: "openai", ambiguous: false };
50
+ }
51
+ return { format: "openai", ambiguous: true };
52
+ }
53
+ function mapProviders(providers, notes) {
54
+ const rows = [];
55
+ for (const [i, p] of providers.entries()) {
56
+ const id = p.name?.trim();
57
+ if (!id) {
58
+ notes.push(`Providers[${i}] has no name \u2014 skipped.`);
59
+ continue;
60
+ }
61
+ const { format, ambiguous } = inferApiFormat(p);
62
+ if (ambiguous) {
63
+ notes.push(
64
+ `Provider '${id}': could not infer apiFormat from base URL \u2014 defaulted to 'openai'. Edit the config if this provider speaks a different wire format.`
65
+ );
66
+ }
67
+ rows.push({
68
+ id,
69
+ apiFormat: format,
70
+ baseUrl: p.api_base_url ?? "",
71
+ apiKey: p.api_key ?? "",
72
+ models: Array.isArray(p.models) ? p.models : void 0
73
+ });
74
+ }
75
+ return rows;
76
+ }
77
+ function noteRouterRoles(router, notes) {
78
+ if (router.think) {
79
+ notes.push(`Router.think \u2192 folded into 'default' (omnicross has no think slot).`);
80
+ }
81
+ if (router.longContext) {
82
+ notes.push(
83
+ `Router.longContext \u2192 folded into 'default' (no longContext slot; longContextThreshold dropped).`
84
+ );
85
+ }
86
+ if (router.image) {
87
+ notes.push(`Router.image \u2192 mapped to 'vision' (CCR forceUseImageAgent dropped).`);
88
+ }
89
+ if (router.webSearch) {
90
+ notes.push(
91
+ `Router.webSearch \u2192 DROPPED. omnicross injects web search via an interception port rather than routing to a natively-online model; the CCR webSearch model ('${router.webSearch}') was not carried over.`
92
+ );
93
+ }
94
+ }
95
+ function mapCcrToOmnicross(ccr) {
96
+ const notes = [];
97
+ const providers = mapProviders(ccr.Providers ?? [], notes);
98
+ noteRouterRoles(ccr.Router ?? {}, notes);
99
+ return { config: { providers }, notes };
100
+ }
101
+
102
+ // src/config.ts
103
+ var import_node_fs2 = require("fs");
104
+
105
+ // src/secrets/envelope.ts
106
+ var import_node_crypto = require("crypto");
107
+ var ENVELOPE_PREFIX = "enc:";
108
+ var ENVELOPE_VERSION = "v1";
109
+ var KEY_BYTES = 32;
110
+ var IV_BYTES = 12;
111
+ var TAG_BYTES = 16;
112
+ function isEnvelope(s) {
113
+ return s.startsWith(ENVELOPE_PREFIX);
114
+ }
115
+ function parseEnvelope(envelope) {
116
+ const parts = envelope.split(":");
117
+ if (parts.length !== 5 || `${parts[0]}:` !== ENVELOPE_PREFIX) {
118
+ throw new Error("secret envelope is malformed (expected enc:v1:<iv>:<tag>:<ciphertext>)");
119
+ }
120
+ const [, version, ivB64, tagB64, ctB64] = parts;
121
+ if (version !== ENVELOPE_VERSION) {
122
+ throw new Error(`unsupported secret envelope version '${version}' (expected ${ENVELOPE_VERSION})`);
123
+ }
124
+ const iv = Buffer.from(ivB64, "base64");
125
+ const tag = Buffer.from(tagB64, "base64");
126
+ const ciphertext = Buffer.from(ctB64, "base64");
127
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
128
+ throw new Error("secret envelope has an invalid iv/tag length");
129
+ }
130
+ return { version, iv, tag, ciphertext };
131
+ }
132
+ function encryptValue(plain, key) {
133
+ if (key.length !== KEY_BYTES) {
134
+ throw new Error(`secret key must be ${KEY_BYTES} bytes`);
135
+ }
136
+ const iv = (0, import_node_crypto.randomBytes)(IV_BYTES);
137
+ const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, iv);
138
+ const ciphertext = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
139
+ const tag = cipher.getAuthTag();
140
+ return [
141
+ ENVELOPE_PREFIX.slice(0, -1),
142
+ // 'enc' (prefix without its trailing ':')
143
+ ENVELOPE_VERSION,
144
+ iv.toString("base64"),
145
+ tag.toString("base64"),
146
+ ciphertext.toString("base64")
147
+ ].join(":");
148
+ }
149
+ function decryptValue(envelope, key) {
150
+ if (key.length !== KEY_BYTES) {
151
+ throw new Error(`secret key must be ${KEY_BYTES} bytes`);
152
+ }
153
+ const { iv, tag, ciphertext } = parseEnvelope(envelope);
154
+ const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", key, iv);
155
+ decipher.setAuthTag(tag);
156
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
157
+ return plain.toString("utf8");
158
+ }
159
+
160
+ // src/secrets/masterKey.ts
161
+ var import_node_crypto2 = require("crypto");
162
+ var import_node_fs = require("fs");
163
+ var import_node_os = require("os");
164
+ var import_node_path = require("path");
165
+ var MASTER_KEY_ENV = "OMNICROSS_MASTER_KEY";
166
+ var KEY_BYTES2 = 32;
167
+ function defaultMasterKeyPath() {
168
+ return (0, import_node_path.join)((0, import_node_os.homedir)(), ".omnicross", "master.key");
169
+ }
170
+ function decodeEnvKey(raw) {
171
+ const trimmed = raw.trim();
172
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
173
+ return Buffer.from(trimmed, "hex");
174
+ }
175
+ const buf = Buffer.from(trimmed, "base64");
176
+ if (buf.length !== KEY_BYTES2) {
177
+ throw new Error(
178
+ `${MASTER_KEY_ENV} is invalid: expected 64 hex chars or base64 decoding to ${KEY_BYTES2} bytes`
179
+ );
180
+ }
181
+ return buf;
182
+ }
183
+ function readKeyFile(path) {
184
+ const raw = (0, import_node_fs.readFileSync)(path);
185
+ if (raw.length === KEY_BYTES2) return raw;
186
+ const text = raw.toString("utf8").trim();
187
+ if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
188
+ const b64 = Buffer.from(text, "base64");
189
+ if (b64.length === KEY_BYTES2) return b64;
190
+ throw new Error(
191
+ `master key file '${path}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
192
+ );
193
+ }
194
+ function generateKeyFile(path) {
195
+ const key = (0, import_node_crypto2.randomBytes)(KEY_BYTES2);
196
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
197
+ (0, import_node_fs.writeFileSync)(path, key, { mode: 384 });
198
+ try {
199
+ (0, import_node_fs.chmodSync)(path, 384);
200
+ } catch {
201
+ }
202
+ return key;
203
+ }
204
+ function resolveMasterKey(options = {}) {
205
+ const envRaw = options.envVar ?? process.env[MASTER_KEY_ENV];
206
+ if (typeof envRaw === "string" && envRaw.trim().length > 0) {
207
+ return decodeEnvKey(envRaw);
208
+ }
209
+ const keyFilePath = options.keyFilePath ?? defaultMasterKeyPath();
210
+ if ((0, import_node_fs.existsSync)(keyFilePath)) {
211
+ return readKeyFile(keyFilePath);
212
+ }
213
+ return generateKeyFile(keyFilePath);
214
+ }
215
+
216
+ // src/secrets/SecretBox.ts
217
+ function isEnvRef(value) {
218
+ return value.startsWith("$");
219
+ }
220
+ var SecretBox = class {
221
+ /** The raw 32-byte master key (resolved lazily). Held privately; never logged. */
222
+ key;
223
+ /** The lazy resolver (used once, then nulled after caching the key). */
224
+ resolver;
225
+ constructor(key) {
226
+ if (typeof key === "function") {
227
+ this.key = null;
228
+ this.resolver = key;
229
+ } else {
230
+ if (key.length !== 32) {
231
+ throw new Error("SecretBox requires a 32-byte master key");
232
+ }
233
+ this.key = key;
234
+ this.resolver = null;
235
+ }
236
+ }
237
+ /** Resolve (and cache) the master key on first crypto use. Validates length. */
238
+ getKey() {
239
+ if (this.key) return this.key;
240
+ if (!this.resolver) {
241
+ throw new Error("SecretBox has no master key");
242
+ }
243
+ const resolved = this.resolver();
244
+ if (resolved.length !== 32) {
245
+ throw new Error("SecretBox requires a 32-byte master key");
246
+ }
247
+ this.key = resolved;
248
+ this.resolver = null;
249
+ return resolved;
250
+ }
251
+ /** Encrypt a plaintext value into a fresh `enc:v1:...` envelope (unconditional). */
252
+ encrypt(plain) {
253
+ return encryptValue(plain, this.getKey());
254
+ }
255
+ /**
256
+ * Decrypt an `enc:v1:...` envelope to plaintext. Wraps a GCM verification
257
+ * failure (wrong master key or a tampered envelope) into a clear, actionable
258
+ * error — the original crypto error (which carries no secret material) is
259
+ * intentionally NOT re-surfaced verbatim and the ciphertext/key are never
260
+ * placed in the message.
261
+ */
262
+ decrypt(envelope) {
263
+ try {
264
+ return decryptValue(envelope, this.getKey());
265
+ } catch {
266
+ throw new Error(
267
+ `failed to decrypt a stored secret: the master key does not match (wrong ${"OMNICROSS_MASTER_KEY"} / master.key) or the encrypted value was tampered with`
268
+ );
269
+ }
270
+ }
271
+ /**
272
+ * READ-direction tri-state: decrypt an `enc:` envelope; pass a `$ENV`
273
+ * reference or legacy plaintext through unchanged. Idempotent on any
274
+ * non-envelope value.
275
+ */
276
+ decryptMaybe(value) {
277
+ if (!value) return value;
278
+ if (isEnvRef(value)) return value;
279
+ if (isEnvelope(value)) return this.decrypt(value);
280
+ return value;
281
+ }
282
+ /**
283
+ * WRITE-direction tri-state: encrypt legacy plaintext; pass a `$ENV` reference
284
+ * (never encrypt indirection) or an already-`enc:` envelope (no `enc:enc:`
285
+ * nesting) through unchanged. Idempotent — re-applying never re-encrypts.
286
+ */
287
+ encryptMaybe(value) {
288
+ if (!value) return value;
289
+ if (isEnvRef(value)) return value;
290
+ if (isEnvelope(value)) return value;
291
+ return this.encrypt(value);
292
+ }
293
+ };
294
+
295
+ // src/secrets/secretFields.ts
296
+ function transformProvider(provider, fn) {
297
+ const next = { ...provider, apiKey: fn(provider.apiKey) };
298
+ if (provider.apiKeys) {
299
+ next.apiKeys = provider.apiKeys.map((entry) => ({ ...entry, apiKey: fn(entry.apiKey) }));
300
+ }
301
+ if (provider.codingPlan && typeof provider.codingPlan.apiKey === "string" && provider.codingPlan.apiKey.length > 0) {
302
+ next.codingPlan = { ...provider.codingPlan, apiKey: fn(provider.codingPlan.apiKey) };
303
+ }
304
+ if (provider.apiModes) {
305
+ next.apiModes = provider.apiModes.map(
306
+ (mode) => typeof mode.apiKey === "string" && mode.apiKey.length > 0 ? { ...mode, apiKey: fn(mode.apiKey) } : mode
307
+ );
308
+ }
309
+ return next;
310
+ }
311
+ function transformConfigSecrets(cfg, fn) {
312
+ const next = {
313
+ ...cfg,
314
+ providers: cfg.providers.map((p) => transformProvider(p, fn))
315
+ };
316
+ if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
317
+ next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
318
+ }
319
+ return next;
320
+ }
321
+ function encryptConfigSecrets(cfg, box) {
322
+ return transformConfigSecrets(cfg, (v) => box.encryptMaybe(v));
323
+ }
324
+ function decryptConfigSecrets(cfg, box) {
325
+ return transformConfigSecrets(cfg, (v) => box.decryptMaybe(v));
326
+ }
327
+ var TOKEN_FIELDS = {
328
+ claude: ["accessToken", "refreshToken"],
329
+ codex: ["accessToken", "refreshToken", "idToken"],
330
+ gemini: ["accessToken", "refreshToken"],
331
+ opencodego: ["apiKey"]
332
+ };
333
+ function transformTokenBlock(block, fields, fn) {
334
+ const next = { ...block };
335
+ for (const field of fields) {
336
+ const value = next[field];
337
+ if (typeof value === "string" && value.length > 0) {
338
+ next[field] = fn(value);
339
+ }
340
+ }
341
+ return next;
342
+ }
343
+ function transformTokens(tokens, fn) {
344
+ const next = { ...tokens };
345
+ const bag = next;
346
+ for (const [provider, fields] of Object.entries(TOKEN_FIELDS)) {
347
+ const block = bag[provider];
348
+ if (block && typeof block === "object" && !Array.isArray(block)) {
349
+ bag[provider] = transformTokenBlock(block, fields, fn);
350
+ }
351
+ const accountsKey = `${provider}Accounts`;
352
+ const accounts = bag[accountsKey];
353
+ if (Array.isArray(accounts)) {
354
+ bag[accountsKey] = accounts.map((entry) => {
355
+ if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
356
+ return {
357
+ ...entry,
358
+ tokens: transformTokenBlock(
359
+ entry.tokens,
360
+ fields,
361
+ fn
362
+ )
363
+ };
364
+ }
365
+ return entry;
366
+ });
367
+ }
368
+ }
369
+ return next;
370
+ }
371
+ function encryptTokens(tokens, box) {
372
+ return transformTokens(tokens, (v) => box.encryptMaybe(v));
373
+ }
374
+ function decryptTokens(tokens, box) {
375
+ return transformTokens(tokens, (v) => box.decryptMaybe(v));
376
+ }
377
+
378
+ // src/config.ts
379
+ var DEFAULT_ADMIN_PORT = 8766;
380
+ function validateAdmin(raw) {
381
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
382
+ const a = raw;
383
+ const out = {};
384
+ if (typeof a["enabled"] === "boolean") out.enabled = a["enabled"];
385
+ if (typeof a["port"] === "number" && Number.isFinite(a["port"])) out.port = a["port"];
386
+ if (typeof a["networkBinding"] === "boolean") out.networkBinding = a["networkBinding"];
387
+ if (typeof a["token"] === "string" && a["token"].length > 0) out.token = a["token"];
388
+ return out;
389
+ }
390
+ function resolveAdminConfig(admin) {
391
+ return {
392
+ enabled: admin?.enabled !== false,
393
+ port: typeof admin?.port === "number" ? admin.port : DEFAULT_ADMIN_PORT,
394
+ networkBinding: admin?.networkBinding === true,
395
+ token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
396
+ };
397
+ }
398
+ var VALID_FORMATS = ["openai", "anthropic", "gemini"];
399
+ function validateApiKeys(raw) {
400
+ if (!Array.isArray(raw)) return void 0;
401
+ const out = [];
402
+ for (const item of raw) {
403
+ if (!item || typeof item !== "object") continue;
404
+ const k = item;
405
+ const id = k["id"];
406
+ const apiKey = k["apiKey"];
407
+ if (typeof id !== "string" || !id.trim()) continue;
408
+ if (typeof apiKey !== "string" || apiKey.length === 0) continue;
409
+ const entry = { id, apiKey };
410
+ if (typeof k["label"] === "string" && k["label"].length > 0) entry.label = k["label"];
411
+ if (typeof k["enabled"] === "boolean") entry.enabled = k["enabled"];
412
+ if (typeof k["weight"] === "number" && Number.isFinite(k["weight"])) entry.weight = k["weight"];
413
+ out.push(entry);
414
+ }
415
+ return out.length > 0 ? out : void 0;
416
+ }
417
+ function validateModelConfigs(raw) {
418
+ if (!Array.isArray(raw)) return void 0;
419
+ const out = [];
420
+ for (const item of raw) {
421
+ if (!item || typeof item !== "object") continue;
422
+ const m = item;
423
+ const id = m["id"];
424
+ if (typeof id !== "string" || !id.trim()) continue;
425
+ const entry = { id };
426
+ if (typeof m["name"] === "string" && m["name"].length > 0) entry.name = m["name"];
427
+ if (typeof m["group"] === "string" && m["group"].length > 0) entry.group = m["group"];
428
+ if (typeof m["enabled"] === "boolean") entry.enabled = m["enabled"];
429
+ if (typeof m["vision"] === "boolean") entry.vision = m["vision"];
430
+ if (typeof m["reasoning"] === "boolean") entry.reasoning = m["reasoning"];
431
+ out.push(entry);
432
+ }
433
+ return out.length > 0 ? out : void 0;
434
+ }
435
+ function validateTransformerEntry(item) {
436
+ if (typeof item === "string") return item.length > 0 ? item : null;
437
+ if (Array.isArray(item) && item.length === 2) {
438
+ const [name, opts] = item;
439
+ if (typeof name === "string" && name.length > 0 && opts && typeof opts === "object" && !Array.isArray(opts)) {
440
+ return [name, opts];
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+ function validateTransformer(raw) {
446
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
447
+ const t = raw;
448
+ const out = {};
449
+ let kept = false;
450
+ if (Array.isArray(t["use"])) {
451
+ const use = [];
452
+ for (const item of t["use"]) {
453
+ const entry = validateTransformerEntry(item);
454
+ if (entry !== null) use.push(entry);
455
+ }
456
+ if (use.length > 0) {
457
+ out.use = use;
458
+ kept = true;
459
+ }
460
+ }
461
+ for (const key of Object.keys(t)) {
462
+ if (key === "use") continue;
463
+ const value = t[key];
464
+ if (value && typeof value === "object") {
465
+ out[key] = value;
466
+ kept = true;
467
+ }
468
+ }
469
+ return kept ? out : void 0;
470
+ }
471
+ function validateCodingPlan(raw) {
472
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
473
+ const c = raw;
474
+ const enabled = c["enabled"] === true;
475
+ const baseUrl = typeof c["baseUrl"] === "string" && c["baseUrl"].length > 0 ? c["baseUrl"] : void 0;
476
+ const apiKey = typeof c["apiKey"] === "string" && c["apiKey"].length > 0 ? c["apiKey"] : void 0;
477
+ const note = typeof c["note"] === "string" && c["note"].length > 0 ? c["note"] : void 0;
478
+ if (!enabled && !baseUrl && !apiKey && !note) return void 0;
479
+ const out = { enabled };
480
+ if (baseUrl) out.baseUrl = baseUrl;
481
+ if (apiKey) out.apiKey = apiKey;
482
+ if (note) out.note = note;
483
+ return out;
484
+ }
485
+ function validateApiModes(raw) {
486
+ if (!Array.isArray(raw)) return void 0;
487
+ const out = [];
488
+ for (const item of raw) {
489
+ if (!item || typeof item !== "object") continue;
490
+ const m = item;
491
+ const id = typeof m["id"] === "string" && m["id"].trim() ? m["id"].trim() : "";
492
+ const baseUrl = typeof m["baseUrl"] === "string" && m["baseUrl"].length > 0 ? m["baseUrl"] : "";
493
+ if (!id || !baseUrl) continue;
494
+ const label = typeof m["label"] === "string" && m["label"].length > 0 ? m["label"] : id;
495
+ const entry = { id, label, baseUrl };
496
+ if (typeof m["apiKey"] === "string" && m["apiKey"].length > 0) entry.apiKey = m["apiKey"];
497
+ if (typeof m["apiKeyPrefix"] === "string" && m["apiKeyPrefix"].length > 0) entry.apiKeyPrefix = m["apiKeyPrefix"];
498
+ if (typeof m["note"] === "string" && m["note"].length > 0) entry.note = m["note"];
499
+ out.push(entry);
500
+ }
501
+ return out.length > 0 ? out : void 0;
502
+ }
503
+ function validateProvider(raw, index) {
504
+ if (!raw || typeof raw !== "object") {
505
+ throw new Error(`config: providers[${index}] is not an object`);
506
+ }
507
+ const p = raw;
508
+ const id = p["id"];
509
+ const apiFormat = p["apiFormat"];
510
+ const baseUrl = p["baseUrl"];
511
+ const apiKey = p["apiKey"];
512
+ if (typeof id !== "string" || !id.trim()) {
513
+ throw new Error(`config: providers[${index}].id is required`);
514
+ }
515
+ if (typeof apiFormat !== "string" || !VALID_FORMATS.includes(apiFormat)) {
516
+ throw new Error(
517
+ `config: providers[${index}].apiFormat must be one of ${VALID_FORMATS.join(", ")}`
518
+ );
519
+ }
520
+ if (typeof baseUrl !== "string" || !baseUrl.trim()) {
521
+ throw new Error(`config: providers[${index}].baseUrl is required`);
522
+ }
523
+ if (typeof apiKey !== "string") {
524
+ throw new Error(`config: providers[${index}].apiKey is required`);
525
+ }
526
+ const models = p["models"];
527
+ const name = typeof p["name"] === "string" && p["name"].length > 0 ? p["name"] : void 0;
528
+ const enabled = typeof p["enabled"] === "boolean" ? p["enabled"] : void 0;
529
+ const isOfficial = typeof p["isOfficial"] === "boolean" ? p["isOfficial"] : void 0;
530
+ const apiVersion = typeof p["apiVersion"] === "string" && p["apiVersion"].length > 0 ? p["apiVersion"] : void 0;
531
+ const maxConcurrency = typeof p["maxConcurrency"] === "number" && Number.isFinite(p["maxConcurrency"]) ? p["maxConcurrency"] : void 0;
532
+ const modelsEndpoint = typeof p["modelsEndpoint"] === "string" && p["modelsEndpoint"].length > 0 ? p["modelsEndpoint"] : void 0;
533
+ return {
534
+ id,
535
+ name,
536
+ apiFormat,
537
+ baseUrl,
538
+ apiKey,
539
+ models: Array.isArray(models) ? models.filter((m) => typeof m === "string") : void 0,
540
+ // Per-model metadata (app-parity child 2): load-guard, collapse-to-undefined.
541
+ modelConfigs: validateModelConfigs(p["modelConfigs"]),
542
+ apiKeys: validateApiKeys(p["apiKeys"]),
543
+ enabled,
544
+ isOfficial,
545
+ apiVersion,
546
+ maxConcurrency,
547
+ modelsEndpoint,
548
+ // Provider transformer config (app-parity child 5): load-guard, collapse-to-
549
+ // undefined; non-secret; ENFORCED via resolveTransformerChain (parity-2 child 2).
550
+ transformer: validateTransformer(p["transformer"]),
551
+ // Coding-plan endpoint (app-parity-2 child 3): load-guard, collapse-to-undefined.
552
+ // SECRET-bearing (apiKey encrypted at rest); enforced by core's resolveProviderEndpoint.
553
+ codingPlan: validateCodingPlan(p["codingPlan"]),
554
+ // API modes (app-parity-2 child 4): load-guard, collapse-to-undefined. Each
555
+ // mode's apiKey is SECRET (encrypted at rest); enforced by core (layer 1).
556
+ apiModes: validateApiModes(p["apiModes"]),
557
+ selectedApiModeId: typeof p["selectedApiModeId"] === "string" && p["selectedApiModeId"].length > 0 ? p["selectedApiModeId"] : void 0
558
+ };
559
+ }
560
+ function validateConfig(raw) {
561
+ if (!raw || typeof raw !== "object") {
562
+ throw new Error("config: top-level value must be an object");
563
+ }
564
+ const obj = raw;
565
+ const providersRaw = obj["providers"];
566
+ if (!Array.isArray(providersRaw)) {
567
+ throw new Error("config: 'providers' must be an array");
568
+ }
569
+ const providers = providersRaw.map((p, i) => validateProvider(p, i));
570
+ const server = obj["server"];
571
+ const admin = validateAdmin(obj["admin"]);
572
+ return { providers, server, admin };
573
+ }
574
+ var secretBox = null;
575
+ function setSecretBox(box) {
576
+ secretBox = box;
577
+ }
578
+ function loadConfig(path) {
579
+ let raw;
580
+ try {
581
+ raw = (0, import_node_fs2.readFileSync)(path, "utf8");
582
+ } catch {
583
+ throw new Error(`config: cannot read file at '${path}'`);
584
+ }
585
+ let parsed;
586
+ try {
587
+ parsed = JSON.parse(raw);
588
+ } catch {
589
+ throw new Error(`config: '${path}' is not valid JSON`);
590
+ }
591
+ const validated = validateConfig(parsed);
592
+ return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
593
+ }
594
+ function saveConfig(path, cfg) {
595
+ const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
596
+ (0, import_node_fs2.writeFileSync)(path, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
597
+ }
598
+
599
+ // src/commands/paths.ts
600
+ var import_node_path2 = require("path");
601
+ function defaultKeysPath(configPath) {
602
+ return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "keys.json");
603
+ }
604
+ function defaultTokensPath(configPath) {
605
+ return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "tokens.json");
606
+ }
607
+ function resolveSecretBox(masterKeyFilePath) {
608
+ return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
609
+ }
610
+
611
+ // src/commands/import-ccr.ts
612
+ async function runImportCcr(argv) {
613
+ const { values, positionals } = (0, import_node_util.parseArgs)({
614
+ args: argv,
615
+ options: {
616
+ out: { type: "string", short: "o" },
617
+ "master-key-file": { type: "string" }
618
+ },
619
+ allowPositionals: true
620
+ });
621
+ const ccrPath = positionals[0];
622
+ if (!ccrPath) {
623
+ throw new Error("import-ccr: a <ccr-config-path> is required");
624
+ }
625
+ const outPath = values.out ?? "omnicross.config.json";
626
+ let raw;
627
+ try {
628
+ raw = JSON.parse((0, import_node_fs3.readFileSync)(ccrPath, "utf8"));
629
+ } catch {
630
+ throw new Error(`import-ccr: cannot read or parse '${ccrPath}'`);
631
+ }
632
+ const ccr = parseCcrConfig(raw);
633
+ const { config, notes } = mapCcrToOmnicross(ccr);
634
+ setSecretBox(resolveSecretBox(values["master-key-file"]));
635
+ try {
636
+ saveConfig(outPath, config);
637
+ } finally {
638
+ setSecretBox(null);
639
+ }
640
+ console.info(`Wrote omnicross config \u2192 ${outPath}`);
641
+ console.info(` providers: ${config.providers.length}`);
642
+ if (notes.length > 0) {
643
+ console.info("Notes:");
644
+ for (const note of notes) console.info(` \u2022 ${note}`);
645
+ }
646
+ }
647
+
648
+ // src/commands/keys.ts
649
+ var import_node_util2 = require("util");
650
+ var import_outbound_api = require("@omnicross/core/outbound-api");
651
+
652
+ // src/ports/JsonOutboundKeyDb.ts
653
+ var import_node_fs4 = require("fs");
654
+ var JsonOutboundKeyDb = class {
655
+ constructor(keysPath) {
656
+ this.keysPath = keysPath;
657
+ }
658
+ keysPath;
659
+ async outboundApiKeysList() {
660
+ return this.readRows();
661
+ }
662
+ async outboundApiKeysGetByHash(hash) {
663
+ const rows = this.readRows();
664
+ const row = rows.find(
665
+ (r) => r.keyHash === hash && r.enabled && r.revokedAt === null
666
+ );
667
+ return row ?? null;
668
+ }
669
+ async outboundApiKeysCreate(input) {
670
+ const rows = this.readRows();
671
+ const row = {
672
+ id: input.id,
673
+ name: input.name,
674
+ keyHash: input.keyHash,
675
+ keyPrefix: input.keyPrefix,
676
+ enabled: true,
677
+ createdAt: input.createdAt ?? Date.now(),
678
+ lastUsedAt: null,
679
+ revokedAt: null
680
+ };
681
+ rows.push(row);
682
+ this.writeRows(rows);
683
+ return row;
684
+ }
685
+ async outboundApiKeysRevoke(id) {
686
+ return this.mutateRow(id, (row) => {
687
+ if (row.revokedAt !== null) return false;
688
+ row.revokedAt = Date.now();
689
+ row.enabled = false;
690
+ return true;
691
+ });
692
+ }
693
+ async outboundApiKeysTouchLastUsed(id) {
694
+ return this.mutateRow(id, (row) => {
695
+ row.lastUsedAt = Date.now();
696
+ return true;
697
+ });
698
+ }
699
+ async outboundApiKeysSetEnabled(id, enabled) {
700
+ return this.mutateRow(id, (row) => {
701
+ if (row.revokedAt !== null) return false;
702
+ row.enabled = enabled;
703
+ return true;
704
+ });
705
+ }
706
+ /** Apply `fn` to the row with `id`, persisting when it returns true. */
707
+ mutateRow(id, fn) {
708
+ const rows = this.readRows();
709
+ const row = rows.find((r) => r.id === id);
710
+ if (!row) return false;
711
+ const changed = fn(row);
712
+ if (changed) this.writeRows(rows);
713
+ return changed;
714
+ }
715
+ /** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
716
+ readRows() {
717
+ if (!(0, import_node_fs4.existsSync)(this.keysPath)) return [];
718
+ try {
719
+ const parsed = JSON.parse((0, import_node_fs4.readFileSync)(this.keysPath, "utf8"));
720
+ return Array.isArray(parsed) ? parsed : [];
721
+ } catch {
722
+ return [];
723
+ }
724
+ }
725
+ writeRows(rows) {
726
+ (0, import_node_fs4.writeFileSync)(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
727
+ }
728
+ };
729
+
730
+ // src/commands/keys.ts
731
+ async function runKeys(argv) {
732
+ const { values, positionals } = (0, import_node_util2.parseArgs)({
733
+ args: argv,
734
+ options: { config: { type: "string", short: "c" } },
735
+ allowPositionals: true
736
+ });
737
+ const configPath = values.config;
738
+ if (!configPath) {
739
+ throw new Error("keys: --config <path> is required");
740
+ }
741
+ const db = new JsonOutboundKeyDb(defaultKeysPath(configPath));
742
+ const action = positionals[0];
743
+ switch (action) {
744
+ case "add":
745
+ return keysAdd(db, positionals[1]);
746
+ case "list":
747
+ return keysList(db);
748
+ case "revoke":
749
+ return keysRevoke(db, positionals[1]);
750
+ default:
751
+ throw new Error(`keys: unknown action '${action ?? ""}' (expected add|list|revoke)`);
752
+ }
753
+ }
754
+ async function keysAdd(db, name) {
755
+ if (!name) throw new Error("keys add: a <name> is required");
756
+ const created = await (0, import_outbound_api.createNamedKey)(db, name);
757
+ console.info(`Created key '${created.name}' (id: ${created.id}).`);
758
+ console.info("");
759
+ console.info(` ${created.plaintextOnce}`);
760
+ console.info("");
761
+ console.info("This is the ONLY time the full key is shown \u2014 store it now.");
762
+ }
763
+ async function keysList(db) {
764
+ const rows = await db.outboundApiKeysList();
765
+ if (rows.length === 0) {
766
+ console.info("No keys.");
767
+ return;
768
+ }
769
+ for (const r of rows) {
770
+ const state = r.revokedAt !== null ? "revoked" : r.enabled ? "enabled" : "disabled";
771
+ const last = r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : "never";
772
+ console.info(
773
+ `${r.id} ${r.keyPrefix}\u2026 ${state} name=${r.name} created=${new Date(r.createdAt).toISOString()} lastUsed=${last}`
774
+ );
775
+ }
776
+ }
777
+ async function keysRevoke(db, id) {
778
+ if (!id) throw new Error("keys revoke: an <id> is required");
779
+ const ok = await db.outboundApiKeysRevoke(id);
780
+ console.info(ok ? `Revoked key '${id}'.` : `No active key with id '${id}'.`);
781
+ }
782
+
783
+ // src/commands/launch.ts
784
+ var import_node_child_process = require("child_process");
785
+ var import_node_fs7 = require("fs");
786
+ var import_node_path4 = require("path");
787
+ var import_node_util3 = require("util");
788
+ var import_cli_launcher = require("@omnicross/cli-launcher");
789
+
790
+ // src/bootstrap.ts
791
+ var import_GeminiCodeAssistProjectResolver = require("@omnicross/core/auth/GeminiCodeAssistProjectResolver");
792
+ var import_ApiKeyPoolService = require("@omnicross/core/completion/ApiKeyPoolService");
793
+ var import_outbound_api4 = require("@omnicross/core/outbound-api");
794
+ var import_subscriptionRegistryPort = require("@omnicross/core/outbound-api/subscriptionRegistryPort");
795
+ var import_gemini_code_assist_resolver = require("@omnicross/core/ports/gemini-code-assist-resolver");
796
+ var import_provider_proxy = require("@omnicross/core/provider-proxy");
797
+ var import_subscriptions4 = require("@omnicross/subscriptions");
798
+
799
+ // src/admin/accountsCodexOAuth.ts
800
+ var import_node_crypto3 = __toESM(require("crypto"), 1);
801
+ var import_subscriptions = require("@omnicross/subscriptions");
802
+ var DEFAULT_CODEX_OAUTH_TTL_MS = 10 * 60 * 1e3;
803
+ var CodexOAuthSessionStore = class {
804
+ constructor(ttlMs = DEFAULT_CODEX_OAUTH_TTL_MS) {
805
+ this.ttlMs = ttlMs;
806
+ }
807
+ ttlMs;
808
+ sessions = /* @__PURE__ */ new Map();
809
+ activeSessionId = null;
810
+ /** Whether a codex sign-in is currently in flight (port 1455 held). */
811
+ isBusy() {
812
+ this.sweep();
813
+ return this.activeSessionId !== null;
814
+ }
815
+ /** Mint a fresh sessionId, mark it pending + active, return the id. */
816
+ begin() {
817
+ this.sweep();
818
+ const sessionId = import_node_crypto3.default.randomBytes(24).toString("base64url");
819
+ this.sessions.set(sessionId, { status: "pending", createdAt: Date.now() });
820
+ this.activeSessionId = sessionId;
821
+ return sessionId;
822
+ }
823
+ /** Settle a flow (done/error) + free the active slot. */
824
+ settle(sessionId, status, error) {
825
+ const prior = this.sessions.get(sessionId);
826
+ this.sessions.set(sessionId, { status, error, createdAt: prior?.createdAt ?? Date.now() });
827
+ if (this.activeSessionId === sessionId) this.activeSessionId = null;
828
+ }
829
+ /** Read a flow's status (token-free), or null when unknown/expired. */
830
+ get(sessionId) {
831
+ this.sweep();
832
+ return this.sessions.get(sessionId) ?? null;
833
+ }
834
+ /** Drop expired flows; free the active slot if the active flow expired. */
835
+ sweep() {
836
+ const now = Date.now();
837
+ for (const [id, s] of this.sessions) {
838
+ if (now - s.createdAt > this.ttlMs) {
839
+ this.sessions.delete(id);
840
+ if (this.activeSessionId === id) this.activeSessionId = null;
841
+ }
842
+ }
843
+ }
844
+ };
845
+ function err(status, message) {
846
+ return { status, body: { error: { type: "admin_api_error", message } } };
847
+ }
848
+ function handleCodexOAuthStart(deps) {
849
+ if (deps.codexSessions.isBusy()) {
850
+ return err(
851
+ 409,
852
+ "a codex sign-in is already in progress (loopback 127.0.0.1:1455 is held) \u2014 finish it in the browser or wait for it to time out"
853
+ );
854
+ }
855
+ const { authUrl, codeVerifier, state } = import_subscriptions.codexOAuth.generateAuthParams();
856
+ const sessionId = deps.codexSessions.begin();
857
+ void runCodexLoopback(sessionId, codeVerifier, state, deps);
858
+ return { status: 200, body: { authUrl, sessionId } };
859
+ }
860
+ async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
861
+ try {
862
+ const code = await deps.codexAwaitLoopback(state);
863
+ const result = await import_subscriptions.codexOAuth.exchangeCodeForTokens(
864
+ { authorizationCode: code, codeVerifier, state },
865
+ deps.oauthExchangeFetch
866
+ );
867
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
868
+ const block = {
869
+ authMethod: "oauth",
870
+ status: "authorized",
871
+ accessToken: result.accessToken,
872
+ refreshToken: result.refreshToken,
873
+ idToken: result.idToken,
874
+ expiresAt,
875
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
876
+ };
877
+ await deps.subscriptionAccountAppender.appendProviderAccount("codex", block);
878
+ deps.codexSessions.settle(sessionId, "done");
879
+ } catch (e) {
880
+ const reason = e instanceof Error ? e.message : "codex sign-in failed";
881
+ deps.codexSessions.settle(sessionId, "error", reason);
882
+ }
883
+ }
884
+ function handleCodexOAuthStatus(sessionId, deps) {
885
+ const s = deps.codexSessions.get(sessionId);
886
+ if (!s) return err(404, "unknown or expired codex sign-in session");
887
+ return { status: 200, body: { state: s.status, ...s.error ? { message: s.error } : {} } };
888
+ }
889
+
890
+ // src/admin/AdminServer.ts
891
+ var import_node_crypto6 = require("crypto");
892
+ var import_node_http2 = __toESM(require("http"), 1);
893
+
894
+ // src/admin/adminApi.ts
895
+ var import_node_http = __toESM(require("http"), 1);
896
+ var import_outbound_api2 = require("@omnicross/core/outbound-api");
897
+
898
+ // src/pool/resolveEnvKey.ts
899
+ function resolveEnvKey(rawKey) {
900
+ if (!rawKey) return "";
901
+ if (rawKey.startsWith("$")) {
902
+ return process.env[rawKey.slice(1)] || "";
903
+ }
904
+ return rawKey;
905
+ }
906
+
907
+ // src/preset-catalog.ts
908
+ var presetsModule = __toESM(require("@omnicross/contracts/provider-presets"), 1);
909
+ function normalizeCatalogModule(m) {
910
+ const ns = m ?? {};
911
+ const fromDefault = ns["default"];
912
+ const picked = (Array.isArray(ns["LLM_PROVIDER_PRESETS"]) ? ns : fromDefault) ?? ns;
913
+ if (!Array.isArray(picked["LLM_PROVIDER_PRESETS"])) {
914
+ throw new Error(
915
+ "preset-catalog: @omnicross/contracts/provider-presets did not resolve a usable catalog (no LLM_PROVIDER_PRESETS array on the namespace or its .default)"
916
+ );
917
+ }
918
+ return picked;
919
+ }
920
+ var catalog = normalizeCatalogModule(presetsModule);
921
+ function getCatalog() {
922
+ return catalog.getAllProviderPresets();
923
+ }
924
+ function getPresetById(idOrPresetId) {
925
+ return catalog.getPresetById(idOrPresetId);
926
+ }
927
+
928
+ // src/preset-map.ts
929
+ var EXCLUSION_REASONS = {
930
+ "openai-response": "daemon rows have no openai-response format; the Responses API needs a transformer chain that a BYO daemon provider row cannot express.",
931
+ "azure-openai": "Azure needs an apiVersion + a deployment-name-as-model URL template + an empty baseUrl; a daemon provider row cannot express that shape."
932
+ };
933
+ var FORMAT_MAP = {
934
+ openai: "openai",
935
+ anthropic: "anthropic",
936
+ google: "gemini",
937
+ "openai-response": null,
938
+ "azure-openai": null
939
+ };
940
+ function resolveFormat(raw) {
941
+ const fmt = raw;
942
+ if (fmt === void 0 || !(fmt in FORMAT_MAP)) {
943
+ return {
944
+ excludedReason: `unknown/unsupported preset apiFormat '${String(raw)}'; no daemon format mapping.`
945
+ };
946
+ }
947
+ const mapped = FORMAT_MAP[fmt];
948
+ if (mapped === null) {
949
+ return { excludedReason: EXCLUSION_REASONS[fmt] };
950
+ }
951
+ return { format: mapped };
952
+ }
953
+ function mapPresetToProvider(preset, opts) {
954
+ const resolved = resolveFormat(preset.apiFormat);
955
+ if ("excludedReason" in resolved) {
956
+ return { excluded: { id: opts.id ?? preset.id, reason: resolved.excludedReason } };
957
+ }
958
+ if (!opts.key) {
959
+ return { missingKey: true };
960
+ }
961
+ const provider = {
962
+ id: opts.id ?? preset.id,
963
+ apiFormat: resolved.format,
964
+ baseUrl: opts.baseUrlOverride ?? preset.api_base_url,
965
+ apiKey: opts.key,
966
+ models: Array.isArray(preset.models) ? preset.models : void 0
967
+ };
968
+ return { provider };
969
+ }
970
+ function listMappablePresets() {
971
+ const mappable = [];
972
+ const excluded = [];
973
+ for (const preset of getCatalog()) {
974
+ const resolved = resolveFormat(preset.apiFormat);
975
+ if ("excludedReason" in resolved) {
976
+ excluded.push({ id: preset.id, reason: resolved.excludedReason });
977
+ continue;
978
+ }
979
+ mappable.push({
980
+ id: preset.id,
981
+ presetId: preset.presetId,
982
+ name: preset.name,
983
+ apiFormat: resolved.format,
984
+ baseUrl: preset.api_base_url,
985
+ models: Array.isArray(preset.models) ? preset.models : []
986
+ });
987
+ }
988
+ return { mappable, excluded };
989
+ }
990
+
991
+ // src/admin/accountsOAuth.ts
992
+ var import_subscriptions2 = require("@omnicross/subscriptions");
993
+
994
+ // src/admin/accountsWrite.ts
995
+ var VALID_PROVIDER_IDS = [
996
+ "claude",
997
+ "codex",
998
+ "gemini",
999
+ "opencodego"
1000
+ ];
1001
+ function asSubscriptionProviderId(id) {
1002
+ return VALID_PROVIDER_IDS.includes(id) ? id : null;
1003
+ }
1004
+ var CLAUDE_AUTH_METHODS = /* @__PURE__ */ new Set(["oauth", "setup_token", "manual"]);
1005
+ var OAUTH_AUTH_METHODS = /* @__PURE__ */ new Set(["oauth", "manual"]);
1006
+ var TOKEN_STATUSES = /* @__PURE__ */ new Set([
1007
+ "unconfigured",
1008
+ "authorized",
1009
+ "configured",
1010
+ "expired",
1011
+ "error"
1012
+ ]);
1013
+ var OPENCODEGO_STATUSES = /* @__PURE__ */ new Set(["unconfigured", "configured", "error"]);
1014
+ function str(v) {
1015
+ return typeof v === "string" ? v : void 0;
1016
+ }
1017
+ function strArr(v) {
1018
+ if (!Array.isArray(v)) return void 0;
1019
+ return v.filter((x) => typeof x === "string");
1020
+ }
1021
+ function validateClaude(body) {
1022
+ const authMethod = str(body["authMethod"]);
1023
+ const status = str(body["status"]);
1024
+ if (!authMethod || !CLAUDE_AUTH_METHODS.has(authMethod)) return null;
1025
+ if (!status || !TOKEN_STATUSES.has(status)) return null;
1026
+ const subscriptionLevel = str(body["subscriptionLevel"]);
1027
+ const out = {
1028
+ authMethod,
1029
+ status
1030
+ };
1031
+ if (subscriptionLevel === "Free" || subscriptionLevel === "Pro" || subscriptionLevel === "Max") {
1032
+ out.subscriptionLevel = subscriptionLevel;
1033
+ }
1034
+ copyOptional(out, body, ["accessToken", "refreshToken", "expiresAt", "setupTokenExpiresAt", "lastRefreshedAt", "errorMessage"]);
1035
+ const scopes = strArr(body["scopes"]);
1036
+ if (scopes) out.scopes = scopes;
1037
+ if (typeof body["isSetupToken"] === "boolean") out.isSetupToken = body["isSetupToken"];
1038
+ return out;
1039
+ }
1040
+ function validateCodex(body) {
1041
+ const authMethod = str(body["authMethod"]);
1042
+ const status = str(body["status"]);
1043
+ if (!authMethod || !OAUTH_AUTH_METHODS.has(authMethod)) return null;
1044
+ if (!status || !TOKEN_STATUSES.has(status)) return null;
1045
+ const out = {
1046
+ authMethod,
1047
+ status
1048
+ };
1049
+ copyOptional(out, body, [
1050
+ "accessToken",
1051
+ "refreshToken",
1052
+ "idToken",
1053
+ "expiresAt",
1054
+ "accountId",
1055
+ "email",
1056
+ "organizationId",
1057
+ "lastRefreshedAt",
1058
+ "errorMessage"
1059
+ ]);
1060
+ return out;
1061
+ }
1062
+ function validateGemini(body) {
1063
+ const authMethod = str(body["authMethod"]);
1064
+ const status = str(body["status"]);
1065
+ if (!authMethod || !OAUTH_AUTH_METHODS.has(authMethod)) return null;
1066
+ if (!status || !TOKEN_STATUSES.has(status)) return null;
1067
+ const out = {
1068
+ authMethod,
1069
+ status
1070
+ };
1071
+ copyOptional(out, body, ["accessToken", "refreshToken", "expiresAt", "lastRefreshedAt", "errorMessage"]);
1072
+ return out;
1073
+ }
1074
+ function validateOpenCodeGo(body) {
1075
+ const authMethod = str(body["authMethod"]);
1076
+ const status = str(body["status"]);
1077
+ if (authMethod !== "manual") return null;
1078
+ if (!status || !OPENCODEGO_STATUSES.has(status)) return null;
1079
+ const out = {
1080
+ authMethod: "manual",
1081
+ status
1082
+ };
1083
+ copyOptional(out, body, ["apiKey", "baseUrl", "zenBaseUrl", "lastRefreshedAt", "errorMessage"]);
1084
+ if (body["modelMap"] && typeof body["modelMap"] === "object") {
1085
+ out.modelMap = body["modelMap"];
1086
+ }
1087
+ if (body["fallbacks"] && typeof body["fallbacks"] === "object") {
1088
+ out.fallbacks = body["fallbacks"];
1089
+ }
1090
+ return out;
1091
+ }
1092
+ function copyOptional(out, body, keys) {
1093
+ const sink = out;
1094
+ for (const key of keys) {
1095
+ const v = body[key];
1096
+ if (typeof v === "string") sink[key] = v;
1097
+ }
1098
+ }
1099
+ function validateTokenBody(providerId, body) {
1100
+ switch (providerId) {
1101
+ case "claude":
1102
+ return validateClaude(body);
1103
+ case "codex":
1104
+ return validateCodex(body);
1105
+ case "gemini":
1106
+ return validateGemini(body);
1107
+ case "opencodego":
1108
+ return validateOpenCodeGo(body);
1109
+ default:
1110
+ return null;
1111
+ }
1112
+ }
1113
+ async function statusEntryFor(reader, providerId) {
1114
+ const all = await reader.listAll();
1115
+ return all.find((a) => a.providerId === providerId) ?? null;
1116
+ }
1117
+
1118
+ // src/admin/accountsOAuth.ts
1119
+ var OAUTH_HTTP_PROVIDERS = /* @__PURE__ */ new Set(["claude", "gemini"]);
1120
+ function err2(status, message) {
1121
+ return { status, body: { error: { type: "admin_api_error", message } } };
1122
+ }
1123
+ function handleOAuthStart(providerId, deps) {
1124
+ if (!OAUTH_HTTP_PROVIDERS.has(providerId)) {
1125
+ return err2(400, `oauth not available for provider '${providerId}'`);
1126
+ }
1127
+ const flow = providerId === "claude" ? import_subscriptions2.claudeOAuth : import_subscriptions2.geminiOAuth;
1128
+ const { authUrl, codeVerifier, state } = flow.generateAuthParams();
1129
+ const sessionId = deps.oauthSessions.put({ providerId, codeVerifier, state });
1130
+ return { status: 200, body: { authUrl, sessionId } };
1131
+ }
1132
+ async function handleOAuthComplete(providerId, body, deps) {
1133
+ if (!OAUTH_HTTP_PROVIDERS.has(providerId)) {
1134
+ return err2(400, `oauth not available for provider '${providerId}'`);
1135
+ }
1136
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : "";
1137
+ const rawCode = typeof body["code"] === "string" ? body["code"] : "";
1138
+ if (!sessionId) return err2(400, "oauth complete requires { sessionId }");
1139
+ if (!rawCode) return err2(400, "oauth complete requires { code }");
1140
+ const session = deps.oauthSessions.take(sessionId);
1141
+ if (!session) return err2(410, "oauth session is unknown, expired, or already used");
1142
+ if (session.providerId !== providerId) {
1143
+ return err2(400, `oauth session does not match provider '${providerId}'`);
1144
+ }
1145
+ let code = rawCode.trim();
1146
+ if (providerId === "claude") {
1147
+ const [splitCode, pastedState] = code.split("#");
1148
+ if (!splitCode) return err2(400, "no authorization code was provided");
1149
+ if (pastedState && pastedState !== session.state) {
1150
+ return err2(400, "oauth state did not match (possible CSRF) \u2014 aborting");
1151
+ }
1152
+ code = splitCode;
1153
+ }
1154
+ let block;
1155
+ try {
1156
+ block = providerId === "claude" ? await exchangeClaude(code, session.codeVerifier, session.state, deps.oauthExchangeFetch) : await exchangeGemini(code, session.codeVerifier, deps.oauthExchangeFetch);
1157
+ } catch (exchangeError) {
1158
+ const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
1159
+ return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
1160
+ }
1161
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block);
1162
+ const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
1163
+ return { status: 200, body: status ? { account: status } : { ok: true } };
1164
+ }
1165
+ async function exchangeClaude(code, codeVerifier, state, exchangeFetch) {
1166
+ const result = await import_subscriptions2.claudeOAuth.exchangeCodeForTokens(
1167
+ { authorizationCode: code, codeVerifier, state },
1168
+ exchangeFetch
1169
+ );
1170
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
1171
+ return {
1172
+ authMethod: "oauth",
1173
+ status: "authorized",
1174
+ accessToken: result.accessToken,
1175
+ refreshToken: result.refreshToken,
1176
+ expiresAt,
1177
+ scopes: result.scopes,
1178
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
1179
+ };
1180
+ }
1181
+ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
1182
+ const result = await import_subscriptions2.geminiOAuth.exchangeCodeForTokens(code, codeVerifier, exchangeFetch);
1183
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
1184
+ return {
1185
+ authMethod: "oauth",
1186
+ status: "authorized",
1187
+ accessToken: result.accessToken,
1188
+ refreshToken: result.refreshToken,
1189
+ expiresAt,
1190
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
1191
+ };
1192
+ }
1193
+
1194
+ // src/ports/account-multi.ts
1195
+ var import_node_crypto4 = require("crypto");
1196
+ var PROVIDER_KEYS = {
1197
+ claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
1198
+ codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
1199
+ gemini: { block: "gemini", accounts: "geminiAccounts", active: "activeGeminiAccountId" },
1200
+ opencodego: {
1201
+ block: "opencodego",
1202
+ accounts: "opencodegoAccounts",
1203
+ active: "activeOpencodegoAccountId"
1204
+ }
1205
+ };
1206
+ function clone(value) {
1207
+ return JSON.parse(JSON.stringify(value));
1208
+ }
1209
+ function legacyAccountId(provider) {
1210
+ return `legacy-${provider}`;
1211
+ }
1212
+ function getAccounts(config, p) {
1213
+ return config[PROVIDER_KEYS[p].accounts] ?? [];
1214
+ }
1215
+ function setAccounts(config, p, accounts) {
1216
+ config[PROVIDER_KEYS[p].accounts] = accounts;
1217
+ }
1218
+ function getActiveId(config, p) {
1219
+ return config[PROVIDER_KEYS[p].active];
1220
+ }
1221
+ function setActiveId(config, p, id) {
1222
+ config[PROVIDER_KEYS[p].active] = id;
1223
+ }
1224
+ function getBlock(config, p) {
1225
+ return config[PROVIDER_KEYS[p].block];
1226
+ }
1227
+ function setBlock(config, p, block) {
1228
+ config[PROVIDER_KEYS[p].block] = block;
1229
+ }
1230
+ function deriveMirror(config, p) {
1231
+ const accounts = getAccounts(config, p);
1232
+ const active = accounts.find((a) => a.id === getActiveId(config, p));
1233
+ if (!active) {
1234
+ setBlock(config, p, void 0);
1235
+ setActiveId(config, p, void 0);
1236
+ if (accounts.length === 0) setAccounts(config, p, void 0);
1237
+ return;
1238
+ }
1239
+ setBlock(config, p, clone(active.tokens));
1240
+ }
1241
+ function migrateLazily(config) {
1242
+ const next = { ...config };
1243
+ for (const p of Object.keys(PROVIDER_KEYS)) {
1244
+ const block = getBlock(next, p);
1245
+ if (!block || getAccounts(next, p).length > 0) continue;
1246
+ const entry = {
1247
+ // DETERMINISTIC id — stable across reads.
1248
+ id: legacyAccountId(p),
1249
+ label: "Account 1",
1250
+ createdAt: next.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
1251
+ tokens: clone(block)
1252
+ };
1253
+ setAccounts(next, p, [entry]);
1254
+ setActiveId(next, p, entry.id);
1255
+ setBlock(next, p, clone(entry.tokens));
1256
+ }
1257
+ return next;
1258
+ }
1259
+ function addAccount(config, p, tokens, label) {
1260
+ const accounts = [...getAccounts(config, p)];
1261
+ const id = (0, import_node_crypto4.randomUUID)();
1262
+ accounts.push({
1263
+ id,
1264
+ label: label ?? `Account ${accounts.length + 1}`,
1265
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1266
+ tokens: clone(tokens)
1267
+ });
1268
+ setAccounts(config, p, accounts);
1269
+ setActiveId(config, p, id);
1270
+ deriveMirror(config, p);
1271
+ return { id };
1272
+ }
1273
+ function removeAccount(config, p, id) {
1274
+ const accounts = getAccounts(config, p);
1275
+ if (!accounts.some((a) => a.id === id)) return { removed: false };
1276
+ const wasActive = getActiveId(config, p) === id;
1277
+ const remaining = accounts.filter((a) => a.id !== id);
1278
+ setAccounts(config, p, remaining.length ? remaining : void 0);
1279
+ if (wasActive) {
1280
+ setActiveId(config, p, remaining.length ? mostRecent(remaining).id : void 0);
1281
+ }
1282
+ deriveMirror(config, p);
1283
+ return { removed: true };
1284
+ }
1285
+ function mostRecent(accounts) {
1286
+ return accounts.reduce((best, cur) => {
1287
+ const bestT = best.createdAt ? Date.parse(best.createdAt) : 0;
1288
+ const curT = cur.createdAt ? Date.parse(cur.createdAt) : 0;
1289
+ return curT >= bestT ? cur : best;
1290
+ }, accounts[0]);
1291
+ }
1292
+ function setActiveAccount(config, p, id) {
1293
+ if (!getAccounts(config, p).some((a) => a.id === id)) return { ok: false };
1294
+ setActiveId(config, p, id);
1295
+ deriveMirror(config, p);
1296
+ return { ok: true };
1297
+ }
1298
+ function getActiveAccount(config, p) {
1299
+ const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
1300
+ return active ? { id: active.id, tokens: active.tokens } : void 0;
1301
+ }
1302
+ function writeBackRefreshById(config, p, capturedId, refreshedTokens) {
1303
+ if (capturedId) {
1304
+ setAccounts(
1305
+ config,
1306
+ p,
1307
+ getAccounts(config, p).map(
1308
+ (a) => a.id === capturedId ? { ...a, tokens: clone(refreshedTokens) } : a
1309
+ )
1310
+ );
1311
+ }
1312
+ deriveMirror(config, p);
1313
+ }
1314
+ function writeActiveTokens(config, p, tokens) {
1315
+ const active = getActiveAccount(config, p);
1316
+ if (active) {
1317
+ writeBackRefreshById(config, p, active.id, tokens);
1318
+ } else {
1319
+ addAccount(config, p, tokens);
1320
+ }
1321
+ }
1322
+ function sanitizeAccounts(config, p) {
1323
+ const accounts = getAccounts(config, p);
1324
+ const activeId = getActiveId(config, p);
1325
+ return accounts.map((a) => {
1326
+ const t = a.tokens;
1327
+ return {
1328
+ id: a.id,
1329
+ label: a.label,
1330
+ status: t.status ?? "unconfigured",
1331
+ expiresAt: t.expiresAt,
1332
+ hasAccessToken: !!(t.accessToken || t.apiKey),
1333
+ isActive: a.id === activeId
1334
+ };
1335
+ });
1336
+ }
1337
+ function clearProvider(config, p) {
1338
+ setBlock(config, p, void 0);
1339
+ setAccounts(config, p, void 0);
1340
+ setActiveId(config, p, void 0);
1341
+ }
1342
+ var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
1343
+
1344
+ // src/migration/packCodec.ts
1345
+ var import_node_crypto5 = require("crypto");
1346
+ var PACK_MAGIC = "OMCXPACK";
1347
+ var PACK_VERSION = 1;
1348
+ var KDF_ALGORITHM = "scrypt";
1349
+ var PACK_PREFIX = `${PACK_MAGIC}${PACK_VERSION}.`;
1350
+ var KEY_BYTES3 = 32;
1351
+ var IV_BYTES2 = 12;
1352
+ var TAG_BYTES2 = 16;
1353
+ var SCRYPT_N = 1 << 15;
1354
+ var SCRYPT_R = 8;
1355
+ var SCRYPT_P = 1;
1356
+ var SCRYPT_SALT_BYTES = 16;
1357
+ var SCRYPT_MAXMEM = 128 * SCRYPT_R * SCRYPT_N * 2;
1358
+ var MIN_PASSPHRASE_LENGTH = 8;
1359
+ var WeakPassphraseError = class extends Error {
1360
+ constructor() {
1361
+ super(`passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`);
1362
+ this.name = "WeakPassphraseError";
1363
+ }
1364
+ };
1365
+ var PackAuthError = class extends Error {
1366
+ constructor(message) {
1367
+ super(message);
1368
+ this.name = "PackAuthError";
1369
+ }
1370
+ };
1371
+ function assertPassphraseStrength(passphrase) {
1372
+ if (typeof passphrase !== "string" || passphrase.length < MIN_PASSPHRASE_LENGTH) {
1373
+ throw new WeakPassphraseError();
1374
+ }
1375
+ }
1376
+ function toB64Url(s) {
1377
+ return Buffer.from(s, "utf8").toString("base64url");
1378
+ }
1379
+ function fromB64Url(s) {
1380
+ return Buffer.from(s, "base64url").toString("utf8");
1381
+ }
1382
+ function deriveKey(passphrase, salt, N, r, p) {
1383
+ return (0, import_node_crypto5.scryptSync)(passphrase, salt, KEY_BYTES3, { N, r, p, maxmem: SCRYPT_MAXMEM });
1384
+ }
1385
+ function aadFor(magic, version, kdf) {
1386
+ return Buffer.from(`${magic}|${version}|${kdf}`, "utf8");
1387
+ }
1388
+ function sealPack(bundleJson, passphrase) {
1389
+ assertPassphraseStrength(passphrase);
1390
+ const salt = (0, import_node_crypto5.randomBytes)(SCRYPT_SALT_BYTES);
1391
+ const iv = (0, import_node_crypto5.randomBytes)(IV_BYTES2);
1392
+ const key = deriveKey(passphrase, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
1393
+ const cipher = (0, import_node_crypto5.createCipheriv)("aes-256-gcm", key, iv);
1394
+ cipher.setAAD(aadFor(PACK_MAGIC, PACK_VERSION, KDF_ALGORITHM));
1395
+ const ciphertext = Buffer.concat([cipher.update(bundleJson, "utf8"), cipher.final()]);
1396
+ const tag = cipher.getAuthTag();
1397
+ const header = {
1398
+ magic: PACK_MAGIC,
1399
+ v: PACK_VERSION,
1400
+ kdf: KDF_ALGORITHM,
1401
+ salt: salt.toString("base64"),
1402
+ N: SCRYPT_N,
1403
+ r: SCRYPT_R,
1404
+ p: SCRYPT_P,
1405
+ iv: iv.toString("base64"),
1406
+ tag: tag.toString("base64")
1407
+ };
1408
+ return `${PACK_PREFIX}${toB64Url(JSON.stringify(header))}.${ciphertext.toString("base64")}`;
1409
+ }
1410
+ function parsePack(packString) {
1411
+ if (typeof packString !== "string" || !packString.startsWith(PACK_PREFIX)) {
1412
+ throw new PackAuthError("migration pack is malformed (bad magic/version prefix)");
1413
+ }
1414
+ const rest = packString.slice(PACK_PREFIX.length);
1415
+ const dot = rest.indexOf(".");
1416
+ if (dot < 0) throw new PackAuthError("migration pack is malformed (missing body)");
1417
+ const headerB64Url = rest.slice(0, dot);
1418
+ const ctB64 = rest.slice(dot + 1);
1419
+ let header;
1420
+ try {
1421
+ header = JSON.parse(fromB64Url(headerB64Url));
1422
+ } catch {
1423
+ throw new PackAuthError("migration pack is malformed (unreadable header)");
1424
+ }
1425
+ if (!header || header.magic !== PACK_MAGIC || header.v !== PACK_VERSION || header.kdf !== KDF_ALGORITHM || typeof header.salt !== "string" || typeof header.iv !== "string" || typeof header.tag !== "string" || typeof header.N !== "number" || typeof header.r !== "number" || typeof header.p !== "number") {
1426
+ throw new PackAuthError("migration pack is malformed (unsupported header)");
1427
+ }
1428
+ const ciphertext = Buffer.from(ctB64, "base64");
1429
+ return { header, ciphertext };
1430
+ }
1431
+ function openPack(packString, passphrase) {
1432
+ assertPassphraseStrength(passphrase);
1433
+ const { header, ciphertext } = parsePack(packString);
1434
+ const salt = Buffer.from(header.salt, "base64");
1435
+ const iv = Buffer.from(header.iv, "base64");
1436
+ const tag = Buffer.from(header.tag, "base64");
1437
+ if (iv.length !== IV_BYTES2 || tag.length !== TAG_BYTES2) {
1438
+ throw new PackAuthError("migration pack is malformed (invalid iv/tag length)");
1439
+ }
1440
+ const key = deriveKey(passphrase, salt, header.N, header.r, header.p);
1441
+ const decipher = (0, import_node_crypto5.createDecipheriv)("aes-256-gcm", key, iv);
1442
+ decipher.setAAD(aadFor(PACK_MAGIC, PACK_VERSION, KDF_ALGORITHM));
1443
+ decipher.setAuthTag(tag);
1444
+ try {
1445
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
1446
+ } catch {
1447
+ throw new PackAuthError("wrong passphrase or tampered migration pack");
1448
+ }
1449
+ }
1450
+
1451
+ // src/migration/migration.ts
1452
+ var BUNDLE_VERSION = 1;
1453
+ async function gatherExport(deps, passphrase) {
1454
+ const cfg = loadConfig(deps.configPath);
1455
+ const tokens = await deps.credentialStore.getFullConfig();
1456
+ const bundle = {
1457
+ v: BUNDLE_VERSION,
1458
+ providers: cfg.providers,
1459
+ tokens
1460
+ };
1461
+ return sealPack(JSON.stringify(bundle), passphrase);
1462
+ }
1463
+ var SUBSCRIPTION_PROVIDERS = ["claude", "codex", "gemini", "opencodego"];
1464
+ function collectProviderTokenBlocks(tokens, provider) {
1465
+ const keys = DAEMON_PROVIDER_KEYS[provider];
1466
+ const bag = tokens;
1467
+ const accounts = bag[keys.accounts];
1468
+ if (Array.isArray(accounts)) {
1469
+ const blocks = [];
1470
+ for (const entry of accounts) {
1471
+ if (entry && typeof entry === "object" && "tokens" in entry) {
1472
+ const tk = entry.tokens;
1473
+ if (tk && typeof tk === "object" && !Array.isArray(tk)) {
1474
+ blocks.push(tk);
1475
+ }
1476
+ }
1477
+ }
1478
+ if (blocks.length > 0) return blocks;
1479
+ }
1480
+ const mirror = bag[keys.block];
1481
+ if (mirror && typeof mirror === "object" && !Array.isArray(mirror)) {
1482
+ return [mirror];
1483
+ }
1484
+ return [];
1485
+ }
1486
+ async function applyImport(packString, passphrase, mode, deps, parseProviderInput2) {
1487
+ const bundleJson = openPack(packString, passphrase);
1488
+ let bundle;
1489
+ try {
1490
+ const parsed = JSON.parse(bundleJson);
1491
+ if (!parsed || typeof parsed !== "object") throw new Error("not an object");
1492
+ bundle = parsed;
1493
+ } catch {
1494
+ throw new Error("migration pack contents are unreadable");
1495
+ }
1496
+ const rawProviders = Array.isArray(bundle.providers) ? bundle.providers : [];
1497
+ const rawTokens = bundle.tokens && typeof bundle.tokens === "object" ? bundle.tokens : { updatedAt: "" };
1498
+ const validatedProviders = [];
1499
+ for (const raw of rawProviders) {
1500
+ if (!raw || typeof raw !== "object") {
1501
+ throw new Error("migration pack has an invalid provider row");
1502
+ }
1503
+ const validated = parseProviderInput2(raw, void 0);
1504
+ if (!validated) {
1505
+ throw new Error("migration pack has an invalid provider row");
1506
+ }
1507
+ validatedProviders.push(validated);
1508
+ }
1509
+ const validatedTokens = [];
1510
+ for (const provider of SUBSCRIPTION_PROVIDERS) {
1511
+ const blocks = collectProviderTokenBlocks(rawTokens, provider);
1512
+ for (const block of blocks) {
1513
+ const valid = validateTokenBody(provider, block);
1514
+ if (!valid) {
1515
+ throw new Error(`migration pack has an invalid token block for '${provider}'`);
1516
+ }
1517
+ validatedTokens.push({ provider, block: valid });
1518
+ }
1519
+ }
1520
+ const cfg = loadConfig(deps.configPath);
1521
+ const counts = {
1522
+ providerKeys: 0,
1523
+ poolKeys: 0,
1524
+ tokenSets: 0,
1525
+ duplicates: 0,
1526
+ skipped: []
1527
+ };
1528
+ for (const incoming of validatedProviders) {
1529
+ const idx = cfg.providers.findIndex((p) => p.id === incoming.id);
1530
+ if (idx >= 0 && mode !== "overwrite") {
1531
+ counts.skipped.push(incoming.id);
1532
+ continue;
1533
+ }
1534
+ if (idx >= 0) {
1535
+ cfg.providers[idx] = incoming;
1536
+ } else {
1537
+ cfg.providers.push(incoming);
1538
+ }
1539
+ counts.providerKeys += 1;
1540
+ if (incoming.apiKeys) counts.poolKeys += incoming.apiKeys.length;
1541
+ }
1542
+ saveConfig(deps.configPath, cfg);
1543
+ deps.llmConfig.reload(cfg);
1544
+ for (const { provider, block } of validatedTokens) {
1545
+ await deps.credentialStore.appendProviderAccount(provider, block);
1546
+ counts.tokenSets += 1;
1547
+ }
1548
+ return counts;
1549
+ }
1550
+
1551
+ // src/admin/adminMigration.ts
1552
+ function err3(status, message) {
1553
+ return { status, body: { error: { type: "admin_api_error", message } } };
1554
+ }
1555
+ async function handleExport(body, deps) {
1556
+ const passphrase = typeof body["passphrase"] === "string" ? body["passphrase"] : "";
1557
+ try {
1558
+ const pack = await gatherExport(deps, passphrase);
1559
+ return { status: 200, body: { pack, version: BUNDLE_VERSION } };
1560
+ } catch (error) {
1561
+ if (error instanceof WeakPassphraseError) {
1562
+ return err3(400, error.message);
1563
+ }
1564
+ return err3(500, "failed to build the migration pack");
1565
+ }
1566
+ }
1567
+ async function handleImport(body, deps) {
1568
+ const blob = typeof body["blob"] === "string" ? body["blob"] : "";
1569
+ const passphrase = typeof body["passphrase"] === "string" ? body["passphrase"] : "";
1570
+ const mode = body["mode"] === "overwrite" ? "overwrite" : "merge";
1571
+ if (!blob) return err3(400, "import requires { blob }");
1572
+ try {
1573
+ const counts = await applyImport(blob, passphrase, mode, deps, deps.parseProviderInput);
1574
+ return { status: 200, body: counts };
1575
+ } catch (error) {
1576
+ if (error instanceof WeakPassphraseError) {
1577
+ return err3(400, error.message);
1578
+ }
1579
+ return err3(400, error instanceof Error ? error.message : "import failed");
1580
+ }
1581
+ }
1582
+
1583
+ // src/admin/adminApi.ts
1584
+ function readBody(req) {
1585
+ return new Promise((resolve, reject) => {
1586
+ const chunks = [];
1587
+ req.on("data", (chunk) => chunks.push(chunk));
1588
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1589
+ req.on("error", reject);
1590
+ });
1591
+ }
1592
+ async function readJsonBody(req) {
1593
+ const raw = await readBody(req);
1594
+ if (!raw.trim()) return {};
1595
+ try {
1596
+ const parsed = JSON.parse(raw);
1597
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1598
+ } catch {
1599
+ return {};
1600
+ }
1601
+ }
1602
+ function writeJson(res, status, body) {
1603
+ res.writeHead(status, { "Content-Type": "application/json" });
1604
+ res.end(JSON.stringify(body));
1605
+ }
1606
+ function writeJsonError(res, status, message) {
1607
+ writeJson(res, status, { error: { type: "admin_api_error", message } });
1608
+ }
1609
+ function maskProviderApiKey(apiKey) {
1610
+ if (!apiKey) return "";
1611
+ if (apiKey.startsWith("$")) return "$ENV(\u2022\u2022\u2022)";
1612
+ const last4 = apiKey.length >= 4 ? apiKey.slice(-4) : apiKey;
1613
+ return `sk-\u2026${last4}`;
1614
+ }
1615
+ function toKeyInfo(row) {
1616
+ return {
1617
+ id: row.id,
1618
+ name: row.name,
1619
+ keyPrefix: row.keyPrefix,
1620
+ enabled: row.enabled,
1621
+ createdAt: row.createdAt,
1622
+ lastUsedAt: row.lastUsedAt,
1623
+ revoked: row.revokedAt !== null
1624
+ };
1625
+ }
1626
+ function toProviderView(row) {
1627
+ return {
1628
+ id: row.id,
1629
+ // app-parity-2 child 1: mutable display name (non-secret) round-trips verbatim;
1630
+ // absent stays absent (the app falls back to the id for display).
1631
+ name: row.name,
1632
+ apiFormat: row.apiFormat,
1633
+ baseUrl: row.baseUrl,
1634
+ models: row.models ?? [],
1635
+ // app-parity child 2: per-model metadata round-trips verbatim (non-secret —
1636
+ // no masking; absent stays absent so a flat-models-only row has no key).
1637
+ modelConfigs: row.modelConfigs,
1638
+ hasApiKey: row.apiKey.length > 0,
1639
+ apiKeyMasked: maskProviderApiKey(row.apiKey),
1640
+ // app-foundation D8: absent `enabled` reads as enabled (back-compat).
1641
+ enabled: row.enabled !== false,
1642
+ // app-parity child 1: non-secret scalar fields round-trip verbatim (no masking).
1643
+ isOfficial: row.isOfficial,
1644
+ apiVersion: row.apiVersion,
1645
+ maxConcurrency: row.maxConcurrency,
1646
+ modelsEndpoint: row.modelsEndpoint,
1647
+ // app-parity child 5: transformer config round-trips VERBATIM (non-secret —
1648
+ // transform-rule names + options, no key material; absent stays absent).
1649
+ transformer: row.transformer,
1650
+ // app-parity-2 child 3: coding-plan endpoint MASKED — the secret `apiKey` is
1651
+ // NEVER serialized out (only a `hasApiKey` boolean); enabled/baseUrl/note are
1652
+ // non-secret. Absent stays absent.
1653
+ codingPlan: row.codingPlan ? {
1654
+ enabled: row.codingPlan.enabled,
1655
+ baseUrl: row.codingPlan.baseUrl,
1656
+ hasApiKey: typeof row.codingPlan.apiKey === "string" && row.codingPlan.apiKey.length > 0,
1657
+ note: row.codingPlan.note
1658
+ } : void 0,
1659
+ // app-parity-2 child 4: API modes MASKED — each mode's secret `apiKey` is
1660
+ // NEVER serialized out (only a per-mode `hasApiKey`); id/label/baseUrl/prefix/
1661
+ // note are non-secret. Absent stays absent.
1662
+ apiModes: row.apiModes ? row.apiModes.map((m) => ({
1663
+ id: m.id,
1664
+ label: m.label,
1665
+ baseUrl: m.baseUrl,
1666
+ hasApiKey: typeof m.apiKey === "string" && m.apiKey.length > 0,
1667
+ apiKeyPrefix: m.apiKeyPrefix,
1668
+ note: m.note
1669
+ })) : void 0,
1670
+ selectedApiModeId: row.selectedApiModeId
1671
+ };
1672
+ }
1673
+ async function handleAdminApi(req, res, path, deps) {
1674
+ const method = (req.method ?? "GET").toUpperCase();
1675
+ const sub = path.slice("/admin/api/".length);
1676
+ const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
1677
+ try {
1678
+ switch (resource) {
1679
+ case "providers":
1680
+ return await handleProviders(req, res, method, rest, deps);
1681
+ case "presets":
1682
+ return handlePresets(res, method);
1683
+ case "keys":
1684
+ return await handleKeys(req, res, method, rest, deps);
1685
+ case "server":
1686
+ return await handleServer(req, res, method, deps);
1687
+ case "accounts":
1688
+ return await handleAccounts(req, res, method, rest, deps);
1689
+ case "status":
1690
+ return await handleStatus(res, method, deps);
1691
+ case "playground":
1692
+ return await handlePlayground(req, res, method, deps);
1693
+ case "export":
1694
+ return await handleMigrationExport(req, res, method, deps);
1695
+ case "import":
1696
+ return await handleMigrationImport(req, res, method, deps);
1697
+ default:
1698
+ return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
1699
+ }
1700
+ } catch (err4) {
1701
+ writeJsonError(res, 500, err4 instanceof Error ? err4.message : String(err4));
1702
+ }
1703
+ }
1704
+ function migrationDeps(deps) {
1705
+ return {
1706
+ configPath: deps.configPath,
1707
+ llmConfig: deps.llmConfig,
1708
+ credentialStore: deps.migrationCredentialStore,
1709
+ parseProviderInput
1710
+ };
1711
+ }
1712
+ async function handleMigrationExport(req, res, method, deps) {
1713
+ if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
1714
+ const body = await readJsonBody(req);
1715
+ const result = await handleExport(body, migrationDeps(deps));
1716
+ return writeJson(res, result.status, result.body);
1717
+ }
1718
+ async function handleMigrationImport(req, res, method, deps) {
1719
+ if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
1720
+ const body = await readJsonBody(req);
1721
+ const result = await handleImport(body, migrationDeps(deps));
1722
+ return writeJson(res, result.status, result.body);
1723
+ }
1724
+ async function handleProviders(req, res, method, rest, deps) {
1725
+ const cfg = loadConfig(deps.configPath);
1726
+ if (method === "GET" && rest.length === 2 && rest[1] === "keys") {
1727
+ return await handleProviderKeys(res, rest[0], cfg, deps);
1728
+ }
1729
+ if (method === "POST" && rest.length === 2 && rest[1] === "keys") {
1730
+ return await handleAddProviderKey(req, res, rest[0], cfg, deps);
1731
+ }
1732
+ if (method === "POST" && rest.length === 4 && rest[1] === "keys" && rest[3] === "enabled") {
1733
+ return await handleToggleProviderKey(req, res, rest[0], rest[2], cfg, deps);
1734
+ }
1735
+ if (method === "PUT" && rest.length === 3 && rest[1] === "keys") {
1736
+ return await handleUpdateProviderKey(req, res, rest[0], rest[2], cfg, deps);
1737
+ }
1738
+ if (method === "DELETE" && rest.length === 3 && rest[1] === "keys") {
1739
+ return await handleDeleteProviderKey(res, rest[0], rest[2], cfg, deps);
1740
+ }
1741
+ if (method === "POST" && rest.length === 1 && rest[0] === "reorder") {
1742
+ return await handleProviderReorder(req, res, cfg, deps);
1743
+ }
1744
+ if (method === "POST" && rest.length === 2 && rest[1] === "discover-models") {
1745
+ return await handleDiscoverModels(res, rest[0], cfg);
1746
+ }
1747
+ if (method === "POST" && rest.length === 2 && rest[1] === "test") {
1748
+ return await handleTestModel(req, res, rest[0], cfg);
1749
+ }
1750
+ if (method === "GET") {
1751
+ return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
1752
+ }
1753
+ if (method === "POST") {
1754
+ const body = await readJsonBody(req);
1755
+ const provider = parseProviderInput(body, void 0);
1756
+ if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
1757
+ if (cfg.providers.some((p) => p.id === provider.id)) {
1758
+ return writeJsonError(res, 409, `provider '${provider.id}' already exists`);
1759
+ }
1760
+ cfg.providers.push(provider);
1761
+ persistProviders(cfg, deps);
1762
+ return writeJson(res, 201, { provider: toProviderView(provider) });
1763
+ }
1764
+ const id = rest[0];
1765
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1766
+ const idx = cfg.providers.findIndex((p) => p.id === id);
1767
+ if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
1768
+ if (method === "PUT") {
1769
+ const body = await readJsonBody(req);
1770
+ const existing = cfg.providers[idx];
1771
+ const updated = parseProviderInput(body, existing);
1772
+ if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
1773
+ cfg.providers[idx] = updated;
1774
+ persistProviders(cfg, deps);
1775
+ return writeJson(res, 200, { provider: toProviderView(updated) });
1776
+ }
1777
+ if (method === "DELETE") {
1778
+ cfg.providers.splice(idx, 1);
1779
+ persistProviders(cfg, deps);
1780
+ return writeJson(res, 200, { ok: true });
1781
+ }
1782
+ return writeJsonError(res, 405, `method ${method} not allowed on providers`);
1783
+ }
1784
+ function persistProviders(cfg, deps) {
1785
+ saveConfig(deps.configPath, cfg);
1786
+ deps.llmConfig.reload(cfg);
1787
+ }
1788
+ async function handleProviderReorder(req, res, cfg, deps) {
1789
+ const body = await readJsonBody(req);
1790
+ const rawOrder = body["order"];
1791
+ if (!Array.isArray(rawOrder)) {
1792
+ return writeJsonError(res, 400, "reorder requires { order: string[] }");
1793
+ }
1794
+ const order = rawOrder.filter((x) => typeof x === "string");
1795
+ const byId = new Map(cfg.providers.map((p) => [p.id, p]));
1796
+ const seen = /* @__PURE__ */ new Set();
1797
+ const reordered = [];
1798
+ for (const id of order) {
1799
+ const row = byId.get(id);
1800
+ if (row && !seen.has(id)) {
1801
+ reordered.push(row);
1802
+ seen.add(id);
1803
+ }
1804
+ }
1805
+ for (const row of cfg.providers) {
1806
+ if (!seen.has(row.id)) {
1807
+ reordered.push(row);
1808
+ seen.add(row.id);
1809
+ }
1810
+ }
1811
+ cfg.providers = reordered;
1812
+ persistProviders(cfg, deps);
1813
+ return writeJson(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
1814
+ }
1815
+ async function handleDiscoverModels(res, id, cfg) {
1816
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1817
+ const row = cfg.providers.find((p) => p.id === id);
1818
+ if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
1819
+ if (row.apiFormat !== "openai") {
1820
+ return writeJson(res, 200, { models: [], unsupportedFormat: true });
1821
+ }
1822
+ const resolvedKey = resolveEnvKey(row.apiKey);
1823
+ const base = row.baseUrl.replace(/\/+$/, "");
1824
+ const url = `${base}/models`;
1825
+ try {
1826
+ const headers = { Accept: "application/json" };
1827
+ if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
1828
+ const response = await fetch(url, { method: "GET", headers });
1829
+ if (!response.ok) {
1830
+ const text = await response.text().catch(() => "");
1831
+ let message = text.slice(0, 300);
1832
+ try {
1833
+ const parsed = JSON.parse(text);
1834
+ message = parsed?.error?.message || parsed?.message || message;
1835
+ } catch {
1836
+ }
1837
+ return writeJson(res, 200, {
1838
+ models: [],
1839
+ error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
1840
+ });
1841
+ }
1842
+ const data = await response.json();
1843
+ const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
1844
+ return writeJson(res, 200, { models });
1845
+ } catch (err4) {
1846
+ const message = err4 instanceof Error ? err4.message : String(err4);
1847
+ return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
1848
+ }
1849
+ }
1850
+ async function handleTestModel(req, res, id, cfg) {
1851
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1852
+ const row = cfg.providers.find((p) => p.id === id);
1853
+ if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
1854
+ const body = await readJsonBody(req);
1855
+ const model = typeof body["model"] === "string" ? body["model"].trim() : "";
1856
+ if (!model) return writeJsonError(res, 400, "test requires a { model } string");
1857
+ if (row.apiFormat === "gemini") {
1858
+ return writeJson(res, 200, { ok: false, unsupportedFormat: true });
1859
+ }
1860
+ const resolvedKey = resolveEnvKey(row.apiKey);
1861
+ if (!resolvedKey) {
1862
+ return writeJson(res, 200, { ok: false, message: "no API key configured for this provider" });
1863
+ }
1864
+ const url = row.baseUrl.replace(/\/+$/, "");
1865
+ const prompt = "Reply with the single word: OK.";
1866
+ const headers = { "Content-Type": "application/json" };
1867
+ let payload;
1868
+ if (row.apiFormat === "anthropic") {
1869
+ headers["x-api-key"] = resolvedKey;
1870
+ headers["anthropic-version"] = "2023-06-01";
1871
+ payload = { model, max_tokens: 16, messages: [{ role: "user", content: prompt }] };
1872
+ } else {
1873
+ headers["Authorization"] = `Bearer ${resolvedKey}`;
1874
+ payload = {
1875
+ model,
1876
+ max_tokens: 16,
1877
+ stream: false,
1878
+ messages: [{ role: "user", content: prompt }]
1879
+ };
1880
+ }
1881
+ const startedAt = Date.now();
1882
+ try {
1883
+ const response = await fetch(url, {
1884
+ method: "POST",
1885
+ headers,
1886
+ body: JSON.stringify(payload)
1887
+ });
1888
+ const latencyMs = Date.now() - startedAt;
1889
+ const text = await response.text().catch(() => "");
1890
+ if (!response.ok) {
1891
+ let message = text.slice(0, 300);
1892
+ try {
1893
+ const parsed = JSON.parse(text);
1894
+ message = parsed?.error?.message || parsed?.message || message;
1895
+ } catch {
1896
+ }
1897
+ return writeJson(res, 200, { ok: false, status: response.status, latencyMs, message });
1898
+ }
1899
+ return writeJson(res, 200, {
1900
+ ok: true,
1901
+ status: response.status,
1902
+ latencyMs,
1903
+ sample: extractSampleText(text, row.apiFormat)
1904
+ });
1905
+ } catch (err4) {
1906
+ const message = err4 instanceof Error ? err4.message : String(err4);
1907
+ return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
1908
+ }
1909
+ }
1910
+ function extractSampleText(text, apiFormat) {
1911
+ try {
1912
+ const data = JSON.parse(text);
1913
+ if (apiFormat === "anthropic") {
1914
+ const content2 = data["content"]?.[0]?.text;
1915
+ return typeof content2 === "string" ? content2.slice(0, 200) : "";
1916
+ }
1917
+ const choice = data["choices"]?.[0];
1918
+ const content = choice?.message?.content;
1919
+ return typeof content === "string" ? content.slice(0, 200) : "";
1920
+ } catch {
1921
+ return "";
1922
+ }
1923
+ }
1924
+ function toPoolKeyView(row, cooldown, deps) {
1925
+ const entries = row.apiKeys && row.apiKeys.length > 0 ? row.apiKeys : row.apiKey.length > 0 ? [{ id: `${row.id}:default`, apiKey: row.apiKey, weight: 1, enabled: true }] : [];
1926
+ return entries.map((e) => {
1927
+ const auto = deps.autoDisableStore.get(e.id);
1928
+ const cd = cooldown[e.id];
1929
+ const health = {};
1930
+ if (cd) health.cooldown = cd;
1931
+ if (auto) health.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
1932
+ return {
1933
+ id: e.id,
1934
+ label: e.label && e.label.length > 0 ? e.label : e.id,
1935
+ // An auto-disabled key reads disabled even when the config flag is true.
1936
+ enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
1937
+ weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
1938
+ apiKeyMasked: maskProviderApiKey(e.apiKey),
1939
+ ...Object.keys(health).length > 0 ? { health } : {}
1940
+ };
1941
+ });
1942
+ }
1943
+ async function handleProviderKeys(res, id, cfg, deps) {
1944
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1945
+ const row = cfg.providers.find((p) => p.id === id);
1946
+ if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
1947
+ const cooldown = await deps.apiKeyPool.getKeyHealth(id);
1948
+ return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
1949
+ }
1950
+ function parsePoolKeyInput(body, existing) {
1951
+ const out = {};
1952
+ if (typeof body["label"] === "string" && body["label"].length > 0) out.label = body["label"];
1953
+ else if (existing?.label) out.label = existing.label;
1954
+ if (typeof body["weight"] === "number" && Number.isFinite(body["weight"])) out.weight = body["weight"];
1955
+ else if (typeof existing?.weight === "number") out.weight = existing.weight;
1956
+ if (typeof body["enabled"] === "boolean") out.enabled = body["enabled"];
1957
+ else if (typeof existing?.enabled === "boolean") out.enabled = existing.enabled;
1958
+ const submitted = typeof body["apiKey"] === "string" && body["apiKey"].length > 0 ? body["apiKey"] : "";
1959
+ const apiKey = submitted || existing?.apiKey || "";
1960
+ if (apiKey) out.apiKey = apiKey;
1961
+ return out;
1962
+ }
1963
+ async function handleAddProviderKey(req, res, id, cfg, deps) {
1964
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1965
+ const idx = cfg.providers.findIndex((p) => p.id === id);
1966
+ if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
1967
+ const body = await readJsonBody(req);
1968
+ const parsed = parsePoolKeyInput(body);
1969
+ if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
1970
+ const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1971
+ const entry = { id: keyId, apiKey: parsed.apiKey };
1972
+ if (parsed.label !== void 0) entry.label = parsed.label;
1973
+ if (parsed.enabled !== void 0) entry.enabled = parsed.enabled;
1974
+ if (parsed.weight !== void 0) entry.weight = parsed.weight;
1975
+ const row = cfg.providers[idx];
1976
+ row.apiKeys = [...row.apiKeys ?? [], entry];
1977
+ persistProviders(cfg, deps);
1978
+ const cooldown = await deps.apiKeyPool.getKeyHealth(id);
1979
+ return writeJson(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
1980
+ }
1981
+ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
1982
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
1983
+ if (!keyId) return writeJsonError(res, 400, "key id required in path");
1984
+ const idx = cfg.providers.findIndex((p) => p.id === id);
1985
+ if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
1986
+ const row = cfg.providers[idx];
1987
+ const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
1988
+ if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
1989
+ const body = await readJsonBody(req);
1990
+ const existing = row.apiKeys[keyIdx];
1991
+ const parsed = parsePoolKeyInput(body, existing);
1992
+ const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
1993
+ if (parsed.label !== void 0) entry.label = parsed.label;
1994
+ if (parsed.enabled !== void 0) entry.enabled = parsed.enabled;
1995
+ if (parsed.weight !== void 0) entry.weight = parsed.weight;
1996
+ row.apiKeys[keyIdx] = entry;
1997
+ persistProviders(cfg, deps);
1998
+ const cooldown = await deps.apiKeyPool.getKeyHealth(id);
1999
+ return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2000
+ }
2001
+ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
2002
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
2003
+ if (!keyId) return writeJsonError(res, 400, "key id required in path");
2004
+ const idx = cfg.providers.findIndex((p) => p.id === id);
2005
+ if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
2006
+ const row = cfg.providers[idx];
2007
+ const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
2008
+ if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
2009
+ row.apiKeys.splice(keyIdx, 1);
2010
+ if (row.apiKeys.length === 0) row.apiKeys = void 0;
2011
+ persistProviders(cfg, deps);
2012
+ const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2013
+ return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2014
+ }
2015
+ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
2016
+ if (!id) return writeJsonError(res, 400, "provider id required in path");
2017
+ if (!keyId) return writeJsonError(res, 400, "key id required in path");
2018
+ const idx = cfg.providers.findIndex((p) => p.id === id);
2019
+ if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
2020
+ const row = cfg.providers[idx];
2021
+ const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
2022
+ if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
2023
+ const body = await readJsonBody(req);
2024
+ row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
2025
+ persistProviders(cfg, deps);
2026
+ const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2027
+ return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2028
+ }
2029
+ function parseApiKeysInput(raw, existing) {
2030
+ if (!Array.isArray(raw)) return existing;
2031
+ const byId = new Map((existing ?? []).map((e) => [e.id, e]));
2032
+ const out = [];
2033
+ for (const item of raw) {
2034
+ if (!item || typeof item !== "object") continue;
2035
+ const k = item;
2036
+ const id = typeof k["id"] === "string" && k["id"].trim() ? k["id"].trim() : "";
2037
+ if (!id) continue;
2038
+ const prior = byId.get(id);
2039
+ const submittedKey = typeof k["apiKey"] === "string" && k["apiKey"].length > 0 ? k["apiKey"] : "";
2040
+ const apiKey = submittedKey || prior?.apiKey || "";
2041
+ if (!apiKey) continue;
2042
+ const entry = { id, apiKey };
2043
+ if (typeof k["label"] === "string" && k["label"].length > 0) entry.label = k["label"];
2044
+ else if (prior?.label) entry.label = prior.label;
2045
+ if (typeof k["enabled"] === "boolean") entry.enabled = k["enabled"];
2046
+ else if (typeof prior?.enabled === "boolean") entry.enabled = prior.enabled;
2047
+ if (typeof k["weight"] === "number" && Number.isFinite(k["weight"])) entry.weight = k["weight"];
2048
+ else if (typeof prior?.weight === "number") entry.weight = prior.weight;
2049
+ out.push(entry);
2050
+ }
2051
+ return out.length > 0 ? out : void 0;
2052
+ }
2053
+ function parseModelConfigsInput(raw, existing) {
2054
+ if (!Array.isArray(raw)) return existing;
2055
+ const byId = new Map((existing ?? []).map((e) => [e.id, e]));
2056
+ const out = [];
2057
+ for (const item of raw) {
2058
+ if (!item || typeof item !== "object") continue;
2059
+ const m = item;
2060
+ const id = typeof m["id"] === "string" && m["id"].trim() ? m["id"].trim() : "";
2061
+ if (!id) continue;
2062
+ const prior = byId.get(id);
2063
+ const entry = { id };
2064
+ if (typeof m["name"] === "string" && m["name"].length > 0) entry.name = m["name"];
2065
+ else if (prior?.name) entry.name = prior.name;
2066
+ if (typeof m["group"] === "string" && m["group"].length > 0) entry.group = m["group"];
2067
+ else if (prior?.group) entry.group = prior.group;
2068
+ if (typeof m["enabled"] === "boolean") entry.enabled = m["enabled"];
2069
+ else if (typeof prior?.enabled === "boolean") entry.enabled = prior.enabled;
2070
+ if (typeof m["vision"] === "boolean") entry.vision = m["vision"];
2071
+ else if (typeof prior?.vision === "boolean") entry.vision = prior.vision;
2072
+ if (typeof m["reasoning"] === "boolean") entry.reasoning = m["reasoning"];
2073
+ else if (typeof prior?.reasoning === "boolean") entry.reasoning = prior.reasoning;
2074
+ out.push(entry);
2075
+ }
2076
+ return out.length > 0 ? out : void 0;
2077
+ }
2078
+ function parseTransformerInput(raw, existing) {
2079
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return existing;
2080
+ const t = raw;
2081
+ const out = {};
2082
+ let kept = false;
2083
+ if (Array.isArray(t["use"])) {
2084
+ const use = [];
2085
+ for (const item of t["use"]) {
2086
+ const entry = validateTransformerEntry(item);
2087
+ if (entry !== null) use.push(entry);
2088
+ }
2089
+ if (use.length > 0) {
2090
+ out.use = use;
2091
+ kept = true;
2092
+ }
2093
+ }
2094
+ for (const key of Object.keys(t)) {
2095
+ if (key === "use") continue;
2096
+ const value = t[key];
2097
+ if (value && typeof value === "object") {
2098
+ out[key] = value;
2099
+ kept = true;
2100
+ }
2101
+ }
2102
+ return kept ? out : void 0;
2103
+ }
2104
+ function parseCodingPlanInput(raw, existing) {
2105
+ const enabled = raw["enabled"] === true;
2106
+ const baseUrl = typeof raw["baseUrl"] === "string" && raw["baseUrl"].length > 0 ? raw["baseUrl"] : raw["baseUrl"] === null ? void 0 : existing?.baseUrl;
2107
+ const apiKey = typeof raw["apiKey"] === "string" && raw["apiKey"].length > 0 ? raw["apiKey"] : existing?.apiKey;
2108
+ const note = typeof raw["note"] === "string" && raw["note"].length > 0 ? raw["note"] : raw["note"] === null ? void 0 : existing?.note;
2109
+ if (!enabled && !baseUrl && !apiKey && !note) return void 0;
2110
+ const out = { enabled };
2111
+ if (baseUrl) out.baseUrl = baseUrl;
2112
+ if (apiKey) out.apiKey = apiKey;
2113
+ if (note) out.note = note;
2114
+ return out;
2115
+ }
2116
+ function parseApiModesInput(raw, existing) {
2117
+ if (!Array.isArray(raw)) return existing;
2118
+ const byId = new Map((existing ?? []).map((m) => [m.id, m]));
2119
+ const out = [];
2120
+ for (const item of raw) {
2121
+ if (!item || typeof item !== "object") continue;
2122
+ const m = item;
2123
+ const id = typeof m["id"] === "string" && m["id"].trim() ? m["id"].trim() : "";
2124
+ const baseUrl = typeof m["baseUrl"] === "string" && m["baseUrl"].length > 0 ? m["baseUrl"] : "";
2125
+ if (!id || !baseUrl) continue;
2126
+ const prior = byId.get(id);
2127
+ const label = typeof m["label"] === "string" && m["label"].length > 0 ? m["label"] : prior?.label ?? id;
2128
+ const entry = { id, label, baseUrl };
2129
+ if (typeof m["apiKey"] === "string" && m["apiKey"].length > 0) entry.apiKey = m["apiKey"];
2130
+ else if (prior?.apiKey) entry.apiKey = prior.apiKey;
2131
+ const prefix = typeof m["apiKeyPrefix"] === "string" && m["apiKeyPrefix"].length > 0 ? m["apiKeyPrefix"] : prior?.apiKeyPrefix;
2132
+ if (prefix) entry.apiKeyPrefix = prefix;
2133
+ const note = typeof m["note"] === "string" && m["note"].length > 0 ? m["note"] : prior?.note;
2134
+ if (note) entry.note = note;
2135
+ out.push(entry);
2136
+ }
2137
+ return out.length > 0 ? out : void 0;
2138
+ }
2139
+ function parseProviderInput(body, existing) {
2140
+ const id = existing ? existing.id : typeof body["id"] === "string" && body["id"].trim() ? body["id"].trim() : void 0;
2141
+ const apiFormat = body["apiFormat"];
2142
+ const baseUrl = body["baseUrl"];
2143
+ if (!id) return null;
2144
+ const name = typeof body["name"] === "string" && body["name"].length > 0 ? body["name"] : body["name"] === null ? void 0 : existing?.name;
2145
+ if (apiFormat !== "openai" && apiFormat !== "anthropic" && apiFormat !== "gemini") return null;
2146
+ if (typeof baseUrl !== "string" || !baseUrl.trim()) return null;
2147
+ const rawKey = body["apiKey"];
2148
+ let apiKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : existing?.apiKey ?? "";
2149
+ const models = Array.isArray(body["models"]) ? body["models"].filter((m) => typeof m === "string") : existing?.models;
2150
+ const modelConfigs = body["modelConfigs"] === null ? void 0 : parseModelConfigsInput(body["modelConfigs"], existing?.modelConfigs);
2151
+ const apiKeys = parseApiKeysInput(body["apiKeys"], existing?.apiKeys);
2152
+ const enabled = typeof body["enabled"] === "boolean" ? body["enabled"] : existing?.enabled;
2153
+ const isOfficial = typeof body["isOfficial"] === "boolean" ? body["isOfficial"] : existing?.isOfficial;
2154
+ const apiVersion = typeof body["apiVersion"] === "string" && body["apiVersion"].length > 0 ? body["apiVersion"] : body["apiVersion"] === null ? void 0 : existing?.apiVersion;
2155
+ const modelsEndpoint = typeof body["modelsEndpoint"] === "string" && body["modelsEndpoint"].length > 0 ? body["modelsEndpoint"] : body["modelsEndpoint"] === null ? void 0 : existing?.modelsEndpoint;
2156
+ const maxConcurrency = typeof body["maxConcurrency"] === "number" && Number.isFinite(body["maxConcurrency"]) ? body["maxConcurrency"] : body["maxConcurrency"] === null ? void 0 : existing?.maxConcurrency;
2157
+ const transformer = body["transformer"] === null ? void 0 : parseTransformerInput(body["transformer"], existing?.transformer);
2158
+ const codingPlan = body["codingPlan"] === null ? void 0 : body["codingPlan"] === void 0 ? existing?.codingPlan : body["codingPlan"] && typeof body["codingPlan"] === "object" && !Array.isArray(body["codingPlan"]) ? parseCodingPlanInput(body["codingPlan"], existing?.codingPlan) : existing?.codingPlan;
2159
+ const apiModes = body["apiModes"] === null ? void 0 : parseApiModesInput(body["apiModes"], existing?.apiModes);
2160
+ const selectedApiModeId = typeof body["selectedApiModeId"] === "string" && body["selectedApiModeId"].length > 0 ? body["selectedApiModeId"] : body["selectedApiModeId"] === null ? void 0 : existing?.selectedApiModeId;
2161
+ if (typeof body["selectedApiModeId"] === "string" && body["selectedApiModeId"].length > 0 && body["selectedApiModeId"] !== existing?.selectedApiModeId && !(typeof rawKey === "string" && rawKey.length > 0)) {
2162
+ const mode = apiModes?.find((m) => m.id === selectedApiModeId);
2163
+ if (mode?.apiKey && typeof body["baseUrl"] === "string" && body["baseUrl"] === mode.baseUrl) {
2164
+ apiKey = mode.apiKey;
2165
+ }
2166
+ }
2167
+ return {
2168
+ id,
2169
+ name,
2170
+ apiFormat,
2171
+ baseUrl: baseUrl.trim(),
2172
+ apiKey,
2173
+ models,
2174
+ modelConfigs,
2175
+ apiKeys,
2176
+ enabled,
2177
+ isOfficial,
2178
+ apiVersion,
2179
+ maxConcurrency,
2180
+ modelsEndpoint,
2181
+ transformer,
2182
+ codingPlan,
2183
+ apiModes,
2184
+ selectedApiModeId
2185
+ };
2186
+ }
2187
+ function handlePresets(res, method) {
2188
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on presets`);
2189
+ const { mappable, excluded } = listMappablePresets();
2190
+ const presets = mappable.map((p) => ({
2191
+ id: p.id,
2192
+ presetId: p.presetId,
2193
+ name: p.name,
2194
+ apiFormat: p.apiFormat,
2195
+ baseUrl: p.baseUrl,
2196
+ models: p.models
2197
+ }));
2198
+ return writeJson(res, 200, { presets, excluded });
2199
+ }
2200
+ async function handleKeys(req, res, method, rest, deps) {
2201
+ if (method === "GET" && rest.length === 0) {
2202
+ const rows = await deps.keyDb.outboundApiKeysList();
2203
+ return writeJson(res, 200, { keys: rows.map(toKeyInfo) });
2204
+ }
2205
+ if (method === "POST" && rest.length === 0) {
2206
+ const body = await readJsonBody(req);
2207
+ const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
2208
+ const created = await (0, import_outbound_api2.createNamedKey)(deps.keyDb, name);
2209
+ return writeJson(res, 201, {
2210
+ id: created.id,
2211
+ name: created.name,
2212
+ keyPrefix: created.keyPrefix,
2213
+ createdAt: created.createdAt,
2214
+ plaintextOnce: created.plaintextOnce
2215
+ });
2216
+ }
2217
+ const id = rest[0];
2218
+ const action = rest[1];
2219
+ if (method === "POST" && id && action === "revoke") {
2220
+ const ok = await deps.keyDb.outboundApiKeysRevoke(id);
2221
+ return writeJson(res, ok ? 200 : 404, { ok });
2222
+ }
2223
+ if (method === "POST" && id && action === "enabled") {
2224
+ const body = await readJsonBody(req);
2225
+ const enabled = body["enabled"] === true;
2226
+ const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
2227
+ return writeJson(res, ok ? 200 : 404, { ok, enabled });
2228
+ }
2229
+ return writeJsonError(res, 405, `method ${method} not allowed on keys`);
2230
+ }
2231
+ async function handleServer(req, res, method, deps) {
2232
+ if (method === "GET") {
2233
+ const config = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
2234
+ return writeJson(res, 200, { server: config });
2235
+ }
2236
+ if (method === "PUT") {
2237
+ const patch = await readJsonBody(req);
2238
+ const current = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
2239
+ const merged = (0, import_outbound_api2.mergeServerConfig)(current, patch);
2240
+ await (0, import_outbound_api2.saveServerConfig)(deps.settingsStore, merged);
2241
+ await deps.outboundApiServer.applyConfig({
2242
+ enabled: merged.enabled,
2243
+ networkBinding: merged.networkBinding,
2244
+ endpoints: merged.endpoints,
2245
+ port: merged.port
2246
+ });
2247
+ return writeJson(res, 200, { server: merged });
2248
+ }
2249
+ return writeJsonError(res, 405, `method ${method} not allowed on server`);
2250
+ }
2251
+ async function handleAccounts(req, res, method, rest, deps) {
2252
+ if (method === "GET" && rest.length === 0) {
2253
+ const accounts = await deps.subscriptionAccounts.listAll();
2254
+ const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
2255
+ return writeJson(res, 200, { accounts, providerAccounts });
2256
+ }
2257
+ if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
2258
+ const result = handleCodexOAuthStatus(rest[2], deps);
2259
+ return writeJson(res, result.status, result.body);
2260
+ }
2261
+ if (method === "PUT" || method === "POST" || method === "DELETE") {
2262
+ const providerId = asSubscriptionProviderId(rest[0]);
2263
+ if (!providerId) {
2264
+ return writeJsonError(res, 400, `unknown subscription provider '${rest[0] ?? ""}'`);
2265
+ }
2266
+ if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
2267
+ const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
2268
+ return writeJson(res, result.status, result.body);
2269
+ }
2270
+ if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
2271
+ const body2 = await readJsonBody(req);
2272
+ const result = await handleOAuthComplete(providerId, body2, deps);
2273
+ return writeJson(res, result.status, result.body);
2274
+ }
2275
+ if (method === "PUT" && rest[1] === "active") {
2276
+ const body2 = await readJsonBody(req);
2277
+ const id = typeof body2["id"] === "string" ? body2["id"] : "";
2278
+ if (!id) return writeJsonError(res, 400, "active switch requires { id }");
2279
+ const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
2280
+ if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
2281
+ return writeJson(res, 200, { ok: true });
2282
+ }
2283
+ if (method === "DELETE" && rest.length >= 2) {
2284
+ const accountId = rest[1];
2285
+ const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
2286
+ if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
2287
+ return writeJson(res, 200, { ok: true });
2288
+ }
2289
+ if (method === "DELETE") {
2290
+ await deps.subscriptionTokenWriter.clearProvider(providerId);
2291
+ return writeJson(res, 200, { ok: true });
2292
+ }
2293
+ const body = await readJsonBody(req);
2294
+ const config = validateTokenBody(providerId, body);
2295
+ if (!config) {
2296
+ return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
2297
+ }
2298
+ await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
2299
+ const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
2300
+ return writeJson(res, 200, status ? { account: status } : { ok: true });
2301
+ }
2302
+ return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
2303
+ }
2304
+ async function handleStatus(res, method, deps) {
2305
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
2306
+ const status = deps.outboundApiServer.getStatus();
2307
+ const serverConfig = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
2308
+ const endpoints = serverConfig.endpoints.map((e) => ({
2309
+ endpoint: e.endpoint,
2310
+ model: e.defaultModel,
2311
+ useSubscription: e.useSubscription
2312
+ }));
2313
+ return writeJson(res, 200, { ...status, endpoints });
2314
+ }
2315
+ function resolvePlaygroundPath(endpoint, body) {
2316
+ switch (endpoint) {
2317
+ case "chat":
2318
+ return "/v1/chat/completions";
2319
+ case "responses":
2320
+ return "/v1/responses";
2321
+ case "messages":
2322
+ return "/v1/messages";
2323
+ case "gemini": {
2324
+ const model = typeof body["model"] === "string" ? body["model"] : "gemini-pro";
2325
+ return `/v1beta/models/${encodeURIComponent(model)}:generateContent`;
2326
+ }
2327
+ default:
2328
+ return null;
2329
+ }
2330
+ }
2331
+ async function handlePlayground(req, res, method, deps) {
2332
+ if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
2333
+ const body = await readJsonBody(req);
2334
+ const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
2335
+ const key = typeof body["key"] === "string" ? body["key"] : "";
2336
+ const payload = body["body"];
2337
+ const status = deps.outboundApiServer.getStatus();
2338
+ if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
2339
+ const path = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2340
+ if (!path) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2341
+ const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
2342
+ await proxyToOutbound(res, status.port, path, key, upstreamBody);
2343
+ }
2344
+ function isRecord(v) {
2345
+ return !!v && typeof v === "object" && !Array.isArray(v);
2346
+ }
2347
+ function proxyToOutbound(res, outboundPort, path, key, body) {
2348
+ return new Promise((resolve) => {
2349
+ const upstream = import_node_http.default.request(
2350
+ {
2351
+ host: "127.0.0.1",
2352
+ port: outboundPort,
2353
+ path,
2354
+ method: "POST",
2355
+ headers: {
2356
+ "Content-Type": "application/json",
2357
+ "Content-Length": Buffer.byteLength(body),
2358
+ Authorization: `Bearer ${key}`
2359
+ }
2360
+ },
2361
+ (proxRes) => {
2362
+ const headers = {};
2363
+ const ct = proxRes.headers["content-type"];
2364
+ if (ct) headers["Content-Type"] = ct;
2365
+ res.writeHead(proxRes.statusCode ?? 502, headers);
2366
+ proxRes.on("data", (chunk) => res.write(chunk));
2367
+ proxRes.on("end", () => {
2368
+ res.end();
2369
+ resolve();
2370
+ });
2371
+ }
2372
+ );
2373
+ upstream.on("error", (err4) => {
2374
+ if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err4.message}`);
2375
+ else res.end();
2376
+ resolve();
2377
+ });
2378
+ upstream.write(body);
2379
+ upstream.end();
2380
+ });
2381
+ }
2382
+
2383
+ // src/admin/client.ts
2384
+ var DASHBOARD_JS = String.raw`
2385
+ (function () {
2386
+ var $ = function (id) { return document.getElementById(id); };
2387
+ var authToken = null; // set if a 401 ever comes back (token-gated deploys)
2388
+
2389
+ function headers(extra) {
2390
+ var h = extra || {};
2391
+ if (authToken) h['Authorization'] = 'Bearer ' + authToken;
2392
+ return h;
2393
+ }
2394
+
2395
+ async function api(method, path, body) {
2396
+ var opt = { method: method, headers: headers(body ? { 'Content-Type': 'application/json' } : {}) };
2397
+ if (body) opt.body = JSON.stringify(body);
2398
+ var res = await fetch('/admin/api/' + path, opt);
2399
+ if (res.status === 401 && !authToken) {
2400
+ var t = window.prompt('Admin token required');
2401
+ if (t) { authToken = t; return api(method, path, body); }
2402
+ }
2403
+ var text = await res.text();
2404
+ var json = null;
2405
+ try { json = text ? JSON.parse(text) : null; } catch (e) { json = { raw: text }; }
2406
+ return { status: res.status, json: json };
2407
+ }
2408
+
2409
+ function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
2410
+ function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
2411
+ function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
2412
+
2413
+ // ── Status ────────────────────────────────────────────────────────────────
2414
+ async function loadStatus() {
2415
+ var r = await api('GET', 'status');
2416
+ var s = r.json || {};
2417
+ $('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
2418
+ var html = '';
2419
+ if (s.running) {
2420
+ html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
2421
+ if (s.formats) {
2422
+ html += '<div class="mono muted">' +
2423
+ 'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
2424
+ '<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
2425
+ }
2426
+ } else {
2427
+ html += 'Outbound server <span class="pill bad">stopped</span>';
2428
+ }
2429
+ $('statusBody').innerHTML = html;
2430
+ }
2431
+
2432
+ // ── Providers ───────────────────────────────────────────────────────────────
2433
+ // Curated presets loaded from GET /admin/api/presets. Selecting one prefills
2434
+ // the add-form (format/base/models); the WRITE still goes through the existing
2435
+ // POST/PUT /admin/api/providers path (no new write endpoint).
2436
+ var presetsById = {};
2437
+ var presetModels = []; // models staged by the last preset prefill
2438
+
2439
+ async function loadPresets() {
2440
+ var r = await api('GET', 'presets');
2441
+ var sel = $('pPreset');
2442
+ // Keep the placeholder; drop any previously appended options.
2443
+ while (sel.options.length > 1) sel.remove(1);
2444
+ presetsById = {};
2445
+ (r.json && r.json.presets || []).forEach(function (p) {
2446
+ presetsById[p.id] = p;
2447
+ var opt = document.createElement('option');
2448
+ opt.value = p.id;
2449
+ opt.textContent = p.name + ' (' + p.apiFormat + ')';
2450
+ sel.appendChild(opt);
2451
+ });
2452
+ }
2453
+
2454
+ function onPresetChange() {
2455
+ var p = presetsById[$('pPreset').value];
2456
+ if (!p) { presetModels = []; return; }
2457
+ if (!$('pId').value.trim()) $('pId').value = p.id;
2458
+ $('pFormat').value = p.apiFormat;
2459
+ $('pBase').value = p.baseUrl;
2460
+ presetModels = Array.isArray(p.models) ? p.models.slice() : [];
2461
+ }
2462
+
2463
+ async function loadProviders() {
2464
+ var r = await api('GET', 'providers');
2465
+ var body = $('providersTable').querySelector('tbody');
2466
+ clear(body);
2467
+ (r.json && r.json.providers || []).forEach(function (p) {
2468
+ var tr = document.createElement('tr');
2469
+ tr.appendChild(td(p.id));
2470
+ tr.appendChild(td(p.apiFormat));
2471
+ tr.appendChild(td(p.baseUrl));
2472
+ tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
2473
+ var act = document.createElement('td');
2474
+ act.appendChild(btn('Edit', 'secondary', function () {
2475
+ $('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
2476
+ }));
2477
+ act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
2478
+ act.appendChild(btn('Delete', 'danger', async function () {
2479
+ if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
2480
+ }));
2481
+ tr.appendChild(act);
2482
+ body.appendChild(tr);
2483
+ });
2484
+ }
2485
+
2486
+ // ── Pool health (read-only; key-pool change) ──────────────────────────────
2487
+ // GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
2488
+ // cold-standby + observable in v1 (no outbound failover yet — see panel note).
2489
+ async function loadProviderKeys(providerId) {
2490
+ var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
2491
+ var panel = $('poolPanel');
2492
+ var body = $('poolTable').querySelector('tbody');
2493
+ clear(body);
2494
+ $('poolTitle').textContent = 'API key pool — ' + providerId;
2495
+ (r.json && r.json.keys || []).forEach(function (k) {
2496
+ var tr = document.createElement('tr');
2497
+ tr.appendChild(td(k.id));
2498
+ tr.appendChild(td(k.label));
2499
+ tr.appendChild(td(k.apiKeyMasked));
2500
+ tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2501
+ tr.appendChild(td(k.weight));
2502
+ var h = '';
2503
+ if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
2504
+ if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
2505
+ tr.appendChild(td(h || 'ok'));
2506
+ body.appendChild(tr);
2507
+ });
2508
+ panel.style.display = 'block';
2509
+ }
2510
+
2511
+ async function saveProvider() {
2512
+ $('pErr').textContent = '';
2513
+ var id = $('pId').value.trim();
2514
+ if (!id) { $('pErr').textContent = 'id required'; return; }
2515
+ var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
2516
+ // Carry the preset-prefilled models (existing parseProviderInput accepts them).
2517
+ if (presetModels.length) payload.models = presetModels;
2518
+ // Try PUT first (edit, blank key keeps existing); fall back to POST (create).
2519
+ var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
2520
+ if (r.status === 404) r = await api('POST', 'providers', payload);
2521
+ if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2522
+ $('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
2523
+ $('pPreset').value = ''; presetModels = []; // reset the picker after a write
2524
+ loadProviders();
2525
+ }
2526
+
2527
+ // ── Keys ────────────────────────────────────────────────────────────────────
2528
+ async function loadKeys() {
2529
+ var r = await api('GET', 'keys');
2530
+ var body = $('keysTable').querySelector('tbody');
2531
+ clear(body);
2532
+ (r.json && r.json.keys || []).forEach(function (k) {
2533
+ var tr = document.createElement('tr');
2534
+ tr.appendChild(td(k.name));
2535
+ tr.appendChild(td(k.keyPrefix));
2536
+ tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2537
+ tr.appendChild(td(k.revoked ? 'yes' : 'no'));
2538
+ var act = document.createElement('td');
2539
+ if (!k.revoked) {
2540
+ act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
2541
+ await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
2542
+ }));
2543
+ act.appendChild(btn('Revoke', 'danger', async function () {
2544
+ if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
2545
+ }));
2546
+ }
2547
+ tr.appendChild(act);
2548
+ body.appendChild(tr);
2549
+ });
2550
+ }
2551
+
2552
+ function showKeyModal(plaintext) {
2553
+ $('keyPlaintext').textContent = plaintext;
2554
+ $('keyModalBg').classList.add('show');
2555
+ $('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
2556
+ $('keyClose').onclick = function () {
2557
+ $('keyModalBg').classList.remove('show');
2558
+ $('keyPlaintext').textContent = ''; // never persist the plaintext
2559
+ };
2560
+ }
2561
+
2562
+ async function createKey() {
2563
+ var name = $('kName').value.trim() || 'key';
2564
+ var r = await api('POST', 'keys', { name: name });
2565
+ if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
2566
+ }
2567
+
2568
+ // ── Server config ───────────────────────────────────────────────────────────
2569
+ async function loadServer() {
2570
+ var r = await api('GET', 'server');
2571
+ var s = (r.json && r.json.server) || {};
2572
+ $('sEnabled').checked = !!s.enabled;
2573
+ $('sLan').checked = !!s.networkBinding;
2574
+ $('sPort').value = s.port || '';
2575
+ var body = $('endpointsTable').querySelector('tbody');
2576
+ clear(body);
2577
+ (s.endpoints || []).forEach(function (e) {
2578
+ var tr = document.createElement('tr');
2579
+ tr.appendChild(td(e.endpoint));
2580
+ tr.appendChild(td(e.defaultModel));
2581
+ tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
2582
+ body.appendChild(tr);
2583
+ });
2584
+ }
2585
+
2586
+ async function saveServer() {
2587
+ var patch = { enabled: $('sEnabled').checked, networkBinding: $('sLan').checked };
2588
+ var port = parseInt($('sPort').value, 10);
2589
+ if (port) patch.port = port;
2590
+ await api('PUT', 'server', patch);
2591
+ loadServer(); loadStatus();
2592
+ }
2593
+
2594
+ // ── Accounts ────────────────────────────────────────────────────────────────
2595
+ // The GET stays token-free (status only). The Save/Clear actions WRITE tokens
2596
+ // (secret IN); the form never renders an existing/stored token (write-only).
2597
+ async function loadAccounts() {
2598
+ var r = await api('GET', 'accounts');
2599
+ var body = $('accountsTable').querySelector('tbody');
2600
+ clear(body);
2601
+ (r.json && r.json.accounts || []).forEach(function (a) {
2602
+ var tr = document.createElement('tr');
2603
+ tr.appendChild(td(a.displayName || a.providerId));
2604
+ tr.appendChild(td(a.kind));
2605
+ var st = document.createElement('td');
2606
+ var ok = a.credentialStatus && a.credentialStatus.ok;
2607
+ var pill = document.createElement('span');
2608
+ pill.className = 'pill ' + (ok ? 'ok' : 'bad');
2609
+ pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
2610
+ st.appendChild(pill);
2611
+ tr.appendChild(st);
2612
+ var act = document.createElement('td');
2613
+ act.appendChild(btn('Clear', 'danger', async function () {
2614
+ if (window.confirm('Clear ' + a.providerId + ' token?')) {
2615
+ await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
2616
+ loadAccounts();
2617
+ }
2618
+ }));
2619
+ tr.appendChild(act);
2620
+ body.appendChild(tr);
2621
+ });
2622
+ renderProviderAccounts(r.json && r.json.providerAccounts || {});
2623
+ }
2624
+
2625
+ // Per-provider sanitized accounts (multi-account). Secrets IN-never-OUT: this
2626
+ // view shows id/label/status/active only — set-active + delete are STATUS-ONLY.
2627
+ function renderProviderAccounts(byProvider) {
2628
+ var body = $('providerAccountsTable').querySelector('tbody');
2629
+ clear(body);
2630
+ Object.keys(byProvider).forEach(function (provider) {
2631
+ (byProvider[provider] || []).forEach(function (acc) {
2632
+ var tr = document.createElement('tr');
2633
+ tr.appendChild(td(provider));
2634
+ tr.appendChild(td(acc.label || acc.id));
2635
+ tr.appendChild(td(acc.status));
2636
+ tr.appendChild(td(acc.isActive ? 'yes' : ''));
2637
+ var act = document.createElement('td');
2638
+ if (!acc.isActive) {
2639
+ act.appendChild(btn('Set active', '', async function () {
2640
+ await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
2641
+ loadAccounts();
2642
+ }));
2643
+ }
2644
+ act.appendChild(btn('Delete', 'danger', async function () {
2645
+ if (window.confirm('Delete account ' + (acc.label || acc.id) + '?')) {
2646
+ await api('DELETE', 'accounts/' + encodeURIComponent(provider) + '/' + encodeURIComponent(acc.id));
2647
+ loadAccounts();
2648
+ }
2649
+ }));
2650
+ tr.appendChild(act);
2651
+ body.appendChild(tr);
2652
+ });
2653
+ });
2654
+ }
2655
+
2656
+ async function saveAccount() {
2657
+ $('acErr').textContent = '';
2658
+ var provider = $('acProvider').value;
2659
+ var raw = $('acBody').value.trim();
2660
+ if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
2661
+ var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
2662
+ var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
2663
+ if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2664
+ $('acBody').value = ''; // never persist/echo the just-saved token
2665
+ loadAccounts();
2666
+ }
2667
+
2668
+ // ── Playground ──────────────────────────────────────────────────────────────
2669
+ async function sendPlayground() {
2670
+ var pre = $('plResponse');
2671
+ pre.classList.remove('muted');
2672
+ pre.textContent = 'sending…';
2673
+ var bodyText = $('plBody').value;
2674
+ var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
2675
+ var r = await fetch('/admin/api/playground', {
2676
+ method: 'POST',
2677
+ headers: headers({ 'Content-Type': 'application/json' }),
2678
+ body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
2679
+ });
2680
+ var text = await r.text();
2681
+ pre.textContent = '[' + r.status + ']\n' + text;
2682
+ }
2683
+
2684
+ function wire() {
2685
+ $('pSave').onclick = saveProvider;
2686
+ $('pPreset').onchange = onPresetChange;
2687
+ $('kCreate').onclick = createKey;
2688
+ $('sSave').onclick = saveServer;
2689
+ $('acSave').onclick = saveAccount;
2690
+ $('plSend').onclick = sendPlayground;
2691
+ refresh();
2692
+ }
2693
+
2694
+ function refresh() {
2695
+ loadStatus(); loadProviders(); loadPresets(); loadKeys(); loadServer(); loadAccounts();
2696
+ }
2697
+
2698
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
2699
+ else wire();
2700
+ })();
2701
+ `;
2702
+
2703
+ // src/admin/html.ts
2704
+ var STYLE = `
2705
+ :root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
2706
+ * { box-sizing: border-box; }
2707
+ body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
2708
+ header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
2709
+ header h1 { font-size:16px; margin:0; font-weight:600; }
2710
+ header .badge { font-size:12px; color:var(--muted); }
2711
+ main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
2712
+ section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
2713
+ section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
2714
+ table { width:100%; border-collapse:collapse; font-size:13px; }
2715
+ th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
2716
+ th { color:var(--muted); font-weight:500; }
2717
+ input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
2718
+ textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
2719
+ button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
2720
+ button.secondary { background:#2a2f3a; }
2721
+ button.danger { background:var(--danger); }
2722
+ .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
2723
+ .muted { color:var(--muted); }
2724
+ .mono { font-family:ui-monospace,Menlo,monospace; }
2725
+ .pill { padding:1px 8px; border-radius:999px; font-size:12px; }
2726
+ .pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
2727
+ .pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
2728
+ pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
2729
+ .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
2730
+ .modal-bg.show { display:flex; }
2731
+ .modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
2732
+ .warn { color:var(--danger); font-size:13px; margin:8px 0; }
2733
+ .err { color:var(--danger); font-size:13px; }
2734
+ `;
2735
+ var BODY = `
2736
+ <header>
2737
+ <h1>omnicross daemon dashboard</h1>
2738
+ <span class="badge" id="statusBadge">connecting\u2026</span>
2739
+ </header>
2740
+ <main>
2741
+ <section id="statusSection">
2742
+ <h2>Runtime status</h2>
2743
+ <div id="statusBody" class="muted">loading\u2026</div>
2744
+ </section>
2745
+
2746
+ <section>
2747
+ <h2>Providers</h2>
2748
+ <table id="providersTable"><thead><tr><th>id</th><th>format</th><th>base URL</th><th>key</th><th></th></tr></thead><tbody></tbody></table>
2749
+ <div class="row">
2750
+ <select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
2751
+ <input id="pId" placeholder="id" size="10" />
2752
+ <select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
2753
+ <input id="pBase" placeholder="base URL" size="26" />
2754
+ <input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
2755
+ <button id="pSave">Save provider</button>
2756
+ </div>
2757
+ <div class="err" id="pErr"></div>
2758
+ <div id="poolPanel" style="display:none; margin-top:12px;">
2759
+ <h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
2760
+ <p class="muted" style="margin:0 0 8px;">Read-only. Multi-key is <b>cold-standby + observable</b> in v1: outbound failover does not yet rotate keys (null-session boundary \u2014 pending the core seam). Keys are masked; edit the pool via the provider's <span class="mono">apiKeys</span>.</p>
2761
+ <table id="poolTable"><thead><tr><th>id</th><th>label</th><th>key</th><th>enabled</th><th>weight</th><th>health</th></tr></thead><tbody></tbody></table>
2762
+ </div>
2763
+ </section>
2764
+
2765
+ <section>
2766
+ <h2>Named keys</h2>
2767
+ <table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
2768
+ <div class="row">
2769
+ <input id="kName" placeholder="key name" size="16" />
2770
+ <button id="kCreate">Create key</button>
2771
+ </div>
2772
+ </section>
2773
+
2774
+ <section>
2775
+ <h2>Server config</h2>
2776
+ <div class="row">
2777
+ <label><input type="checkbox" id="sEnabled" /> enabled</label>
2778
+ <label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
2779
+ <label>port <input id="sPort" size="6" /></label>
2780
+ <button id="sSave">Apply server config</button>
2781
+ </div>
2782
+ <table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
2783
+ </section>
2784
+
2785
+ <section>
2786
+ <h2>Accounts <span class="muted">(subscription tokens)</span></h2>
2787
+ <table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
2788
+ <h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
2789
+ <table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
2790
+ <div class="row">
2791
+ <select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
2792
+ <button id="acSave">Save token</button>
2793
+ </div>
2794
+ <textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
2795
+ <p class="muted">Write-only: paste a token JSON to authorize this provider. The token is shown only on entry \u2014 it is never read back or displayed. Stored as plain JSON in <span class="mono">tokens.json</span>.</p>
2796
+ <div class="err" id="acErr"></div>
2797
+ </section>
2798
+
2799
+ <section>
2800
+ <h2>Playground</h2>
2801
+ <div class="row">
2802
+ <select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
2803
+ <input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
2804
+ <button id="plSend">Send</button>
2805
+ </div>
2806
+ <textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
2807
+ <pre id="plResponse" class="muted">response will appear here</pre>
2808
+ </section>
2809
+ </main>
2810
+
2811
+ <div class="modal-bg" id="keyModalBg">
2812
+ <div class="modal">
2813
+ <h2>Key created</h2>
2814
+ <p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
2815
+ <pre id="keyPlaintext" class="mono"></pre>
2816
+ <div class="row">
2817
+ <button id="keyCopy">Copy</button>
2818
+ <button class="secondary" id="keyClose">Close</button>
2819
+ </div>
2820
+ </div>
2821
+ </div>
2822
+ `;
2823
+ var DASHBOARD_HTML = `<!doctype html>
2824
+ <html lang="en">
2825
+ <head>
2826
+ <meta charset="utf-8" />
2827
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2828
+ <title>omnicross daemon dashboard</title>
2829
+ <style>${STYLE}</style>
2830
+ </head>
2831
+ <body>
2832
+ ${BODY}
2833
+ <script>${DASHBOARD_JS}</script>
2834
+ </body>
2835
+ </html>`;
2836
+
2837
+ // src/admin/AdminServer.ts
2838
+ var LOOPBACK_ADDR = "127.0.0.1";
2839
+ var LAN_ADDR = "0.0.0.0";
2840
+ var AdminServer = class {
2841
+ constructor(deps) {
2842
+ this.deps = deps;
2843
+ }
2844
+ deps;
2845
+ server = null;
2846
+ boundPort = 0;
2847
+ boundAddr = LOOPBACK_ADDR;
2848
+ /**
2849
+ * Start the admin listener honoring the resolved admin config. Returns the
2850
+ * actual bound port, or `0` when it refuses/declines to bind (disabled or the
2851
+ * LAN fail-closed gate). Idempotent: a second call returns the bound port.
2852
+ */
2853
+ async start() {
2854
+ if (this.server) return this.boundPort;
2855
+ const cfg = this.deps.getAdminConfig();
2856
+ if (!cfg.enabled) return 0;
2857
+ if (cfg.networkBinding && !cfg.token) {
2858
+ console.error(
2859
+ "[AdminServer] REFUSING to bind: admin.networkBinding (LAN/0.0.0.0) requires a non-empty admin.token. Set admin.token in config.json or disable networkBinding. Dashboard stays DOWN (fail closed)."
2860
+ );
2861
+ return 0;
2862
+ }
2863
+ const bindAddr = cfg.networkBinding ? LAN_ADDR : LOOPBACK_ADDR;
2864
+ const actualPort = await this.listen(bindAddr, cfg.port);
2865
+ this.boundAddr = bindAddr;
2866
+ this.boundPort = actualPort;
2867
+ console.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
2868
+ return actualPort;
2869
+ }
2870
+ /** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
2871
+ listen(bindAddr, port) {
2872
+ return new Promise((resolve, reject) => {
2873
+ const server = import_node_http2.default.createServer((req, res) => {
2874
+ this.onRequest(req, res);
2875
+ });
2876
+ const onError = (err4) => {
2877
+ if (err4.code === "EADDRINUSE" && port !== 0) {
2878
+ server.removeListener("error", onError);
2879
+ this.listen(bindAddr, 0).then(resolve, reject);
2880
+ return;
2881
+ }
2882
+ reject(err4);
2883
+ };
2884
+ server.on("error", onError);
2885
+ server.listen(port, bindAddr, () => {
2886
+ const addr = server.address();
2887
+ if (addr && typeof addr === "object") {
2888
+ server.removeListener("error", onError);
2889
+ server.on("error", (e) => console.error("[AdminServer] server error", e));
2890
+ this.server = server;
2891
+ resolve(addr.port);
2892
+ } else {
2893
+ reject(new Error("Failed to get admin server address"));
2894
+ }
2895
+ });
2896
+ });
2897
+ }
2898
+ /** Per-request handler: auth gate (when a token is set) → routing. */
2899
+ onRequest(req, res) {
2900
+ void this.dispatch(req, res).catch((err4) => {
2901
+ const message = err4 instanceof Error ? err4.message : String(err4);
2902
+ console.error("[AdminServer] unhandled error:", message);
2903
+ if (!res.headersSent) {
2904
+ res.writeHead(500, { "Content-Type": "application/json" });
2905
+ res.end(JSON.stringify({ error: { type: "admin_error", message } }));
2906
+ }
2907
+ });
2908
+ }
2909
+ async dispatch(req, res) {
2910
+ const cfg = this.deps.getAdminConfig();
2911
+ if (cfg.token && !this.isAuthorized(req, cfg.token)) {
2912
+ res.writeHead(401, { "Content-Type": "application/json" });
2913
+ res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
2914
+ return;
2915
+ }
2916
+ const url = req.url ?? "/";
2917
+ const path = url.split("?")[0];
2918
+ if ((req.method === "GET" || req.method === "HEAD") && (path === "/" || path === "/admin")) {
2919
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2920
+ res.end(req.method === "HEAD" ? void 0 : DASHBOARD_HTML);
2921
+ return;
2922
+ }
2923
+ if (path.startsWith("/admin/api/")) {
2924
+ await handleAdminApi(req, res, path, this.deps);
2925
+ return;
2926
+ }
2927
+ res.writeHead(404, { "Content-Type": "application/json" });
2928
+ res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
2929
+ }
2930
+ /** Constant-time bearer/header check against the configured token. */
2931
+ isAuthorized(req, token) {
2932
+ const header = req.headers["authorization"];
2933
+ const bearer = typeof header === "string" && header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : void 0;
2934
+ const xToken = req.headers["x-admin-token"];
2935
+ const presented = bearer ?? (typeof xToken === "string" ? xToken.trim() : void 0);
2936
+ return constantTimeEquals(presented, token);
2937
+ }
2938
+ /** Stop the listener and release the port. */
2939
+ async stop() {
2940
+ const server = this.server;
2941
+ if (!server) return;
2942
+ this.server = null;
2943
+ this.boundPort = 0;
2944
+ return new Promise((resolve) => {
2945
+ server.close(() => resolve());
2946
+ });
2947
+ }
2948
+ /** A live status snapshot. */
2949
+ getStatus() {
2950
+ const running = this.server !== null;
2951
+ if (!running) return { running: false, port: 0, url: null };
2952
+ const host = this.boundAddr === LAN_ADDR ? LOOPBACK_ADDR : this.boundAddr;
2953
+ return { running: true, port: this.boundPort, url: `http://${host}:${this.boundPort}` };
2954
+ }
2955
+ };
2956
+ function constantTimeEquals(a, b) {
2957
+ if (typeof a !== "string") return false;
2958
+ const bufA = Buffer.from(a, "utf8");
2959
+ const bufB = Buffer.from(b, "utf8");
2960
+ if (bufA.length !== bufB.length) return false;
2961
+ return (0, import_node_crypto6.timingSafeEqual)(bufA, bufB);
2962
+ }
2963
+
2964
+ // src/admin/oauthSessions.ts
2965
+ var import_node_crypto7 = __toESM(require("crypto"), 1);
2966
+ var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
2967
+ var OAuthSessionStore = class {
2968
+ constructor(ttlMs = DEFAULT_OAUTH_SESSION_TTL_MS) {
2969
+ this.ttlMs = ttlMs;
2970
+ }
2971
+ ttlMs;
2972
+ sessions = /* @__PURE__ */ new Map();
2973
+ /**
2974
+ * Mint a fresh opaque `sessionId`, stash the pending session, and return the
2975
+ * id. Sweeps expired entries first so the map never grows unbounded.
2976
+ */
2977
+ put(session) {
2978
+ this.sweep();
2979
+ const sessionId = import_node_crypto7.default.randomBytes(24).toString("base64url");
2980
+ this.sessions.set(sessionId, { ...session, createdAt: Date.now() });
2981
+ return sessionId;
2982
+ }
2983
+ /**
2984
+ * SINGLE-USE consume: return + delete the session for `sessionId`, or `null`
2985
+ * when it is unknown, already used, or past its TTL (in which case it is
2986
+ * dropped). A `null` return means the completer must reject (no exchange, no
2987
+ * write).
2988
+ */
2989
+ take(sessionId) {
2990
+ this.sweep();
2991
+ const session = this.sessions.get(sessionId);
2992
+ if (!session) return null;
2993
+ this.sessions.delete(sessionId);
2994
+ if (Date.now() - session.createdAt > this.ttlMs) return null;
2995
+ return session;
2996
+ }
2997
+ /** Drop every session past its TTL. Called on each put/take. */
2998
+ sweep() {
2999
+ const now = Date.now();
3000
+ for (const [id, session] of this.sessions) {
3001
+ if (now - session.createdAt > this.ttlMs) this.sessions.delete(id);
3002
+ }
3003
+ }
3004
+ };
3005
+
3006
+ // src/commands/loopbackCallback.ts
3007
+ var import_node_http3 = require("http");
3008
+ var LOOPBACK_HOST = "127.0.0.1";
3009
+ var LOOPBACK_PORT = 1455;
3010
+ var CALLBACK_PATH = "/auth/callback";
3011
+ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
3012
+ function pageHtml(message) {
3013
+ return `<!doctype html><meta charset="utf-8"><title>omnicross login</title><body style="font-family:sans-serif;padding:2rem"><h2>${message}</h2><p>You can close this window and return to the terminal.</p></body>`;
3014
+ }
3015
+ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
3016
+ return new Promise((resolve, reject) => {
3017
+ let settled = false;
3018
+ const finish = (server2, fn) => {
3019
+ if (settled) return;
3020
+ settled = true;
3021
+ clearTimeout(timer);
3022
+ server2.close(() => fn());
3023
+ };
3024
+ const server = (0, import_node_http3.createServer)((req, res) => {
3025
+ const url = new URL(req.url ?? "", `http://${LOOPBACK_HOST}:${LOOPBACK_PORT}`);
3026
+ if (url.pathname !== CALLBACK_PATH) {
3027
+ res.writeHead(404, { "Content-Type": "text/html" });
3028
+ res.end(pageHtml("Not found"));
3029
+ return;
3030
+ }
3031
+ const code = url.searchParams.get("code");
3032
+ const state = url.searchParams.get("state");
3033
+ if (!code) {
3034
+ res.writeHead(400, { "Content-Type": "text/html" });
3035
+ res.end(pageHtml("Login failed: missing authorization code."));
3036
+ finish(server, () => reject(new Error("login: callback did not include an authorization code")));
3037
+ return;
3038
+ }
3039
+ if (state !== expectedState) {
3040
+ res.writeHead(400, { "Content-Type": "text/html" });
3041
+ res.end(pageHtml("Login failed: state mismatch."));
3042
+ finish(server, () => reject(new Error("login: callback state did not match (possible CSRF) \u2014 aborting")));
3043
+ return;
3044
+ }
3045
+ res.writeHead(200, { "Content-Type": "text/html" });
3046
+ res.end(pageHtml("Login complete."));
3047
+ finish(server, () => resolve(code));
3048
+ });
3049
+ server.on("error", (err4) => {
3050
+ if (settled) return;
3051
+ settled = true;
3052
+ clearTimeout(timer);
3053
+ if (err4.code === "EADDRINUSE") {
3054
+ reject(
3055
+ new Error(
3056
+ `login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
3057
+ )
3058
+ );
3059
+ } else {
3060
+ reject(err4);
3061
+ }
3062
+ });
3063
+ const timer = setTimeout(() => {
3064
+ finish(server, () => reject(new Error(`login: timed out after ${Math.round(timeoutMs / 1e3)}s waiting for the callback`)));
3065
+ }, timeoutMs);
3066
+ if (typeof timer.unref === "function") timer.unref();
3067
+ server.listen(LOOPBACK_PORT, LOOPBACK_HOST);
3068
+ });
3069
+ }
3070
+
3071
+ // src/pool/autoDisableStore.ts
3072
+ var AutoDisableStore = class {
3073
+ records = /* @__PURE__ */ new Map();
3074
+ /** Record (or overwrite) an auth-failure auto-disable for `keyId`. */
3075
+ markAutoDisabled(keyId, status, at) {
3076
+ this.records.set(keyId, { status, at, reason: "auth_failure" });
3077
+ }
3078
+ /** Whether `keyId` is currently auto-disabled in this process. */
3079
+ isDisabled(keyId) {
3080
+ return this.records.has(keyId);
3081
+ }
3082
+ /** Read the auto-disable record for `keyId`, or `undefined` when healthy. */
3083
+ get(keyId) {
3084
+ return this.records.get(keyId);
3085
+ }
3086
+ /** Clear all records (tests / teardown). */
3087
+ clear() {
3088
+ this.records.clear();
3089
+ }
3090
+ };
3091
+
3092
+ // src/pool/loadPoolKeys.ts
3093
+ var secretBox2 = null;
3094
+ function setSecretBox2(box) {
3095
+ secretBox2 = box;
3096
+ }
3097
+ function readKeyValue(rawKey) {
3098
+ return secretBox2 ? secretBox2.decryptMaybe(rawKey) : rawKey;
3099
+ }
3100
+ function normalizeEntry(providerId, entry, sortOrder, autoDisabled) {
3101
+ const enabledInConfig = entry.enabled !== false;
3102
+ const enabled = enabledInConfig && !autoDisabled.isDisabled(entry.id);
3103
+ return {
3104
+ id: entry.id,
3105
+ providerId,
3106
+ label: entry.label && entry.label.length > 0 ? entry.label : entry.id,
3107
+ apiKey: readKeyValue(entry.apiKey),
3108
+ enabled,
3109
+ weight: typeof entry.weight === "number" && Number.isFinite(entry.weight) ? entry.weight : 1,
3110
+ sortOrder
3111
+ };
3112
+ }
3113
+ function createPoolKeysLoader(getProviderRow, autoDisabled) {
3114
+ return async (providerId) => {
3115
+ const row = getProviderRow(providerId);
3116
+ if (!row) return [];
3117
+ const pool = (row.apiKeys ?? []).filter((k) => k.apiKey.length > 0);
3118
+ if (pool.length > 0) {
3119
+ return pool.map((entry, i) => normalizeEntry(providerId, entry, i, autoDisabled));
3120
+ }
3121
+ if (row.apiKey.length > 0) {
3122
+ return [
3123
+ normalizeEntry(
3124
+ providerId,
3125
+ { id: `${providerId}:default`, apiKey: row.apiKey, weight: 1, enabled: true },
3126
+ 0,
3127
+ autoDisabled
3128
+ )
3129
+ ];
3130
+ }
3131
+ return [];
3132
+ };
3133
+ }
3134
+
3135
+ // src/ports/ConfigFileProviderConfigSource.ts
3136
+ var import_core = require("@omnicross/core");
3137
+ var EMPTY_CHAIN = {
3138
+ providerTransformers: [],
3139
+ modelTransformers: []
3140
+ };
3141
+ var FORMAT_TRANSFORMER = {
3142
+ anthropic: "anthropic",
3143
+ gemini: "gemini"
3144
+ };
3145
+ var ConfigFileProviderConfigSource = class {
3146
+ providers = /* @__PURE__ */ new Map();
3147
+ transformerService;
3148
+ /**
3149
+ * Optional reload-hook (key-pool change, design D4). A no-type-coupling
3150
+ * callback invoked at the END of `reload(...)`. `buildDaemon` injects
3151
+ * `() => pool.invalidateCache()` so the `ApiKeyPoolService.keyCache` is
3152
+ * flushed after a hot-reload swaps the catalog — WITHOUT this port ever
3153
+ * importing/depending on `ApiKeyPoolService`. Absent = no-op (single-key
3154
+ * boots that never construct a pool stay byte-identical).
3155
+ */
3156
+ reloadHook;
3157
+ constructor(config) {
3158
+ for (const p of config.providers) this.providers.set(p.id, p);
3159
+ this.transformerService = new import_core.TransformerService();
3160
+ void (0, import_core.registerBuiltinTransformers)(this.transformerService);
3161
+ }
3162
+ // ── Reload hook (key-pool design D4) ───────────────────────────────────────
3163
+ /**
3164
+ * Register a callback fired after every `reload(...)`. Used by `buildDaemon`
3165
+ * to invalidate the pool's keyCache on a hot-reload. The port stays ignorant
3166
+ * of what the callback does (no pool type dependency).
3167
+ */
3168
+ setReloadHook(fn) {
3169
+ this.reloadHook = fn;
3170
+ }
3171
+ /**
3172
+ * Read the live (post-reload) provider row for `providerId`, or `undefined`.
3173
+ * Exposed so the pool's `loadKeys` reads the SAME live catalog Map this port
3174
+ * serves (so a hot-reload is observed on the next load after `invalidateCache`).
3175
+ */
3176
+ getProviderRow(providerId) {
3177
+ return this.providers.get(providerId);
3178
+ }
3179
+ /** Await the built-in transformer registration (tests await this before dispatch). */
3180
+ async ready() {
3181
+ await (0, import_core.registerBuiltinTransformers)(this.transformerService);
3182
+ }
3183
+ // ── Hot-reload seam (admin dashboard, RT3 design D6) ───────────────────────
3184
+ /**
3185
+ * Replace the live provider catalog in place (additive — does NOT touch the
3186
+ * ten port methods, the seeded `TransformerService`, or `ready()`). Called by
3187
+ * the admin API after a provider POST/PUT/DELETE persists `config.json`, so the
3188
+ * next outbound request sees the new catalog WITHOUT a daemon restart. The Map
3189
+ * swap is synchronous; an in-flight request keeps its already-resolved
3190
+ * provider (no locking needed for a single-operator local daemon).
3191
+ */
3192
+ reload(config) {
3193
+ this.setProviders(config.providers);
3194
+ this.reloadHook?.();
3195
+ }
3196
+ /** Clear + repopulate the private providers Map from a fresh provider list. */
3197
+ setProviders(providers) {
3198
+ this.providers.clear();
3199
+ for (const p of providers) this.providers.set(p.id, p);
3200
+ }
3201
+ // ── REAL methods ──────────────────────────────────────────────────────────
3202
+ async getProvider(id) {
3203
+ const row = this.providers.get(id);
3204
+ if (!row) return null;
3205
+ return toLLMProvider(row);
3206
+ }
3207
+ getTransformerService() {
3208
+ return this.transformerService;
3209
+ }
3210
+ async getMainTransformer(providerId) {
3211
+ const row = this.providers.get(providerId);
3212
+ if (!row || row.apiFormat === "openai") return null;
3213
+ const name = FORMAT_TRANSFORMER[row.apiFormat];
3214
+ const instances = this.transformerService.resolveTransformerReferences([name]);
3215
+ return instances[0] ?? null;
3216
+ }
3217
+ async resolveTransformerChain(providerId, _model) {
3218
+ const row = this.providers.get(providerId);
3219
+ if (!row) return EMPTY_CHAIN;
3220
+ const customRefs = row.transformer?.use ?? [];
3221
+ if (customRefs.length === 0) return EMPTY_CHAIN;
3222
+ const formatName = row.apiFormat === "openai" ? void 0 : FORMAT_TRANSFORMER[row.apiFormat];
3223
+ const effectiveRefs = formatName ? customRefs.filter((ref) => (typeof ref === "string" ? ref : ref[0]) !== formatName) : customRefs;
3224
+ if (effectiveRefs.length === 0) return EMPTY_CHAIN;
3225
+ return {
3226
+ providerTransformers: this.transformerService.resolveTransformerReferences(effectiveRefs),
3227
+ modelTransformers: []
3228
+ };
3229
+ }
3230
+ // ── STUBS (never hit on the BYO single-key path) ────────────────────────────
3231
+ async resolveRoutedModel() {
3232
+ return null;
3233
+ }
3234
+ async resolveEffectiveModels() {
3235
+ return {};
3236
+ }
3237
+ async getAgentDefaultModels() {
3238
+ return {};
3239
+ }
3240
+ async hasVisionCapability() {
3241
+ return false;
3242
+ }
3243
+ async getGlobalModelParameters() {
3244
+ return {};
3245
+ }
3246
+ async getDiscoveredModelMaxTokens() {
3247
+ return void 0;
3248
+ }
3249
+ };
3250
+ function resolvePreferredApiKey(row) {
3251
+ if (row.apiKey.length > 0) return row.apiKey;
3252
+ const firstEnabled = row.apiKeys?.find((k) => k.enabled !== false && k.apiKey.length > 0);
3253
+ return firstEnabled?.apiKey ?? "";
3254
+ }
3255
+ function toLLMProvider(row) {
3256
+ const apiFormat = row.apiFormat === "gemini" ? "google" : row.apiFormat;
3257
+ const transformer = row.apiFormat === "openai" ? void 0 : { use: [FORMAT_TRANSFORMER[row.apiFormat]] };
3258
+ const allModels = row.models ?? [];
3259
+ const models = row.modelConfigs ? allModels.filter((id) => row.modelConfigs.find((c) => c.id === id)?.enabled !== false) : allModels;
3260
+ return {
3261
+ id: row.id,
3262
+ name: row.id,
3263
+ apiFormat,
3264
+ api_base_url: row.baseUrl,
3265
+ api_key: resolvePreferredApiKey(row),
3266
+ models,
3267
+ enabled: true,
3268
+ transformer,
3269
+ // app-parity-2 child 3: POPULATE the coding-plan endpoint onto the core
3270
+ // `LLMProvider` (structurally identical to the contracts `CodingPlanConfig`).
3271
+ // The daemon does NOT resolve endpoints itself — core's shared
3272
+ // `resolveProviderEndpoint` (wired into `buildProviderApiUrl` + the BYO proxy
3273
+ // key path) reads `provider.codingPlan` and, when `enabled` + `baseUrl`, routes
3274
+ // via the coding-plan endpoint (key = `codingPlan.apiKey || api_key`). Absent →
3275
+ // undefined → the plain `api_base_url`/`api_key` path (byte-identical to before).
3276
+ codingPlan: row.codingPlan,
3277
+ // app-parity-2 child 4: POPULATE the API modes + selected id onto the core
3278
+ // `LLMProvider` (structurally identical to the contracts `ApiMode`). Core's
3279
+ // shared `resolveProviderEndpoint` (layer 1) reads them: when a mode is selected
3280
+ // it reports `source:'api-mode'` and uses `api_base_url || mode.baseUrl` /
3281
+ // `api_key || mode.apiKey`. The row's `baseUrl`/`apiKey` hold the EFFECTIVE
3282
+ // endpoint (synced on switch — baseUrl app-side, the secret key server-side in
3283
+ // `parseProviderInput`), so customizations are preserved (the row value wins).
3284
+ apiModes: row.apiModes,
3285
+ selectedApiModeId: row.selectedApiModeId,
3286
+ // Official-Anthropic signature handling only matters for the Anthropic
3287
+ // ingress (deferred → 502); leave it off for the BYO transform path.
3288
+ isOfficial: false
3289
+ };
3290
+ }
3291
+
3292
+ // src/ports/ConsoleLogger.ts
3293
+ var ConsoleLogger = class {
3294
+ info(message, meta) {
3295
+ if (meta === void 0) console.info(message);
3296
+ else console.info(message, meta);
3297
+ }
3298
+ warn(message, meta) {
3299
+ if (meta === void 0) console.warn(message);
3300
+ else console.warn(message, meta);
3301
+ }
3302
+ error(message, error, meta) {
3303
+ if (error === void 0 && meta === void 0) console.error(message);
3304
+ else if (meta === void 0) console.error(message, error);
3305
+ else console.error(message, error, meta);
3306
+ }
3307
+ debug(message, meta) {
3308
+ if (meta === void 0) console.debug(message);
3309
+ else console.debug(message, meta);
3310
+ }
3311
+ };
3312
+
3313
+ // src/ports/JsonApiServerSettingsStore.ts
3314
+ var import_node_fs5 = require("fs");
3315
+ var import_outbound_api3 = require("@omnicross/core/outbound-api");
3316
+ var JsonApiServerSettingsStore = class {
3317
+ constructor(configPath) {
3318
+ this.configPath = configPath;
3319
+ }
3320
+ configPath;
3321
+ async get(key) {
3322
+ if (key !== import_outbound_api3.OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
3323
+ const file = this.readFile();
3324
+ return file.server ?? void 0;
3325
+ }
3326
+ async set(key, value) {
3327
+ if (key !== import_outbound_api3.OUTBOUND_API_SERVER_CONFIG_KEY) return;
3328
+ const file = this.readFile();
3329
+ file.server = value;
3330
+ (0, import_node_fs5.writeFileSync)(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
3331
+ }
3332
+ /** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
3333
+ readFile() {
3334
+ try {
3335
+ const raw = (0, import_node_fs5.readFileSync)(this.configPath, "utf8");
3336
+ const parsed = JSON.parse(raw);
3337
+ if (parsed && typeof parsed === "object") return parsed;
3338
+ } catch {
3339
+ }
3340
+ return {};
3341
+ }
3342
+ };
3343
+
3344
+ // src/ports/JsonSubscriptionCredentialStore.ts
3345
+ var import_node_fs6 = require("fs");
3346
+ var import_node_path3 = require("path");
3347
+ var import_subscriptions3 = require("@omnicross/subscriptions");
3348
+ var JsonSubscriptionCredentialStore = class {
3349
+ /**
3350
+ * @param tokensPath on-disk `tokens.json` location.
3351
+ * @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
3352
+ * @param fetchImpl injectable HTTP port for the OAuth refresh round-trips
3353
+ * (oauth design D4). Defaults to the global `fetch` so boot
3354
+ * is unchanged; tests inject a mock fetch. NOT used by any
3355
+ * read/write path — only by `refresh*Token`.
3356
+ */
3357
+ constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
3358
+ this.tokensPath = tokensPath;
3359
+ this.box = box;
3360
+ this.fetchImpl = fetchImpl;
3361
+ }
3362
+ tokensPath;
3363
+ box;
3364
+ fetchImpl;
3365
+ /** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
3366
+ * file is absent/corrupt). This is the hot read — the codex / gemini auth
3367
+ * strategies pull `accessToken` / `expiresAt` / `status` from it. */
3368
+ async getFullConfig() {
3369
+ return this.readConfig();
3370
+ }
3371
+ /** Current Claude OAuth access token, or `null` when none is stored. No inline
3372
+ * refresh here — the lead-window / 401-retry refresh is driven by the
3373
+ * subscription auth strategy, which calls `refreshClaudeToken` (now real). */
3374
+ async getValidClaudeAccessToken() {
3375
+ return this.readConfig().claude?.accessToken ?? null;
3376
+ }
3377
+ /** Current OpenCodeGo static API key, or `null` when none is stored. */
3378
+ async getValidOpenCodeGoApiKey() {
3379
+ return this.readConfig().opencodego?.apiKey ?? null;
3380
+ }
3381
+ /**
3382
+ * DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
3383
+ * each provider's accounts to the secret-free `SubscriptionAccountSanitized`
3384
+ * shape (id/label/status/expiresAt/hasAccessToken/isActive) — NEVER a token.
3385
+ * Used by the admin accounts GET (secret-IN-never-OUT).
3386
+ */
3387
+ async listSanitizedAccounts() {
3388
+ const config = this.readConfig();
3389
+ const out = {};
3390
+ for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
3391
+ const sanitized = sanitizeAccounts(config, provider);
3392
+ if (sanitized.length > 0) out[provider] = sanitized;
3393
+ }
3394
+ return out;
3395
+ }
3396
+ /**
3397
+ * Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
3398
+ * the block has no refresh_token (setup-token / manual) — no upstream call, the
3399
+ * block is untouched. Otherwise mint via the shared claude refresh flow and
3400
+ * write back access+refresh+expiresAt+status:authorized+lastRefreshedAt.
3401
+ * On failure → status:expired +
3402
+ * errorMessage → `false`.
3403
+ */
3404
+ async refreshClaudeToken() {
3405
+ const config = this.readConfig();
3406
+ const active = getActiveAccount(config, "claude");
3407
+ const claude = active?.tokens;
3408
+ if (!active || !claude?.refreshToken) return false;
3409
+ const capturedId = active.id;
3410
+ this.materializeMigration(config);
3411
+ try {
3412
+ const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3413
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3414
+ const next = {
3415
+ ...claude,
3416
+ accessToken: result.accessToken,
3417
+ refreshToken: result.refreshToken,
3418
+ expiresAt,
3419
+ status: "authorized",
3420
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3421
+ errorMessage: void 0
3422
+ };
3423
+ this.writeBackById("claude", capturedId, next);
3424
+ return true;
3425
+ } catch (error) {
3426
+ this.markExpiredById("claude", capturedId, claude, error);
3427
+ return false;
3428
+ }
3429
+ }
3430
+ /**
3431
+ * Refresh the Codex (ChatGPT) OAuth access token. Same shape
3432
+ * as claude, additionally writing back the refreshed `idToken`.
3433
+ * HONEST `false` when no refresh_token.
3434
+ */
3435
+ async refreshCodexToken() {
3436
+ const config = this.readConfig();
3437
+ const active = getActiveAccount(config, "codex");
3438
+ const codex = active?.tokens;
3439
+ if (!active || !codex?.refreshToken) return false;
3440
+ const capturedId = active.id;
3441
+ this.materializeMigration(config);
3442
+ try {
3443
+ const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken, this.fetchImpl);
3444
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3445
+ const next = {
3446
+ ...codex,
3447
+ accessToken: result.accessToken,
3448
+ refreshToken: result.refreshToken,
3449
+ idToken: result.idToken,
3450
+ expiresAt,
3451
+ status: "authorized",
3452
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3453
+ errorMessage: void 0
3454
+ };
3455
+ this.writeBackById("codex", capturedId, next);
3456
+ return true;
3457
+ } catch (error) {
3458
+ this.markExpiredById("codex", capturedId, codex, error);
3459
+ return false;
3460
+ }
3461
+ }
3462
+ /**
3463
+ * Refresh the Gemini (Google) OAuth access token. The Google
3464
+ * refresh response does NOT return a refresh_token, so this writes ONLY
3465
+ * access+expiresAt (+status/lastRefreshedAt) and DELIBERATELY leaves the
3466
+ * existing `refreshToken` untouched (overwriting it with `undefined` would
3467
+ * destroy the ability to refresh again). HONEST `false` when no refresh_token.
3468
+ */
3469
+ async refreshGeminiToken() {
3470
+ const config = this.readConfig();
3471
+ const active = getActiveAccount(config, "gemini");
3472
+ const gemini = active?.tokens;
3473
+ if (!active || !gemini?.refreshToken) return false;
3474
+ const capturedId = active.id;
3475
+ this.materializeMigration(config);
3476
+ try {
3477
+ const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
3478
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3479
+ const next = {
3480
+ ...gemini,
3481
+ // KEEP the existing refreshToken (response omits it).
3482
+ accessToken: result.accessToken,
3483
+ expiresAt,
3484
+ status: "authorized",
3485
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3486
+ errorMessage: void 0
3487
+ };
3488
+ this.writeBackById("gemini", capturedId, next);
3489
+ return true;
3490
+ } catch (error) {
3491
+ this.markExpiredById("gemini", capturedId, gemini, error);
3492
+ return false;
3493
+ }
3494
+ }
3495
+ /**
3496
+ * Materialize a lazily-synthesized account id to disk (design D3). On a legacy
3497
+ * single-slot file, `readConfig` synthesizes a NON-deterministic account id
3498
+ * per read; without persisting it, the later write-back (which re-reads) would
3499
+ * synthesize a DIFFERENT id and miss the captured account. Persisting the
3500
+ * migrated config here makes the id durable so the write-back keys correctly.
3501
+ * Idempotent: a config whose ids are already on disk re-persists byte-equal.
3502
+ */
3503
+ materializeMigration(migrated) {
3504
+ this.persist(migrated);
3505
+ }
3506
+ /**
3507
+ * Write refreshed tokens back to the captured account by id (oauth design D4),
3508
+ * re-derive the mirror from the CURRENT active id, re-stamp + persist. A switch
3509
+ * mid-refresh leaves the refreshed tokens in the captured (now non-active)
3510
+ * account and keeps the CURRENT active account's tokens in the mirror.
3511
+ */
3512
+ writeBackById(providerId, capturedId, block) {
3513
+ const config = this.readConfig();
3514
+ writeBackRefreshById(config, providerId, capturedId, block);
3515
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3516
+ }
3517
+ /**
3518
+ * Mark the captured account `status:'expired'` + errorMessage on a refresh
3519
+ * failure, keyed by id, then re-derive the mirror.
3520
+ */
3521
+ markExpiredById(providerId, capturedId, block, error) {
3522
+ const errorMessage = error instanceof Error ? error.message : "Refresh failed";
3523
+ this.writeBackById(providerId, capturedId, {
3524
+ ...block,
3525
+ status: "expired",
3526
+ errorMessage
3527
+ });
3528
+ }
3529
+ /**
3530
+ * DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
3531
+ * provider's token block into the current `AccountTokensConfig`, stamp a fresh
3532
+ * `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
3533
+ * OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
3534
+ * tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
3535
+ * so a first-ever write still produces a valid config. No cache → the next read
3536
+ * sees this write.
3537
+ */
3538
+ async writeProviderTokens(providerId, config) {
3539
+ const current = this.readConfig();
3540
+ writeActiveTokens(current, providerId, config);
3541
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3542
+ }
3543
+ /**
3544
+ * DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
3545
+ * (optional label) and set it active, then re-derive the mirror — used by
3546
+ * `omnicross login <provider> --label` to add an account instead of overwriting.
3547
+ */
3548
+ async appendProviderAccount(providerId, config, label) {
3549
+ const current = this.readConfig();
3550
+ const result = addAccount(current, providerId, config, label);
3551
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3552
+ return result;
3553
+ }
3554
+ /**
3555
+ * DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
3556
+ * account for a provider; rejects an unknown id. Re-derives the mirror.
3557
+ */
3558
+ async setActiveAccount(providerId, id) {
3559
+ const current = this.readConfig();
3560
+ const result = setActiveAccount(current, providerId, id);
3561
+ if (!result.ok) return result;
3562
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3563
+ return result;
3564
+ }
3565
+ /**
3566
+ * DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
3567
+ * account; promote the most-recent remaining on active-removal (or clear the
3568
+ * mirror when none remain). Re-derives the mirror.
3569
+ */
3570
+ async removeAccount(providerId, id) {
3571
+ const current = this.readConfig();
3572
+ const result = removeAccount(current, providerId, id);
3573
+ if (!result.removed) return result;
3574
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3575
+ return result;
3576
+ }
3577
+ /**
3578
+ * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
3579
+ * block from `tokens.json` and re-persist (the strategies already tolerate an
3580
+ * absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
3581
+ * provider was already absent (still re-stamps + persists).
3582
+ */
3583
+ async clearProvider(providerId) {
3584
+ const current = this.readConfig();
3585
+ clearProvider(current, providerId);
3586
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3587
+ }
3588
+ /** Write the merged config to disk as pretty JSON (mkdir parent if needed).
3589
+ * Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
3590
+ * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
3591
+ * write — incl. child 4's future refresh writes — lands encrypted. */
3592
+ persist(config) {
3593
+ (0, import_node_fs6.mkdirSync)((0, import_node_path3.dirname)(this.tokensPath), { recursive: true });
3594
+ const encrypted = encryptTokens(config, this.box);
3595
+ (0, import_node_fs6.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
3596
+ }
3597
+ /**
3598
+ * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
3599
+ * the token-material fields so every getter returns plaintext (the
3600
+ * subscription bearer path is byte-identical).
3601
+ *
3602
+ * The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
3603
+ * file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
3604
+ * wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
3605
+ * box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
3606
+ * SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
3607
+ * tokens" and silently send the WRONG bearer upstream → 401). Mirrors
3608
+ * `config.ts loadConfig`, which decrypts outside its parse try.
3609
+ */
3610
+ readConfig() {
3611
+ if (!(0, import_node_fs6.existsSync)(this.tokensPath)) return { updatedAt: "" };
3612
+ let parsed;
3613
+ try {
3614
+ const raw = JSON.parse((0, import_node_fs6.readFileSync)(this.tokensPath, "utf8"));
3615
+ parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
3616
+ } catch {
3617
+ parsed = null;
3618
+ }
3619
+ if (!parsed) return { updatedAt: "" };
3620
+ const decrypted = decryptTokens(parsed, this.box);
3621
+ return migrateLazily(decrypted);
3622
+ }
3623
+ };
3624
+
3625
+ // src/bootstrap.ts
3626
+ function buildDaemon(config, paths) {
3627
+ const logger = new ConsoleLogger();
3628
+ const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
3629
+ setSecretBox(secretBox3);
3630
+ setSecretBox2(secretBox3);
3631
+ const decryptedConfig = decryptConfigSecrets(config, secretBox3);
3632
+ const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
3633
+ const keyDb = new JsonOutboundKeyDb(paths.keysPath);
3634
+ const settingsStore = new JsonApiServerSettingsStore(paths.configPath);
3635
+ const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
3636
+ const subscriptionAccounts = new import_subscriptions4.SubscriptionAccountService(credentialStore);
3637
+ (0, import_subscriptions4.setSubscriptionAccountService)(subscriptionAccounts);
3638
+ const subscriptionRegistry = new import_subscriptions4.SubscriptionProviderRegistry(
3639
+ subscriptionAccounts,
3640
+ credentialStore
3641
+ );
3642
+ (0, import_subscriptions4.setSubscriptionProviderRegistry)(subscriptionRegistry);
3643
+ (0, import_gemini_code_assist_resolver.setGeminiCodeAssistResolver)((0, import_GeminiCodeAssistProjectResolver.getGeminiCodeAssistProjectResolver)());
3644
+ const autoDisableStore = new AutoDisableStore();
3645
+ const apiKeyPool = new import_ApiKeyPoolService.ApiKeyPoolService(
3646
+ createPoolKeysLoader((id) => llmConfig.getProviderRow(id), autoDisableStore),
3647
+ resolveEnvKey,
3648
+ logger,
3649
+ async (keyId) => {
3650
+ autoDisableStore.markAutoDisabled(keyId, 0, Date.now());
3651
+ return true;
3652
+ },
3653
+ async (keyId, status, at) => {
3654
+ autoDisableStore.markAutoDisabled(keyId, status, at);
3655
+ }
3656
+ );
3657
+ const providerProxy = (0, import_provider_proxy.getProviderProxy)({ llmConfig, apiKeyPool });
3658
+ llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
3659
+ const outboundApiServer = (0, import_outbound_api4.getOutboundApiServer)({
3660
+ db: keyDb,
3661
+ llmConfig,
3662
+ providerProxy,
3663
+ proxyDeps: providerProxy.getDeps()
3664
+ });
3665
+ const adminServer = new AdminServer({
3666
+ configPath: paths.configPath,
3667
+ llmConfig,
3668
+ keyDb,
3669
+ settingsStore,
3670
+ outboundApiServer,
3671
+ subscriptionAccounts,
3672
+ // Least-authority token WRITER (design D4) — the concrete credential store
3673
+ // exposes `writeProviderTokens` / `clearProvider` as daemon-only methods (NOT
3674
+ // on the `SubscriptionCredentialStore` port). The admin API sees ONLY these two
3675
+ // mutators through the `SubscriptionTokenWriter` shape, never a token read.
3676
+ subscriptionTokenWriter: credentialStore,
3677
+ // Read-only pool-health view (key-pool design D7): the admin API reads
3678
+ // `getKeyHealth` (cooldown) + the in-memory auto-disable store; the key
3679
+ // values themselves NEVER leave (masked via `maskProviderApiKey`).
3680
+ apiKeyPool,
3681
+ autoDisableStore,
3682
+ // Interactive OAuth login over admin HTTP (app-parity child 4, design
3683
+ // D1/D2-a). The in-memory pending-session store (NEVER serialized), the
3684
+ // injected token-exchange fetch (global `fetch` here; mocked in tests), and a
3685
+ // NARROW `{ appendProviderAccount }` handle from the concrete credential store
3686
+ // (NOT widening the least-authority writer — no token-returning read reachable).
3687
+ oauthSessions: new OAuthSessionStore(),
3688
+ // Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
3689
+ // inject a mock so no real token endpoint is hit.
3690
+ oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetch(url, init)),
3691
+ subscriptionAccountAppender: credentialStore,
3692
+ // Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
3693
+ // + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
3694
+ // the app polls the token-free status. A test seam (`paths.codexAwaitLoopback`)
3695
+ // can inject a mock so no real port is bound.
3696
+ codexSessions: new CodexOAuthSessionStore(),
3697
+ codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs) => awaitLoopbackCode(state, timeoutMs)),
3698
+ // Migration pack (app-parity child 6, design D2/D3) — the concrete credential
3699
+ // store provides BOTH the full DECRYPTED read (`getFullConfig`, export) and
3700
+ // the multi-account append (`appendProviderAccount`, import re-encrypts at-
3701
+ // rest). Confined to the export/import handlers; never reached by a GET.
3702
+ migrationCredentialStore: credentialStore,
3703
+ // Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
3704
+ // plaintext bearer the AdminServer's constant-time compare expects (D4).
3705
+ getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
3706
+ });
3707
+ return {
3708
+ logger,
3709
+ llmConfig,
3710
+ keyDb,
3711
+ settingsStore,
3712
+ providerProxy,
3713
+ outboundApiServer,
3714
+ apiKeyPool,
3715
+ autoDisableStore,
3716
+ credentialStore,
3717
+ subscriptionRegistry,
3718
+ subscriptionAccounts,
3719
+ adminServer
3720
+ };
3721
+ }
3722
+
3723
+ // src/commands/launch.ts
3724
+ var SUPPORTED_LAUNCH_CLIS = [
3725
+ "claude",
3726
+ "codex",
3727
+ "gemini",
3728
+ "qwen",
3729
+ "copilot",
3730
+ "opencode"
3731
+ ];
3732
+ var CMD_UNSAFE_RE = /[&|<>^"%]/;
3733
+ function buildCliSpawnPlan(opts) {
3734
+ const { platform, cliName, cliArgs } = opts;
3735
+ if (platform !== "win32") {
3736
+ return { command: cliName, args: cliArgs, viaCmdShim: false };
3737
+ }
3738
+ const probe = opts.resolveInPath ?? resolveInPathDefault;
3739
+ const exe = probe(`${cliName}.exe`);
3740
+ if (exe) {
3741
+ return { command: exe, args: cliArgs, viaCmdShim: false };
3742
+ }
3743
+ const cmdShim = probe(`${cliName}.cmd`);
3744
+ if (!cmdShim) {
3745
+ return { command: cliName, args: cliArgs, viaCmdShim: false };
3746
+ }
3747
+ const unsafe = [cmdShim, ...cliArgs].filter((a) => CMD_UNSAFE_RE.test(a));
3748
+ if (unsafe.length > 0) {
3749
+ throw new Error(
3750
+ `launch: "${cliName}" resolves to an npm .cmd shim, which must run through cmd.exe \u2014 but ${unsafe.length} argument(s) contain quote/metacharacters (${unsafe[0]}). Refusing to pass them through cmd.exe (it would parse them before the CLI runs). Install the native ${cliName} executable (.exe on PATH) or drop the offending arguments.`
3751
+ );
3752
+ }
3753
+ const payload = [cmdShim, ...cliArgs].map((a) => `"${a}"`).join(" ");
3754
+ return {
3755
+ command: opts.comSpec ?? process.env["ComSpec"] ?? "cmd.exe",
3756
+ args: ["/d", "/s", "/c", `"${payload}"`],
3757
+ viaCmdShim: true
3758
+ };
3759
+ }
3760
+ function resolveInPathDefault(candidate) {
3761
+ const segments = (process.env["PATH"] ?? "").split(import_node_path4.delimiter).filter(Boolean);
3762
+ for (const seg of segments) {
3763
+ const full = (0, import_node_path4.join)(seg, candidate);
3764
+ if ((0, import_node_fs7.existsSync)(full)) return full;
3765
+ }
3766
+ return null;
3767
+ }
3768
+ async function runLaunch(argv, deps) {
3769
+ const sep = argv.indexOf("--");
3770
+ const own = sep === -1 ? argv : argv.slice(0, sep);
3771
+ const passthrough = sep === -1 ? [] : argv.slice(sep + 1);
3772
+ const { values, positionals } = (0, import_node_util3.parseArgs)({
3773
+ args: own,
3774
+ options: {
3775
+ provider: { type: "string", short: "p" },
3776
+ model: { type: "string", short: "m" },
3777
+ config: { type: "string", short: "c" },
3778
+ cwd: { type: "string" },
3779
+ "master-key-file": { type: "string" }
3780
+ },
3781
+ allowPositionals: true
3782
+ });
3783
+ const cliName = positionals[0];
3784
+ if (!cliName || !SUPPORTED_LAUNCH_CLIS.includes(cliName)) {
3785
+ throw new Error(
3786
+ `launch: a supported <cli> is required \u2014 one of: ${SUPPORTED_LAUNCH_CLIS.join(", ")}` + (cliName ? ` (got "${cliName}")` : "")
3787
+ );
3788
+ }
3789
+ const cli = cliName;
3790
+ if (!values.provider) throw new Error("launch: --provider <id> is required");
3791
+ if (!values.model) throw new Error("launch: --model <model> is required");
3792
+ if (!values.config) throw new Error("launch: --config <path> is required");
3793
+ const config = loadConfig(values.config);
3794
+ const paths = {
3795
+ configPath: values.config,
3796
+ keysPath: defaultKeysPath(values.config),
3797
+ tokensPath: defaultTokensPath(values.config),
3798
+ masterKeyFilePath: values["master-key-file"]
3799
+ };
3800
+ const daemon = buildDaemon(config, paths);
3801
+ try {
3802
+ await daemon.llmConfig.ready();
3803
+ await daemon.providerProxy.start();
3804
+ } catch (err4) {
3805
+ daemon.apiKeyPool.dispose();
3806
+ throw err4;
3807
+ }
3808
+ let launch;
3809
+ try {
3810
+ launch = await buildLaunchConfig(cli, daemon.llmConfig, {
3811
+ providerId: values.provider,
3812
+ model: values.model
3813
+ });
3814
+ } catch (err4) {
3815
+ await daemon.providerProxy.stop();
3816
+ daemon.apiKeyPool.dispose();
3817
+ throw err4;
3818
+ }
3819
+ try {
3820
+ const plan = buildCliSpawnPlan({
3821
+ platform: process.platform,
3822
+ cliName: cli,
3823
+ cliArgs: [...launch.extraArgs ?? [], ...passthrough],
3824
+ resolveInPath: deps?.resolveInPath
3825
+ });
3826
+ console.info(`launching ${cli} via omnicross proxy ${launch.baseUrl}`);
3827
+ console.info(` provider: ${values.provider} model: ${values.model}`);
3828
+ const spawnCli = deps?.spawnCli ?? spawnCliInherit;
3829
+ return await spawnCli({
3830
+ ...plan,
3831
+ env: { ...process.env, ...launch.env },
3832
+ cwd: values.cwd
3833
+ });
3834
+ } finally {
3835
+ launch.onSessionEnd();
3836
+ await daemon.providerProxy.stop();
3837
+ daemon.apiKeyPool.dispose();
3838
+ }
3839
+ }
3840
+ async function buildLaunchConfig(cli, llmConfig, opts) {
3841
+ const common = {
3842
+ llmConfig,
3843
+ providerId: opts.providerId,
3844
+ model: opts.model,
3845
+ // Stable, bounded session id — pool failover (poolseam) fires on launch
3846
+ // traffic with one binding per CLI flavor.
3847
+ sessionId: `launch:${cli}`
3848
+ };
3849
+ switch (cli) {
3850
+ case "claude":
3851
+ return (0, import_cli_launcher.buildClaudeCliLaunchConfig)(common);
3852
+ case "codex":
3853
+ return (0, import_cli_launcher.buildCodexLaunchConfig)(common);
3854
+ case "gemini":
3855
+ return (0, import_cli_launcher.buildGeminiCliLaunchConfig)(common);
3856
+ case "qwen":
3857
+ case "copilot":
3858
+ case "opencode":
3859
+ return (0, import_cli_launcher.buildChatCliLaunchConfig)({ backendId: cli, ...common });
3860
+ default: {
3861
+ const _exhaustive = cli;
3862
+ throw new Error(`Unsupported launch CLI: ${String(_exhaustive)}`);
3863
+ }
3864
+ }
3865
+ }
3866
+ function spawnCliInherit(plan) {
3867
+ return new Promise((resolve, reject) => {
3868
+ const child = (0, import_node_child_process.spawn)(plan.command, plan.args, {
3869
+ stdio: "inherit",
3870
+ env: plan.env,
3871
+ cwd: plan.cwd,
3872
+ // Only the cmd.exe fallback needs verbatim args (we pre-quoted them).
3873
+ windowsVerbatimArguments: plan.viaCmdShim || void 0
3874
+ });
3875
+ const onSignal = (sig) => {
3876
+ try {
3877
+ child.kill(sig);
3878
+ } catch {
3879
+ }
3880
+ };
3881
+ process.on("SIGINT", onSignal);
3882
+ process.on("SIGTERM", onSignal);
3883
+ const detach = () => {
3884
+ process.removeListener("SIGINT", onSignal);
3885
+ process.removeListener("SIGTERM", onSignal);
3886
+ };
3887
+ child.on("error", (err4) => {
3888
+ detach();
3889
+ if (err4.code === "ENOENT") {
3890
+ reject(
3891
+ new Error(
3892
+ `launch: "${plan.command}" not found on PATH \u2014 install the CLI first.`
3893
+ )
3894
+ );
3895
+ return;
3896
+ }
3897
+ reject(err4);
3898
+ });
3899
+ child.on("exit", (code, signal) => {
3900
+ detach();
3901
+ resolve(code ?? (signal ? 1 : 0));
3902
+ });
3903
+ });
3904
+ }
3905
+
3906
+ // src/commands/login.ts
3907
+ var import_node_child_process2 = require("child_process");
3908
+ var import_node_readline = require("readline");
3909
+ var import_node_util4 = require("util");
3910
+ var import_subscriptions5 = require("@omnicross/subscriptions");
3911
+ var PROVIDERS = ["claude", "codex", "gemini"];
3912
+ async function runLogin(argv, deps) {
3913
+ const { values, positionals } = (0, import_node_util4.parseArgs)({
3914
+ args: argv,
3915
+ options: {
3916
+ config: { type: "string", short: "c" },
3917
+ "master-key-file": { type: "string" },
3918
+ // Optional user label for the appended account (multi-account).
3919
+ label: { type: "string" }
3920
+ },
3921
+ allowPositionals: true
3922
+ });
3923
+ const provider = positionals[0];
3924
+ if (!provider) {
3925
+ throw new Error(`login: a <provider> is required (one of ${PROVIDERS.join("|")})`);
3926
+ }
3927
+ if (!isLoginProvider(provider)) {
3928
+ throw new Error(`login: unknown provider '${provider}' (expected ${PROVIDERS.join("|")})`);
3929
+ }
3930
+ if (!values.config) {
3931
+ throw new Error("login: --config <path> is required");
3932
+ }
3933
+ const resolved = {
3934
+ openBrowser: deps?.openBrowser ?? openBrowser,
3935
+ promptPaste: deps?.promptPaste ?? promptPaste,
3936
+ awaitLoopback: deps?.awaitLoopback ?? ((state) => awaitLoopbackCode(state)),
3937
+ tokensFetch: deps?.tokensFetch
3938
+ };
3939
+ const box = resolveSecretBox(values["master-key-file"]);
3940
+ setSecretBox(box);
3941
+ try {
3942
+ const tokensPath = defaultTokensPath(values.config);
3943
+ const exchangeFetch = resolved.tokensFetch ?? ((url, init) => fetch(url, init));
3944
+ const store = new JsonSubscriptionCredentialStore(tokensPath, box, exchangeFetch);
3945
+ const expiresAt = await runProviderLogin(
3946
+ provider,
3947
+ store,
3948
+ resolved,
3949
+ exchangeFetch,
3950
+ values.label
3951
+ );
3952
+ console.info(`Logged in to '${provider}' \u2192 ${tokensPath}`);
3953
+ console.info(` token: [stored, encrypted] expiresAt: ${expiresAt ?? "n/a"}`);
3954
+ } finally {
3955
+ setSecretBox(null);
3956
+ }
3957
+ }
3958
+ async function runProviderLogin(provider, store, deps, exchangeFetch, label) {
3959
+ if (provider === "codex") return loginCodex(store, deps, exchangeFetch, label);
3960
+ if (provider === "claude") return loginClaude(store, deps, exchangeFetch, label);
3961
+ return loginGemini(store, deps, exchangeFetch, label);
3962
+ }
3963
+ async function loginCodex(store, deps, exchangeFetch, label) {
3964
+ const { authUrl, codeVerifier, state } = import_subscriptions5.codexOAuth.generateAuthParams();
3965
+ await presentUrl(authUrl, deps);
3966
+ const code = await deps.awaitLoopback(state);
3967
+ const result = await import_subscriptions5.codexOAuth.exchangeCodeForTokens(
3968
+ { authorizationCode: code, codeVerifier, state },
3969
+ exchangeFetch
3970
+ );
3971
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3972
+ const block = {
3973
+ authMethod: "oauth",
3974
+ status: "authorized",
3975
+ accessToken: result.accessToken,
3976
+ refreshToken: result.refreshToken,
3977
+ idToken: result.idToken,
3978
+ expiresAt,
3979
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3980
+ };
3981
+ await store.appendProviderAccount("codex", block, label);
3982
+ logMasked("codex", result.accessToken);
3983
+ return expiresAt;
3984
+ }
3985
+ async function loginClaude(store, deps, exchangeFetch, label) {
3986
+ const { authUrl, codeVerifier, state } = import_subscriptions5.claudeOAuth.generateAuthParams();
3987
+ await presentUrl(authUrl, deps);
3988
+ const pasted = (await deps.promptPaste("Paste the authorization code (code#state): ")).trim();
3989
+ const [code, pastedState] = pasted.split("#");
3990
+ if (!code) throw new Error("login: no authorization code was pasted");
3991
+ if (pastedState && pastedState !== state) {
3992
+ throw new Error("login: pasted state did not match (possible CSRF) \u2014 aborting");
3993
+ }
3994
+ const result = await import_subscriptions5.claudeOAuth.exchangeCodeForTokens(
3995
+ { authorizationCode: code, codeVerifier, state },
3996
+ exchangeFetch
3997
+ );
3998
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3999
+ const block = {
4000
+ authMethod: "oauth",
4001
+ status: "authorized",
4002
+ accessToken: result.accessToken,
4003
+ refreshToken: result.refreshToken,
4004
+ expiresAt,
4005
+ scopes: result.scopes,
4006
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
4007
+ };
4008
+ await store.appendProviderAccount("claude", block, label);
4009
+ logMasked("claude", result.accessToken);
4010
+ return expiresAt;
4011
+ }
4012
+ async function loginGemini(store, deps, exchangeFetch, label) {
4013
+ const { authUrl, codeVerifier } = import_subscriptions5.geminiOAuth.generateAuthParams();
4014
+ await presentUrl(authUrl, deps);
4015
+ const code = (await deps.promptPaste("Paste the authorization code: ")).trim();
4016
+ if (!code) throw new Error("login: no authorization code was pasted");
4017
+ const result = await import_subscriptions5.geminiOAuth.exchangeCodeForTokens(code, codeVerifier, exchangeFetch);
4018
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4019
+ const block = {
4020
+ authMethod: "oauth",
4021
+ status: "authorized",
4022
+ accessToken: result.accessToken,
4023
+ refreshToken: result.refreshToken,
4024
+ expiresAt,
4025
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
4026
+ };
4027
+ await store.appendProviderAccount("gemini", block, label);
4028
+ logMasked("gemini", result.accessToken);
4029
+ return expiresAt;
4030
+ }
4031
+ function isLoginProvider(value) {
4032
+ return PROVIDERS.includes(value);
4033
+ }
4034
+ async function presentUrl(authUrl, deps) {
4035
+ console.info("Open this URL in your browser to authorize:");
4036
+ console.info(` ${authUrl}`);
4037
+ const launched = await deps.openBrowser(authUrl).catch(() => false);
4038
+ if (!launched) {
4039
+ console.info("(Could not open a browser automatically \u2014 open the URL above manually.)");
4040
+ }
4041
+ }
4042
+ function logMasked(provider, accessToken) {
4043
+ console.info(` ${provider} access token: ${maskProviderApiKey(accessToken)}`);
4044
+ }
4045
+ function buildOpenBrowserCommand(platform, url) {
4046
+ if (platform === "win32") {
4047
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
4048
+ }
4049
+ if (platform === "darwin") {
4050
+ return { command: "open", args: [url] };
4051
+ }
4052
+ return { command: "xdg-open", args: [url] };
4053
+ }
4054
+ function openBrowser(url) {
4055
+ return new Promise((resolve) => {
4056
+ try {
4057
+ const { command, args } = buildOpenBrowserCommand(process.platform, url);
4058
+ const child = (0, import_node_child_process2.spawn)(command, args, { stdio: "ignore", detached: true });
4059
+ child.on("error", () => resolve(false));
4060
+ child.unref();
4061
+ resolve(true);
4062
+ } catch {
4063
+ resolve(false);
4064
+ }
4065
+ });
4066
+ }
4067
+ function promptPaste(prompt) {
4068
+ const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
4069
+ return new Promise((resolve) => {
4070
+ rl.question(prompt, (answer) => {
4071
+ rl.close();
4072
+ resolve(answer);
4073
+ });
4074
+ });
4075
+ }
4076
+
4077
+ // src/commands/providers.ts
4078
+ var import_node_crypto8 = require("crypto");
4079
+ var import_node_util5 = require("util");
4080
+ async function runProviders(argv) {
4081
+ const { values, positionals } = (0, import_node_util5.parseArgs)({
4082
+ args: argv,
4083
+ options: {
4084
+ config: { type: "string", short: "c" },
4085
+ key: { type: "string" },
4086
+ id: { type: "string" },
4087
+ "base-url": { type: "string", short: "b" },
4088
+ label: { type: "string" },
4089
+ weight: { type: "string" },
4090
+ "master-key-file": { type: "string" }
4091
+ },
4092
+ allowPositionals: true
4093
+ });
4094
+ const configPath = values.config;
4095
+ if (!configPath) {
4096
+ throw new Error("providers: --config <path> is required");
4097
+ }
4098
+ const action = positionals[0];
4099
+ if (action === "presets") return providersPresets();
4100
+ setSecretBox(resolveSecretBox(values["master-key-file"]));
4101
+ try {
4102
+ switch (action) {
4103
+ case "add":
4104
+ return providersAdd(configPath, positionals[1], {
4105
+ key: values.key,
4106
+ id: values.id,
4107
+ baseUrl: values["base-url"]
4108
+ });
4109
+ case "keys":
4110
+ return providersKeys(configPath, positionals[1]);
4111
+ case "add-key":
4112
+ return providersAddKey(configPath, positionals[1], {
4113
+ key: values.key,
4114
+ label: values.label,
4115
+ weight: values.weight
4116
+ });
4117
+ case "rm-key":
4118
+ return providersRmKey(configPath, positionals[1], positionals[2]);
4119
+ default:
4120
+ throw new Error(
4121
+ `providers: unknown action '${action ?? ""}' (expected presets|add|keys|add-key|rm-key)`
4122
+ );
4123
+ }
4124
+ } finally {
4125
+ setSecretBox(null);
4126
+ }
4127
+ }
4128
+ function providersPresets() {
4129
+ const { mappable, excluded } = listMappablePresets();
4130
+ console.info(`Mappable presets (${mappable.length}):`);
4131
+ for (const p of mappable) {
4132
+ console.info(` ${p.id} ${p.apiFormat} ${p.baseUrl} models=${p.models.length}`);
4133
+ }
4134
+ if (excluded.length > 0) {
4135
+ console.info("");
4136
+ console.info(`Excluded (${excluded.length}):`);
4137
+ for (const e of excluded) {
4138
+ console.info(` ${e.id} EXCLUDED ${e.reason}`);
4139
+ }
4140
+ }
4141
+ }
4142
+ function providersAdd(configPath, presetId, opts) {
4143
+ if (!presetId) {
4144
+ throw new Error("providers add: a <presetId> is required");
4145
+ }
4146
+ const preset = getPresetById(presetId);
4147
+ if (!preset) {
4148
+ const ids = listMappablePresets().mappable.map((p) => p.id).join(", ");
4149
+ throw new Error(`providers add: unknown preset '${presetId}'. Available: ${ids}`);
4150
+ }
4151
+ if (!opts.key) {
4152
+ throw new Error(`providers add: --key <key|$ENV_VAR> is required (preset '${presetId}' carries no key)`);
4153
+ }
4154
+ const result = mapPresetToProvider(preset, {
4155
+ key: opts.key,
4156
+ id: opts.id,
4157
+ baseUrlOverride: opts.baseUrl
4158
+ });
4159
+ if ("excluded" in result) {
4160
+ throw new Error(`providers add: preset '${presetId}' cannot be mapped \u2014 ${result.excluded.reason}`);
4161
+ }
4162
+ if ("missingKey" in result) {
4163
+ throw new Error(`providers add: --key <key|$ENV_VAR> is required`);
4164
+ }
4165
+ const { provider } = result;
4166
+ const cfg = loadConfig(configPath);
4167
+ if (cfg.providers.some((p) => p.id === provider.id)) {
4168
+ throw new Error(`providers add: a provider with id '${provider.id}' already exists in ${configPath}`);
4169
+ }
4170
+ cfg.providers.push(provider);
4171
+ saveConfig(configPath, cfg);
4172
+ console.info(`Added provider '${provider.id}' (${provider.apiFormat}) \u2192 ${configPath}`);
4173
+ console.info(` baseUrl: ${provider.baseUrl}`);
4174
+ console.info(` models: ${(provider.models ?? []).length}`);
4175
+ }
4176
+ function effectivePool(row) {
4177
+ if (row.apiKeys && row.apiKeys.length > 0) return row.apiKeys;
4178
+ if (row.apiKey.length > 0) return [{ id: `${row.id}:default`, apiKey: row.apiKey, weight: 1, enabled: true }];
4179
+ return [];
4180
+ }
4181
+ function providersKeys(configPath, providerId) {
4182
+ if (!providerId) throw new Error("providers keys: a <providerId> is required");
4183
+ const cfg = loadConfig(configPath);
4184
+ const row = cfg.providers.find((p) => p.id === providerId);
4185
+ if (!row) throw new Error(`providers keys: unknown provider '${providerId}'`);
4186
+ const pool = effectivePool(row);
4187
+ console.info(`Pool for '${providerId}' (${pool.length} key${pool.length === 1 ? "" : "s"}):`);
4188
+ for (const k of pool) {
4189
+ const enabled = k.enabled !== false ? "enabled" : "disabled";
4190
+ const weight = typeof k.weight === "number" ? k.weight : 1;
4191
+ console.info(` ${k.id} ${k.label ?? k.id} ${maskProviderApiKey(k.apiKey)} ${enabled} weight=${weight}`);
4192
+ }
4193
+ }
4194
+ function providersAddKey(configPath, providerId, opts) {
4195
+ if (!providerId) throw new Error("providers add-key: a <providerId> is required");
4196
+ if (!opts.key) throw new Error("providers add-key: --key <key|$ENV_VAR> is required");
4197
+ const cfg = loadConfig(configPath);
4198
+ const row = cfg.providers.find((p) => p.id === providerId);
4199
+ if (!row) throw new Error(`providers add-key: unknown provider '${providerId}'`);
4200
+ const entry = { id: (0, import_node_crypto8.randomUUID)(), apiKey: opts.key };
4201
+ if (opts.label) entry.label = opts.label;
4202
+ if (opts.weight !== void 0) {
4203
+ const w = Number(opts.weight);
4204
+ if (!Number.isFinite(w)) throw new Error("providers add-key: --weight must be a number");
4205
+ entry.weight = w;
4206
+ }
4207
+ row.apiKeys = [...row.apiKeys ?? [], entry];
4208
+ saveConfig(configPath, cfg);
4209
+ console.info(`Added pool key '${entry.id}' to provider '${providerId}' \u2192 ${configPath} (not hot-reloaded)`);
4210
+ }
4211
+ function providersRmKey(configPath, providerId, keyId) {
4212
+ if (!providerId) throw new Error("providers rm-key: a <providerId> is required");
4213
+ if (!keyId) throw new Error("providers rm-key: a <keyId> is required");
4214
+ const cfg = loadConfig(configPath);
4215
+ const row = cfg.providers.find((p) => p.id === providerId);
4216
+ if (!row) throw new Error(`providers rm-key: unknown provider '${providerId}'`);
4217
+ const before = row.apiKeys ?? [];
4218
+ const after = before.filter((k) => k.id !== keyId);
4219
+ if (after.length === before.length) {
4220
+ throw new Error(`providers rm-key: provider '${providerId}' has no pool key '${keyId}'`);
4221
+ }
4222
+ row.apiKeys = after.length > 0 ? after : void 0;
4223
+ saveConfig(configPath, cfg);
4224
+ console.info(`Removed pool key '${keyId}' from provider '${providerId}' \u2192 ${configPath} (not hot-reloaded)`);
4225
+ }
4226
+
4227
+ // src/commands/secrets.ts
4228
+ var import_node_fs8 = require("fs");
4229
+ var import_node_util6 = require("util");
4230
+ async function runSecrets(argv) {
4231
+ const { values, positionals } = (0, import_node_util6.parseArgs)({
4232
+ args: argv,
4233
+ options: {
4234
+ config: { type: "string", short: "c" },
4235
+ "master-key-file": { type: "string" },
4236
+ "new-master-key-file": { type: "string" },
4237
+ force: { type: "boolean" }
4238
+ },
4239
+ allowPositionals: true
4240
+ });
4241
+ const args = {
4242
+ config: values.config,
4243
+ masterKeyFile: values["master-key-file"],
4244
+ newMasterKeyFile: values["new-master-key-file"],
4245
+ force: values.force === true
4246
+ };
4247
+ if (!args.config) {
4248
+ throw new Error("secrets: --config <path> is required");
4249
+ }
4250
+ const action = positionals[0];
4251
+ switch (action) {
4252
+ case "encrypt":
4253
+ return secretsEncrypt(args);
4254
+ case "status":
4255
+ return secretsStatus(args);
4256
+ case "rotate":
4257
+ return secretsRotate(args);
4258
+ case "decrypt":
4259
+ return secretsDecrypt(args);
4260
+ default:
4261
+ throw new Error(
4262
+ `secrets: unknown action '${action ?? ""}' (expected encrypt|status|rotate)`
4263
+ );
4264
+ }
4265
+ }
4266
+ function secretsEncrypt(args) {
4267
+ const box = resolveSecretBox(args.masterKeyFile);
4268
+ setSecretBox(box);
4269
+ try {
4270
+ const cfg = loadConfig(args.config);
4271
+ saveConfig(args.config, cfg);
4272
+ encryptTokensFileInPlace(args.config, box);
4273
+ } finally {
4274
+ setSecretBox(null);
4275
+ }
4276
+ console.info(`Encrypted secrets in ${args.config}` + tokensSuffix(args.config));
4277
+ }
4278
+ function classify(raw) {
4279
+ if (raw.startsWith("$")) return "env-ref";
4280
+ if (isEnvelope(raw)) return "encrypted";
4281
+ return "plaintext";
4282
+ }
4283
+ function safeDisplay(raw, cls) {
4284
+ if (cls === "encrypted") return "[encrypted]";
4285
+ return maskProviderApiKey(raw);
4286
+ }
4287
+ function secretsStatus(args) {
4288
+ const cfg = readRawConfig(args.config);
4289
+ console.info(`Secret status for ${args.config}:`);
4290
+ for (const p of cfg.providers) {
4291
+ reportField(`provider '${p.id}'.apiKey`, p.apiKey);
4292
+ for (const k of p.apiKeys ?? []) {
4293
+ reportField(`provider '${p.id}'.apiKeys['${k.id}']`, k.apiKey);
4294
+ }
4295
+ }
4296
+ if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
4297
+ reportField("admin.token", cfg.admin.token);
4298
+ }
4299
+ const tokensPath = defaultTokensPath(args.config);
4300
+ if ((0, import_node_fs8.existsSync)(tokensPath)) {
4301
+ console.info(`Secret status for ${tokensPath}:`);
4302
+ reportTokenFields(tokensPath);
4303
+ }
4304
+ }
4305
+ function reportField(name, raw) {
4306
+ const cls = classify(raw);
4307
+ console.info(` ${name}: ${cls} ${safeDisplay(raw, cls)}`);
4308
+ }
4309
+ function reportTokenFields(tokensPath) {
4310
+ const parsed = readRawJson(tokensPath);
4311
+ const blocks = {
4312
+ claude: ["accessToken", "refreshToken"],
4313
+ codex: ["accessToken", "refreshToken", "idToken"],
4314
+ gemini: ["accessToken", "refreshToken"],
4315
+ opencodego: ["apiKey"]
4316
+ };
4317
+ for (const [provider, fields] of Object.entries(blocks)) {
4318
+ const block = parsed[provider];
4319
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
4320
+ for (const field of fields) {
4321
+ const value = block[field];
4322
+ if (typeof value === "string" && value.length > 0) {
4323
+ reportField(`${provider}.${field}`, value);
4324
+ }
4325
+ }
4326
+ }
4327
+ }
4328
+ function secretsRotate(args) {
4329
+ if (!args.newMasterKeyFile) {
4330
+ throw new Error("secrets rotate: --new-master-key-file <path> is required");
4331
+ }
4332
+ const oldBox = resolveSecretBox(args.masterKeyFile);
4333
+ const newBox = resolveSecretBox(args.newMasterKeyFile);
4334
+ setSecretBox(oldBox);
4335
+ let cfg;
4336
+ let tokensPlain = null;
4337
+ const tokensPath = defaultTokensPath(args.config);
4338
+ try {
4339
+ cfg = loadConfig(args.config);
4340
+ if ((0, import_node_fs8.existsSync)(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
4341
+ } finally {
4342
+ setSecretBox(null);
4343
+ }
4344
+ setSecretBox(newBox);
4345
+ try {
4346
+ saveConfig(args.config, cfg);
4347
+ if (tokensPlain) writeTokensEncrypted(tokensPath, tokensPlain, newBox);
4348
+ } finally {
4349
+ setSecretBox(null);
4350
+ }
4351
+ console.info(`Rotated master key for ${args.config}` + tokensSuffix(args.config));
4352
+ }
4353
+ function secretsDecrypt(args) {
4354
+ if (!args.force) {
4355
+ throw new Error(
4356
+ "secrets decrypt: refusing without --force (this writes plaintext secrets to disk)"
4357
+ );
4358
+ }
4359
+ console.error(
4360
+ "WARNING: secrets decrypt --force writes ALL secrets back to PLAINTEXT on disk. Only use this to roll back to an older daemon. The file will no longer be encrypted."
4361
+ );
4362
+ const box = resolveSecretBox(args.masterKeyFile);
4363
+ const tokensPath = defaultTokensPath(args.config);
4364
+ setSecretBox(box);
4365
+ let cfg;
4366
+ let tokensPlain = null;
4367
+ try {
4368
+ cfg = loadConfig(args.config);
4369
+ if ((0, import_node_fs8.existsSync)(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
4370
+ } finally {
4371
+ setSecretBox(null);
4372
+ }
4373
+ saveConfig(args.config, cfg);
4374
+ if (tokensPlain) {
4375
+ (0, import_node_fs8.writeFileSync)(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
4376
+ }
4377
+ console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
4378
+ }
4379
+ function readRawConfig(path) {
4380
+ let parsed;
4381
+ try {
4382
+ parsed = JSON.parse((0, import_node_fs8.readFileSync)(path, "utf8"));
4383
+ } catch {
4384
+ throw new Error(`secrets: cannot read or parse '${path}'`);
4385
+ }
4386
+ return validateConfig(parsed);
4387
+ }
4388
+ function readRawJson(path) {
4389
+ try {
4390
+ const parsed = JSON.parse((0, import_node_fs8.readFileSync)(path, "utf8"));
4391
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
4392
+ return parsed;
4393
+ }
4394
+ } catch {
4395
+ }
4396
+ return {};
4397
+ }
4398
+ function encryptTokensFileInPlace(configPath, box) {
4399
+ const tokensPath = defaultTokensPath(configPath);
4400
+ if (!(0, import_node_fs8.existsSync)(tokensPath)) return;
4401
+ const plain = decryptTokensFile(tokensPath, box);
4402
+ writeTokensEncrypted(tokensPath, plain, box);
4403
+ }
4404
+ function decryptTokensFile(tokensPath, box) {
4405
+ const raw = readRawJson(tokensPath);
4406
+ return walkTokens(raw, (v) => box.decryptMaybe(v));
4407
+ }
4408
+ function writeTokensEncrypted(tokensPath, plain, box) {
4409
+ const encrypted = encryptTokens(
4410
+ { updatedAt: "", ...plain },
4411
+ box
4412
+ );
4413
+ (0, import_node_fs8.writeFileSync)(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
4414
+ }
4415
+ var TOKEN_FIELDS2 = {
4416
+ claude: ["accessToken", "refreshToken"],
4417
+ codex: ["accessToken", "refreshToken", "idToken"],
4418
+ gemini: ["accessToken", "refreshToken"],
4419
+ opencodego: ["apiKey"]
4420
+ };
4421
+ function walkTokens(raw, fn) {
4422
+ const next = { ...raw };
4423
+ for (const [provider, fields] of Object.entries(TOKEN_FIELDS2)) {
4424
+ const block = next[provider];
4425
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
4426
+ const nextBlock = { ...block };
4427
+ for (const field of fields) {
4428
+ const value = nextBlock[field];
4429
+ if (typeof value === "string" && value.length > 0) nextBlock[field] = fn(value);
4430
+ }
4431
+ next[provider] = nextBlock;
4432
+ }
4433
+ return next;
4434
+ }
4435
+ function tokensSuffix(configPath) {
4436
+ return (0, import_node_fs8.existsSync)(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
4437
+ }
4438
+
4439
+ // src/commands/start.ts
4440
+ var import_node_util7 = require("util");
4441
+ var import_outbound_api5 = require("@omnicross/core/outbound-api");
4442
+ async function runStart(argv) {
4443
+ const { values } = (0, import_node_util7.parseArgs)({
4444
+ args: argv,
4445
+ options: {
4446
+ config: { type: "string", short: "c" },
4447
+ "no-dashboard": { type: "boolean" },
4448
+ "master-key-file": { type: "string" }
4449
+ },
4450
+ allowPositionals: false
4451
+ });
4452
+ const configPath = values.config;
4453
+ if (!configPath) {
4454
+ throw new Error("start: --config <path> is required");
4455
+ }
4456
+ const config = loadConfig(configPath);
4457
+ const paths = {
4458
+ configPath,
4459
+ keysPath: defaultKeysPath(configPath),
4460
+ tokensPath: defaultTokensPath(configPath),
4461
+ masterKeyFilePath: values["master-key-file"]
4462
+ };
4463
+ const daemon = buildDaemon(config, paths);
4464
+ await daemon.llmConfig.ready();
4465
+ await daemon.providerProxy.start();
4466
+ const serverConfig = await (0, import_outbound_api5.loadServerConfig)(daemon.settingsStore);
4467
+ await daemon.outboundApiServer.applyConfig({
4468
+ enabled: true,
4469
+ networkBinding: serverConfig.networkBinding,
4470
+ endpoints: serverConfig.endpoints,
4471
+ port: serverConfig.port
4472
+ });
4473
+ let dashboardUrl = null;
4474
+ if (!values["no-dashboard"]) {
4475
+ await daemon.adminServer.start();
4476
+ dashboardUrl = daemon.adminServer.getStatus().url;
4477
+ }
4478
+ const status = daemon.outboundApiServer.getStatus();
4479
+ console.info("omnicross daemon is running.");
4480
+ if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);
4481
+ console.info(` loopback : ${status.loopbackUrl ?? "(not bound)"}`);
4482
+ if (status.lanUrl) console.info(` lan : ${status.lanUrl}`);
4483
+ if (status.formats) {
4484
+ console.info(" endpoints:");
4485
+ console.info(` chat ${status.formats.chat}`);
4486
+ console.info(` responses ${status.formats.responses}`);
4487
+ console.info(` gemini ${status.formats.gemini}`);
4488
+ console.info(` messages ${status.formats.messages}`);
4489
+ }
4490
+ console.info(" responses subscription routing: enabled (drop tokens.json + useSubscription:true)");
4491
+ console.info(" (deferred: /v1/messages subscription, Gemini paid Code-Assist tier)");
4492
+ if (dashboardUrl) {
4493
+ console.info(" dashboard: localhost-default; --no-dashboard to disable; admin.token for a bearer gate");
4494
+ }
4495
+ console.info("Press Ctrl+C to stop.");
4496
+ }
4497
+
4498
+ // src/cli.ts
4499
+ var USAGE = `omnicross \u2014 standalone @omnicross/core daemon
4500
+
4501
+ Usage:
4502
+ omnicross start --config <path> Boot the daemon (BYO-key serving).
4503
+ omnicross keys add <name> --config <p> Mint a named API key (shown once).
4504
+ omnicross keys list --config <p> List stored keys (no secrets).
4505
+ omnicross keys revoke <id> --config <p> Revoke a key.
4506
+ omnicross providers presets --config <p> List curated presets (mappable + excluded).
4507
+ omnicross providers add <presetId> --key <k|$ENV> --config <p> [--id <id>] [--base-url <url>]
4508
+ Add a provider row from a preset (+ your key).
4509
+ omnicross providers keys <providerId> --config <p> List a provider's key pool (masked).
4510
+ omnicross providers add-key <providerId> --key <k|$ENV> --config <p> [--label <l>] [--weight <n>]
4511
+ Append a pool key (offline; not hot-reloaded).
4512
+ omnicross providers rm-key <providerId> <keyId> --config <p> Remove a pool key (offline).
4513
+ omnicross login <provider> --config <p> Browser OAuth login (claude|codex|gemini); stores tokens encrypted.
4514
+ omnicross launch <cli> --provider <id> --model <m> --config <p> [--cwd <dir>] [-- <cli-args\u2026>]
4515
+ Launch a Code CLI (claude|codex|gemini|qwen|copilot|opencode)
4516
+ against an in-process proxy (route-token auth; BYO).
4517
+ omnicross import-ccr <ccr.json> [--out <p>] Translate a CCR config.
4518
+ omnicross secrets encrypt --config <p> Encrypt all at-rest secrets in place.
4519
+ omnicross secrets status --config <p> Report each secret field (no values shown).
4520
+ omnicross secrets rotate --config <p> --new-master-key-file <p> Re-seal under a new master key.
4521
+ `;
4522
+ async function main() {
4523
+ const [, , subcommand, ...rest] = process.argv;
4524
+ switch (subcommand) {
4525
+ case "start":
4526
+ await runStart(rest);
4527
+ return;
4528
+ case "keys":
4529
+ await runKeys(rest);
4530
+ return;
4531
+ case "providers":
4532
+ await runProviders(rest);
4533
+ return;
4534
+ case "login":
4535
+ await runLogin(rest);
4536
+ return;
4537
+ case "launch":
4538
+ process.exitCode = await runLaunch(rest);
4539
+ return;
4540
+ case "import-ccr":
4541
+ await runImportCcr(rest);
4542
+ return;
4543
+ case "secrets":
4544
+ await runSecrets(rest);
4545
+ return;
4546
+ case void 0:
4547
+ case "-h":
4548
+ case "--help":
4549
+ case "help":
4550
+ console.info(USAGE);
4551
+ return;
4552
+ default:
4553
+ console.error(`Unknown command: ${subcommand}
4554
+ `);
4555
+ console.error(USAGE);
4556
+ process.exitCode = 1;
4557
+ }
4558
+ }
4559
+ main().catch((err4) => {
4560
+ console.error(err4 instanceof Error ? err4.message : String(err4));
4561
+ process.exitCode = 1;
4562
+ });