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