@elvatis_com/openclaw-cli-bridge-elvatis 1.9.1 → 2.1.1
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/.ai/handoff/NEXT_ACTIONS.md +4 -1
- package/.ai/handoff/STATUS.md +55 -50
- package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +14 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +11 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/auto-publish.yml +68 -0
- package/.github/workflows/codeql.yml +40 -0
- package/CODE_OF_CONDUCT.md +38 -0
- package/CONTRIBUTING.md +15 -108
- package/LICENSE +216 -0
- package/README.md +37 -7
- package/SECURITY.md +17 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +4 -3
- package/src/cli-runner.ts +178 -19
- package/src/codex-auth-import.ts +127 -0
- package/src/grok-client.ts +1 -1
- package/src/proxy-server.ts +145 -22
- package/src/session-manager.ts +346 -0
- package/src/workdir.ts +108 -0
- package/test/chatgpt-proxy.test.ts +2 -2
- package/test/claude-proxy.test.ts +2 -2
- package/test/cli-runner-extended.test.ts +267 -0
- package/test/codex-auth-import.test.ts +244 -0
- package/test/grok-proxy.test.ts +2 -2
- package/test/proxy-e2e.test.ts +274 -2
- package/test/session-manager.test.ts +446 -0
- package/test/workdir.test.ts +152 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for SessionManager.
|
|
3
|
+
*
|
|
4
|
+
* Mocks child_process.spawn so no real CLIs are executed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import type { ChildProcess } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
// ── Mock child_process.spawn ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Minimal fake ChildProcess for testing. */
|
|
14
|
+
function makeFakeProc(): ChildProcess & {
|
|
15
|
+
_stdout: EventEmitter;
|
|
16
|
+
_stderr: EventEmitter;
|
|
17
|
+
_emit: (event: string, ...args: unknown[]) => void;
|
|
18
|
+
} {
|
|
19
|
+
const proc = new EventEmitter() as any;
|
|
20
|
+
proc._stdout = new EventEmitter();
|
|
21
|
+
proc._stderr = new EventEmitter();
|
|
22
|
+
proc.stdout = proc._stdout;
|
|
23
|
+
proc.stderr = proc._stderr;
|
|
24
|
+
proc.stdin = {
|
|
25
|
+
write: vi.fn((_data: string, _enc: string, cb?: () => void) => { cb?.(); }),
|
|
26
|
+
end: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
proc.kill = vi.fn(() => true);
|
|
29
|
+
proc.pid = 12345;
|
|
30
|
+
proc._emit = (event: string, ...args: unknown[]) => proc.emit(event, ...args);
|
|
31
|
+
return proc;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// vi.hoisted variables are available inside vi.mock factories
|
|
35
|
+
const { mockSpawn, mockExecSync, latestProcRef, mockCreateIsolatedWorkdir, mockCleanupWorkdir, mockSweepOrphanedWorkdirs } = vi.hoisted(() => ({
|
|
36
|
+
mockSpawn: vi.fn(),
|
|
37
|
+
mockExecSync: vi.fn(),
|
|
38
|
+
latestProcRef: { current: null as any },
|
|
39
|
+
mockCreateIsolatedWorkdir: vi.fn(() => "/tmp/cli-bridge-fake123"),
|
|
40
|
+
mockCleanupWorkdir: vi.fn(() => true),
|
|
41
|
+
mockSweepOrphanedWorkdirs: vi.fn(() => 0),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
45
|
+
const orig = await importOriginal<typeof import("node:child_process")>();
|
|
46
|
+
return { ...orig, spawn: mockSpawn, execSync: mockExecSync };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Mock claude-auth to prevent real token operations
|
|
50
|
+
vi.mock("../src/claude-auth.js", () => ({
|
|
51
|
+
ensureClaudeToken: vi.fn(async () => {}),
|
|
52
|
+
refreshClaudeToken: vi.fn(async () => {}),
|
|
53
|
+
scheduleTokenRefresh: vi.fn(async () => {}),
|
|
54
|
+
stopTokenRefresh: vi.fn(),
|
|
55
|
+
setAuthLogger: vi.fn(),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// Mock workdir module to prevent real FS operations in unit tests
|
|
59
|
+
vi.mock("../src/workdir.js", () => ({
|
|
60
|
+
createIsolatedWorkdir: mockCreateIsolatedWorkdir,
|
|
61
|
+
cleanupWorkdir: mockCleanupWorkdir,
|
|
62
|
+
sweepOrphanedWorkdirs: mockSweepOrphanedWorkdirs,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Now import SessionManager (uses the mocked spawn)
|
|
66
|
+
import { SessionManager } from "../src/session-manager.js";
|
|
67
|
+
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("SessionManager", () => {
|
|
71
|
+
let mgr: SessionManager;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
mgr = new SessionManager();
|
|
75
|
+
mockSpawn.mockImplementation(() => {
|
|
76
|
+
const proc = makeFakeProc();
|
|
77
|
+
latestProcRef.current = proc;
|
|
78
|
+
return proc;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
mgr.stop();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── spawn() ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("spawn()", () => {
|
|
89
|
+
it("returns a hex sessionId", () => {
|
|
90
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
91
|
+
expect(id).toMatch(/^[a-f0-9]{16}$/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns unique sessionIds on repeated calls", () => {
|
|
95
|
+
const id1 = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
|
|
96
|
+
const id2 = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "b" }]);
|
|
97
|
+
expect(id1).not.toBe(id2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── poll() ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("poll()", () => {
|
|
104
|
+
it("returns running status for an active session", () => {
|
|
105
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
106
|
+
const result = mgr.poll(id);
|
|
107
|
+
expect(result).not.toBeNull();
|
|
108
|
+
expect(result!.running).toBe(true);
|
|
109
|
+
expect(result!.status).toBe("running");
|
|
110
|
+
expect(result!.exitCode).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns exited status after process closes", () => {
|
|
114
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
115
|
+
// Simulate process exit
|
|
116
|
+
latestProcRef.current._emit("close", 0);
|
|
117
|
+
const result = mgr.poll(id);
|
|
118
|
+
expect(result!.running).toBe(false);
|
|
119
|
+
expect(result!.status).toBe("exited");
|
|
120
|
+
expect(result!.exitCode).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns null for unknown sessionId", () => {
|
|
124
|
+
expect(mgr.poll("0000000000000000")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── log() ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("log()", () => {
|
|
131
|
+
it("returns buffered stdout output", () => {
|
|
132
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
133
|
+
latestProcRef.current._stdout.emit("data", Buffer.from("Hello "));
|
|
134
|
+
latestProcRef.current._stdout.emit("data", Buffer.from("World"));
|
|
135
|
+
const result = mgr.log(id);
|
|
136
|
+
expect(result).not.toBeNull();
|
|
137
|
+
expect(result!.stdout).toBe("Hello World");
|
|
138
|
+
expect(result!.offset).toBe(11);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns buffered stderr output", () => {
|
|
142
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
143
|
+
latestProcRef.current._stderr.emit("data", Buffer.from("warning"));
|
|
144
|
+
const result = mgr.log(id);
|
|
145
|
+
expect(result!.stderr).toBe("warning");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("supports offset to get incremental output", () => {
|
|
149
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
150
|
+
latestProcRef.current._stdout.emit("data", Buffer.from("ABCDE"));
|
|
151
|
+
const result = mgr.log(id, 3);
|
|
152
|
+
expect(result!.stdout).toBe("DE");
|
|
153
|
+
expect(result!.offset).toBe(5);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns null for unknown sessionId", () => {
|
|
157
|
+
expect(mgr.log("0000000000000000")).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── write() ────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("write()", () => {
|
|
164
|
+
it("writes data to stdin of a running session", () => {
|
|
165
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
166
|
+
const proc = latestProcRef.current;
|
|
167
|
+
const ok = mgr.write(id, "input data");
|
|
168
|
+
expect(ok).toBe(true);
|
|
169
|
+
expect(proc.stdin.write).toHaveBeenCalledWith("input data", "utf8");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns false for unknown sessionId", () => {
|
|
173
|
+
expect(mgr.write("0000000000000000", "data")).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns false for an exited session", () => {
|
|
177
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
178
|
+
latestProcRef.current._emit("close", 0);
|
|
179
|
+
expect(mgr.write(id, "data")).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── kill() ─────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("kill()", () => {
|
|
186
|
+
it("sends SIGTERM to a running session", () => {
|
|
187
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
188
|
+
const proc = latestProcRef.current;
|
|
189
|
+
const ok = mgr.kill(id);
|
|
190
|
+
expect(ok).toBe(true);
|
|
191
|
+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
192
|
+
// Status should be "killed"
|
|
193
|
+
const poll = mgr.poll(id);
|
|
194
|
+
expect(poll!.status).toBe("killed");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns false for unknown sessionId", () => {
|
|
198
|
+
expect(mgr.kill("0000000000000000")).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns false for an already-exited session", () => {
|
|
202
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
203
|
+
latestProcRef.current._emit("close", 0);
|
|
204
|
+
expect(mgr.kill(id)).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── list() ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("list()", () => {
|
|
211
|
+
it("returns all sessions", () => {
|
|
212
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
|
|
213
|
+
mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "b" }]);
|
|
214
|
+
const list = mgr.list();
|
|
215
|
+
expect(list).toHaveLength(2);
|
|
216
|
+
expect(list[0].sessionId).toMatch(/^[a-f0-9]{16}$/);
|
|
217
|
+
expect(list[0].model).toBe("cli-gemini/gemini-2.5-pro");
|
|
218
|
+
expect(list[0].status).toBe("running");
|
|
219
|
+
expect(list[1].model).toBe("cli-claude/claude-sonnet-4-6");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns empty array when no sessions exist", () => {
|
|
223
|
+
expect(mgr.list()).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── cleanup() ──────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("cleanup()", () => {
|
|
230
|
+
it("removes sessions older than TTL", () => {
|
|
231
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
232
|
+
|
|
233
|
+
// Manually set startTime to far in the past
|
|
234
|
+
const sessions = (mgr as any).sessions as Map<string, any>;
|
|
235
|
+
const entry = sessions.get(id)!;
|
|
236
|
+
entry.startTime = Date.now() - 31 * 60 * 1000; // 31 minutes ago
|
|
237
|
+
|
|
238
|
+
mgr.cleanup();
|
|
239
|
+
|
|
240
|
+
expect(mgr.poll(id)).toBeNull();
|
|
241
|
+
expect(mgr.list()).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("kills running sessions before removing them", () => {
|
|
245
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
246
|
+
const proc = latestProcRef.current;
|
|
247
|
+
|
|
248
|
+
const sessions = (mgr as any).sessions as Map<string, any>;
|
|
249
|
+
sessions.get(id)!.startTime = Date.now() - 31 * 60 * 1000;
|
|
250
|
+
|
|
251
|
+
mgr.cleanup();
|
|
252
|
+
|
|
253
|
+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("does not remove recent sessions", () => {
|
|
257
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
258
|
+
mgr.cleanup();
|
|
259
|
+
expect(mgr.list()).toHaveLength(1);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ── resolveCliCommand (via spawn behavior) ─────────────────────────────
|
|
264
|
+
|
|
265
|
+
describe("resolveCliCommand routing", () => {
|
|
266
|
+
it("routes cli-gemini/ to 'gemini' command", () => {
|
|
267
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "test" }]);
|
|
268
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
269
|
+
"gemini",
|
|
270
|
+
expect.arrayContaining(["-m", "gemini-2.5-pro"]),
|
|
271
|
+
expect.any(Object)
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("routes cli-claude/ to 'claude' command", () => {
|
|
276
|
+
mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "test" }]);
|
|
277
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
278
|
+
"claude",
|
|
279
|
+
expect.arrayContaining(["--model", "claude-sonnet-4-6"]),
|
|
280
|
+
expect.any(Object)
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("routes openai-codex/ to 'codex' command", () => {
|
|
285
|
+
mgr.spawn("openai-codex/gpt-5.3-codex", [{ role: "user", content: "test" }]);
|
|
286
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
287
|
+
"codex",
|
|
288
|
+
expect.arrayContaining(["--model", "gpt-5.3-codex"]),
|
|
289
|
+
expect.any(Object)
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("routes opencode/ to 'opencode' command", () => {
|
|
294
|
+
mgr.spawn("opencode/default", [{ role: "user", content: "test" }]);
|
|
295
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
296
|
+
"opencode",
|
|
297
|
+
expect.arrayContaining(["run"]),
|
|
298
|
+
expect.any(Object)
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("routes pi/ to 'pi' command", () => {
|
|
303
|
+
mgr.spawn("pi/default", [{ role: "user", content: "test" }]);
|
|
304
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
305
|
+
"pi",
|
|
306
|
+
expect.arrayContaining(["-p"]),
|
|
307
|
+
expect.any(Object)
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("passes workdir option as cwd", () => {
|
|
312
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "test" }], { workdir: "/tmp/test" });
|
|
313
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
314
|
+
"gemini",
|
|
315
|
+
expect.any(Array),
|
|
316
|
+
expect.objectContaining({ cwd: "/tmp/test" })
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── process error handling ─────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("process error handling", () => {
|
|
324
|
+
it("marks session as exited on process error", () => {
|
|
325
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
326
|
+
latestProcRef.current._emit("error", new Error("spawn ENOENT"));
|
|
327
|
+
const result = mgr.poll(id);
|
|
328
|
+
expect(result!.running).toBe(false);
|
|
329
|
+
expect(result!.status).toBe("exited");
|
|
330
|
+
expect(result!.exitCode).toBe(1);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── stop() ─────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe("stop()", () => {
|
|
337
|
+
it("kills all running sessions", () => {
|
|
338
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
|
|
339
|
+
const proc1 = latestProcRef.current;
|
|
340
|
+
mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "b" }]);
|
|
341
|
+
const proc2 = latestProcRef.current;
|
|
342
|
+
|
|
343
|
+
mgr.stop();
|
|
344
|
+
|
|
345
|
+
expect(proc1.kill).toHaveBeenCalledWith("SIGTERM");
|
|
346
|
+
expect(proc2.kill).toHaveBeenCalledWith("SIGTERM");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("cleans up isolated workdirs on stop", () => {
|
|
350
|
+
mockCleanupWorkdir.mockClear();
|
|
351
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }], { isolateWorkdir: true });
|
|
352
|
+
|
|
353
|
+
mgr.stop();
|
|
354
|
+
|
|
355
|
+
expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ── workdir isolation (Issue #6) ───────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
describe("workdir isolation", () => {
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
mockCreateIsolatedWorkdir.mockClear();
|
|
364
|
+
mockCleanupWorkdir.mockClear();
|
|
365
|
+
mockSweepOrphanedWorkdirs.mockClear();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("creates an isolated workdir when isolateWorkdir is true", () => {
|
|
369
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
|
|
370
|
+
expect(mockCreateIsolatedWorkdir).toHaveBeenCalledTimes(1);
|
|
371
|
+
|
|
372
|
+
// The session should use the created workdir as cwd
|
|
373
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
374
|
+
"gemini",
|
|
375
|
+
expect.any(Array),
|
|
376
|
+
expect.objectContaining({ cwd: "/tmp/cli-bridge-fake123" })
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Session info should include the isolated workdir
|
|
380
|
+
const list = mgr.list();
|
|
381
|
+
const session = list.find(s => s.sessionId === id);
|
|
382
|
+
expect(session?.isolatedWorkdir).toBe("/tmp/cli-bridge-fake123");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("does not create isolated workdir when isolateWorkdir is false", () => {
|
|
386
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
387
|
+
expect(mockCreateIsolatedWorkdir).not.toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("does not create isolated workdir when explicit workdir is provided", () => {
|
|
391
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], {
|
|
392
|
+
isolateWorkdir: true,
|
|
393
|
+
workdir: "/explicit/dir",
|
|
394
|
+
});
|
|
395
|
+
expect(mockCreateIsolatedWorkdir).not.toHaveBeenCalled();
|
|
396
|
+
|
|
397
|
+
// Should use the explicit workdir
|
|
398
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
399
|
+
"gemini",
|
|
400
|
+
expect.any(Array),
|
|
401
|
+
expect.objectContaining({ cwd: "/explicit/dir" })
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("cleans up isolated workdir when process exits", () => {
|
|
406
|
+
mockCleanupWorkdir.mockClear();
|
|
407
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
|
|
408
|
+
|
|
409
|
+
// Simulate process exit
|
|
410
|
+
latestProcRef.current._emit("close", 0);
|
|
411
|
+
|
|
412
|
+
expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("cleans up isolated workdir on process error", () => {
|
|
416
|
+
mockCleanupWorkdir.mockClear();
|
|
417
|
+
mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
|
|
418
|
+
|
|
419
|
+
// Simulate process error
|
|
420
|
+
latestProcRef.current._emit("error", new Error("spawn ENOENT"));
|
|
421
|
+
|
|
422
|
+
expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("cleans up isolated workdir during cleanup sweep", () => {
|
|
426
|
+
mockCleanupWorkdir.mockClear();
|
|
427
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
|
|
428
|
+
|
|
429
|
+
// Make the session old enough for cleanup
|
|
430
|
+
const sessions = (mgr as any).sessions as Map<string, any>;
|
|
431
|
+
sessions.get(id)!.startTime = Date.now() - 31 * 60 * 1000;
|
|
432
|
+
|
|
433
|
+
mgr.cleanup();
|
|
434
|
+
|
|
435
|
+
expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
|
|
436
|
+
expect(mockSweepOrphanedWorkdirs).toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("session without isolation has null isolatedWorkdir", () => {
|
|
440
|
+
const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
|
|
441
|
+
const list = mgr.list();
|
|
442
|
+
const session = list.find(s => s.sessionId === id);
|
|
443
|
+
expect(session?.isolatedWorkdir).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for workdir isolation (Issue #6).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
6
|
+
import { existsSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, basename } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
createIsolatedWorkdir,
|
|
11
|
+
cleanupWorkdir,
|
|
12
|
+
sweepOrphanedWorkdirs,
|
|
13
|
+
getWorkdirBase,
|
|
14
|
+
} from "../src/workdir.js";
|
|
15
|
+
|
|
16
|
+
// Track created dirs for cleanup in case tests fail
|
|
17
|
+
const createdDirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const dir of createdDirs) {
|
|
21
|
+
try {
|
|
22
|
+
const { rmSync } = require("node:fs");
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
}
|
|
26
|
+
createdDirs.length = 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("createIsolatedWorkdir()", () => {
|
|
30
|
+
it("creates a directory that exists", () => {
|
|
31
|
+
const dir = createIsolatedWorkdir();
|
|
32
|
+
createdDirs.push(dir);
|
|
33
|
+
expect(existsSync(dir)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("creates a directory with the cli-bridge- prefix", () => {
|
|
37
|
+
const dir = createIsolatedWorkdir();
|
|
38
|
+
createdDirs.push(dir);
|
|
39
|
+
expect(basename(dir)).toMatch(/^cli-bridge-/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("creates unique directories on repeated calls", () => {
|
|
43
|
+
const dir1 = createIsolatedWorkdir();
|
|
44
|
+
const dir2 = createIsolatedWorkdir();
|
|
45
|
+
createdDirs.push(dir1, dir2);
|
|
46
|
+
expect(dir1).not.toBe(dir2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts a custom base directory", () => {
|
|
50
|
+
const customBase = join(tmpdir(), "workdir-test-base");
|
|
51
|
+
mkdirSync(customBase, { recursive: true });
|
|
52
|
+
createdDirs.push(customBase);
|
|
53
|
+
|
|
54
|
+
const dir = createIsolatedWorkdir(customBase);
|
|
55
|
+
createdDirs.push(dir);
|
|
56
|
+
expect(dir.startsWith(customBase)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("cleanupWorkdir()", () => {
|
|
61
|
+
it("removes an existing workdir", () => {
|
|
62
|
+
const dir = createIsolatedWorkdir();
|
|
63
|
+
// Create a file inside to verify recursive removal
|
|
64
|
+
writeFileSync(join(dir, "test.txt"), "hello");
|
|
65
|
+
expect(existsSync(dir)).toBe(true);
|
|
66
|
+
|
|
67
|
+
const result = cleanupWorkdir(dir);
|
|
68
|
+
expect(result).toBe(true);
|
|
69
|
+
expect(existsSync(dir)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns false for non-existent directory", () => {
|
|
73
|
+
const result = cleanupWorkdir(join(tmpdir(), "cli-bridge-nonexistent"));
|
|
74
|
+
// rmSync with force:true doesn't throw for non-existent
|
|
75
|
+
expect(result).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refuses to remove directories without cli-bridge- prefix", () => {
|
|
79
|
+
const result = cleanupWorkdir("/tmp/some-other-dir");
|
|
80
|
+
expect(result).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("refuses to remove empty string", () => {
|
|
84
|
+
const result = cleanupWorkdir("");
|
|
85
|
+
expect(result).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("sweepOrphanedWorkdirs()", () => {
|
|
90
|
+
it("removes workdirs older than the threshold", () => {
|
|
91
|
+
const base = join(tmpdir(), "sweep-test-" + Date.now());
|
|
92
|
+
mkdirSync(base, { recursive: true });
|
|
93
|
+
createdDirs.push(base);
|
|
94
|
+
|
|
95
|
+
// Create a fake old workdir
|
|
96
|
+
const oldDir = join(base, "cli-bridge-oldone");
|
|
97
|
+
mkdirSync(oldDir);
|
|
98
|
+
// Set mtime to 2 hours ago
|
|
99
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
100
|
+
const { utimesSync } = require("node:fs");
|
|
101
|
+
utimesSync(oldDir, twoHoursAgo, twoHoursAgo);
|
|
102
|
+
|
|
103
|
+
const removed = sweepOrphanedWorkdirs(base);
|
|
104
|
+
expect(removed).toBe(1);
|
|
105
|
+
expect(existsSync(oldDir)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not remove recent workdirs", () => {
|
|
109
|
+
const base = join(tmpdir(), "sweep-test-recent-" + Date.now());
|
|
110
|
+
mkdirSync(base, { recursive: true });
|
|
111
|
+
createdDirs.push(base);
|
|
112
|
+
|
|
113
|
+
const recentDir = join(base, "cli-bridge-recent");
|
|
114
|
+
mkdirSync(recentDir);
|
|
115
|
+
|
|
116
|
+
const removed = sweepOrphanedWorkdirs(base);
|
|
117
|
+
expect(removed).toBe(0);
|
|
118
|
+
expect(existsSync(recentDir)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("ignores non-cli-bridge directories", () => {
|
|
122
|
+
const base = join(tmpdir(), "sweep-test-ignore-" + Date.now());
|
|
123
|
+
mkdirSync(base, { recursive: true });
|
|
124
|
+
createdDirs.push(base);
|
|
125
|
+
|
|
126
|
+
const otherDir = join(base, "other-dir");
|
|
127
|
+
mkdirSync(otherDir);
|
|
128
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
129
|
+
const { utimesSync } = require("node:fs");
|
|
130
|
+
utimesSync(otherDir, twoHoursAgo, twoHoursAgo);
|
|
131
|
+
|
|
132
|
+
const removed = sweepOrphanedWorkdirs(base);
|
|
133
|
+
expect(removed).toBe(0);
|
|
134
|
+
expect(existsSync(otherDir)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns 0 for non-existent base", () => {
|
|
138
|
+
const removed = sweepOrphanedWorkdirs("/nonexistent/path");
|
|
139
|
+
expect(removed).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("getWorkdirBase()", () => {
|
|
144
|
+
it("returns tmpdir by default", () => {
|
|
145
|
+
// Clear env var if set
|
|
146
|
+
const prev = process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE;
|
|
147
|
+
delete process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE;
|
|
148
|
+
expect(getWorkdirBase()).toBe(tmpdir());
|
|
149
|
+
// Restore
|
|
150
|
+
if (prev !== undefined) process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE = prev;
|
|
151
|
+
});
|
|
152
|
+
});
|