@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/LICENSE +201 -0
- package/README.md +13 -0
- package/dist/account-transaction.d.ts +46 -0
- package/dist/account-transaction.js +223 -0
- package/dist/call-attribution-store.d.ts +16 -0
- package/dist/call-attribution-store.js +116 -0
- package/dist/cliproxy-sidecar.d.ts +17 -0
- package/dist/cliproxy-sidecar.js +148 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +1190 -0
- package/dist/test/account-transaction.test.d.ts +1 -0
- package/dist/test/account-transaction.test.js +184 -0
- package/dist/test/call-attribution-store.test.d.ts +1 -0
- package/dist/test/call-attribution-store.test.js +73 -0
- package/dist/test/daemon.test.d.ts +1 -0
- package/dist/test/daemon.test.js +1022 -0
- package/package.json +50 -0
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
|
+
}
|