@velum-labs/routekit-tool-claude 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,646 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import test from "node:test";
9
+ import { installClaudeIntegration, uninstallClaudeIntegration } from "../install.js";
10
+ const testingGlobal = globalThis;
11
+ const OWNER = {
12
+ id: "example-host",
13
+ displayName: "Example Host",
14
+ installCommand: "example install claude",
15
+ uninstallCommand: "example uninstall claude",
16
+ startCommand: "example serve"
17
+ };
18
+ function install(configDirectory, gatewayUrl = "http://127.0.0.1:9999/", authToken) {
19
+ return installClaudeIntegration({
20
+ gatewayUrl,
21
+ ...(authToken !== undefined ? { authToken } : {}),
22
+ owner: OWNER,
23
+ claudeConfigDir: configDirectory
24
+ });
25
+ }
26
+ test("Claude managed install updates and restores the exact original settings", async () => {
27
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-install-"));
28
+ const configPath = join(configDirectory, "settings.json");
29
+ const original = '{ "permissions": { "allow": ["Bash(git status)"] } }\n';
30
+ writeFileSync(configPath, original);
31
+ try {
32
+ const installed = await install(configDirectory, "http://127.0.0.1:9999/", "gateway-secret");
33
+ assert.equal(installed.action, "installed");
34
+ assert.deepEqual(installed.managedKeys.sort(), [
35
+ "ANTHROPIC_AUTH_TOKEN",
36
+ "ANTHROPIC_BASE_URL",
37
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
38
+ ]);
39
+ const settings = JSON.parse(readFileSync(configPath, "utf8"));
40
+ assert.deepEqual(settings.permissions, { allow: ["Bash(git status)"] });
41
+ assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:9999");
42
+ assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, "gateway-secret");
43
+ assert.equal(statSync(configPath).mode & 0o777, 0o600);
44
+ const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
45
+ assert.deepEqual({
46
+ version: JSON.parse(readFileSync(manifestPath, "utf8")).version,
47
+ state: JSON.parse(readFileSync(manifestPath, "utf8")).state,
48
+ mode: statSync(manifestPath).mode & 0o777
49
+ }, { version: 2, state: "installed", mode: 0o600 });
50
+ assert.equal((await install(configDirectory, "http://127.0.0.1:8888", "updated-secret")).action, "updated");
51
+ assert.equal(JSON.parse(readFileSync(configPath, "utf8")).env.ANTHROPIC_BASE_URL, "http://127.0.0.1:8888");
52
+ assert.equal((await uninstallClaudeIntegration({
53
+ ownerId: OWNER.id,
54
+ claudeConfigDir: configDirectory
55
+ })).removed, true);
56
+ assert.equal(readFileSync(configPath, "utf8"), original);
57
+ assert.equal(existsSync(manifestPath), false);
58
+ }
59
+ finally {
60
+ rmSync(configDirectory, { recursive: true, force: true });
61
+ }
62
+ });
63
+ test("Claude uninstall preserves user edits made after install", async () => {
64
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-edits-"));
65
+ const configPath = join(configDirectory, "settings.json");
66
+ try {
67
+ await install(configDirectory);
68
+ const settings = JSON.parse(readFileSync(configPath, "utf8"));
69
+ settings.theme = "dark";
70
+ settings.env.USER_SETTING = "kept";
71
+ writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`);
72
+ await uninstallClaudeIntegration({
73
+ ownerId: OWNER.id,
74
+ claudeConfigDir: configDirectory
75
+ });
76
+ assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), {
77
+ theme: "dark",
78
+ env: { USER_SETTING: "kept" }
79
+ });
80
+ }
81
+ finally {
82
+ rmSync(configDirectory, { recursive: true, force: true });
83
+ }
84
+ });
85
+ test("Claude install refuses malformed settings and user-owned env conflicts", async () => {
86
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-conflict-"));
87
+ const configPath = join(configDirectory, "settings.json");
88
+ try {
89
+ writeFileSync(configPath, "{not-json");
90
+ await assert.rejects(install(configDirectory), /not valid JSON/);
91
+ writeFileSync(configPath, `${JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://user.example" } })}\n`);
92
+ await assert.rejects(install(configDirectory), /already define env\.ANTHROPIC_BASE_URL/);
93
+ assert.equal(existsSync(join(configDirectory, `.${OWNER.id}-integration.json`)), false);
94
+ }
95
+ finally {
96
+ rmSync(configDirectory, { recursive: true, force: true });
97
+ }
98
+ });
99
+ function failOnceAt(boundary) {
100
+ let failed = false;
101
+ testingGlobal.__routekitClaudeInstallFaultInjector = (reached) => {
102
+ if (!failed && reached === boundary) {
103
+ failed = true;
104
+ throw new Error(`simulated crash after ${boundary}`);
105
+ }
106
+ };
107
+ }
108
+ function clearFaultInjector() {
109
+ delete testingGlobal.__routekitClaudeInstallFaultInjector;
110
+ }
111
+ function spawnBlockedInstall(configDirectory, boundary) {
112
+ const installModule = new URL("../install.js", import.meta.url).href;
113
+ const script = `
114
+ const [moduleUrl, configDirectory, boundary] = process.argv.slice(1);
115
+ const { installClaudeIntegration } = await import(moduleUrl);
116
+ globalThis.__routekitClaudeInstallFaultInjector = (reached) => {
117
+ if (reached !== boundary) return;
118
+ process.send?.({ boundary: reached });
119
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
120
+ };
121
+ await installClaudeIntegration({
122
+ gatewayUrl: "http://127.0.0.1:9999",
123
+ owner: ${JSON.stringify(OWNER)},
124
+ claudeConfigDir: configDirectory
125
+ });
126
+ `;
127
+ return spawn(process.execPath, [
128
+ "--input-type=module",
129
+ "-e",
130
+ script,
131
+ installModule,
132
+ configDirectory,
133
+ boundary
134
+ ], { stdio: ["ignore", "ignore", "inherit", "ipc"] });
135
+ }
136
+ async function waitForBoundary(child, boundary) {
137
+ const [message] = await Promise.race([
138
+ once(child, "message"),
139
+ once(child, "exit").then(([code, signal]) => {
140
+ throw new Error(`install child exited before ${boundary} (code=${String(code)}, signal=${String(signal)})`);
141
+ })
142
+ ]);
143
+ assert.deepEqual(message, { boundary });
144
+ }
145
+ async function killAndWait(child) {
146
+ const exited = once(child, "exit");
147
+ child.kill("SIGKILL");
148
+ await exited;
149
+ }
150
+ test("install recovery is safe at every atomic write boundary", async () => {
151
+ const boundaries = [
152
+ "install-pending",
153
+ "install-settings",
154
+ "install-committed"
155
+ ];
156
+ for (const boundary of boundaries) {
157
+ const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-${boundary}-`));
158
+ const configPath = join(configDirectory, "settings.json");
159
+ const original = `{"boundary":${JSON.stringify(boundary)}}\n`;
160
+ writeFileSync(configPath, original);
161
+ try {
162
+ failOnceAt(boundary);
163
+ await assert.rejects(install(configDirectory), /simulated crash/);
164
+ clearFaultInjector();
165
+ await install(configDirectory);
166
+ await uninstallClaudeIntegration({
167
+ ownerId: OWNER.id,
168
+ claudeConfigDir: configDirectory
169
+ });
170
+ assert.equal(readFileSync(configPath, "utf8"), original);
171
+ }
172
+ finally {
173
+ clearFaultInjector();
174
+ rmSync(configDirectory, { recursive: true, force: true });
175
+ }
176
+ }
177
+ });
178
+ test("update recovery rolls back settings and manifest at every write boundary", async () => {
179
+ const boundaries = [
180
+ "install-pending",
181
+ "install-settings",
182
+ "install-committed"
183
+ ];
184
+ for (const boundary of boundaries) {
185
+ const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-update-${boundary}-`));
186
+ const configPath = join(configDirectory, "settings.json");
187
+ const original = '{"theme":"light"}\n';
188
+ writeFileSync(configPath, original);
189
+ try {
190
+ await install(configDirectory, "http://127.0.0.1:7000");
191
+ failOnceAt(boundary);
192
+ await assert.rejects(install(configDirectory, "http://127.0.0.1:7001"), /simulated crash/);
193
+ clearFaultInjector();
194
+ await install(configDirectory, "http://127.0.0.1:7002");
195
+ assert.equal(JSON.parse(readFileSync(configPath, "utf8")).env.ANTHROPIC_BASE_URL, "http://127.0.0.1:7002");
196
+ await uninstallClaudeIntegration({
197
+ ownerId: OWNER.id,
198
+ claudeConfigDir: configDirectory
199
+ });
200
+ assert.equal(readFileSync(configPath, "utf8"), original);
201
+ }
202
+ finally {
203
+ clearFaultInjector();
204
+ rmSync(configDirectory, { recursive: true, force: true });
205
+ }
206
+ }
207
+ });
208
+ test("uninstall recovery finishes idempotently at every write boundary", async () => {
209
+ const boundaries = [
210
+ "uninstall-pending",
211
+ "uninstall-settings",
212
+ "uninstall-committed"
213
+ ];
214
+ for (const boundary of boundaries) {
215
+ const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-${boundary}-`));
216
+ const configPath = join(configDirectory, "settings.json");
217
+ const original = '{"theme":"light"}\n';
218
+ writeFileSync(configPath, original);
219
+ try {
220
+ await install(configDirectory);
221
+ failOnceAt(boundary);
222
+ await assert.rejects(uninstallClaudeIntegration({
223
+ ownerId: OWNER.id,
224
+ claudeConfigDir: configDirectory
225
+ }), /simulated crash/);
226
+ clearFaultInjector();
227
+ await uninstallClaudeIntegration({
228
+ ownerId: OWNER.id,
229
+ claudeConfigDir: configDirectory
230
+ });
231
+ assert.equal(readFileSync(configPath, "utf8"), original);
232
+ assert.equal(existsSync(join(configDirectory, `.${OWNER.id}-integration.json`)), false);
233
+ }
234
+ finally {
235
+ clearFaultInjector();
236
+ rmSync(configDirectory, { recursive: true, force: true });
237
+ }
238
+ }
239
+ });
240
+ test("uninstall recovery refuses an unexpected external settings edit", async () => {
241
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-recovery-edit-"));
242
+ const configPath = join(configDirectory, "settings.json");
243
+ try {
244
+ await install(configDirectory);
245
+ failOnceAt("uninstall-pending");
246
+ await assert.rejects(uninstallClaudeIntegration({
247
+ ownerId: OWNER.id,
248
+ claudeConfigDir: configDirectory
249
+ }), /simulated crash/);
250
+ clearFaultInjector();
251
+ writeFileSync(configPath, '{"external":true}\n');
252
+ await assert.rejects(uninstallClaudeIntegration({
253
+ ownerId: OWNER.id,
254
+ claudeConfigDir: configDirectory
255
+ }), /refused to overwrite the external edit/);
256
+ assert.equal(readFileSync(configPath, "utf8"), '{"external":true}\n');
257
+ }
258
+ finally {
259
+ clearFaultInjector();
260
+ rmSync(configDirectory, { recursive: true, force: true });
261
+ }
262
+ });
263
+ test("install recovery refuses an unexpected external settings edit", async () => {
264
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-install-edit-"));
265
+ const configPath = join(configDirectory, "settings.json");
266
+ writeFileSync(configPath, '{"before":true}\n');
267
+ try {
268
+ failOnceAt("install-settings");
269
+ await assert.rejects(install(configDirectory), /simulated crash/);
270
+ clearFaultInjector();
271
+ writeFileSync(configPath, '{"external":true}\n');
272
+ await assert.rejects(install(configDirectory), /refused to overwrite the external edit/);
273
+ assert.equal(readFileSync(configPath, "utf8"), '{"external":true}\n');
274
+ }
275
+ finally {
276
+ clearFaultInjector();
277
+ rmSync(configDirectory, { recursive: true, force: true });
278
+ }
279
+ });
280
+ test("explicit config directory wins over CLAUDE_CONFIG_DIR, which wins over the default", async () => {
281
+ const root = mkdtempSync(join(tmpdir(), "routekit-claude-precedence-"));
282
+ const environmentDirectory = join(root, "environment");
283
+ const explicitDirectory = join(root, "explicit");
284
+ const previous = process.env.CLAUDE_CONFIG_DIR;
285
+ try {
286
+ process.env.CLAUDE_CONFIG_DIR = environmentDirectory;
287
+ const fromEnvironment = await installClaudeIntegration({
288
+ gatewayUrl: "http://127.0.0.1:9999",
289
+ owner: OWNER
290
+ });
291
+ assert.equal(fromEnvironment.configPath, join(environmentDirectory, "settings.json"));
292
+ const explicit = await installClaudeIntegration({
293
+ gatewayUrl: "http://127.0.0.1:9999",
294
+ owner: OWNER,
295
+ claudeConfigDir: explicitDirectory
296
+ });
297
+ assert.equal(explicit.configPath, join(explicitDirectory, "settings.json"));
298
+ }
299
+ finally {
300
+ if (previous === undefined)
301
+ delete process.env.CLAUDE_CONFIG_DIR;
302
+ else
303
+ process.env.CLAUDE_CONFIG_DIR = previous;
304
+ rmSync(root, { recursive: true, force: true });
305
+ }
306
+ });
307
+ test("install never persists or takes ownership of ANTHROPIC_MODEL", async () => {
308
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-model-"));
309
+ const configPath = join(configDirectory, "settings.json");
310
+ const original = '{"env":{"ANTHROPIC_MODEL":"user-choice"}}\n';
311
+ writeFileSync(configPath, original);
312
+ try {
313
+ const result = await install(configDirectory);
314
+ const settings = JSON.parse(readFileSync(configPath, "utf8"));
315
+ assert.equal(settings.env.ANTHROPIC_MODEL, "user-choice");
316
+ assert.equal(result.managedKeys.includes("ANTHROPIC_MODEL"), false);
317
+ await uninstallClaudeIntegration({
318
+ ownerId: OWNER.id,
319
+ claudeConfigDir: configDirectory
320
+ });
321
+ assert.equal(readFileSync(configPath, "utf8"), original);
322
+ }
323
+ finally {
324
+ rmSync(configDirectory, { recursive: true, force: true });
325
+ }
326
+ });
327
+ test("updating a legacy install removes its formerly managed ANTHROPIC_MODEL", async () => {
328
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-legacy-model-"));
329
+ const configPath = join(configDirectory, "settings.json");
330
+ const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
331
+ const original = '{"theme":"light"}\n';
332
+ const installed = `${JSON.stringify({
333
+ theme: "light",
334
+ env: {
335
+ ANTHROPIC_BASE_URL: "http://127.0.0.1:7000",
336
+ ANTHROPIC_AUTH_TOKEN: "routekit",
337
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
338
+ ANTHROPIC_MODEL: "claude-code/legacy"
339
+ }
340
+ }, null, 2)}\n`;
341
+ writeFileSync(configPath, installed);
342
+ writeFileSync(manifestPath, `${JSON.stringify({
343
+ version: 1,
344
+ ownerId: OWNER.id,
345
+ originalContent: original,
346
+ exactRestoreEligible: true,
347
+ installedContentHashes: [
348
+ createHash("sha256").update(installed).digest("hex")
349
+ ],
350
+ managedEnvValues: {
351
+ ANTHROPIC_BASE_URL: ["http://127.0.0.1:7000"],
352
+ ANTHROPIC_AUTH_TOKEN: ["routekit"],
353
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"],
354
+ ANTHROPIC_MODEL: ["claude-code/legacy"]
355
+ }
356
+ }, null, 2)}\n`);
357
+ try {
358
+ await install(configDirectory, "http://127.0.0.1:7001");
359
+ const settings = JSON.parse(readFileSync(configPath, "utf8"));
360
+ assert.equal("ANTHROPIC_MODEL" in settings.env, false);
361
+ await uninstallClaudeIntegration({
362
+ ownerId: OWNER.id,
363
+ claudeConfigDir: configDirectory
364
+ });
365
+ assert.equal(readFileSync(configPath, "utf8"), original);
366
+ }
367
+ finally {
368
+ rmSync(configDirectory, { recursive: true, force: true });
369
+ }
370
+ });
371
+ function writeInterruptedLegacyUpdate(configDirectory, phase) {
372
+ const configPath = join(configDirectory, "settings.json");
373
+ const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
374
+ const original = '{"theme":"light"}\n';
375
+ const content = (baseUrl, authToken, model) => `${JSON.stringify({
376
+ theme: "light",
377
+ env: {
378
+ ANTHROPIC_BASE_URL: baseUrl,
379
+ ANTHROPIC_AUTH_TOKEN: authToken,
380
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
381
+ ANTHROPIC_MODEL: model
382
+ }
383
+ }, null, 2)}\n`;
384
+ const before = content("http://127.0.0.1:7000", "before-token", "claude-code/before");
385
+ const after = content("http://127.0.0.1:7001", "after-token", "claude-code/after");
386
+ writeFileSync(configPath, phase === "before-settings" ? before : after);
387
+ writeFileSync(manifestPath, `${JSON.stringify({
388
+ version: 1,
389
+ ownerId: OWNER.id,
390
+ originalContent: original,
391
+ exactRestoreEligible: true,
392
+ installedContentHashes: [before, after].map((value) => createHash("sha256").update(value).digest("hex")),
393
+ managedEnvValues: {
394
+ ANTHROPIC_BASE_URL: [
395
+ "http://127.0.0.1:7000",
396
+ "http://127.0.0.1:7001"
397
+ ],
398
+ ANTHROPIC_AUTH_TOKEN: ["before-token", "after-token"],
399
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"],
400
+ ANTHROPIC_MODEL: [
401
+ "claude-code/before",
402
+ "claude-code/after"
403
+ ]
404
+ }
405
+ }, null, 2)}\n`);
406
+ return { configPath, manifestPath, original };
407
+ }
408
+ test("install migrates interrupted v1 updates before and after settings", async () => {
409
+ for (const phase of ["before-settings", "after-settings"]) {
410
+ const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-v1-install-${phase}-`));
411
+ const { configPath, manifestPath, original } = writeInterruptedLegacyUpdate(configDirectory, phase);
412
+ try {
413
+ const result = await install(configDirectory, "http://127.0.0.1:7002", "final-token");
414
+ assert.equal(result.action, "updated");
415
+ const settings = JSON.parse(readFileSync(configPath, "utf8"));
416
+ assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:7002");
417
+ assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, "final-token");
418
+ assert.equal("ANTHROPIC_MODEL" in settings.env, false);
419
+ assert.deepEqual({
420
+ version: JSON.parse(readFileSync(manifestPath, "utf8")).version,
421
+ state: JSON.parse(readFileSync(manifestPath, "utf8")).state
422
+ }, { version: 2, state: "installed" });
423
+ await uninstallClaudeIntegration({
424
+ ownerId: OWNER.id,
425
+ claudeConfigDir: configDirectory
426
+ });
427
+ assert.equal(readFileSync(configPath, "utf8"), original);
428
+ }
429
+ finally {
430
+ rmSync(configDirectory, { recursive: true, force: true });
431
+ }
432
+ }
433
+ });
434
+ test("uninstall migrates interrupted v1 updates before and after settings", async () => {
435
+ for (const phase of ["before-settings", "after-settings"]) {
436
+ const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-v1-uninstall-${phase}-`));
437
+ const { configPath, manifestPath, original } = writeInterruptedLegacyUpdate(configDirectory, phase);
438
+ try {
439
+ assert.equal((await uninstallClaudeIntegration({
440
+ ownerId: OWNER.id,
441
+ claudeConfigDir: configDirectory
442
+ })).removed, true);
443
+ assert.equal(readFileSync(configPath, "utf8"), original);
444
+ assert.equal(existsSync(manifestPath), false);
445
+ }
446
+ finally {
447
+ rmSync(configDirectory, { recursive: true, force: true });
448
+ }
449
+ }
450
+ });
451
+ test("legacy migration refuses unrecognized settings without overwriting", async () => {
452
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-v1-external-"));
453
+ const { configPath, manifestPath } = writeInterruptedLegacyUpdate(configDirectory, "after-settings");
454
+ const external = '{"external":true}\n';
455
+ writeFileSync(configPath, external);
456
+ const manifest = readFileSync(manifestPath, "utf8");
457
+ try {
458
+ await assert.rejects(install(configDirectory), /does not match the current settings/);
459
+ assert.equal(readFileSync(configPath, "utf8"), external);
460
+ assert.equal(readFileSync(manifestPath, "utf8"), manifest);
461
+ }
462
+ finally {
463
+ rmSync(configDirectory, { recursive: true, force: true });
464
+ }
465
+ });
466
+ test("untouched original settings discard a stale pre-install v1 manifest", async () => {
467
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-v1-original-"));
468
+ const configPath = join(configDirectory, "settings.json");
469
+ const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
470
+ const original = '{"theme":"light"}\n';
471
+ const target = `${JSON.stringify({
472
+ theme: "light",
473
+ env: {
474
+ ANTHROPIC_BASE_URL: "http://127.0.0.1:7000",
475
+ ANTHROPIC_AUTH_TOKEN: "routekit",
476
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
477
+ }
478
+ }, null, 2)}\n`;
479
+ const writeStaleManifest = () => {
480
+ writeFileSync(manifestPath, `${JSON.stringify({
481
+ version: 1,
482
+ ownerId: OWNER.id,
483
+ originalContent: original,
484
+ exactRestoreEligible: true,
485
+ installedContentHashes: [original, target].map((value) => createHash("sha256").update(value).digest("hex")),
486
+ managedEnvValues: {
487
+ ANTHROPIC_BASE_URL: ["http://127.0.0.1:7000"],
488
+ ANTHROPIC_AUTH_TOKEN: ["routekit"],
489
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"]
490
+ }
491
+ }, null, 2)}\n`);
492
+ };
493
+ writeFileSync(configPath, original);
494
+ try {
495
+ writeStaleManifest();
496
+ assert.equal((await uninstallClaudeIntegration({
497
+ ownerId: OWNER.id,
498
+ claudeConfigDir: configDirectory
499
+ })).removed, false);
500
+ assert.equal(existsSync(manifestPath), false);
501
+ assert.equal(readFileSync(configPath, "utf8"), original);
502
+ writeStaleManifest();
503
+ assert.equal((await install(configDirectory)).action, "installed");
504
+ await uninstallClaudeIntegration({
505
+ ownerId: OWNER.id,
506
+ claudeConfigDir: configDirectory
507
+ });
508
+ assert.equal(readFileSync(configPath, "utf8"), original);
509
+ }
510
+ finally {
511
+ rmSync(configDirectory, { recursive: true, force: true });
512
+ }
513
+ });
514
+ test("new config directories are 0700 and exact restore preserves settings mode", async () => {
515
+ const root = mkdtempSync(join(tmpdir(), "routekit-claude-mode-"));
516
+ const createdDirectory = join(root, "nested", "claude");
517
+ const existingDirectory = join(root, "existing");
518
+ mkdirSync(existingDirectory);
519
+ const configPath = join(existingDirectory, "settings.json");
520
+ const original = '{"theme":"dark"}\n';
521
+ writeFileSync(configPath, original);
522
+ chmodSync(configPath, 0o640);
523
+ try {
524
+ await install(createdDirectory);
525
+ assert.equal(statSync(createdDirectory).mode & 0o777, 0o700);
526
+ await install(existingDirectory);
527
+ await uninstallClaudeIntegration({
528
+ ownerId: OWNER.id,
529
+ claudeConfigDir: existingDirectory
530
+ });
531
+ assert.equal(readFileSync(configPath, "utf8"), original);
532
+ assert.equal(statSync(configPath).mode & 0o777, 0o640);
533
+ }
534
+ finally {
535
+ rmSync(root, { recursive: true, force: true });
536
+ }
537
+ });
538
+ test("Claude config and private files reject symlinks and non-file entries", async () => {
539
+ const root = mkdtempSync(join(tmpdir(), "routekit-claude-file-safety-"));
540
+ try {
541
+ const targetDirectory = join(root, "target");
542
+ mkdirSync(targetDirectory);
543
+ const linkedDirectory = join(root, "linked");
544
+ symlinkSync(targetDirectory, linkedDirectory);
545
+ await assert.rejects(install(linkedDirectory), /must not be a symlink/);
546
+ const fileDirectory = join(root, "not-a-directory");
547
+ writeFileSync(fileDirectory, "");
548
+ await assert.rejects(install(fileDirectory), /not a directory/);
549
+ for (const entryName of [
550
+ "settings.json",
551
+ `.${OWNER.id}-integration.json`,
552
+ ".routekit-claude-integration.lock",
553
+ ".routekit-claude-integration.lock.reap"
554
+ ]) {
555
+ const symlinkDirectory = join(root, `symlink-${entryName.replaceAll(".", "-")}`);
556
+ mkdirSync(symlinkDirectory);
557
+ symlinkSync(join(root, "missing-target"), join(symlinkDirectory, entryName));
558
+ await assert.rejects(install(symlinkDirectory), /must not be a symlink/);
559
+ const nonFileDirectory = join(root, `non-file-${entryName.replaceAll(".", "-")}`);
560
+ mkdirSync(nonFileDirectory);
561
+ mkdirSync(join(nonFileDirectory, entryName));
562
+ await assert.rejects(install(nonFileDirectory), /must be a regular file/);
563
+ }
564
+ }
565
+ finally {
566
+ rmSync(root, { recursive: true, force: true });
567
+ }
568
+ });
569
+ test("a partial process lock is stabilized and safely recovered", async () => {
570
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-malformed-lock-"));
571
+ const lockPath = join(configDirectory, ".routekit-claude-integration.lock");
572
+ writeFileSync(lockPath, '{"pid":"not-a-process"}\n');
573
+ try {
574
+ await install(configDirectory);
575
+ assert.equal(existsSync(lockPath), false);
576
+ await uninstallClaudeIntegration({
577
+ ownerId: OWNER.id,
578
+ claudeConfigDir: configDirectory
579
+ });
580
+ }
581
+ finally {
582
+ rmSync(configDirectory, { recursive: true, force: true });
583
+ }
584
+ });
585
+ test("normal release does not remove a lock record it no longer owns", async () => {
586
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-replaced-lock-"));
587
+ const lockPath = join(configDirectory, ".routekit-claude-integration.lock");
588
+ testingGlobal.__routekitClaudeInstallFaultInjector = (boundary) => {
589
+ if (boundary !== "install-pending")
590
+ return;
591
+ const replacement = JSON.parse(readFileSync(lockPath, "utf8"));
592
+ replacement.nonce = "replacement-owner";
593
+ writeFileSync(lockPath, `${JSON.stringify(replacement)}\n`);
594
+ throw new Error("simulated lock replacement");
595
+ };
596
+ try {
597
+ await assert.rejects(install(configDirectory), /simulated lock replacement/);
598
+ assert.equal(JSON.parse(readFileSync(lockPath, "utf8")).nonce, "replacement-owner");
599
+ }
600
+ finally {
601
+ clearFaultInjector();
602
+ rmSync(configDirectory, { recursive: true, force: true });
603
+ }
604
+ });
605
+ test("a live process-owned lock refuses concurrent mutation", async () => {
606
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-lock-"));
607
+ const child = spawnBlockedInstall(configDirectory, "install-pending");
608
+ try {
609
+ await waitForBoundary(child, "install-pending");
610
+ await assert.rejects(install(configDirectory), /timed out waiting for lifecycle lock/);
611
+ }
612
+ finally {
613
+ await killAndWait(child);
614
+ rmSync(configDirectory, { recursive: true, force: true });
615
+ }
616
+ });
617
+ test("SIGKILL stale-lock reclamation reaches install transaction recovery", async () => {
618
+ const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-sigkill-"));
619
+ const configPath = join(configDirectory, "settings.json");
620
+ const lockPath = join(configDirectory, ".routekit-claude-integration.lock");
621
+ const original = '{"theme":"light"}\n';
622
+ writeFileSync(configPath, original);
623
+ const child = spawnBlockedInstall(configDirectory, "install-pending");
624
+ try {
625
+ await waitForBoundary(child, "install-pending");
626
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
627
+ assert.equal(lock.pid, child.pid);
628
+ assert.equal(typeof lock.processIdentity, "string");
629
+ assert.equal(Number.isFinite(Date.parse(lock.acquiredAt)), true);
630
+ await killAndWait(child);
631
+ assert.equal(existsSync(lockPath), true);
632
+ assert.equal((await install(configDirectory)).action, "installed");
633
+ assert.equal(existsSync(lockPath), false);
634
+ await uninstallClaudeIntegration({
635
+ ownerId: OWNER.id,
636
+ claudeConfigDir: configDirectory
637
+ });
638
+ assert.equal(readFileSync(configPath, "utf8"), original);
639
+ }
640
+ finally {
641
+ if (child.exitCode === null && child.signalCode === null) {
642
+ await killAndWait(child);
643
+ }
644
+ rmSync(configDirectory, { recursive: true, force: true });
645
+ }
646
+ });
@@ -0,0 +1 @@
1
+ export {};