offrouter-adapter-claude 0.0.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/package.json +15 -0
- package/package.json.bak +16 -0
- package/src/hooks/pre-tool-use.ts +19 -0
- package/src/hooks/user-prompt-submit.test.ts +222 -0
- package/src/hooks/user-prompt-submit.ts +262 -0
- package/src/index.ts +49 -0
- package/src/install.test.ts +589 -0
- package/src/install.ts +595 -0
- package/src/plugin.test.ts +150 -0
- package/src/rollback.ts +329 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { loadConfig } from "offrouter-core";
|
|
7
|
+
import {
|
|
8
|
+
CLAUDE_INSTALL_STATE_REL,
|
|
9
|
+
INSTALLED_PLUGIN_REL,
|
|
10
|
+
installClaudeProfile,
|
|
11
|
+
listAllowlistedPersonalClaudeProfiles,
|
|
12
|
+
resolveClaudeProfileDir,
|
|
13
|
+
type ClaudeInstallOptions,
|
|
14
|
+
type ClaudeInstallResult,
|
|
15
|
+
} from "./install.js";
|
|
16
|
+
import { rollbackClaudeProfile } from "./rollback.js";
|
|
17
|
+
|
|
18
|
+
const tempRoots: string[] = [];
|
|
19
|
+
|
|
20
|
+
async function makeTempHome(prefix: string): Promise<string> {
|
|
21
|
+
const { mkdtemp } = await import("node:fs/promises");
|
|
22
|
+
const root = await mkdtemp(join(tmpdir(), prefix));
|
|
23
|
+
tempRoots.push(root);
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function exists(path: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await access(path, constants.F_OK);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function writeAllowlist(
|
|
37
|
+
offrouterHome: string,
|
|
38
|
+
profiles: string[],
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
await mkdir(offrouterHome, { recursive: true });
|
|
41
|
+
const listed = profiles.map((p) => JSON.stringify(p)).join(", ");
|
|
42
|
+
await writeFile(
|
|
43
|
+
join(offrouterHome, "config.toml"),
|
|
44
|
+
`allowlisted_profiles = [${listed}]\n`,
|
|
45
|
+
"utf8",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function baseEnv(home: string): NodeJS.ProcessEnv {
|
|
50
|
+
return {
|
|
51
|
+
...process.env,
|
|
52
|
+
HOME: home,
|
|
53
|
+
OFFROUTER_HOME: join(home, ".offrouter"),
|
|
54
|
+
OFFROUTER_CLAUDE_PROFILES_DIR: join(home, "claude-profiles"),
|
|
55
|
+
// Never inherit real workspace trust / profile for these unit tests
|
|
56
|
+
OFFROUTER_PROFILE: undefined,
|
|
57
|
+
OFFROUTER_WORKSPACE_TRUSTED: undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function installOpts(
|
|
62
|
+
home: string,
|
|
63
|
+
profile: string,
|
|
64
|
+
overrides: Partial<ClaudeInstallOptions> = {},
|
|
65
|
+
): Promise<ClaudeInstallOptions> {
|
|
66
|
+
const env = baseEnv(home);
|
|
67
|
+
const config = await loadConfig({
|
|
68
|
+
env,
|
|
69
|
+
homedir: () => home,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
profile,
|
|
73
|
+
env,
|
|
74
|
+
homedir: () => home,
|
|
75
|
+
config,
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
while (tempRoots.length > 0) {
|
|
82
|
+
const root = tempRoots.pop();
|
|
83
|
+
if (root) {
|
|
84
|
+
await rm(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("resolveClaudeProfileDir", () => {
|
|
90
|
+
it("uses OFFROUTER_CLAUDE_PROFILES_DIR/<profile> when set", () => {
|
|
91
|
+
const home = "/tmp/not-used-home";
|
|
92
|
+
const dir = resolveClaudeProfileDir("claude-personal", {
|
|
93
|
+
env: {
|
|
94
|
+
OFFROUTER_CLAUDE_PROFILES_DIR: "/tmp/profiles-root",
|
|
95
|
+
},
|
|
96
|
+
homedir: () => home,
|
|
97
|
+
});
|
|
98
|
+
expect(dir).toBe(join("/tmp/profiles-root", "claude-personal"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("maps profile id claude to ~/.claude and claude-personal to ~/.claude-personal", () => {
|
|
102
|
+
const home = "/tmp/fake-home-map";
|
|
103
|
+
expect(
|
|
104
|
+
resolveClaudeProfileDir("claude", {
|
|
105
|
+
env: {},
|
|
106
|
+
homedir: () => home,
|
|
107
|
+
}),
|
|
108
|
+
).toBe(join(home, ".claude"));
|
|
109
|
+
expect(
|
|
110
|
+
resolveClaudeProfileDir("claude-personal", {
|
|
111
|
+
env: {},
|
|
112
|
+
homedir: () => home,
|
|
113
|
+
}),
|
|
114
|
+
).toBe(join(home, ".claude-personal"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects unsafe profile ids before resolving profile paths", () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
resolveClaudeProfileDir("../claude-personal", {
|
|
120
|
+
env: {
|
|
121
|
+
OFFROUTER_CLAUDE_PROFILES_DIR: "/tmp/profiles-root",
|
|
122
|
+
},
|
|
123
|
+
homedir: () => "/tmp/fake-home",
|
|
124
|
+
}),
|
|
125
|
+
).toThrow(/unsafe profile id/i);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("installClaudeProfile", () => {
|
|
130
|
+
it("dry-run shows exact file changes and does not write files", async () => {
|
|
131
|
+
const home = await makeTempHome("lr-install-dry-");
|
|
132
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
|
|
133
|
+
const opts = await installOpts(home, "claude-personal", { dryRun: true });
|
|
134
|
+
const result = await installClaudeProfile(opts);
|
|
135
|
+
|
|
136
|
+
expect(result.ok).toBe(true);
|
|
137
|
+
expect(result.dryRun).toBe(true);
|
|
138
|
+
expect(result.profile).toBe("claude-personal");
|
|
139
|
+
expect(result.changes.length).toBeGreaterThan(0);
|
|
140
|
+
|
|
141
|
+
const profileDir = resolveClaudeProfileDir("claude-personal", opts);
|
|
142
|
+
const targetHook = join(
|
|
143
|
+
profileDir,
|
|
144
|
+
"skills",
|
|
145
|
+
"offrouter",
|
|
146
|
+
"hooks",
|
|
147
|
+
"hooks.json",
|
|
148
|
+
);
|
|
149
|
+
const statePath = join(
|
|
150
|
+
profileDir,
|
|
151
|
+
".offrouter",
|
|
152
|
+
"claude-install-state.json",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
result.changes.some(
|
|
157
|
+
(c) =>
|
|
158
|
+
c.path.endsWith(
|
|
159
|
+
join("skills", "offrouter", "hooks", "hooks.json"),
|
|
160
|
+
) || c.relativePath === "skills/offrouter/hooks/hooks.json",
|
|
161
|
+
),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
expect(await exists(targetHook)).toBe(false);
|
|
164
|
+
expect(await exists(statePath)).toBe(false);
|
|
165
|
+
expect(await exists(join(home, ".claude"))).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("install writes only selected profile paths and not sibling work/default dirs", async () => {
|
|
169
|
+
const home = await makeTempHome("lr-install-scope-");
|
|
170
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
|
|
171
|
+
|
|
172
|
+
// Pre-create sibling profile dirs with markers so we can detect writes.
|
|
173
|
+
const profilesRoot = join(home, "claude-profiles");
|
|
174
|
+
await mkdir(join(profilesRoot, "claude-work"), { recursive: true });
|
|
175
|
+
await mkdir(join(profilesRoot, "claude"), { recursive: true });
|
|
176
|
+
await writeFile(
|
|
177
|
+
join(profilesRoot, "claude-work", "marker.txt"),
|
|
178
|
+
"work-untouched",
|
|
179
|
+
"utf8",
|
|
180
|
+
);
|
|
181
|
+
await writeFile(
|
|
182
|
+
join(profilesRoot, "claude", "marker.txt"),
|
|
183
|
+
"default-untouched",
|
|
184
|
+
"utf8",
|
|
185
|
+
);
|
|
186
|
+
await mkdir(join(home, ".claude"), { recursive: true });
|
|
187
|
+
await writeFile(
|
|
188
|
+
join(home, ".claude", "marker.txt"),
|
|
189
|
+
"global-untouched",
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const opts = await installOpts(home, "claude-personal");
|
|
194
|
+
const result = await installClaudeProfile(opts);
|
|
195
|
+
expect(result.ok).toBe(true);
|
|
196
|
+
expect(result.dryRun).toBe(false);
|
|
197
|
+
|
|
198
|
+
const profileDir = resolveClaudeProfileDir("claude-personal", opts);
|
|
199
|
+
const hookPath = join(
|
|
200
|
+
profileDir,
|
|
201
|
+
"skills",
|
|
202
|
+
"offrouter",
|
|
203
|
+
"hooks",
|
|
204
|
+
"hooks.json",
|
|
205
|
+
);
|
|
206
|
+
expect(await exists(hookPath)).toBe(true);
|
|
207
|
+
const hooksRaw = await readFile(hookPath, "utf8");
|
|
208
|
+
const hooks = JSON.parse(hooksRaw) as {
|
|
209
|
+
hooks?: {
|
|
210
|
+
UserPromptSubmit?: Array<{
|
|
211
|
+
hooks?: Array<{ command?: string }>;
|
|
212
|
+
}>;
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
const commands =
|
|
216
|
+
hooks.hooks?.UserPromptSubmit?.flatMap((e) =>
|
|
217
|
+
(e.hooks ?? []).map((h) => h.command ?? ""),
|
|
218
|
+
) ?? [];
|
|
219
|
+
const joined = commands.join("\n");
|
|
220
|
+
expect(joined).toContain("OFFROUTER_PROFILE=claude-personal");
|
|
221
|
+
expect(joined).toContain("offrouter hooks claude user-prompt-submit");
|
|
222
|
+
expect(joined).not.toContain("OFFROUTER_WORKSPACE_TRUSTED");
|
|
223
|
+
|
|
224
|
+
// Sibling profile and real home ~/.claude markers untouched
|
|
225
|
+
expect(
|
|
226
|
+
await readFile(join(profilesRoot, "claude-work", "marker.txt"), "utf8"),
|
|
227
|
+
).toBe("work-untouched");
|
|
228
|
+
expect(
|
|
229
|
+
await readFile(join(profilesRoot, "claude", "marker.txt"), "utf8"),
|
|
230
|
+
).toBe("default-untouched");
|
|
231
|
+
expect(await readFile(join(home, ".claude", "marker.txt"), "utf8")).toBe(
|
|
232
|
+
"global-untouched",
|
|
233
|
+
);
|
|
234
|
+
expect(await exists(join(profilesRoot, "claude-work", "skills"))).toBe(
|
|
235
|
+
false,
|
|
236
|
+
);
|
|
237
|
+
expect(await exists(join(home, ".claude", "skills"))).toBe(false);
|
|
238
|
+
|
|
239
|
+
// State lives inside target profile only
|
|
240
|
+
const statePath = join(
|
|
241
|
+
profileDir,
|
|
242
|
+
".offrouter",
|
|
243
|
+
"claude-install-state.json",
|
|
244
|
+
);
|
|
245
|
+
expect(await exists(statePath)).toBe(true);
|
|
246
|
+
const state = JSON.parse(await readFile(statePath, "utf8")) as {
|
|
247
|
+
profile: string;
|
|
248
|
+
files: Record<string, { previous: string | null }>;
|
|
249
|
+
};
|
|
250
|
+
expect(state.profile).toBe("claude-personal");
|
|
251
|
+
expect(Object.keys(state.files).length).toBeGreaterThan(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses denied work profiles", async () => {
|
|
255
|
+
const home = await makeTempHome("lr-install-work-");
|
|
256
|
+
// Even if someone allowlists a work profile, policy still denies *-work.
|
|
257
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-work"]);
|
|
258
|
+
const opts = await installOpts(home, "claude-work", { dryRun: true });
|
|
259
|
+
const result = await installClaudeProfile(opts);
|
|
260
|
+
expect(result.ok).toBe(false);
|
|
261
|
+
expect(result.errorCode).toBe("work_profile_denied");
|
|
262
|
+
expect(result.message.toLowerCase()).toMatch(/work|denied/);
|
|
263
|
+
expect(
|
|
264
|
+
await exists(
|
|
265
|
+
join(home, "claude-profiles", "claude-work", "skills", "offrouter"),
|
|
266
|
+
),
|
|
267
|
+
).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("refuses work-like profile ids such as claude-work-staging even if allowlisted", async () => {
|
|
271
|
+
const home = await makeTempHome("lr-install-work-staging-");
|
|
272
|
+
// Core policy only hard-denies ids ending in -work, so claude-work-staging
|
|
273
|
+
// would otherwise pass routeRequest. The installer must still refuse it.
|
|
274
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-work-staging"]);
|
|
275
|
+
const opts = await installOpts(home, "claude-work-staging", {
|
|
276
|
+
dryRun: true,
|
|
277
|
+
});
|
|
278
|
+
const result = await installClaudeProfile(opts);
|
|
279
|
+
expect(result.ok).toBe(false);
|
|
280
|
+
expect(result.errorCode).toBe("work_profile_denied");
|
|
281
|
+
expect(result.message.toLowerCase()).toContain("work");
|
|
282
|
+
expect(
|
|
283
|
+
await exists(
|
|
284
|
+
join(
|
|
285
|
+
home,
|
|
286
|
+
"claude-profiles",
|
|
287
|
+
"claude-work-staging",
|
|
288
|
+
"skills",
|
|
289
|
+
"offrouter",
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("refuses unallowlisted personal profiles", async () => {
|
|
296
|
+
const home = await makeTempHome("lr-install-unallow-");
|
|
297
|
+
await writeAllowlist(join(home, ".offrouter"), []); // nothing allowlisted
|
|
298
|
+
const opts = await installOpts(home, "claude-personal", { dryRun: true });
|
|
299
|
+
const result = await installClaudeProfile(opts);
|
|
300
|
+
expect(result.ok).toBe(false);
|
|
301
|
+
expect(result.errorCode).toBe("profile_not_allowlisted");
|
|
302
|
+
expect(result.message.toLowerCase()).toMatch(
|
|
303
|
+
/allowlist|not allowlisted|explicitly/,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("refuses unsafe profile ids with a structured install error", async () => {
|
|
308
|
+
const home = await makeTempHome("lr-install-unsafe-");
|
|
309
|
+
await writeAllowlist(join(home, ".offrouter"), ["../claude-personal"]);
|
|
310
|
+
const opts = await installOpts(home, "../claude-personal", {
|
|
311
|
+
dryRun: true,
|
|
312
|
+
});
|
|
313
|
+
const result = await installClaudeProfile(opts);
|
|
314
|
+
expect(result.ok).toBe(false);
|
|
315
|
+
expect(result.errorCode).toBe("install_error");
|
|
316
|
+
expect(result.profileDir).toBe("");
|
|
317
|
+
expect(result.message.toLowerCase()).toContain("unsafe profile id");
|
|
318
|
+
expect(await exists(join(home, "skills"))).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("refuses allowlisted non-Claude profiles for Claude install", async () => {
|
|
322
|
+
const home = await makeTempHome("lr-install-non-claude-");
|
|
323
|
+
await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
|
|
324
|
+
const opts = await installOpts(home, "codex-personal", { dryRun: true });
|
|
325
|
+
const result = await installClaudeProfile(opts);
|
|
326
|
+
expect(result.ok).toBe(false);
|
|
327
|
+
expect(result.errorCode).toBe("install_error");
|
|
328
|
+
expect(result.message.toLowerCase()).toContain("claude install");
|
|
329
|
+
expect(
|
|
330
|
+
await exists(join(home, "claude-profiles", "codex-personal", "skills")),
|
|
331
|
+
).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("install --all-personal --dry-run returns a matrix of allowlisted personal Claude profiles", async () => {
|
|
335
|
+
const home = await makeTempHome("lr-install-all-");
|
|
336
|
+
await writeAllowlist(join(home, ".offrouter"), [
|
|
337
|
+
"claude",
|
|
338
|
+
"claude-personal",
|
|
339
|
+
"claude-hobby",
|
|
340
|
+
"codex-personal",
|
|
341
|
+
"claude-work",
|
|
342
|
+
]);
|
|
343
|
+
const env = baseEnv(home);
|
|
344
|
+
const config = await loadConfig({ env, homedir: () => home });
|
|
345
|
+
const profiles = listAllowlistedPersonalClaudeProfiles(config);
|
|
346
|
+
expect(profiles).toEqual(
|
|
347
|
+
expect.arrayContaining(["claude-personal", "claude-hobby"]),
|
|
348
|
+
);
|
|
349
|
+
expect(profiles).not.toContain("claude");
|
|
350
|
+
expect(profiles).not.toContain("claude-work");
|
|
351
|
+
expect(profiles).not.toContain("codex-personal");
|
|
352
|
+
|
|
353
|
+
const matrix: ClaudeInstallResult[] = [];
|
|
354
|
+
for (const profile of profiles) {
|
|
355
|
+
matrix.push(
|
|
356
|
+
await installClaudeProfile({
|
|
357
|
+
profile,
|
|
358
|
+
env,
|
|
359
|
+
homedir: () => home,
|
|
360
|
+
config,
|
|
361
|
+
dryRun: true,
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
expect(matrix.every((r) => r.ok && r.dryRun)).toBe(true);
|
|
366
|
+
expect(matrix.map((r) => r.profile).sort()).toEqual(
|
|
367
|
+
["claude-hobby", "claude-personal"].sort(),
|
|
368
|
+
);
|
|
369
|
+
expect(
|
|
370
|
+
matrix.every((r) =>
|
|
371
|
+
r.changes.some((c) => c.kind === "create" || c.kind === "update"),
|
|
372
|
+
),
|
|
373
|
+
).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("rollback restores previous files from metadata", async () => {
|
|
377
|
+
const home = await makeTempHome("lr-install-rb-");
|
|
378
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
|
|
379
|
+
|
|
380
|
+
const opts = await installOpts(home, "claude-personal");
|
|
381
|
+
const profileDir = resolveClaudeProfileDir("claude-personal", opts);
|
|
382
|
+
const existingPath = join(
|
|
383
|
+
profileDir,
|
|
384
|
+
"skills",
|
|
385
|
+
"offrouter",
|
|
386
|
+
"hooks",
|
|
387
|
+
"hooks.json",
|
|
388
|
+
);
|
|
389
|
+
await mkdir(join(profileDir, "skills", "offrouter", "hooks"), {
|
|
390
|
+
recursive: true,
|
|
391
|
+
});
|
|
392
|
+
await writeFile(existingPath, '{"description":"preexisting"}\n', "utf8");
|
|
393
|
+
|
|
394
|
+
const installResult = await installClaudeProfile(opts);
|
|
395
|
+
expect(installResult.ok).toBe(true);
|
|
396
|
+
const siblingStateFile = join(profileDir, ".offrouter", "notes.json");
|
|
397
|
+
await writeFile(siblingStateFile, '{"keep":true}\n', "utf8");
|
|
398
|
+
const afterInstall = await readFile(existingPath, "utf8");
|
|
399
|
+
expect(afterInstall).not.toContain("preexisting");
|
|
400
|
+
expect(afterInstall).toContain("OFFROUTER_PROFILE=claude-personal");
|
|
401
|
+
|
|
402
|
+
const rb = await rollbackClaudeProfile({
|
|
403
|
+
profile: "claude-personal",
|
|
404
|
+
env: opts.env,
|
|
405
|
+
homedir: opts.homedir,
|
|
406
|
+
dryRun: false,
|
|
407
|
+
});
|
|
408
|
+
expect(rb.ok).toBe(true);
|
|
409
|
+
expect(await readFile(existingPath, "utf8")).toContain("preexisting");
|
|
410
|
+
|
|
411
|
+
// Files created by OffRouter with no previous content are removed
|
|
412
|
+
const pluginManifest = join(
|
|
413
|
+
profileDir,
|
|
414
|
+
"skills",
|
|
415
|
+
"offrouter",
|
|
416
|
+
".claude-plugin",
|
|
417
|
+
"plugin.json",
|
|
418
|
+
);
|
|
419
|
+
expect(await exists(pluginManifest)).toBe(false);
|
|
420
|
+
|
|
421
|
+
const statePath = join(
|
|
422
|
+
profileDir,
|
|
423
|
+
".offrouter",
|
|
424
|
+
"claude-install-state.json",
|
|
425
|
+
);
|
|
426
|
+
expect(await exists(statePath)).toBe(false);
|
|
427
|
+
expect(await readFile(siblingStateFile, "utf8")).toBe('{"keep":true}\n');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("refuses to reinstall when prior install state is invalid", async () => {
|
|
431
|
+
const home = await makeTempHome("lr-install-corrupt-state-");
|
|
432
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
|
|
433
|
+
const opts = await installOpts(home, "claude-personal");
|
|
434
|
+
const profileDir = resolveClaudeProfileDir("claude-personal", opts);
|
|
435
|
+
const hookPath = join(
|
|
436
|
+
profileDir,
|
|
437
|
+
"skills",
|
|
438
|
+
"offrouter",
|
|
439
|
+
"hooks",
|
|
440
|
+
"hooks.json",
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const first = await installClaudeProfile(opts);
|
|
444
|
+
expect(first.ok).toBe(true);
|
|
445
|
+
await writeFile(hookPath, '{"description":"user edit"}\n', "utf8");
|
|
446
|
+
await writeFile(
|
|
447
|
+
join(profileDir, ".offrouter", "claude-install-state.json"),
|
|
448
|
+
"{not valid json",
|
|
449
|
+
"utf8",
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const second = await installClaudeProfile(opts);
|
|
453
|
+
expect(second.ok).toBe(false);
|
|
454
|
+
expect(second.message.toLowerCase()).toContain("invalid install state");
|
|
455
|
+
expect(await readFile(hookPath, "utf8")).toBe(
|
|
456
|
+
'{"description":"user edit"}\n',
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("rollback refuses install state paths outside the installed plugin root", async () => {
|
|
461
|
+
const home = await makeTempHome("lr-install-rb-escape-");
|
|
462
|
+
const env = baseEnv(home);
|
|
463
|
+
const profile = "claude-personal";
|
|
464
|
+
const profileDir = resolveClaudeProfileDir(profile, {
|
|
465
|
+
env,
|
|
466
|
+
homedir: () => home,
|
|
467
|
+
});
|
|
468
|
+
const outsidePath = join(home, "outside.txt");
|
|
469
|
+
const statePath = join(profileDir, CLAUDE_INSTALL_STATE_REL);
|
|
470
|
+
await writeFile(outsidePath, "keep\n", "utf8");
|
|
471
|
+
await mkdir(join(profileDir, ".offrouter"), { recursive: true });
|
|
472
|
+
await writeFile(
|
|
473
|
+
statePath,
|
|
474
|
+
`${JSON.stringify(
|
|
475
|
+
{
|
|
476
|
+
version: 1,
|
|
477
|
+
profile,
|
|
478
|
+
harness: "claude",
|
|
479
|
+
installedAt: new Date(0).toISOString(),
|
|
480
|
+
pluginRootRelative: INSTALLED_PLUGIN_REL,
|
|
481
|
+
files: {
|
|
482
|
+
"../../outside.txt": { previous: null },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
null,
|
|
486
|
+
2,
|
|
487
|
+
)}\n`,
|
|
488
|
+
"utf8",
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const rb = await rollbackClaudeProfile({
|
|
492
|
+
profile,
|
|
493
|
+
env,
|
|
494
|
+
homedir: () => home,
|
|
495
|
+
});
|
|
496
|
+
expect(rb.ok).toBe(false);
|
|
497
|
+
expect(rb.errorCode).toBe("invalid_install_state");
|
|
498
|
+
expect(await readFile(outsidePath, "utf8")).toBe("keep\n");
|
|
499
|
+
expect(await exists(statePath)).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("rollback refuses prefix-matching paths that still escape the plugin root", async () => {
|
|
503
|
+
const home = await makeTempHome("lr-install-rb-prefix-escape-");
|
|
504
|
+
const env = baseEnv(home);
|
|
505
|
+
const profile = "claude-personal";
|
|
506
|
+
const profileDir = resolveClaudeProfileDir(profile, {
|
|
507
|
+
env,
|
|
508
|
+
homedir: () => home,
|
|
509
|
+
});
|
|
510
|
+
const secretPath = join(home, "secret.txt");
|
|
511
|
+
const statePath = join(profileDir, CLAUDE_INSTALL_STATE_REL);
|
|
512
|
+
await writeFile(secretPath, "top-secret\n", "utf8");
|
|
513
|
+
await mkdir(join(profileDir, ".offrouter"), { recursive: true });
|
|
514
|
+
await writeFile(
|
|
515
|
+
statePath,
|
|
516
|
+
`${JSON.stringify(
|
|
517
|
+
{
|
|
518
|
+
version: 1,
|
|
519
|
+
profile,
|
|
520
|
+
harness: "claude",
|
|
521
|
+
installedAt: new Date(0).toISOString(),
|
|
522
|
+
pluginRootRelative: INSTALLED_PLUGIN_REL,
|
|
523
|
+
files: {
|
|
524
|
+
// Starts with the plugin prefix but traverses out via "..".
|
|
525
|
+
"skills/offrouter/../../../secret.txt": { previous: null },
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
null,
|
|
529
|
+
2,
|
|
530
|
+
)}\n`,
|
|
531
|
+
"utf8",
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const rb = await rollbackClaudeProfile({
|
|
535
|
+
profile,
|
|
536
|
+
env,
|
|
537
|
+
homedir: () => home,
|
|
538
|
+
});
|
|
539
|
+
expect(rb.ok).toBe(false);
|
|
540
|
+
expect(rb.errorCode).toBe("invalid_install_state");
|
|
541
|
+
expect(await readFile(secretPath, "utf8")).toBe("top-secret\n");
|
|
542
|
+
expect(await exists(statePath)).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("repeated install is idempotent and preserves original rollback", async () => {
|
|
546
|
+
const home = await makeTempHome("lr-install-idemp-");
|
|
547
|
+
await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
|
|
548
|
+
const opts = await installOpts(home, "claude-personal");
|
|
549
|
+
const profileDir = resolveClaudeProfileDir("claude-personal", opts);
|
|
550
|
+
const hookPath = join(
|
|
551
|
+
profileDir,
|
|
552
|
+
"skills",
|
|
553
|
+
"offrouter",
|
|
554
|
+
"hooks",
|
|
555
|
+
"hooks.json",
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
await mkdir(join(profileDir, "skills", "offrouter", "hooks"), {
|
|
559
|
+
recursive: true,
|
|
560
|
+
});
|
|
561
|
+
await writeFile(hookPath, '{"description":"original"}\n', "utf8");
|
|
562
|
+
|
|
563
|
+
const first = await installClaudeProfile(opts);
|
|
564
|
+
const second = await installClaudeProfile(opts);
|
|
565
|
+
expect(first.ok).toBe(true);
|
|
566
|
+
expect(second.ok).toBe(true);
|
|
567
|
+
|
|
568
|
+
const after = await readFile(hookPath, "utf8");
|
|
569
|
+
expect(after).toContain("OFFROUTER_PROFILE=claude-personal");
|
|
570
|
+
|
|
571
|
+
// State still tracks original contents for true rollback
|
|
572
|
+
const state = JSON.parse(
|
|
573
|
+
await readFile(
|
|
574
|
+
join(profileDir, ".offrouter", "claude-install-state.json"),
|
|
575
|
+
"utf8",
|
|
576
|
+
),
|
|
577
|
+
) as { files: Record<string, { previous: string | null }> };
|
|
578
|
+
const hookRel = "skills/offrouter/hooks/hooks.json";
|
|
579
|
+
expect(state.files[hookRel]?.previous).toContain("original");
|
|
580
|
+
|
|
581
|
+
const rb = await rollbackClaudeProfile({
|
|
582
|
+
profile: "claude-personal",
|
|
583
|
+
env: opts.env,
|
|
584
|
+
homedir: opts.homedir,
|
|
585
|
+
});
|
|
586
|
+
expect(rb.ok).toBe(true);
|
|
587
|
+
expect(await readFile(hookPath, "utf8")).toContain("original");
|
|
588
|
+
});
|
|
589
|
+
});
|