@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.
- package/LICENSE +201 -0
- package/README.md +20 -0
- package/dist/driver.d.ts +23 -0
- package/dist/driver.js +379 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +34 -0
- package/dist/install.d.ts +26 -0
- package/dist/install.js +519 -0
- package/dist/launch.d.ts +6 -0
- package/dist/launch.js +51 -0
- package/dist/test/driver.test.d.ts +1 -0
- package/dist/test/driver.test.js +153 -0
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +646 -0
- package/dist/test/launch.test.d.ts +1 -0
- package/dist/test/launch.test.js +96 -0
- package/package.json +48 -0
|
@@ -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 {};
|