-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstallAgent.test.ts
More file actions
286 lines (250 loc) · 14.5 KB
/
Copy pathinstallAgent.test.ts
File metadata and controls
286 lines (250 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// installAgent.test.ts — the per-agent (codex/cursor/gemini) install writers (ARP-503).
//
// All disk is mocked (a tiny in-memory fs) — no real ~/.codex / ~/.cursor / ~/.gemini
// is touched. Each writer is exercised for: the correct USER-GLOBAL path, the right
// MCP + hook shape, idempotence (re-run = no write), preserving foreign content, the
// corrupt-config refusal, and the version gate.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
runInstallAgent,
parseInstallAgent,
hookCommand,
legacyHookCommand,
cursorDeeplink,
type AgentInstallDeps,
} from './installAgent.js';
const HOME = '/home/dev';
function fakeFs(initial: Record<string, string> = {}) {
const files: Record<string, string> = { ...initial };
const modes: Record<string, number> = {};
const deps: AgentInstallDeps = {
readFileImpl: async (p: string) => {
if (p in files) return files[p];
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
writeFileImpl: async (p: string, d: string) => void (files[p] = d),
mkdirImpl: async () => {},
chmodImpl: async (p: string, m: number) => void (modes[p] = m),
};
return { files, modes, deps };
}
const noProbe = async () => null;
// A deterministic Node bin dir for the Cursor wrapper-script tests (ARP-692), so the
// generated scripts don't depend on the real `process.execPath` of the test runner.
const NODE_BIN = '/opt/node22/bin';
// --- parse + helpers ---------------------------------------------------------
test('parseInstallAgent maps aliases + rejects unknown', () => {
assert.equal(parseInstallAgent('codex'), 'codex');
assert.equal(parseInstallAgent('cursor'), 'cursor');
assert.equal(parseInstallAgent('gemini'), 'gemini');
assert.equal(parseInstallAgent('gemini-cli'), 'gemini');
assert.equal(parseInstallAgent('claude-code'), 'claude-code');
assert.equal(parseInstallAgent('cc'), 'claude-code');
assert.equal(parseInstallAgent('vim'), null);
assert.equal(parseInstallAgent(undefined), null);
});
test('hookCommand routes through the shared --from-hook entrypoint, detached + self-updating (@latest)', () => {
assert.equal(hookCommand('codex'), 'npx -y backthread@latest capture --from-hook --agent codex --detach');
assert.equal(hookCommand('gemini-cli'), 'npx -y backthread@latest capture --from-hook --agent gemini-cli --detach');
// legacyHookCommand is the pre-@latest form we migrate FROM (never @latest).
assert.equal(legacyHookCommand('codex'), 'npx -y backthread capture --from-hook --agent codex --detach');
});
test('cursorDeeplink encodes the MCP server config', () => {
const link = cursorDeeplink();
assert.match(link, /^cursor:\/\/anysphere\.cursor-deeplink\/mcp\/install\?name=backthread&config=/);
const b64 = link.split('config=')[1];
const decoded = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
assert.equal(decoded.command, 'npx');
assert.deepEqual(decoded.args, ['-y', 'backthread', 'mcp']);
});
// --- Gemini ------------------------------------------------------------------
test('gemini: writes mcpServers + SessionEnd hook to ~/.gemini/settings.json, then idempotent', async () => {
const path = '/home/dev/.gemini/settings.json';
const fs1 = fakeFs();
const r1 = await runInstallAgent('gemini', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
assert.equal(r1.writes[0].path, path);
assert.equal(r1.writes[0].wrote, true);
const s = JSON.parse(fs1.files[path]);
assert.equal(s.mcpServers.backthread.command, 'npx');
assert.deepEqual(s.mcpServers.backthread.args, ['-y', 'backthread', 'mcp']);
assert.match(s.hooks.SessionEnd[0].hooks[0].command, /--from-hook --agent gemini-cli --detach/);
const fs2 = fakeFs({ [path]: fs1.files[path] });
const r2 = await runInstallAgent('gemini', { home: HOME, ...fs2.deps, probeVersionImpl: noProbe });
assert.equal(r2.writes[0].wrote, false); // re-run is a no-op
});
test('gemini: preserves the user’s existing settings + foreign hooks', async () => {
const path = '/home/dev/.gemini/settings.json';
const fs1 = fakeFs({
[path]: JSON.stringify({
theme: 'dark',
mcpServers: { other: { command: 'x' } },
hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'their-tool' }] }] },
}),
});
await runInstallAgent('gemini', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
const s = JSON.parse(fs1.files[path]);
assert.equal(s.theme, 'dark'); // untouched
assert.equal(s.mcpServers.other.command, 'x'); // other server kept
assert.equal(s.mcpServers.backthread.command, 'npx'); // ours added
assert.equal(s.hooks.SessionEnd.length, 2); // foreign hook kept, ours appended
assert.equal(s.hooks.SessionEnd[0].hooks[0].command, 'their-tool');
});
test('gemini: MIGRATES the pre-@latest hook command to @latest IN PLACE (no duplicate) — ARP-739', async () => {
const path = '/home/dev/.gemini/settings.json';
const fs1 = fakeFs({
[path]: JSON.stringify({
mcpServers: { backthread: { command: 'npx', args: ['-y', 'backthread', 'mcp'] } },
hooks: {
SessionEnd: [
{ hooks: [{ type: 'command', name: 'backthread-capture', command: legacyHookCommand('gemini-cli') }] },
],
},
}),
});
const r = await runInstallAgent('gemini', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
assert.equal(r.writes[0].wrote, true); // the stale hook was migrated
const s = JSON.parse(fs1.files[path]);
assert.equal(s.hooks.SessionEnd.length, 1); // migrated in place, NOT a second group (double-capture)
assert.equal(s.hooks.SessionEnd[0].hooks.length, 1);
assert.equal(s.hooks.SessionEnd[0].hooks[0].command, hookCommand('gemini-cli')); // now @latest
assert.equal(s.hooks.SessionEnd[0].hooks[0].name, 'backthread-capture'); // existing field preserved
});
// --- Codex -------------------------------------------------------------------
test('codex: appends [mcp_servers.backthread] to config.toml + writes the Stop hook; idempotent', async () => {
const toml = '/home/dev/.codex/config.toml';
const hooks = '/home/dev/.codex/hooks.json';
const fs1 = fakeFs();
await runInstallAgent('codex', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
assert.match(fs1.files[toml], /\[mcp_servers\.backthread\]/);
assert.match(fs1.files[toml], /command = "npx"/);
assert.match(fs1.files[toml], /args = \["-y", "backthread", "mcp"\]/);
const hj = JSON.parse(fs1.files[hooks]);
assert.match(hj.hooks.Stop[0].hooks[0].command, /--from-hook --agent codex --detach/);
assert.equal(hj.hooks.Stop[0].hooks[0].timeout, 60);
const fs2 = fakeFs({ [toml]: fs1.files[toml], [hooks]: fs1.files[hooks] });
const r2 = await runInstallAgent('codex', { home: HOME, ...fs2.deps, probeVersionImpl: noProbe });
assert.ok(r2.writes.every((w) => !w.wrote)); // both no-op on re-run
});
test('codex: preserves existing config.toml content when appending the MCP table', async () => {
const toml = '/home/dev/.codex/config.toml';
const fs1 = fakeFs({ [toml]: 'model = "o3"\n\n[some.other]\nkey = 1\n' });
await runInstallAgent('codex', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
assert.match(fs1.files[toml], /model = "o3"/); // root key kept
assert.match(fs1.files[toml], /\[some\.other\]/); // foreign table kept
assert.match(fs1.files[toml], /\[mcp_servers\.backthread\]/); // ours appended at the end
// Our table is appended LAST (a trailing table is TOML-append-safe).
assert.ok(fs1.files[toml].indexOf('[some.other]') < fs1.files[toml].indexOf('[mcp_servers.backthread]'));
});
test('codex: MIGRATES the pre-@latest Stop hook to @latest IN PLACE (no duplicate, timeout preserved) — ARP-739', async () => {
const hooks = '/home/dev/.codex/hooks.json';
const fs1 = fakeFs({
[hooks]: JSON.stringify({
hooks: { Stop: [{ hooks: [{ type: 'command', command: legacyHookCommand('codex'), timeout: 60 }] }] },
}),
});
const r = await runInstallAgent('codex', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe });
const hookWrite = r.writes.find((w) => w.path === hooks)!;
assert.equal(hookWrite.wrote, true); // migrated
const hj = JSON.parse(fs1.files[hooks]);
assert.equal(hj.hooks.Stop.length, 1); // migrated in place, NOT duplicated
assert.equal(hj.hooks.Stop[0].hooks[0].command, hookCommand('codex')); // now @latest
assert.equal(hj.hooks.Stop[0].hooks[0].timeout, 60); // existing field preserved
});
// --- Cursor ------------------------------------------------------------------
const CAPTURE_SCRIPT = '/home/dev/.cursor/hooks/backthread-capture.sh';
const MCP_SCRIPT = '/home/dev/.cursor/hooks/backthread-mcp.sh';
test('cursor: writes node-resolving wrapper scripts + points mcp.json/hooks.json at them by absolute path (ARP-692)', async () => {
const fs1 = fakeFs();
const r = await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
// (1) Both wrapper scripts written, chmod 0755, pin the Node bin dir on PATH then exec npx.
const capture = fs1.files[CAPTURE_SCRIPT];
assert.match(capture, /^#!\/bin\/sh/);
assert.match(capture, /PATH="\$NODE_BIN_DIR:\$PATH"/);
assert.match(capture, /NODE_BIN_DIR='\/opt\/node22\/bin'/);
assert.match(capture, /exec npx -y backthread@latest capture --from-hook --agent cursor --detach/); // hook self-updates
assert.equal(fs1.modes[CAPTURE_SCRIPT], 0o755);
const mcpScript = fs1.files[MCP_SCRIPT];
assert.match(mcpScript, /exec npx -y backthread mcp/); // MCP server stays bare (not @latest)
assert.doesNotMatch(mcpScript, /backthread@latest/);
assert.equal(fs1.modes[MCP_SCRIPT], 0o755);
// (2) mcp.json points at the absolute MCP wrapper, not a bare `npx`.
const mcp = JSON.parse(fs1.files['/home/dev/.cursor/mcp.json']);
assert.equal(mcp.mcpServers.backthread.command, MCP_SCRIPT);
assert.deepEqual(mcp.mcpServers.backthread.args, []);
// (3) hooks.json stop points at the absolute capture wrapper.
const hj = JSON.parse(fs1.files['/home/dev/.cursor/hooks.json']);
assert.equal(hj.version, 1);
assert.equal(hj.hooks.stop[0].command, CAPTURE_SCRIPT);
// The deeplink stays machine-agnostic (plain npx) — it's rendered on the website.
assert.match(r.deeplink ?? '', /^cursor:\/\//);
});
test('cursor: migrates the pre-ARP-692 inline hook command to the wrapper script (no duplicate)', async () => {
const path = '/home/dev/.cursor/hooks.json';
const fs1 = fakeFs({
[path]: JSON.stringify({ version: 1, hooks: { stop: [{ command: legacyHookCommand('cursor') }] } }),
});
const r = await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
const hookWrite = r.writes.find((w) => w.path.endsWith('hooks.json'))!;
assert.equal(hookWrite.wrote, true); // migrated
const stop = JSON.parse(fs1.files[path]).hooks.stop;
assert.equal(stop.length, 1); // old inline stripped, not duplicated
assert.equal(stop[0].command, CAPTURE_SCRIPT);
});
test('cursor: preserves an existing hooks.json version (no downgrade) once on the wrapper', async () => {
const path = '/home/dev/.cursor/hooks.json';
const fs1 = fakeFs({ [path]: JSON.stringify({ version: 2, hooks: { stop: [{ command: CAPTURE_SCRIPT }] } }) });
const r = await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
const hookWrite = r.writes.find((w) => w.path.endsWith('hooks.json'))!;
assert.equal(hookWrite.wrote, false); // our wrapper hook + a version already present → no-op
assert.equal(JSON.parse(fs1.files[path]).version, 2); // version untouched (not downgraded to 1)
});
test('cursor: preserves a foreign stop hook + foreign mcp server', async () => {
const fs1 = fakeFs({
'/home/dev/.cursor/hooks.json': JSON.stringify({ version: 1, hooks: { stop: [{ command: 'their-tool' }] } }),
'/home/dev/.cursor/mcp.json': JSON.stringify({ mcpServers: { other: { command: 'x' } } }),
});
await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
const stop = JSON.parse(fs1.files['/home/dev/.cursor/hooks.json']).hooks.stop;
assert.equal(stop.length, 2);
assert.ok(stop.some((h: { command: string }) => h.command === 'their-tool'));
assert.ok(stop.some((h: { command: string }) => h.command === CAPTURE_SCRIPT));
const mcp = JSON.parse(fs1.files['/home/dev/.cursor/mcp.json']);
assert.equal(mcp.mcpServers.other.command, 'x'); // foreign server kept
assert.equal(mcp.mcpServers.backthread.command, MCP_SCRIPT); // ours added
});
test('cursor: idempotent re-run writes nothing', async () => {
const fs1 = fakeFs();
await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
const fs2 = fakeFs({ ...fs1.files });
const r2 = await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs2.deps, probeVersionImpl: noProbe });
assert.ok(r2.writes.every((w) => !w.wrote));
});
test('cursor: a content-identical re-run RE-ASSERTS the exec bit (self-heal a stripped 0755)', async () => {
const fs1 = fakeFs();
await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs1.deps, probeVersionImpl: noProbe });
// Simulate the exec bit being stripped after install (a sync tool / manual edit).
const fs2 = fakeFs({ ...fs1.files });
delete fs2.modes[CAPTURE_SCRIPT];
delete fs2.modes[MCP_SCRIPT];
const r2 = await runInstallAgent('cursor', { home: HOME, nodeBinDir: NODE_BIN, ...fs2.deps, probeVersionImpl: noProbe });
assert.ok(r2.writes.every((w) => !w.wrote)); // content unchanged → still reported as no-write
assert.equal(fs2.modes[CAPTURE_SCRIPT], 0o755); // …but the exec bit was restored
assert.equal(fs2.modes[MCP_SCRIPT], 0o755);
});
// --- corrupt config + version gate -------------------------------------------
test('a corrupt existing config THROWS (never clobbered)', async () => {
const fs1 = fakeFs({ '/home/dev/.gemini/settings.json': '{ not json' });
await assert.rejects(
runInstallAgent('gemini', { home: HOME, ...fs1.deps, probeVersionImpl: noProbe }),
/not valid JSON|refusing/i,
);
});
test('version gate: warns on a too-old agent, silent on new / undetectable', async () => {
const old = await runInstallAgent('gemini', { home: HOME, ...fakeFs().deps, probeVersionImpl: async () => '0.10.0' });
assert.match(old.versionWarning ?? '', /0\.26\.0\+/);
const ok = await runInstallAgent('gemini', { home: HOME, ...fakeFs().deps, probeVersionImpl: async () => 'gemini 1.2.0' });
assert.equal(ok.versionWarning, null);
const none = await runInstallAgent('gemini', { home: HOME, ...fakeFs().deps, probeVersionImpl: noProbe });
assert.equal(none.versionWarning, null);
});