@velum-labs/routekit-daemon 0.9.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/index.js ADDED
@@ -0,0 +1,1190 @@
1
+ /**
2
+ * Singleton RouteKit daemon.
3
+ *
4
+ * One process owns a private authenticated control listener and one stable
5
+ * model-gateway front door. Router generations run on ephemeral loopback
6
+ * ports behind that front door; config/account reload builds a complete new
7
+ * generation before atomically switching new traffic and draining the old.
8
+ */
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { CLIPROXY_API_KEY_ENV, CLIPROXY_BASE_URL_ENV, accountStoreEntries, cliproxyAuthDirectory, cliproxyAccountEntries, cliproxyAccountMatchesKind, cliproxyApiKey, cliproxyBaseUrl, cliproxyCredentialValid, defaultSubscriptionAccountDirectory, removeCliproxyAccount, removeSubscriptionAccount, sanitizeSubscriptionLabel } from "@velum-labs/routekit-accounts";
12
+ import { configuredProviderIds, globalRouterConfigPath, parseRouterConfigDocument, routekitHome, writeRouterConfig } from "@velum-labs/routekit-config";
13
+ import { createRouteKitControlHandler, ROUTEKIT_CONTROL_CAPABILITY } from "@velum-labs/routekit-control";
14
+ import { startSwitchingGatewayProxy } from "@velum-labs/routekit-gateway";
15
+ import { PROVIDERS, accountKindForCliproxyAuthType, resolveAccountConnector } from "@velum-labs/routekit-registry";
16
+ import { startRouter } from "@velum-labs/routekit-router";
17
+ import { acquireLifecycleLock, CONTROL_PROTOCOL_VERSION, ControlClient, ControlError, createPortlessSession, createServiceRecordStore, extendCleanupGrace, generateControlToken, nextServiceGeneration, processIdentity, registerCleanup, startControlServer, supervisorFromEnv, writeFileAtomic } from "@velum-labs/routekit-runtime";
18
+ import { createConsentManager } from "@velum-labs/routekit-telemetry-core";
19
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
20
+ import { createCliproxySidecar } from "./cliproxy-sidecar.js";
21
+ import { cleanupAccountTransaction, markAccountTransactionCommitted, prepareAccountTransaction, recoverAccountTransactions, rollbackAccountTransaction } from "./account-transaction.js";
22
+ import { CallAttributionStore } from "./call-attribution-store.js";
23
+ export const ROUTEKIT_DAEMON_KIND = "daemon";
24
+ export const ROUTEKIT_PRODUCT = "routekit";
25
+ function dataTokenPath(home) {
26
+ return join(home, "secrets", "data-token");
27
+ }
28
+ function redactedProcessArgs(args) {
29
+ const result = [];
30
+ for (let index = 0; index < args.length; index += 1) {
31
+ const value = args[index];
32
+ if (value === "--auth-token") {
33
+ index += 1;
34
+ result.push("--auth-token", "[REDACTED]");
35
+ }
36
+ else if (value.startsWith("--auth-token=")) {
37
+ result.push("--auth-token=[REDACTED]");
38
+ }
39
+ else {
40
+ result.push(value);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ function resolveDataToken(home, input) {
46
+ const path = input.authTokenFile ?? dataTokenPath(home);
47
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
48
+ const token = input.authToken ??
49
+ (existsSync(path) ? readFileSync(path, "utf8").trim() : generateControlToken());
50
+ if (token.length === 0)
51
+ throw new Error("RouteKit data-plane token is empty");
52
+ writeFileAtomic(path, `${token}\n`, { mode: 0o600 });
53
+ chmodSync(path, 0o600);
54
+ return { token, path };
55
+ }
56
+ function revisionPath(home) {
57
+ return join(home, "daemon-revisions.json");
58
+ }
59
+ function readRevisions(home) {
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(revisionPath(home), "utf8"));
62
+ return {
63
+ config: typeof parsed.config === "number" && Number.isSafeInteger(parsed.config)
64
+ ? parsed.config
65
+ : 0,
66
+ accounts: typeof parsed.accounts === "number" && Number.isSafeInteger(parsed.accounts)
67
+ ? parsed.accounts
68
+ : 0,
69
+ daemon: typeof parsed.daemon === "number" && Number.isSafeInteger(parsed.daemon)
70
+ ? parsed.daemon
71
+ : 0
72
+ };
73
+ }
74
+ catch {
75
+ return { config: 0, accounts: 0, daemon: 0 };
76
+ }
77
+ }
78
+ function writeRevisions(home, revisions) {
79
+ mkdirSync(home, { recursive: true, mode: 0o700 });
80
+ writeFileAtomic(revisionPath(home), `${JSON.stringify(revisions, null, 2)}\n`, {
81
+ mode: 0o600
82
+ });
83
+ chmodSync(revisionPath(home), 0o600);
84
+ }
85
+ function writeSnapshot(home, category, name, value) {
86
+ const directory = join(home, category);
87
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
88
+ const path = join(directory, `${name}.json`);
89
+ writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
90
+ chmodSync(path, 0o600);
91
+ }
92
+ function canonicalConfigDocument(path) {
93
+ if (!existsSync(path)) {
94
+ throw new ControlError({
95
+ code: "unavailable",
96
+ message: `canonical router config not found: ${path}; run ` +
97
+ "`routekit config init` or `routekit config import --from <path>`"
98
+ });
99
+ }
100
+ return readFileSync(path, "utf8");
101
+ }
102
+ function parseConfigDocument(document) {
103
+ try {
104
+ return parseRouterConfigDocument(document, "daemon config update");
105
+ }
106
+ catch (error) {
107
+ throw new ControlError({
108
+ code: "bad_request",
109
+ message: error instanceof Error ? error.message : String(error)
110
+ });
111
+ }
112
+ }
113
+ function revisionConflict(expected, actual) {
114
+ throw new ControlError({
115
+ code: "conflict",
116
+ message: `revision conflict: expected ${expected}, current ${actual}`,
117
+ details: { expected, actual }
118
+ });
119
+ }
120
+ function accountEntries(env) {
121
+ return accountStoreEntries(env).map(({ path: _path, ...entry }) => entry);
122
+ }
123
+ function providerCredentialAvailable(provider, accounts, env) {
124
+ if (provider === "claude-code" || provider === "codex") {
125
+ return accounts.some((entry) => entry.subscriptionKind === provider);
126
+ }
127
+ if (provider === "cliproxy") {
128
+ return ((env[CLIPROXY_API_KEY_ENV] ?? "").length > 0 || cliproxyApiKey(env) !== undefined);
129
+ }
130
+ const info = PROVIDERS[provider];
131
+ if (info?.keyEnv === undefined)
132
+ return true;
133
+ return (env[info.keyEnv] ?? "").length > 0;
134
+ }
135
+ function safeCredentialBlob(kind, value) {
136
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
137
+ throw new ControlError({ code: "bad_request", message: "credential must be an object" });
138
+ }
139
+ const record = structuredClone(value);
140
+ const valid = kind === "claude-code"
141
+ ? typeof record.claudeAiOauth?.accessToken ===
142
+ "string"
143
+ : typeof record.tokens?.access_token === "string" ||
144
+ typeof record.access_token === "string";
145
+ if (!valid) {
146
+ throw new ControlError({
147
+ code: "bad_request",
148
+ message: `credential does not have the expected ${kind} token shape`
149
+ });
150
+ }
151
+ return record;
152
+ }
153
+ function safeCliproxyCredentialBlob(kind, value) {
154
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
155
+ throw new ControlError({ code: "bad_request", message: "credential must be an object" });
156
+ }
157
+ const record = structuredClone(value);
158
+ const type = typeof record.type === "string" ? record.type : undefined;
159
+ const classified = type === undefined
160
+ ? undefined
161
+ : accountKindForCliproxyAuthType(type) ?? resolveAccountConnector(type)?.kind;
162
+ if (classified !== kind || !cliproxyCredentialValid(record, type)) {
163
+ throw new ControlError({
164
+ code: "bad_request",
165
+ message: `credential does not have the expected ${kind} connector shape`
166
+ });
167
+ }
168
+ return record;
169
+ }
170
+ function safeCliproxyLabel(label) {
171
+ if (label.length === 0 ||
172
+ label.startsWith(".") ||
173
+ basename(label) !== label ||
174
+ label.includes("\\")) {
175
+ throw new ControlError({
176
+ code: "bad_request",
177
+ message: "connector account label is not path-safe"
178
+ });
179
+ }
180
+ return label;
181
+ }
182
+ async function healthyControl(record) {
183
+ if (record.controlToken === undefined)
184
+ return false;
185
+ try {
186
+ const client = new ControlClient({
187
+ url: record.url,
188
+ token: record.controlToken,
189
+ timeoutMs: 1_000
190
+ });
191
+ const health = await client.health();
192
+ return health.protocol === CONTROL_PROTOCOL_VERSION;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ export async function startRouteKitDaemon(options) {
199
+ const env = options.env ?? process.env;
200
+ const home = options.stateHome ?? routekitHome(env);
201
+ const configPath = options.configPath ?? globalRouterConfigPath();
202
+ const drainGraceMs = options.drainGraceMs ?? 30_000;
203
+ const dataAuth = resolveDataToken(home, options);
204
+ const store = createServiceRecordStore({ home, product: ROUTEKIT_PRODUCT });
205
+ // Held for the daemon's whole lifetime. Lifecycle clients use daemon.lock
206
+ // while this authority lock prevents any second daemon from becoming live.
207
+ const authority = await acquireLifecycleLock(join(store.directory, "daemon-authority.lock"), {
208
+ timeoutMs: 30_000,
209
+ onWait: async () => {
210
+ const existing = store.read(ROUTEKIT_DAEMON_KIND);
211
+ return existing !== undefined && (await healthyControl(existing))
212
+ ? new ControlError({
213
+ code: "conflict",
214
+ message: `RouteKit daemon is already running (pid ${existing.pid})`
215
+ })
216
+ : undefined;
217
+ }
218
+ });
219
+ let accountRecovery;
220
+ try {
221
+ accountRecovery = recoverAccountTransactions(home);
222
+ }
223
+ catch (error) {
224
+ authority.release();
225
+ throw error;
226
+ }
227
+ let control;
228
+ let proxy;
229
+ let portless;
230
+ let sidecarRef;
231
+ let activeRouter;
232
+ let record;
233
+ let closed = false;
234
+ let draining = false;
235
+ let lifecycle = "running";
236
+ let revisions = readRevisions(home);
237
+ let currentDocument = canonicalConfigDocument(configPath);
238
+ let currentConfig = parseConfigDocument(currentDocument);
239
+ let mutationTail = Promise.resolve();
240
+ const serializeMutation = async (operation) => {
241
+ if (lifecycle !== "running") {
242
+ throw new ControlError({
243
+ code: "unavailable",
244
+ message: "RouteKit daemon is shutting down"
245
+ });
246
+ }
247
+ const result = mutationTail.then(operation);
248
+ mutationTail = result.then(() => undefined, () => undefined);
249
+ return await result;
250
+ };
251
+ const startedAt = new Date().toISOString();
252
+ try {
253
+ const previous = store.read(ROUTEKIT_DAEMON_KIND);
254
+ if (previous !== undefined &&
255
+ previous.pid !== process.pid &&
256
+ (await healthyControl(previous))) {
257
+ throw new ControlError({
258
+ code: "conflict",
259
+ message: `RouteKit daemon is already running (pid ${previous.pid})`
260
+ });
261
+ }
262
+ if (previous !== undefined && previous.pid !== process.pid) {
263
+ // A live-but-unhealthy daemon is not safe to replace under its feet.
264
+ throw new ControlError({
265
+ code: "unavailable",
266
+ message: `RouteKit daemon pid ${previous.pid} is alive but its control plane is unhealthy; stop it before recovery`
267
+ });
268
+ }
269
+ const generation = nextServiceGeneration(Math.max(previous?.generation ?? 0, revisions.daemon));
270
+ revisions.daemon = generation;
271
+ writeRevisions(home, revisions);
272
+ const sidecar = createCliproxySidecar({ env });
273
+ sidecarRef = sidecar;
274
+ const callAttributions = new CallAttributionStore();
275
+ const wantsCliproxySidecar = (config) => config.providers["cliproxy"] !== undefined;
276
+ // Router generations reach the managed sidecar with its own ingress key
277
+ // and configured listen address; resolved per generation so state created
278
+ // by the first login (key, config) is seen without a daemon restart.
279
+ const routerEnv = () => {
280
+ const injected = { ...env };
281
+ if ((env[CLIPROXY_API_KEY_ENV] ?? "").length === 0) {
282
+ const key = cliproxyApiKey(env);
283
+ if (key !== undefined)
284
+ injected[CLIPROXY_API_KEY_ENV] = key;
285
+ }
286
+ if ((env[CLIPROXY_BASE_URL_ENV] ?? "").length === 0) {
287
+ injected[CLIPROXY_BASE_URL_ENV] = cliproxyBaseUrl(env);
288
+ }
289
+ return injected;
290
+ };
291
+ const startGeneration = async (config) => await startRouter({
292
+ config,
293
+ host: "127.0.0.1",
294
+ port: 0,
295
+ env: routerEnv(),
296
+ provenance: callAttributions,
297
+ drainGraceMs
298
+ });
299
+ await sidecar.reconcile(wantsCliproxySidecar(currentConfig));
300
+ activeRouter = await startGeneration(currentConfig);
301
+ proxy = await startSwitchingGatewayProxy({
302
+ target: activeRouter.url,
303
+ host: options.host ?? "127.0.0.1",
304
+ port: options.port ?? 8080,
305
+ authToken: dataAuth.token
306
+ });
307
+ portless = await createPortlessSession(options.portless ?? env.ROUTEKIT_PORTLESS !== "0", { project: "routekit", ownerLabel: "routekit-daemon", bareNames: [] });
308
+ const dataUrl = portless.enabled
309
+ ? portless.register("gateway", proxy.port())
310
+ : proxy.url();
311
+ const replaceRouter = async (nextConfig, nextDocument, input) => {
312
+ // Sidecar reconcile runs before the generation commits; any failure
313
+ // below must put the sidecar back to the still-live currentConfig.
314
+ let candidate;
315
+ try {
316
+ await sidecar.reconcile(wantsCliproxySidecar(nextConfig));
317
+ candidate = await startGeneration(nextConfig);
318
+ }
319
+ catch (error) {
320
+ try {
321
+ await sidecar.reconcile(wantsCliproxySidecar(currentConfig));
322
+ }
323
+ catch {
324
+ // Best-effort rollback; surface the original mutation failure.
325
+ }
326
+ throw error;
327
+ }
328
+ const previousDocument = currentDocument;
329
+ const previousRevisions = { ...revisions };
330
+ const nextRevisions = { ...revisions };
331
+ if (input.configRevision === true)
332
+ nextRevisions.config += 1;
333
+ if (input.accountRevision === true)
334
+ nextRevisions.accounts += 1;
335
+ try {
336
+ if (input.write)
337
+ writeRouterConfig(configPath, nextConfig);
338
+ writeRevisions(home, nextRevisions);
339
+ await input.beforeSwap?.();
340
+ }
341
+ catch (error) {
342
+ if (input.write) {
343
+ writeFileAtomic(configPath, previousDocument, { mode: 0o600 });
344
+ chmodSync(configPath, 0o600);
345
+ }
346
+ revisions = previousRevisions;
347
+ writeRevisions(home, previousRevisions);
348
+ await candidate.close();
349
+ await sidecar.reconcile(wantsCliproxySidecar(currentConfig));
350
+ throw error;
351
+ }
352
+ const previousRouter = activeRouter;
353
+ // From this point the mutation is committed. `swapTarget` is synchronous
354
+ // and non-throwing; retirement failures must never close the candidate.
355
+ const previousTarget = proxy?.swapTarget(candidate.url);
356
+ activeRouter = candidate;
357
+ currentConfig = nextConfig;
358
+ currentDocument = input.write ? readFileSync(configPath, "utf8") : nextDocument;
359
+ revisions = nextRevisions;
360
+ if (previousRouter !== undefined) {
361
+ try {
362
+ if (previousTarget !== undefined) {
363
+ await proxy?.waitForTargetIdle(previousTarget, drainGraceMs);
364
+ }
365
+ await previousRouter.gateway.drain(drainGraceMs);
366
+ await previousRouter.close();
367
+ }
368
+ catch (error) {
369
+ process.stderr.write(`routekit retired router cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
370
+ }
371
+ }
372
+ };
373
+ const configSnapshot = () => ({
374
+ path: configPath,
375
+ document: currentDocument,
376
+ revision: revisions.config,
377
+ sources: ["global"]
378
+ });
379
+ let handlers;
380
+ const telemetry = createConsentManager({
381
+ path: () => join(home, "telemetry.json"),
382
+ environmentVariable: "ROUTEKIT_TELEMETRY"
383
+ });
384
+ handlers = {
385
+ "daemon.status": async () => ({
386
+ pid: process.pid,
387
+ startedAt,
388
+ packageVersion: options.packageVersion,
389
+ protocolVersion: CONTROL_PROTOCOL_VERSION,
390
+ generation,
391
+ configRevision: revisions.config,
392
+ accountRevision: revisions.accounts,
393
+ controlUrl: control?.url ?? "",
394
+ dataUrl,
395
+ dataPort: proxy?.port() ?? 0,
396
+ supervisor: supervisorFromEnv(env),
397
+ draining
398
+ }),
399
+ "daemon.reload": async (params) => {
400
+ await serializeMutation(async () => {
401
+ if (params.expectedRevision !== undefined &&
402
+ params.expectedRevision !== revisions.config) {
403
+ revisionConflict(params.expectedRevision, revisions.config);
404
+ }
405
+ const document = canonicalConfigDocument(configPath);
406
+ await replaceRouter(parseConfigDocument(document), document, {
407
+ write: false,
408
+ configRevision: true
409
+ });
410
+ });
411
+ return {
412
+ reloaded: true,
413
+ configRevision: revisions.config,
414
+ accountRevision: revisions.accounts
415
+ };
416
+ },
417
+ "daemon.prepareShutdown": async (params) => {
418
+ if (lifecycle !== "running")
419
+ return { accepted: true };
420
+ lifecycle = "quiescing";
421
+ draining = true;
422
+ await mutationTail;
423
+ queueMicrotask(() => options.onShutdownRequested?.(params.reason));
424
+ return { accepted: true };
425
+ },
426
+ "config.get": async () => configSnapshot(),
427
+ "config.update": async (params) => {
428
+ await serializeMutation(async () => {
429
+ if (params.expectedRevision !== revisions.config) {
430
+ revisionConflict(params.expectedRevision, revisions.config);
431
+ }
432
+ const next = parseConfigDocument(params.document);
433
+ await replaceRouter(next, params.document, {
434
+ write: true,
435
+ configRevision: true
436
+ });
437
+ });
438
+ return configSnapshot();
439
+ },
440
+ "config.import": async (params) => {
441
+ await serializeMutation(async () => {
442
+ if (params.expectedRevision !== revisions.config) {
443
+ revisionConflict(params.expectedRevision, revisions.config);
444
+ }
445
+ const next = parseConfigDocument(params.document);
446
+ await replaceRouter(next, params.document, {
447
+ write: true,
448
+ configRevision: true
449
+ });
450
+ });
451
+ return configSnapshot();
452
+ },
453
+ "providers.status": async (_params, context) => {
454
+ const accounts = accountEntries(env);
455
+ const live = await activeRouter.providerStatuses(context.signal);
456
+ const result = {
457
+ providers: configuredProviderIds(currentConfig).map((provider) => {
458
+ const status = live.find((entry) => entry.provider === provider);
459
+ return {
460
+ provider,
461
+ configured: true,
462
+ credentialAvailable: providerCredentialAvailable(provider, accounts, env),
463
+ models: status?.models ?? [],
464
+ ...(status?.error !== undefined ? { error: status.error } : {})
465
+ };
466
+ })
467
+ };
468
+ writeSnapshot(home, "health", "providers", {
469
+ checkedAt: new Date().toISOString(),
470
+ providers: result.providers
471
+ });
472
+ return result;
473
+ },
474
+ "providers.set": async (params) => {
475
+ await serializeMutation(async () => {
476
+ const raw = parseYaml(currentDocument);
477
+ const providers = typeof raw.providers === "object" &&
478
+ raw.providers !== null &&
479
+ !Array.isArray(raw.providers)
480
+ ? { ...raw.providers }
481
+ : {};
482
+ if (params.enabled)
483
+ providers[params.provider] ??= {};
484
+ else
485
+ delete providers[params.provider];
486
+ raw.providers = providers;
487
+ const document = stringifyYaml(raw);
488
+ await replaceRouter(parseConfigDocument(document), document, {
489
+ write: true,
490
+ configRevision: true
491
+ });
492
+ });
493
+ return configSnapshot();
494
+ },
495
+ "models.list": async (params) => {
496
+ // Self-call over the loopback listener: the public dataUrl may be a
497
+ // portless HTTPS route whose local CA Node does not trust.
498
+ const response = await fetch(`${proxy.url()}/v1/models`, {
499
+ headers: { authorization: `Bearer ${dataAuth.token}` }
500
+ });
501
+ if (!response.ok) {
502
+ throw new ControlError({
503
+ code: "unavailable",
504
+ message: `gateway model discovery failed (${response.status})`
505
+ });
506
+ }
507
+ const body = (await response.json());
508
+ const models = (body.data ?? []).filter((model) => params.provider === undefined || model.id.startsWith(`${params.provider}/`));
509
+ const result = {
510
+ models,
511
+ ...(currentConfig.defaultModel !== undefined
512
+ ? { defaultModel: currentConfig.defaultModel }
513
+ : {}),
514
+ revision: revisions.config
515
+ };
516
+ writeSnapshot(home, "catalog", "models", {
517
+ updatedAt: new Date().toISOString(),
518
+ defaultModel: result.defaultModel,
519
+ models
520
+ });
521
+ return result;
522
+ },
523
+ "models.info": async (params) => {
524
+ const model = activeRouter.modelInfo(params.model);
525
+ if (model === undefined) {
526
+ throw new ControlError({
527
+ code: "not_found",
528
+ message: `unknown model: ${params.model}`
529
+ });
530
+ }
531
+ return {
532
+ ...model,
533
+ capabilities: { ...model.capabilities },
534
+ reasoning: model.reasoning === null ? null : { ...model.reasoning }
535
+ };
536
+ },
537
+ "calls.inspect": async (params) => {
538
+ const inspection = callAttributions.get(params.callId);
539
+ if (inspection === undefined) {
540
+ throw new ControlError({
541
+ code: "not_found",
542
+ message: `unknown or expired model call: ${params.callId}`
543
+ });
544
+ }
545
+ return inspection;
546
+ },
547
+ "accounts.list": async () => ({
548
+ accounts: accountEntries(env).map((entry) => {
549
+ if (entry.connector === "native")
550
+ return entry;
551
+ const { credentialValid: _credentialValid, ...listed } = entry;
552
+ return listed;
553
+ }),
554
+ revision: revisions.accounts
555
+ }),
556
+ "accounts.status": async () => {
557
+ const entries = accountEntries(env);
558
+ const cliproxyConfigured = currentConfig.providers["cliproxy"] !== undefined;
559
+ const cliproxyReachable = entries.some((entry) => entry.connector === "cliproxy") && cliproxyConfigured
560
+ ? await sidecar.reachable()
561
+ : false;
562
+ return {
563
+ accounts: entries.map((entry) => {
564
+ if (entry.connector === "cliproxy") {
565
+ return {
566
+ subscriptionKind: entry.subscriptionKind,
567
+ label: entry.label,
568
+ connector: entry.connector,
569
+ ...(entry.localOnly === true ? { localOnly: true } : {}),
570
+ credentialValid: entry.credentialValid,
571
+ configured: cliproxyConfigured,
572
+ relayOpen: entry.credentialValid && cliproxyConfigured && cliproxyReachable,
573
+ active: entry.credentialValid && cliproxyConfigured && cliproxyReachable,
574
+ models: []
575
+ };
576
+ }
577
+ const member = activeRouter
578
+ .accountSnapshots()
579
+ .find((snapshot) => snapshot.mode === entry.subscriptionKind)
580
+ ?.members.find((candidate) => candidate.label === entry.label);
581
+ return {
582
+ subscriptionKind: entry.subscriptionKind,
583
+ label: entry.label,
584
+ connector: entry.connector,
585
+ credentialValid: member?.credentialValid ?? false,
586
+ configured: currentConfig.providers[entry.subscriptionKind] !== undefined,
587
+ relayOpen: member?.relayReady === true &&
588
+ currentConfig.providers[entry.subscriptionKind] !== undefined,
589
+ active: member?.active ?? false,
590
+ models: member?.models ?? [],
591
+ ...(member?.limits !== undefined ? { limits: member.limits } : {})
592
+ };
593
+ }),
594
+ revision: revisions.accounts,
595
+ recovery: {
596
+ state: accountRecovery.recovered > 0 ? "recovered" : "clean",
597
+ recovered: accountRecovery.recovered,
598
+ cleaned: accountRecovery.cleaned
599
+ }
600
+ };
601
+ },
602
+ "accounts.enroll": async (params) => {
603
+ await serializeMutation(async () => {
604
+ const label = sanitizeSubscriptionLabel(params.label);
605
+ if (label !== params.label || label.startsWith(".")) {
606
+ throw new ControlError({
607
+ code: "bad_request",
608
+ message: "account label must already be normalized"
609
+ });
610
+ }
611
+ const directory = defaultSubscriptionAccountDirectory(params.kind, env);
612
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
613
+ const path = join(directory, `${label}.json`);
614
+ if (existsSync(path)) {
615
+ throw new ControlError({
616
+ code: "conflict",
617
+ message: `${params.kind}/${label} is already enrolled; remove it before enrolling again`
618
+ });
619
+ }
620
+ const previous = existsSync(path) ? readFileSync(path) : undefined;
621
+ writeFileAtomic(path, `${JSON.stringify(safeCredentialBlob(params.kind, params.credential), null, 2)}\n`, { mode: 0o600 });
622
+ chmodSync(path, 0o600);
623
+ try {
624
+ await replaceRouter(currentConfig, currentDocument, {
625
+ write: false,
626
+ accountRevision: true
627
+ });
628
+ }
629
+ catch (error) {
630
+ if (previous === undefined)
631
+ rmSync(path, { force: true });
632
+ else {
633
+ writeFileAtomic(path, previous.toString("utf8"), { mode: 0o600 });
634
+ chmodSync(path, 0o600);
635
+ }
636
+ throw error;
637
+ }
638
+ });
639
+ return { enrolled: true, revision: revisions.accounts };
640
+ },
641
+ "accounts.enrollActivate": async (params) => {
642
+ const resolved = resolveAccountConnector(params.kind);
643
+ if (resolved === undefined) {
644
+ throw new ControlError({
645
+ code: "bad_request",
646
+ message: `unknown subscription kind: ${params.kind}`
647
+ });
648
+ }
649
+ const kind = resolved.kind;
650
+ const connector = resolved.info.connector;
651
+ const provider = connector === "cliproxy" ? "cliproxy" : kind;
652
+ const seenLabels = new Set();
653
+ const prepared = params.accounts.map((account) => {
654
+ const label = connector === "native"
655
+ ? sanitizeSubscriptionLabel(account.label)
656
+ : safeCliproxyLabel(account.label);
657
+ if (label !== account.label ||
658
+ (connector === "native" && label.startsWith("."))) {
659
+ throw new ControlError({
660
+ code: "bad_request",
661
+ message: "account label must already be normalized"
662
+ });
663
+ }
664
+ if (seenLabels.has(label)) {
665
+ throw new ControlError({
666
+ code: "bad_request",
667
+ message: `duplicate account label: ${label}`
668
+ });
669
+ }
670
+ seenLabels.add(label);
671
+ const directory = connector === "native"
672
+ ? defaultSubscriptionAccountDirectory(kind, env)
673
+ : cliproxyAuthDirectory(env);
674
+ const path = join(directory, `${label}.json`);
675
+ let credential = account.credential;
676
+ if (credential === undefined) {
677
+ if (!existsSync(path)) {
678
+ throw new ControlError({
679
+ code: "not_found",
680
+ message: `${kind}/${label} is not enrolled`
681
+ });
682
+ }
683
+ try {
684
+ credential = JSON.parse(readFileSync(path, "utf8"));
685
+ }
686
+ catch {
687
+ throw new ControlError({
688
+ code: "bad_request",
689
+ message: `${kind}/${label} has an invalid stored credential`
690
+ });
691
+ }
692
+ }
693
+ const blob = connector === "native"
694
+ ? safeCredentialBlob(kind, credential)
695
+ : safeCliproxyCredentialBlob(kind, credential);
696
+ const content = `${JSON.stringify(blob, null, 2)}\n`;
697
+ if (connector === "native" &&
698
+ account.credential !== undefined &&
699
+ existsSync(path) &&
700
+ readFileSync(path, "utf8") !== content) {
701
+ throw new ControlError({
702
+ code: "conflict",
703
+ message: `${kind}/${label} is already enrolled with different credentials`
704
+ });
705
+ }
706
+ return {
707
+ label,
708
+ directory,
709
+ path,
710
+ content,
711
+ credentialProvided: account.credential !== undefined
712
+ };
713
+ });
714
+ await serializeMutation(async () => {
715
+ for (const entry of prepared) {
716
+ if (!entry.credentialProvided) {
717
+ if (!existsSync(entry.path)) {
718
+ throw new ControlError({
719
+ code: "not_found",
720
+ message: `${kind}/${entry.label} is not enrolled`
721
+ });
722
+ }
723
+ let stored;
724
+ try {
725
+ stored = JSON.parse(readFileSync(entry.path, "utf8"));
726
+ }
727
+ catch {
728
+ throw new ControlError({
729
+ code: "bad_request",
730
+ message: `${kind}/${entry.label} has an invalid stored credential`
731
+ });
732
+ }
733
+ const blob = connector === "native"
734
+ ? safeCredentialBlob(kind, stored)
735
+ : safeCliproxyCredentialBlob(kind, stored);
736
+ entry.content = `${JSON.stringify(blob, null, 2)}\n`;
737
+ }
738
+ else if (connector === "native" &&
739
+ existsSync(entry.path) &&
740
+ readFileSync(entry.path, "utf8") !== entry.content) {
741
+ throw new ControlError({
742
+ code: "conflict",
743
+ message: `${kind}/${entry.label} is already enrolled with different credentials`
744
+ });
745
+ }
746
+ }
747
+ const unchanged = prepared.every((entry) => existsSync(entry.path) &&
748
+ readFileSync(entry.path, "utf8") === entry.content);
749
+ if (unchanged &&
750
+ currentConfig.providers[provider] !== undefined) {
751
+ return;
752
+ }
753
+ const raw = parseYaml(currentDocument);
754
+ const providers = typeof raw.providers === "object" &&
755
+ raw.providers !== null &&
756
+ !Array.isArray(raw.providers)
757
+ ? { ...raw.providers }
758
+ : {};
759
+ providers[provider] ??= {};
760
+ raw.providers = providers;
761
+ const nextDocument = stringifyYaml(raw);
762
+ const nextConfig = parseConfigDocument(nextDocument);
763
+ const previousDocument = currentDocument;
764
+ const previousConfig = currentConfig;
765
+ const transaction = prepareAccountTransaction({
766
+ home,
767
+ configPath,
768
+ accountPaths: prepared.map((entry) => entry.path),
769
+ accountRoots: prepared.map((entry) => entry.directory),
770
+ kind,
771
+ provider,
772
+ labels: prepared.map((entry) => entry.label)
773
+ });
774
+ options.onAccountTransactionPhase?.("prepared");
775
+ let routerReplaced = false;
776
+ try {
777
+ for (const entry of prepared) {
778
+ mkdirSync(entry.directory, { recursive: true, mode: 0o700 });
779
+ chmodSync(entry.directory, 0o700);
780
+ writeFileAtomic(entry.path, entry.content, { mode: 0o600 });
781
+ chmodSync(entry.path, 0o600);
782
+ }
783
+ options.onAccountTransactionPhase?.("credentials-written");
784
+ await replaceRouter(nextConfig, nextDocument, {
785
+ write: true,
786
+ configRevision: true,
787
+ accountRevision: true,
788
+ beforeSwap: async () => {
789
+ markAccountTransactionCommitted(transaction);
790
+ if (connector === "cliproxy")
791
+ await sidecar.refresh();
792
+ options.onAccountTransactionPhase?.("committed");
793
+ }
794
+ });
795
+ routerReplaced = true;
796
+ options.onAccountTransactionPhase?.("router-swapped");
797
+ try {
798
+ cleanupAccountTransaction(transaction);
799
+ }
800
+ catch {
801
+ // A committed manifest is cleanup-only on the next daemon start.
802
+ }
803
+ }
804
+ catch (error) {
805
+ const rollbackFailures = [];
806
+ try {
807
+ rollbackAccountTransaction(transaction, home);
808
+ }
809
+ catch (rollbackError) {
810
+ rollbackFailures.push(rollbackError);
811
+ }
812
+ if (connector === "cliproxy") {
813
+ try {
814
+ await sidecar.refresh();
815
+ }
816
+ catch (rollbackError) {
817
+ rollbackFailures.push(rollbackError);
818
+ }
819
+ }
820
+ if (routerReplaced) {
821
+ try {
822
+ await replaceRouter(previousConfig, previousDocument, {
823
+ write: false
824
+ });
825
+ }
826
+ catch (rollbackError) {
827
+ rollbackFailures.push(rollbackError);
828
+ }
829
+ }
830
+ if (rollbackFailures.length > 0) {
831
+ throw new AggregateError([error, ...rollbackFailures], `could not activate ${kind}; rollback failed`);
832
+ }
833
+ throw error;
834
+ }
835
+ });
836
+ return {
837
+ enrolled: prepared.map((entry) => ({
838
+ subscriptionKind: kind,
839
+ label: entry.label
840
+ })),
841
+ activated: true,
842
+ configPath,
843
+ configRevision: revisions.config,
844
+ accountRevision: revisions.accounts
845
+ };
846
+ },
847
+ "accounts.remove": async (params) => {
848
+ const resolved = resolveAccountConnector(params.kind);
849
+ const rawCliproxyEntry = resolved === undefined
850
+ ? cliproxyAccountEntries(env).find((entry) => entry.kind === params.kind && entry.label === params.label)
851
+ : undefined;
852
+ if (resolved === undefined && rawCliproxyEntry === undefined) {
853
+ throw new ControlError({
854
+ code: "bad_request",
855
+ message: `unknown subscription kind: ${params.kind}`
856
+ });
857
+ }
858
+ const kind = resolved?.kind ?? params.kind;
859
+ let removed = false;
860
+ await serializeMutation(async () => {
861
+ // Prefer the native account store when both connectors have a file
862
+ // for the same label (claude-code/codex). Fall back to the cliproxy
863
+ // store so legacy orphan auth files (type: claude|codex) and the
864
+ // gemini/grok/kimi kinds remain removable through one surface.
865
+ const nativeDirectory = resolved?.info.connector === "native"
866
+ ? defaultSubscriptionAccountDirectory(kind, env)
867
+ : undefined;
868
+ const nativePath = nativeDirectory !== undefined
869
+ ? join(nativeDirectory, `${params.label}.json`)
870
+ : undefined;
871
+ if (nativePath !== undefined && existsSync(nativePath)) {
872
+ const nativeKind = kind;
873
+ const activeNativeDirectory = dirname(nativePath);
874
+ const hasRemainingAccount = accountEntries(env).some((entry) => entry.connector === "native" &&
875
+ entry.subscriptionKind === nativeKind &&
876
+ entry.label !== params.label);
877
+ const raw = parseYaml(currentDocument);
878
+ const providers = typeof raw.providers === "object" &&
879
+ raw.providers !== null &&
880
+ !Array.isArray(raw.providers)
881
+ ? { ...raw.providers }
882
+ : {};
883
+ const disableProvider = !hasRemainingAccount &&
884
+ currentConfig.providers[nativeKind] !== undefined;
885
+ if (disableProvider) {
886
+ for (const providerKey of nativeKind === "claude-code"
887
+ ? ["claude-code", "claudeCode", "claude"]
888
+ : [nativeKind]) {
889
+ delete providers[providerKey];
890
+ }
891
+ raw.providers = providers;
892
+ if (typeof raw.defaultModel === "string" &&
893
+ raw.defaultModel.startsWith(`${nativeKind}/`)) {
894
+ delete raw.defaultModel;
895
+ }
896
+ }
897
+ const nextDocument = disableProvider
898
+ ? stringifyYaml(raw)
899
+ : currentDocument;
900
+ const nextConfig = disableProvider
901
+ ? parseConfigDocument(nextDocument)
902
+ : currentConfig;
903
+ const transaction = prepareAccountTransaction({
904
+ home,
905
+ configPath,
906
+ accountPaths: [nativePath],
907
+ accountRoots: [activeNativeDirectory],
908
+ kind: nativeKind,
909
+ provider: nativeKind,
910
+ labels: [params.label]
911
+ });
912
+ try {
913
+ const result = removeSubscriptionAccount(nativeKind, params.label, { accountsDirectory: activeNativeDirectory });
914
+ removed = result.removed;
915
+ if (!result.removed) {
916
+ cleanupAccountTransaction(transaction);
917
+ return;
918
+ }
919
+ await replaceRouter(nextConfig, nextDocument, {
920
+ write: disableProvider,
921
+ configRevision: disableProvider,
922
+ accountRevision: true,
923
+ beforeSwap: () => markAccountTransactionCommitted(transaction)
924
+ });
925
+ try {
926
+ cleanupAccountTransaction(transaction);
927
+ }
928
+ catch {
929
+ // A committed manifest is cleanup-only on the next daemon start.
930
+ }
931
+ }
932
+ catch (error) {
933
+ const rollbackFailures = [];
934
+ try {
935
+ rollbackAccountTransaction(transaction, home);
936
+ }
937
+ catch (rollbackError) {
938
+ rollbackFailures.push(rollbackError);
939
+ }
940
+ if (rollbackFailures.length > 0) {
941
+ throw new AggregateError([error, ...rollbackFailures], `could not remove ${kind}/${params.label}; rollback failed`);
942
+ }
943
+ throw error;
944
+ }
945
+ return;
946
+ }
947
+ const entry = cliproxyAccountEntries(env).find((candidate) => candidate.label === params.label &&
948
+ (resolved === undefined
949
+ ? candidate.kind === kind
950
+ : cliproxyAccountMatchesKind(candidate, kind)));
951
+ if (entry === undefined)
952
+ return;
953
+ const previous = readFileSync(entry.path);
954
+ const result = removeCliproxyAccount(params.label, env);
955
+ removed = result.removed;
956
+ if (!result.removed)
957
+ return;
958
+ try {
959
+ await sidecar.refresh();
960
+ await replaceRouter(currentConfig, currentDocument, {
961
+ write: false,
962
+ accountRevision: true
963
+ });
964
+ }
965
+ catch (error) {
966
+ writeFileAtomic(entry.path, previous.toString("utf8"), { mode: 0o600 });
967
+ chmodSync(entry.path, 0o600);
968
+ try {
969
+ await sidecar.refresh();
970
+ }
971
+ catch {
972
+ // Best-effort process rollback; preserve the mutation failure.
973
+ }
974
+ throw error;
975
+ }
976
+ });
977
+ return { removed, revision: revisions.accounts };
978
+ },
979
+ "accounts.sync": async () => {
980
+ // A connector login wrote new account state outside the control
981
+ // channel (the cliproxy auth store); rebuild the router generation and
982
+ // reconcile the managed sidecar against the rescanned stores.
983
+ await serializeMutation(async () => {
984
+ await sidecar.refresh();
985
+ await replaceRouter(currentConfig, currentDocument, {
986
+ write: false,
987
+ accountRevision: true
988
+ });
989
+ });
990
+ return { synced: true, revision: revisions.accounts };
991
+ },
992
+ "accounts.usage": async (_params, context) => {
993
+ return await activeRouter.usage(context.signal);
994
+ },
995
+ "telemetry.get": async () => ({ enabled: telemetry.resolve().enabled }),
996
+ "telemetry.set": async (params) => {
997
+ await serializeMutation(async () => {
998
+ if (params.enabled)
999
+ telemetry.enable();
1000
+ else
1001
+ telemetry.disable();
1002
+ });
1003
+ return { enabled: telemetry.resolve().enabled };
1004
+ },
1005
+ "doctor.run": async (_params, context) => {
1006
+ const providers = await activeRouter.providerStatuses(context.signal);
1007
+ const configuredProviders = configuredProviderIds(currentConfig);
1008
+ const accounts = accountEntries(env);
1009
+ const missingProviders = [
1010
+ ...new Set(accounts
1011
+ .filter((entry) => {
1012
+ const provider = entry.connector === "cliproxy"
1013
+ ? "cliproxy"
1014
+ : entry.subscriptionKind;
1015
+ return currentConfig.providers[provider] === undefined;
1016
+ })
1017
+ .map((entry) => entry.subscriptionKind))
1018
+ ];
1019
+ const providerOnly = ["claude-code", "codex", "cliproxy"].filter((provider) => currentConfig.providers[provider] !== undefined &&
1020
+ !accounts.some((entry) => provider === "cliproxy"
1021
+ ? entry.connector === "cliproxy"
1022
+ : entry.subscriptionKind === provider));
1023
+ const consistent = missingProviders.length === 0 && providerOnly.length === 0;
1024
+ return {
1025
+ checks: [
1026
+ { name: "canonical config", ok: existsSync(configPath), detail: configPath },
1027
+ { name: "control plane", ok: control !== undefined },
1028
+ { name: "model gateway", ok: proxy !== undefined, detail: dataUrl },
1029
+ {
1030
+ name: "provider configuration",
1031
+ ok: configuredProviders.length > 0,
1032
+ detail: configuredProviders.length > 0
1033
+ ? `${configuredProviders.length} provider(s) configured`
1034
+ : "no providers configured; run `routekit providers add <provider>`"
1035
+ },
1036
+ {
1037
+ name: "account activation recovery",
1038
+ ok: true,
1039
+ detail: accountRecovery.recovered > 0
1040
+ ? `recovered ${accountRecovery.recovered} interrupted operation(s)`
1041
+ : "clean"
1042
+ },
1043
+ {
1044
+ name: "account/provider consistency",
1045
+ ok: consistent,
1046
+ detail: consistent
1047
+ ? "consistent"
1048
+ : [
1049
+ ...(missingProviders.length > 0
1050
+ ? [`routing disabled: ${missingProviders.join(", ")}`]
1051
+ : []),
1052
+ ...(providerOnly.length > 0
1053
+ ? [`credential missing: ${providerOnly.join(", ")}`]
1054
+ : [])
1055
+ ].join("; ")
1056
+ },
1057
+ ...(wantsCliproxySidecar(currentConfig)
1058
+ ? [
1059
+ {
1060
+ name: "cliproxy sidecar",
1061
+ ok: await sidecar.reachable(),
1062
+ detail: sidecar.managed()
1063
+ ? sidecar.running()
1064
+ ? "managed; running"
1065
+ : "managed; not running"
1066
+ : "external"
1067
+ }
1068
+ ]
1069
+ : []),
1070
+ ...providers.map((provider) => ({
1071
+ name: `${provider.provider} live discovery`,
1072
+ ok: provider.ok,
1073
+ detail: provider.error ?? `${provider.models.length} model(s)`
1074
+ }))
1075
+ ]
1076
+ };
1077
+ },
1078
+ "launcher.prepare": async (params) => {
1079
+ const listed = await handlers["models.list"]({}, {
1080
+ signal: new AbortController().signal,
1081
+ requestId: "internal"
1082
+ });
1083
+ const model = params.model ?? listed.defaultModel ?? listed.models[0]?.id;
1084
+ if (model === undefined || !listed.models.some((entry) => entry.id === model)) {
1085
+ throw new ControlError({
1086
+ code: "not_found",
1087
+ message: params.model === undefined ? "no model is available" : `unknown model: ${params.model}`
1088
+ });
1089
+ }
1090
+ return {
1091
+ tool: params.tool,
1092
+ model,
1093
+ gatewayUrl: dataUrl,
1094
+ authToken: dataAuth.token,
1095
+ env: {}
1096
+ };
1097
+ }
1098
+ };
1099
+ control = await startControlServer({
1100
+ handler: createRouteKitControlHandler(handlers),
1101
+ token: generateControlToken(),
1102
+ product: ROUTEKIT_PRODUCT,
1103
+ packageVersion: options.packageVersion,
1104
+ capabilities: [ROUTEKIT_CONTROL_CAPABILITY],
1105
+ onError: (error, context) => {
1106
+ const operation = context.method ?? "control transport";
1107
+ console.error(`RouteKit ${operation} failed (request ${context.requestId}):`, error);
1108
+ }
1109
+ });
1110
+ record = store.write({
1111
+ kind: ROUTEKIT_DAEMON_KIND,
1112
+ pid: process.pid,
1113
+ ...(processIdentity(process.pid) !== undefined
1114
+ ? { processIdentity: processIdentity(process.pid) }
1115
+ : {}),
1116
+ url: control.url,
1117
+ port: control.port,
1118
+ startedAt,
1119
+ version: options.packageVersion,
1120
+ protocolVersion: CONTROL_PROTOCOL_VERSION,
1121
+ controlToken: control.token,
1122
+ dataUrl,
1123
+ dataPort: proxy.port(),
1124
+ host: options.host ?? "127.0.0.1",
1125
+ portless: portless.enabled,
1126
+ drainGraceMs,
1127
+ authTokenFile: dataAuth.path,
1128
+ generation,
1129
+ supervisor: supervisorFromEnv(env),
1130
+ ...(process.argv[1] !== undefined ? { binPath: process.argv[1] } : {}),
1131
+ args: redactedProcessArgs(process.argv.slice(2)),
1132
+ cwd: process.cwd()
1133
+ });
1134
+ extendCleanupGrace(drainGraceMs + 10_000);
1135
+ let closeRun;
1136
+ const close = () => {
1137
+ closeRun ??= (async () => {
1138
+ closed = true;
1139
+ if (lifecycle === "running")
1140
+ lifecycle = "quiescing";
1141
+ draining = true;
1142
+ await mutationTail;
1143
+ lifecycle = "draining";
1144
+ await proxy?.drain(drainGraceMs);
1145
+ await activeRouter?.close();
1146
+ await sidecar.close();
1147
+ await control?.close();
1148
+ if (portless?.enabled)
1149
+ portless.unregister("gateway");
1150
+ store.remove(ROUTEKIT_DAEMON_KIND, { ifPid: process.pid });
1151
+ authority.release();
1152
+ lifecycle = "closed";
1153
+ })();
1154
+ return closeRun;
1155
+ };
1156
+ registerCleanup(close);
1157
+ process.on("SIGHUP", () => {
1158
+ void Promise.resolve(handlers["daemon.reload"]({}, {
1159
+ signal: new AbortController().signal,
1160
+ requestId: "sighup"
1161
+ })).catch((error) => {
1162
+ process.stderr.write(`routekit daemon reload failed: ${error instanceof Error ? error.message : String(error)}\n`);
1163
+ });
1164
+ });
1165
+ return {
1166
+ record,
1167
+ dataUrl,
1168
+ controlUrl: control.url,
1169
+ close,
1170
+ reload: async () => {
1171
+ await handlers["daemon.reload"]({}, {
1172
+ signal: new AbortController().signal,
1173
+ requestId: "direct"
1174
+ });
1175
+ }
1176
+ };
1177
+ }
1178
+ catch (error) {
1179
+ await proxy?.close();
1180
+ await activeRouter?.close();
1181
+ await sidecarRef?.close();
1182
+ await control?.close();
1183
+ if (portless?.enabled)
1184
+ portless.unregister("gateway");
1185
+ if (record !== undefined)
1186
+ store.remove(ROUTEKIT_DAEMON_KIND, { ifPid: process.pid });
1187
+ authority.release();
1188
+ throw error;
1189
+ }
1190
+ }