@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.
@@ -0,0 +1,1022 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import test from "node:test";
7
+ import { CLIPROXY_PINNED_VERSION } from "@velum-labs/routekit-accounts";
8
+ import { RouteKitControlClient } from "@velum-labs/routekit-control";
9
+ import { ControlClient, ControlError, createServiceRecordStore } from "@velum-labs/routekit-runtime";
10
+ import { parse as parseYaml } from "yaml";
11
+ import { startRouteKitDaemon } from "../index.js";
12
+ import { prepareAccountTransaction } from "../account-transaction.js";
13
+ async function mockProvider() {
14
+ const server = createServer((req, res) => {
15
+ if (req.url === "/v1/models") {
16
+ res.setHeader("content-type", "application/json");
17
+ res.end(JSON.stringify({
18
+ data: [
19
+ {
20
+ id: "mock-model",
21
+ object: "model",
22
+ capabilities: { streaming: "supported", tools: "degraded" },
23
+ supported_reasoning_levels: ["high"]
24
+ }
25
+ ]
26
+ }));
27
+ return;
28
+ }
29
+ req.resume();
30
+ req.on("end", () => {
31
+ const send = () => {
32
+ res.setHeader("content-type", "application/json");
33
+ res.end(JSON.stringify({
34
+ choices: [
35
+ {
36
+ index: 0,
37
+ message: { role: "assistant", content: "daemon answer" },
38
+ finish_reason: "stop"
39
+ }
40
+ ]
41
+ }));
42
+ };
43
+ if (req.headers["x-test-slow"] === "1")
44
+ setTimeout(send, 500);
45
+ else
46
+ send();
47
+ });
48
+ });
49
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
50
+ const port = server.address().port;
51
+ return {
52
+ url: `http://127.0.0.1:${port}/v1`,
53
+ close: async () => await new Promise((resolve) => server.close(() => resolve()))
54
+ };
55
+ }
56
+ async function withMockAnthropicDiscovery(run) {
57
+ const originalFetch = globalThis.fetch;
58
+ globalThis.fetch = async (input, init) => {
59
+ const url = new URL(input instanceof Request ? input.url : input.toString());
60
+ if (url.hostname === "api.anthropic.com" && url.pathname === "/v1/models") {
61
+ return new Response(JSON.stringify({ data: [{ id: "claude-test-model", type: "model" }] }), { headers: { "content-type": "application/json" } });
62
+ }
63
+ return await originalFetch(input, init);
64
+ };
65
+ try {
66
+ return await run();
67
+ }
68
+ finally {
69
+ globalThis.fetch = originalFetch;
70
+ }
71
+ }
72
+ async function withMockNativeDiscovery(kind, run) {
73
+ if (kind === "claude-code")
74
+ return await withMockAnthropicDiscovery(run);
75
+ const originalFetch = globalThis.fetch;
76
+ globalThis.fetch = async (input, init) => {
77
+ const url = new URL(input instanceof Request ? input.url : input.toString());
78
+ if (url.hostname === "chatgpt.com" &&
79
+ url.pathname.startsWith("/backend-api/codex/") &&
80
+ url.pathname.endsWith("/models")) {
81
+ return Response.json({ models: [{ slug: "gpt-test-model" }] });
82
+ }
83
+ return await originalFetch(input, init);
84
+ };
85
+ try {
86
+ return await run();
87
+ }
88
+ finally {
89
+ globalThis.fetch = originalFetch;
90
+ }
91
+ }
92
+ function nativeCredential(kind) {
93
+ return kind === "claude-code"
94
+ ? {
95
+ claudeAiOauth: {
96
+ accessToken: "test-access",
97
+ refreshToken: "test-refresh",
98
+ expiresAt: Date.now() + 3_600_000
99
+ }
100
+ }
101
+ : {
102
+ tokens: {
103
+ access_token: "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTl9.",
104
+ refresh_token: "test-refresh",
105
+ account_id: "acct-test"
106
+ }
107
+ };
108
+ }
109
+ test("singleton daemon exposes authenticated control and a stable reloadable data plane", async () => {
110
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-"));
111
+ const stateHome = join(root, "state");
112
+ const configPath = join(root, "router.yaml");
113
+ writeFileSync(configPath, "providers:\n openai: {}\ndefaultModel: openai/mock-model\n");
114
+ const upstream = await mockProvider();
115
+ const daemon = await startRouteKitDaemon({
116
+ packageVersion: "1.2.3",
117
+ stateHome,
118
+ configPath,
119
+ port: 0,
120
+ portless: false,
121
+ drainGraceMs: 2_000,
122
+ env: {
123
+ ...process.env,
124
+ HOME: root,
125
+ ROUTEKIT_HOME: stateHome,
126
+ OPENAI_API_KEY: "test-key",
127
+ OPENAI_BASE_URL: upstream.url,
128
+ ROUTEKIT_PORTLESS: "0"
129
+ }
130
+ });
131
+ try {
132
+ const record = createServiceRecordStore({
133
+ home: stateHome,
134
+ product: "routekit"
135
+ }).read("daemon");
136
+ assert.ok(record !== undefined);
137
+ assert.equal(record.pid, process.pid);
138
+ assert.equal(record.dataUrl, daemon.dataUrl);
139
+ assert.equal(record.protocolVersion, "control.v1");
140
+ assert.equal(record.generation, 1);
141
+ assert.equal(statSync(join(stateHome, "services", "daemon.json")).mode & 0o777, 0o600);
142
+ assert.ok(record.authTokenFile !== undefined);
143
+ const dataToken = readFileSync(record.authTokenFile, "utf8").trim();
144
+ assert.equal((await fetch(`${daemon.dataUrl}/v1/models`)).status, 401);
145
+ await assert.rejects(new ControlClient({ url: record.url, token: "wrong" }).health());
146
+ const client = new RouteKitControlClient({
147
+ url: record.url,
148
+ token: record.controlToken
149
+ });
150
+ const status = await client.call("daemon.status", {});
151
+ assert.equal(status.packageVersion, "1.2.3");
152
+ assert.equal(status.dataUrl, daemon.dataUrl);
153
+ const models = await client.call("models.list", {});
154
+ assert.deepEqual(models.models.map((model) => model.id), ["openai/mock-model"]);
155
+ const modelInfo = await client.call("models.info", { model: "openai/mock-model" });
156
+ assert.equal(modelInfo.id, "openai/mock-model");
157
+ assert.equal(modelInfo.provider, "openai");
158
+ assert.equal(modelInfo.nativeModel, "mock-model");
159
+ assert.equal(modelInfo.accountClass, "api-key");
160
+ assert.equal(modelInfo.billingMode, "metered-api");
161
+ assert.equal(modelInfo.default, true);
162
+ assert.deepEqual(modelInfo.capabilities, {
163
+ streaming: "supported",
164
+ tools: "degraded"
165
+ });
166
+ assert.deepEqual(modelInfo.reasoning?.efforts, [{ id: "high" }]);
167
+ assert.doesNotMatch(JSON.stringify(modelInfo), /test-key/);
168
+ await assert.rejects(client.call("models.info", { model: "openai/not-real" }), (error) => error instanceof ControlError &&
169
+ error.code === "not_found" &&
170
+ /unknown model/.test(error.message));
171
+ const beforeUrl = status.dataUrl;
172
+ const snapshot = await client.call("config.get", {});
173
+ await assert.rejects(client.call("config.update", {
174
+ expectedRevision: snapshot.revision,
175
+ document: "providers:\n openai:\n apiKey: must-not-enter-daemon-state\n"
176
+ }), /inline credential/);
177
+ const updated = await client.call("config.update", {
178
+ expectedRevision: snapshot.revision,
179
+ document: "providers:\n openai:\n strategy: sticky\ndefaultModel: openai/mock-model\n"
180
+ }, { idempotencyKey: "config-one" });
181
+ assert.equal(updated.revision, snapshot.revision + 1);
182
+ assert.equal((await client.call("daemon.status", {})).dataUrl, beforeUrl);
183
+ assert.equal((await fetch(`${beforeUrl}/health`)).status, 200);
184
+ const inflight = fetch(`${beforeUrl}/v1/chat/completions`, {
185
+ method: "POST",
186
+ headers: {
187
+ "content-type": "application/json",
188
+ authorization: `Bearer ${dataToken}`,
189
+ "x-test-slow": "1"
190
+ },
191
+ body: JSON.stringify({
192
+ model: "openai/mock-model",
193
+ messages: [{ role: "user", content: "finish during reload" }]
194
+ })
195
+ });
196
+ await new Promise((resolve) => setTimeout(resolve, 50));
197
+ const reloaded = client.call("config.update", {
198
+ expectedRevision: updated.revision,
199
+ document: "providers:\n openai:\n strategy: round_robin\ndefaultModel: openai/mock-model\n"
200
+ });
201
+ const response = await inflight;
202
+ assert.equal(response.status, 200);
203
+ const callId = response.headers.get("x-routekit-model-call-id");
204
+ assert.ok(callId);
205
+ assert.match(await response.text(), /daemon answer/);
206
+ const afterInflight = await reloaded;
207
+ assert.equal(afterInflight.revision, updated.revision + 1);
208
+ const inspection = await client.call("calls.inspect", { callId });
209
+ assert.equal(inspection.callId, callId);
210
+ assert.equal(inspection.effectiveModel, "openai/mock-model");
211
+ assert.equal(inspection.nativeModel, "mock-model");
212
+ assert.equal(inspection.provider, "openai");
213
+ assert.equal(inspection.billingMode, "api_key");
214
+ assert.deepEqual(inspection.retries, {
215
+ attempts: 1,
216
+ total: 0,
217
+ accountFailovers: 0
218
+ });
219
+ assert.equal(inspection.cost.unknownUsage, true);
220
+ assert.equal(inspection.cost.unknownCost, true);
221
+ assert.equal("account" in inspection, false);
222
+ const rejected = await fetch(`${beforeUrl}/v1/chat/completions`, {
223
+ method: "POST",
224
+ headers: {
225
+ "content-type": "application/json",
226
+ authorization: `Bearer ${dataToken}`
227
+ },
228
+ body: JSON.stringify({
229
+ model: "openai/missing-model",
230
+ messages: [{ role: "user", content: "reject this" }]
231
+ })
232
+ });
233
+ assert.equal(rejected.status, 400);
234
+ const rejectedCallId = rejected.headers.get("x-routekit-model-call-id");
235
+ assert.ok(rejectedCallId);
236
+ await rejected.text();
237
+ const rejectedInspection = await client.call("calls.inspect", {
238
+ callId: rejectedCallId
239
+ });
240
+ assert.equal(rejectedInspection.status, "failed");
241
+ assert.equal(rejectedInspection.effectiveModel, "openai/missing-model");
242
+ assert.equal(rejectedInspection.provider, "openai");
243
+ assert.equal(rejectedInspection.error?.kind, "validation_error");
244
+ const embedding = await fetch(`${beforeUrl}/v1/embeddings`, {
245
+ method: "POST",
246
+ headers: {
247
+ "content-type": "application/json",
248
+ authorization: `Bearer ${dataToken}`
249
+ },
250
+ body: JSON.stringify({
251
+ model: "openai/mock-model",
252
+ input: "embed this"
253
+ })
254
+ });
255
+ assert.equal(embedding.status, 200);
256
+ const embeddingCallId = embedding.headers.get("x-routekit-model-call-id");
257
+ assert.ok(embeddingCallId);
258
+ await embedding.text();
259
+ const embeddingInspection = await client.call("calls.inspect", {
260
+ callId: embeddingCallId
261
+ });
262
+ assert.equal(embeddingInspection.effectiveModel, "openai/mock-model");
263
+ assert.equal(embeddingInspection.nativeModel, "mock-model");
264
+ assert.equal(embeddingInspection.provider, "openai");
265
+ assert.equal(embeddingInspection.billingMode, "api_key");
266
+ await assert.rejects(client.call("calls.inspect", { callId: "model_call_missing" }), (error) => error instanceof ControlError && error.code === "not_found");
267
+ await assert.rejects(client.call("config.update", {
268
+ expectedRevision: snapshot.revision,
269
+ document: "providers: {}\n"
270
+ }), (error) => error instanceof ControlError && error.code === "conflict");
271
+ assert.equal((await client.call("config.get", {})).revision, afterInflight.revision);
272
+ const concurrent = await Promise.allSettled([
273
+ client.call("config.update", {
274
+ expectedRevision: afterInflight.revision,
275
+ document: "providers:\n openai:\n strategy: sticky\ndefaultModel: openai/mock-model\n"
276
+ }),
277
+ client.call("config.update", {
278
+ expectedRevision: afterInflight.revision,
279
+ document: "providers:\n openai:\n strategy: capacity_weighted\ndefaultModel: openai/mock-model\n"
280
+ })
281
+ ]);
282
+ assert.equal(concurrent.filter((result) => result.status === "fulfilled").length, 1);
283
+ assert.equal(concurrent.filter((result) => result.status === "rejected" &&
284
+ result.reason instanceof ControlError &&
285
+ result.reason.code === "conflict").length, 1);
286
+ assert.equal((await client.call("config.get", {})).revision, afterInflight.revision + 1);
287
+ const enrolled = await client.call("accounts.enroll", {
288
+ kind: "codex",
289
+ label: "work",
290
+ credential: {
291
+ tokens: {
292
+ access_token: "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTl9.",
293
+ refresh_token: "must-not-be-returned",
294
+ account_id: "acct-work"
295
+ }
296
+ }
297
+ }, { idempotencyKey: "enroll-work" });
298
+ assert.equal(enrolled.enrolled, true);
299
+ const accounts = await client.call("accounts.list", {});
300
+ assert.deepEqual(accounts.accounts, [
301
+ { subscriptionKind: "codex", label: "work", connector: "native" }
302
+ ]);
303
+ assert.doesNotMatch(JSON.stringify(accounts), /must-not-be-returned/);
304
+ const removed = await client.call("accounts.remove", { kind: "codex", label: "work" }, { idempotencyKey: "remove-work" });
305
+ assert.equal(removed.removed, true);
306
+ await assert.rejects(client.call("accounts.remove", { kind: "github", label: "work" }, { idempotencyKey: "remove-unknown" }), (error) => error instanceof ControlError && /unknown subscription kind/.test(error.message));
307
+ }
308
+ finally {
309
+ await daemon.close();
310
+ await upstream.close();
311
+ rmSync(root, { recursive: true, force: true });
312
+ }
313
+ });
314
+ test("native provider stays enabled until its last account is removed", async () => {
315
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-native-remove-"));
316
+ const stateHome = join(root, "state");
317
+ const configPath = join(root, "router.yaml");
318
+ const accountsDirectory = join(stateHome, "subscriptions", "claude-code");
319
+ const firstPath = join(accountsDirectory, "first.json");
320
+ const lastPath = join(accountsDirectory, "last.json");
321
+ mkdirSync(accountsDirectory, { recursive: true });
322
+ for (const [path, suffix] of [
323
+ [firstPath, "first"],
324
+ [lastPath, "last"]
325
+ ]) {
326
+ writeFileSync(path, JSON.stringify({
327
+ claudeAiOauth: {
328
+ accessToken: `${suffix}-access`,
329
+ refreshToken: `${suffix}-refresh`,
330
+ expiresAt: Date.now() + 3_600_000
331
+ }
332
+ }), { mode: 0o600 });
333
+ }
334
+ writeFileSync(configPath, [
335
+ "providers:",
336
+ " openai: {}",
337
+ " claude:",
338
+ " strategy: round_robin",
339
+ "defaultModel: claude-code/claude-test-model",
340
+ ""
341
+ ].join("\n"));
342
+ const upstream = await mockProvider();
343
+ try {
344
+ await withMockAnthropicDiscovery(async () => {
345
+ const daemon = await startRouteKitDaemon({
346
+ packageVersion: "1.2.3",
347
+ stateHome,
348
+ configPath,
349
+ port: 0,
350
+ portless: false,
351
+ env: {
352
+ ...process.env,
353
+ HOME: root,
354
+ ROUTEKIT_HOME: stateHome,
355
+ OPENAI_API_KEY: "test-key",
356
+ OPENAI_BASE_URL: upstream.url,
357
+ ROUTEKIT_PORTLESS: "0"
358
+ }
359
+ });
360
+ try {
361
+ const client = new RouteKitControlClient({
362
+ url: daemon.record.url,
363
+ token: daemon.record.controlToken
364
+ });
365
+ const initial = await client.call("daemon.status", {});
366
+ const first = await client.call("accounts.remove", { kind: "claude-code", label: "first" }, { idempotencyKey: "remove-first-claude" });
367
+ assert.equal(first.removed, true);
368
+ assert.equal(existsSync(firstPath), false);
369
+ assert.equal(existsSync(lastPath), true);
370
+ const afterFirst = await client.call("daemon.status", {});
371
+ assert.equal(afterFirst.configRevision, initial.configRevision);
372
+ assert.equal(afterFirst.accountRevision, initial.accountRevision + 1);
373
+ const firstConfig = parseYaml((await client.call("config.get", {})).document);
374
+ assert.ok(firstConfig.providers.claude !== undefined);
375
+ assert.equal(firstConfig.defaultModel, "claude-code/claude-test-model");
376
+ const last = await client.call("accounts.remove", { kind: "claude-code", label: "last" }, { idempotencyKey: "remove-last-claude" });
377
+ assert.equal(last.removed, true);
378
+ assert.equal(existsSync(lastPath), false);
379
+ const afterLast = await client.call("daemon.status", {});
380
+ assert.equal(afterLast.configRevision, afterFirst.configRevision + 1);
381
+ assert.equal(afterLast.accountRevision, afterFirst.accountRevision + 1);
382
+ const lastConfig = parseYaml((await client.call("config.get", {})).document);
383
+ assert.equal(lastConfig.providers["claude-code"], undefined);
384
+ assert.equal(lastConfig.defaultModel, undefined);
385
+ assert.deepEqual((await client.call("providers.status", {})).providers.map((provider) => provider.provider), ["openai"]);
386
+ assert.deepEqual((await client.call("models.list", {})).models.map((model) => model.id), ["openai/mock-model"]);
387
+ assert.equal((await client.call("doctor.run", {})).checks.find((check) => check.name === "account/provider consistency")?.ok, true);
388
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
389
+ const repeated = await client.call("accounts.remove", { kind: "claude-code", label: "last" }, { idempotencyKey: "remove-last-claude-again" });
390
+ assert.equal(repeated.removed, false);
391
+ assert.deepEqual(await client.call("daemon.status", {}), afterLast);
392
+ }
393
+ finally {
394
+ await daemon.close();
395
+ }
396
+ });
397
+ }
398
+ finally {
399
+ await upstream.close();
400
+ rmSync(root, { recursive: true, force: true });
401
+ }
402
+ });
403
+ for (const kind of ["claude-code", "codex"]) {
404
+ test(`last sole ${kind} account leaves a healthy unconfigured daemon without provider credentials`, async () => {
405
+ const root = mkdtempSync(join(tmpdir(), `routekit-daemon-sole-${kind}-`));
406
+ const stateHome = join(root, "state");
407
+ const configPath = join(root, "router.yaml");
408
+ const accountsDirectory = join(stateHome, "subscriptions", kind);
409
+ const accountPath = join(accountsDirectory, "only.json");
410
+ mkdirSync(accountsDirectory, { recursive: true });
411
+ writeFileSync(accountPath, JSON.stringify(nativeCredential(kind)), { mode: 0o600 });
412
+ writeFileSync(configPath, [
413
+ "providers:",
414
+ ` ${kind === "claude-code" ? "claude" : kind}: {}`,
415
+ `defaultModel: ${kind}/${kind === "claude-code" ? "claude-test-model" : "gpt-test-model"}`,
416
+ ""
417
+ ].join("\n"));
418
+ try {
419
+ await withMockNativeDiscovery(kind, async () => {
420
+ const daemon = await startRouteKitDaemon({
421
+ packageVersion: "1.2.3",
422
+ stateHome,
423
+ configPath,
424
+ port: 0,
425
+ portless: false,
426
+ env: {
427
+ HOME: root,
428
+ ROUTEKIT_HOME: stateHome,
429
+ ROUTEKIT_PORTLESS: "0"
430
+ }
431
+ });
432
+ try {
433
+ const client = new RouteKitControlClient({
434
+ url: daemon.record.url,
435
+ token: daemon.record.controlToken
436
+ });
437
+ const before = await client.call("daemon.status", {});
438
+ const result = await client.call("accounts.remove", { kind, label: "only" }, { idempotencyKey: `remove-only-${kind}` });
439
+ assert.equal(result.removed, true);
440
+ assert.equal(result.revision, before.accountRevision + 1);
441
+ assert.equal(existsSync(accountPath), false);
442
+ const after = await client.call("daemon.status", {});
443
+ assert.equal(after.configRevision, before.configRevision + 1);
444
+ assert.equal(after.accountRevision, before.accountRevision + 1);
445
+ const config = parseYaml((await client.call("config.get", {})).document);
446
+ assert.deepEqual(Object.keys(config.providers), []);
447
+ assert.equal(config.defaultModel, undefined);
448
+ assert.deepEqual((await client.call("providers.status", {})).providers, []);
449
+ const listed = await client.call("models.list", {});
450
+ assert.deepEqual(listed.models, []);
451
+ assert.equal(listed.defaultModel, undefined);
452
+ const currentStatus = await client.call("daemon.status", {});
453
+ assert.equal(currentStatus.dataUrl, daemon.dataUrl);
454
+ assert.equal(currentStatus.draining, false);
455
+ const doctor = await client.call("doctor.run", {});
456
+ assert.deepEqual(doctor.checks.find((check) => check.name === "provider configuration"), {
457
+ name: "provider configuration",
458
+ ok: false,
459
+ detail: "no providers configured; run `routekit providers add <provider>`"
460
+ });
461
+ assert.equal(doctor.checks.find((check) => check.name === "account/provider consistency")?.ok, true);
462
+ await assert.rejects(client.call("launcher.prepare", { tool: "codex" }), (error) => error instanceof ControlError &&
463
+ error.code === "not_found" &&
464
+ /no model is available/.test(error.message));
465
+ assert.equal((await fetch(`${daemon.dataUrl}/health`)).status, 200);
466
+ const dataToken = readFileSync(daemon.record.authTokenFile, "utf8").trim();
467
+ const gatewayModels = await fetch(`${daemon.dataUrl}/v1/models`, {
468
+ headers: { authorization: `Bearer ${dataToken}` }
469
+ });
470
+ assert.equal(gatewayModels.status, 200);
471
+ assert.deepEqual(await gatewayModels.json(), {
472
+ object: "list",
473
+ data: [],
474
+ models: []
475
+ });
476
+ const unavailableResponse = await fetch(`${daemon.dataUrl}/v1/chat/completions`, {
477
+ method: "POST",
478
+ headers: {
479
+ authorization: `Bearer ${dataToken}`,
480
+ "content-type": "application/json"
481
+ },
482
+ body: JSON.stringify({
483
+ messages: [{ role: "user", content: "hello" }]
484
+ })
485
+ });
486
+ assert.equal(unavailableResponse.status, 503);
487
+ assert.match(await unavailableResponse.text(), /no model is available/);
488
+ const modelResponse = await fetch(`${daemon.dataUrl}/v1/chat/completions`, {
489
+ method: "POST",
490
+ headers: {
491
+ authorization: `Bearer ${dataToken}`,
492
+ "content-type": "application/json"
493
+ },
494
+ body: JSON.stringify({
495
+ model: `${kind}/removed-model`,
496
+ messages: [{ role: "user", content: "hello" }]
497
+ })
498
+ });
499
+ assert.equal(modelResponse.status, 400);
500
+ assert.match(await modelResponse.text(), /unknown model/);
501
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
502
+ }
503
+ finally {
504
+ await daemon.close();
505
+ }
506
+ });
507
+ }
508
+ finally {
509
+ rmSync(root, { recursive: true, force: true });
510
+ }
511
+ });
512
+ }
513
+ test("native removal failure before deletion cleans its prepared transaction", async () => {
514
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-remove-symlink-"));
515
+ const stateHome = join(root, "state");
516
+ const configPath = join(root, "router.yaml");
517
+ const accountsDirectory = join(stateHome, "subscriptions", "claude-code");
518
+ const externalPath = join(root, "external.json");
519
+ const accountPath = join(accountsDirectory, "linked.json");
520
+ mkdirSync(accountsDirectory, { recursive: true });
521
+ writeFileSync(externalPath, JSON.stringify(nativeCredential("claude-code")), {
522
+ mode: 0o600
523
+ });
524
+ symlinkSync(externalPath, accountPath);
525
+ writeFileSync(configPath, [
526
+ "providers:",
527
+ " openai: {}",
528
+ "defaultModel: openai/mock-model",
529
+ ""
530
+ ].join("\n"));
531
+ const upstream = await mockProvider();
532
+ const daemon = await startRouteKitDaemon({
533
+ packageVersion: "1.2.3",
534
+ stateHome,
535
+ configPath,
536
+ port: 0,
537
+ portless: false,
538
+ env: {
539
+ ...process.env,
540
+ HOME: root,
541
+ ROUTEKIT_HOME: stateHome,
542
+ OPENAI_API_KEY: "test-key",
543
+ OPENAI_BASE_URL: upstream.url,
544
+ ROUTEKIT_PORTLESS: "0"
545
+ }
546
+ });
547
+ try {
548
+ const client = new RouteKitControlClient({
549
+ url: daemon.record.url,
550
+ token: daemon.record.controlToken
551
+ });
552
+ const before = await client.call("daemon.status", {});
553
+ await assert.rejects(client.call("accounts.remove", { kind: "claude-code", label: "linked" }, { idempotencyKey: "remove-linked-claude" }), (error) => error instanceof ControlError);
554
+ assert.equal(existsSync(accountPath), true);
555
+ assert.equal(existsSync(externalPath), true);
556
+ assert.deepEqual(await client.call("daemon.status", {}), before);
557
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
558
+ }
559
+ finally {
560
+ await daemon.close();
561
+ await upstream.close();
562
+ rmSync(root, { recursive: true, force: true });
563
+ }
564
+ });
565
+ test("failed last native account removal restores credential and config", async () => {
566
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-native-rollback-"));
567
+ const stateHome = join(root, "state");
568
+ const configPath = join(root, "router.yaml");
569
+ const accountsDirectory = join(stateHome, "subscriptions", "claude-code");
570
+ const accountPath = join(accountsDirectory, "work.json");
571
+ mkdirSync(accountsDirectory, { recursive: true });
572
+ const credential = JSON.stringify({
573
+ claudeAiOauth: {
574
+ accessToken: "rollback-access",
575
+ refreshToken: "rollback-refresh",
576
+ expiresAt: Date.now() + 3_600_000
577
+ }
578
+ });
579
+ writeFileSync(accountPath, credential, { mode: 0o600 });
580
+ writeFileSync(configPath, [
581
+ "providers:",
582
+ " openai: {}",
583
+ " claude-code: {}",
584
+ "defaultModel: claude-code/claude-test-model",
585
+ ""
586
+ ].join("\n"));
587
+ const upstream = await mockProvider();
588
+ try {
589
+ await withMockAnthropicDiscovery(async () => {
590
+ const daemon = await startRouteKitDaemon({
591
+ packageVersion: "1.2.3",
592
+ stateHome,
593
+ configPath,
594
+ port: 0,
595
+ portless: false,
596
+ env: {
597
+ ...process.env,
598
+ HOME: root,
599
+ ROUTEKIT_HOME: stateHome,
600
+ OPENAI_API_KEY: "test-key",
601
+ OPENAI_BASE_URL: upstream.url,
602
+ ROUTEKIT_PORTLESS: "0"
603
+ }
604
+ });
605
+ try {
606
+ const client = new RouteKitControlClient({
607
+ url: daemon.record.url,
608
+ token: daemon.record.controlToken
609
+ });
610
+ const beforeStatus = await client.call("daemon.status", {});
611
+ const beforeDocument = (await client.call("config.get", {})).document;
612
+ await upstream.close();
613
+ await assert.rejects(client.call("accounts.remove", { kind: "claude-code", label: "work" }, { idempotencyKey: "remove-last-claude-failure" }));
614
+ assert.equal(readFileSync(accountPath, "utf8"), credential);
615
+ assert.equal((await client.call("config.get", {})).document, beforeDocument);
616
+ assert.deepEqual(await client.call("daemon.status", {}), beforeStatus);
617
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
618
+ }
619
+ finally {
620
+ await daemon.close();
621
+ }
622
+ });
623
+ }
624
+ finally {
625
+ await upstream.close();
626
+ rmSync(root, { recursive: true, force: true });
627
+ }
628
+ });
629
+ async function freePort() {
630
+ const server = createServer();
631
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
632
+ const port = server.address().port;
633
+ await new Promise((resolve) => server.close(() => resolve()));
634
+ return port;
635
+ }
636
+ function processAlive(pid) {
637
+ try {
638
+ process.kill(pid, 0);
639
+ return true;
640
+ }
641
+ catch {
642
+ return false;
643
+ }
644
+ }
645
+ async function waitFor(predicate, timeoutMs) {
646
+ const deadline = Date.now() + timeoutMs;
647
+ while (Date.now() < deadline) {
648
+ if (await predicate())
649
+ return true;
650
+ await new Promise((resolve) => setTimeout(resolve, 100));
651
+ }
652
+ return await predicate();
653
+ }
654
+ test("daemon owns the cliproxy sidecar: spawn, restart, account routing, shutdown", async () => {
655
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-cliproxy-"));
656
+ const stateHome = join(root, "state");
657
+ const configPath = join(root, "router.yaml");
658
+ writeFileSync(configPath, "providers:\n cliproxy: {}\ndefaultModel: cliproxy/g-model\n");
659
+ const cliproxyDirectory = join(stateHome, "cliproxy");
660
+ const authDirectory = join(cliproxyDirectory, "auth");
661
+ const markerPath = join(root, "sidecar-pids.log");
662
+ const port = await freePort();
663
+ // Managed sidecar config: RouteKit-owned ingress key and listen port.
664
+ mkdirSync(authDirectory, { recursive: true, mode: 0o700 });
665
+ writeFileSync(join(cliproxyDirectory, "config.yaml"), [
666
+ 'host: "127.0.0.1"',
667
+ `port: ${port}`,
668
+ `auth-dir: "${authDirectory}"`,
669
+ "api-keys:",
670
+ ' - "rk-test-ingress-key"',
671
+ ""
672
+ ].join("\n"));
673
+ writeFileSync(join(authDirectory, "antigravity-user@example.com.json"), JSON.stringify({ type: "antigravity", access_token: "test-access" }));
674
+ // Fake pinned binary: records its pid and serves /v1/models on the
675
+ // configured port so discovery and reachability run against it.
676
+ const binary = join(cliproxyDirectory, "bin", CLIPROXY_PINNED_VERSION, "cli-proxy-api");
677
+ mkdirSync(dirname(binary), { recursive: true });
678
+ writeFileSync(binary, [
679
+ "#!/usr/bin/env node",
680
+ 'const fs = require("node:fs");',
681
+ 'const http = require("node:http");',
682
+ 'const cfg = fs.readFileSync(process.argv[process.argv.indexOf("--config") + 1], "utf8");',
683
+ "const port = Number(/port:\\s*(\\d+)/.exec(cfg)[1]);",
684
+ // Record the pid only after the listener is accepting so crash-recovery
685
+ // waiters do not race the bind.
686
+ "http.createServer((req, res) => {",
687
+ ' res.setHeader("content-type", "application/json");',
688
+ ' res.end(JSON.stringify({ data: [{ id: "g-model", object: "model" }] }));',
689
+ '}).listen(port, "127.0.0.1", () => {',
690
+ ` fs.appendFileSync(${JSON.stringify(markerPath)}, process.pid + "\\n");`,
691
+ "});",
692
+ ""
693
+ ].join("\n"));
694
+ chmodSync(binary, 0o755);
695
+ let failActivation = false;
696
+ const daemon = await startRouteKitDaemon({
697
+ packageVersion: "1.2.3",
698
+ stateHome,
699
+ configPath,
700
+ port: 0,
701
+ portless: false,
702
+ drainGraceMs: 2_000,
703
+ onAccountTransactionPhase: (phase) => {
704
+ if (failActivation && phase === "credentials-written") {
705
+ throw new Error("injected activation failure");
706
+ }
707
+ },
708
+ env: {
709
+ ...process.env,
710
+ HOME: root,
711
+ ROUTEKIT_HOME: stateHome,
712
+ ROUTEKIT_PORTLESS: "0",
713
+ ROUTEKIT_CLIPROXY_API_KEY: undefined,
714
+ ROUTEKIT_CLIPROXY_BASE_URL: undefined
715
+ }
716
+ });
717
+ let firstPid = 0;
718
+ try {
719
+ const record = createServiceRecordStore({
720
+ home: stateHome,
721
+ product: "routekit"
722
+ }).read("daemon");
723
+ assert.ok(record?.controlToken !== undefined);
724
+ const client = new RouteKitControlClient({
725
+ url: record.url,
726
+ token: record.controlToken
727
+ });
728
+ // The daemon spawned the sidecar and the router discovers through it
729
+ // with the injected managed ingress key + base URL.
730
+ const pids = readFileSync(markerPath, "utf8").trim().split("\n").map(Number);
731
+ assert.equal(pids.length, 1);
732
+ firstPid = pids[0];
733
+ assert.ok(processAlive(firstPid));
734
+ const models = await client.call("models.list", {});
735
+ assert.deepEqual(models.models.map((model) => model.id), ["cliproxy/g-model"]);
736
+ // One unified account surface: the cliproxy store shows up beside native
737
+ // accounts with its connector and a live relay.
738
+ const status = await client.call("accounts.status", {});
739
+ assert.deepEqual(status.accounts, [
740
+ {
741
+ subscriptionKind: "gemini",
742
+ label: "antigravity-user@example.com",
743
+ connector: "cliproxy",
744
+ localOnly: true,
745
+ credentialValid: true,
746
+ configured: true,
747
+ relayOpen: true,
748
+ active: true,
749
+ models: []
750
+ }
751
+ ]);
752
+ // Crash recovery: kill the sidecar; the daemon respawns it.
753
+ process.kill(firstPid, "SIGKILL");
754
+ assert.ok(await waitFor(() => {
755
+ const seen = readFileSync(markerPath, "utf8").trim().split("\n");
756
+ return seen.length === 2;
757
+ }, 10_000), "sidecar was not respawned after a crash");
758
+ // Wait until the respawned listener answers discovery before mutating.
759
+ assert.ok(await waitFor(async () => {
760
+ try {
761
+ const listed = await client.call("models.list", {});
762
+ return listed.models.some((model) => model.id === "cliproxy/g-model");
763
+ }
764
+ catch {
765
+ return false;
766
+ }
767
+ }, 10_000), "respawned sidecar did not become discoverable");
768
+ const respawnedPid = Number(readFileSync(markerPath, "utf8").trim().split("\n")[1]);
769
+ // accounts.sync rescans the store and restarts the managed sidecar so it
770
+ // cannot miss an auth-directory watch event.
771
+ writeFileSync(join(authDirectory, "broken-account.json"), "{not-json");
772
+ writeFileSync(join(authDirectory, "kimi-invalid.json"), JSON.stringify({ type: "kimi" }));
773
+ const synced = await client.call("accounts.sync", {}, { idempotencyKey: "sync-1" });
774
+ assert.equal(synced.synced, true);
775
+ assert.ok(await waitFor(() => readFileSync(markerPath, "utf8").trim().split("\n").length === 3, 10_000), "accounts.sync did not restart the managed sidecar");
776
+ assert.equal(processAlive(respawnedPid), false);
777
+ const refreshedStatus = await client.call("accounts.status", {});
778
+ assert.equal(refreshedStatus.accounts.find((entry) => entry.label === "antigravity-user@example.com")?.credentialValid, true);
779
+ assert.equal(refreshedStatus.accounts.find((entry) => entry.label === "kimi-invalid")
780
+ ?.credentialValid, false);
781
+ assert.equal(refreshedStatus.accounts.find((entry) => entry.label === "broken-account")
782
+ ?.credentialValid, false);
783
+ const syncedPid = Number(readFileSync(markerPath, "utf8").trim().split("\n")[2]);
784
+ // Unclassified/corrupt auth files remain removable using the kind shown
785
+ // by accounts.list rather than becoming stuck in the store.
786
+ const unknownRemoved = await client.call("accounts.remove", { kind: "broken", label: "broken-account" }, { idempotencyKey: "remove-broken" });
787
+ assert.equal(unknownRemoved.removed, true);
788
+ assert.equal(existsSync(join(authDirectory, "broken-account.json")), false);
789
+ assert.ok(await waitFor(() => readFileSync(markerPath, "utf8").trim().split("\n").length === 4, 10_000), "accounts.remove did not restart the managed sidecar");
790
+ assert.equal(processAlive(syncedPid), false);
791
+ // Legacy cliproxy aliases canonicalize and remove through the native kind.
792
+ writeFileSync(join(authDirectory, "legacy-claude@example.com.json"), JSON.stringify({ type: "claude", access_token: "legacy-access" }));
793
+ const orphanRemoved = await client.call("accounts.remove", { kind: "claude-code", label: "legacy-claude@example.com" }, { idempotencyKey: "remove-legacy-claude" });
794
+ assert.equal(orphanRemoved.removed, true);
795
+ assert.equal(existsSync(join(authDirectory, "legacy-claude@example.com.json")), false);
796
+ const beforeActivation = await client.call("daemon.status", {});
797
+ failActivation = true;
798
+ await assert.rejects(client.call("accounts.enrollActivate", {
799
+ kind: "kimi",
800
+ accounts: [
801
+ {
802
+ label: "kimi-rollback",
803
+ credential: {
804
+ type: "kimi",
805
+ access_token: "rollback-access",
806
+ expiry: "2999-01-01T00:00:00Z"
807
+ }
808
+ }
809
+ ]
810
+ }, { idempotencyKey: "activate-kimi-failure" }));
811
+ failActivation = false;
812
+ assert.equal(existsSync(join(authDirectory, "kimi-rollback.json")), false);
813
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
814
+ assert.equal((await client.call("daemon.status", {})).configRevision, beforeActivation.configRevision);
815
+ assert.equal((await client.call("daemon.status", {})).accountRevision, beforeActivation.accountRevision);
816
+ const activationParams = {
817
+ kind: "grok",
818
+ accounts: [
819
+ {
820
+ label: "xai-transaction@example.com",
821
+ credential: {
822
+ type: "xai",
823
+ token: {
824
+ access_token: "transaction-access",
825
+ expires_at: Math.floor(Date.now() / 1_000) + 3_600
826
+ }
827
+ }
828
+ }
829
+ ]
830
+ };
831
+ const activated = await client.call("accounts.enrollActivate", activationParams, { idempotencyKey: "activate-grok" });
832
+ assert.equal(activated.activated, true);
833
+ assert.equal(activated.configRevision, beforeActivation.configRevision + 1);
834
+ assert.equal(activated.accountRevision, beforeActivation.accountRevision + 1);
835
+ assert.equal(existsSync(join(authDirectory, "xai-transaction@example.com.json")), true);
836
+ assert.doesNotMatch(JSON.stringify(activated), /transaction-access/);
837
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
838
+ // A fresh transport retry converges on the committed state without
839
+ // incrementing either revision again.
840
+ const replayed = await client.call("accounts.enrollActivate", activationParams, { idempotencyKey: "activate-grok-retry" });
841
+ assert.equal(replayed.configRevision, activated.configRevision);
842
+ assert.equal(replayed.accountRevision, activated.accountRevision);
843
+ const activatedStatus = await client.call("accounts.status", {});
844
+ assert.equal(activatedStatus.accounts.find((entry) => entry.label === "xai-transaction@example.com")?.configured, true);
845
+ const beforeClaude = await client.call("daemon.status", {});
846
+ const claudeActivation = {
847
+ kind: "claude-code",
848
+ accounts: [
849
+ {
850
+ label: "claude-work",
851
+ credential: {
852
+ claudeAiOauth: {
853
+ accessToken: "claude-transaction-access",
854
+ refreshToken: "claude-transaction-refresh",
855
+ expiresAt: Date.now() + 3_600_000
856
+ }
857
+ }
858
+ }
859
+ ]
860
+ };
861
+ failActivation = true;
862
+ await assert.rejects(client.call("accounts.enrollActivate", claudeActivation, {
863
+ idempotencyKey: "activate-claude-failure"
864
+ }), (error) => error instanceof ControlError);
865
+ failActivation = false;
866
+ const claudePath = join(stateHome, "subscriptions", "claude-code", "claude-work.json");
867
+ assert.equal(existsSync(claudePath), false);
868
+ assert.equal(existsSync(join(stateHome, "account-transactions")), false);
869
+ assert.equal((await client.call("daemon.status", {})).configRevision, beforeClaude.configRevision);
870
+ assert.equal((await client.call("daemon.status", {})).accountRevision, beforeClaude.accountRevision);
871
+ await withMockAnthropicDiscovery(async () => {
872
+ const claudeActivated = await client.call("accounts.enrollActivate", claudeActivation, { idempotencyKey: "activate-claude" });
873
+ assert.equal(claudeActivated.activated, true);
874
+ assert.equal(claudeActivated.configRevision, beforeClaude.configRevision + 1);
875
+ assert.equal(claudeActivated.accountRevision, beforeClaude.accountRevision + 1);
876
+ assert.equal(existsSync(claudePath), true);
877
+ assert.match(readFileSync(configPath, "utf8"), /claude-code:/);
878
+ assert.doesNotMatch(JSON.stringify(claudeActivated), /claude-transaction-access|claude-transaction-refresh/);
879
+ const claudeReplay = await client.call("accounts.enrollActivate", claudeActivation, { idempotencyKey: "activate-claude-retry" });
880
+ assert.equal(claudeReplay.configRevision, claudeActivated.configRevision);
881
+ assert.equal(claudeReplay.accountRevision, claudeActivated.accountRevision);
882
+ assert.equal((await client.call("accounts.status", {})).accounts.find((entry) => entry.subscriptionKind === "claude-code" && entry.label === "claude-work")?.configured, true);
883
+ assert.equal((await client.call("accounts.remove", { kind: "claude-code", label: "claude-work" }, { idempotencyKey: "remove-claude-work" })).removed, true);
884
+ assert.equal(existsSync(claudePath), false);
885
+ });
886
+ const removed = await client.call("accounts.remove", { kind: "gemini", label: "antigravity-user@example.com" }, { idempotencyKey: "remove-gemini" });
887
+ assert.equal(removed.removed, true);
888
+ assert.equal(existsSync(join(authDirectory, "antigravity-user@example.com.json")), false);
889
+ }
890
+ finally {
891
+ await daemon.close();
892
+ }
893
+ const survivors = readFileSync(markerPath, "utf8")
894
+ .trim()
895
+ .split("\n")
896
+ .map(Number)
897
+ .filter(processAlive);
898
+ for (const pid of survivors)
899
+ process.kill(pid, "SIGKILL");
900
+ assert.deepEqual(survivors, [], "daemon shutdown must stop the managed sidecar");
901
+ rmSync(root, { recursive: true, force: true });
902
+ });
903
+ test("second daemon cannot claim authority and generations remain monotonic", async () => {
904
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-singleton-"));
905
+ const stateHome = join(root, "state");
906
+ const configPath = join(root, "router.yaml");
907
+ writeFileSync(configPath, "providers:\n openai: {}\ndefaultModel: openai/mock-model\n");
908
+ const upstream = await mockProvider();
909
+ const options = {
910
+ packageVersion: "1.0.0",
911
+ stateHome,
912
+ configPath,
913
+ port: 0,
914
+ portless: false,
915
+ env: {
916
+ ...process.env,
917
+ HOME: root,
918
+ ROUTEKIT_HOME: stateHome,
919
+ OPENAI_API_KEY: "test-key",
920
+ OPENAI_BASE_URL: upstream.url,
921
+ ROUTEKIT_PORTLESS: "0"
922
+ }
923
+ };
924
+ const first = await startRouteKitDaemon(options);
925
+ try {
926
+ await assert.rejects(startRouteKitDaemon(options), (error) => {
927
+ assert.match(error instanceof Error ? error.message : String(error), /already running/);
928
+ assert.equal(JSON.stringify(error).includes(first.record.controlToken ?? "impossible-token"), false, "singleton conflicts must not disclose the control credential");
929
+ return true;
930
+ });
931
+ assert.equal(first.record.generation, 1);
932
+ }
933
+ finally {
934
+ await first.close();
935
+ }
936
+ const second = await startRouteKitDaemon(options);
937
+ try {
938
+ assert.equal(second.record.generation, 2);
939
+ }
940
+ finally {
941
+ await second.close();
942
+ await upstream.close();
943
+ rmSync(root, { recursive: true, force: true });
944
+ }
945
+ });
946
+ async function assertInterruptedNativeActivationRecovery(kind) {
947
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-recovery-"));
948
+ const stateHome = join(root, "state");
949
+ const configPath = join(root, "router.yaml");
950
+ const accountPath = join(stateHome, "subscriptions", kind, "interrupted.json");
951
+ const priorConfig = "providers:\n openai: {}\ndefaultModel: openai/mock-model\n";
952
+ writeFileSync(configPath, priorConfig);
953
+ prepareAccountTransaction({
954
+ home: stateHome,
955
+ configPath,
956
+ accountPaths: [accountPath],
957
+ kind,
958
+ provider: kind,
959
+ labels: ["interrupted"]
960
+ });
961
+ mkdirSync(dirname(accountPath), { recursive: true });
962
+ writeFileSync(accountPath, JSON.stringify(kind === "claude-code"
963
+ ? {
964
+ claudeAiOauth: {
965
+ accessToken: "interrupted-access",
966
+ refreshToken: "interrupted-refresh"
967
+ }
968
+ }
969
+ : {
970
+ tokens: {
971
+ access_token: "interrupted-access",
972
+ refresh_token: "interrupted-refresh"
973
+ }
974
+ }));
975
+ writeFileSync(configPath, `providers:\n openai: {}\n ${kind}: {}\ndefaultModel: openai/mock-model\n`);
976
+ writeFileSync(join(stateHome, "daemon-revisions.json"), JSON.stringify({ config: 1, accounts: 1, daemon: 0 }));
977
+ const upstream = await mockProvider();
978
+ const daemon = await startRouteKitDaemon({
979
+ packageVersion: "1.2.3",
980
+ stateHome,
981
+ configPath,
982
+ port: 0,
983
+ portless: false,
984
+ env: {
985
+ ...process.env,
986
+ HOME: root,
987
+ ROUTEKIT_HOME: stateHome,
988
+ OPENAI_API_KEY: "test-key",
989
+ OPENAI_BASE_URL: upstream.url,
990
+ ROUTEKIT_PORTLESS: "0"
991
+ }
992
+ });
993
+ try {
994
+ assert.equal(existsSync(accountPath), false);
995
+ assert.equal(readFileSync(configPath, "utf8"), priorConfig);
996
+ const client = new RouteKitControlClient({
997
+ url: daemon.record.url,
998
+ token: daemon.record.controlToken
999
+ });
1000
+ const accounts = await client.call("accounts.status", {});
1001
+ assert.deepEqual(accounts.accounts, []);
1002
+ assert.deepEqual(accounts.recovery, {
1003
+ state: "recovered",
1004
+ recovered: 1,
1005
+ cleaned: 0
1006
+ });
1007
+ const doctor = await client.call("doctor.run", {});
1008
+ assert.equal(doctor.checks.find((check) => check.name === "account activation recovery")?.detail, "recovered 1 interrupted operation(s)");
1009
+ assert.doesNotMatch(JSON.stringify({ accounts, doctor }), /interrupted-access/);
1010
+ }
1011
+ finally {
1012
+ await daemon.close();
1013
+ await upstream.close();
1014
+ rmSync(root, { recursive: true, force: true });
1015
+ }
1016
+ }
1017
+ test("daemon recovers interrupted activation before loading config or starting routers", async () => {
1018
+ await assertInterruptedNativeActivationRecovery("codex");
1019
+ });
1020
+ test("daemon recovers interrupted Claude activation before loading config or starting routers", async () => {
1021
+ await assertInterruptedNativeActivationRecovery("claude-code");
1022
+ });