@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// ─── Hoisted mocks ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
// Mock randomUUID so session IDs are deterministic
|
|
9
|
+
vi.mock("node:crypto", () => ({ randomUUID: () => "test-session-id" }));
|
|
10
|
+
|
|
11
|
+
// Mock path-resolver for binary resolution
|
|
12
|
+
const mockResolveBinary = vi.hoisted(() => vi.fn((_name: string): string | null => "/usr/bin/claude"));
|
|
13
|
+
const mockGetEnrichedPath = vi.hoisted(() => vi.fn(() => "/usr/bin:/usr/local/bin"));
|
|
14
|
+
vi.mock("./path-resolver.js", () => ({ resolveBinary: mockResolveBinary, getEnrichedPath: mockGetEnrichedPath }));
|
|
15
|
+
|
|
16
|
+
// Mock container-manager for container validation in relaunch
|
|
17
|
+
const mockIsContainerAlive = vi.hoisted(() => vi.fn((): "running" | "stopped" | "missing" => "running"));
|
|
18
|
+
const mockHasBinaryInContainer = vi.hoisted(() => vi.fn((): boolean => true));
|
|
19
|
+
const mockStartContainer = vi.hoisted(() => vi.fn());
|
|
20
|
+
const mockGetContainerById = vi.hoisted(() => vi.fn((_containerId: string) => undefined as any));
|
|
21
|
+
vi.mock("./container-manager.js", () => ({
|
|
22
|
+
containerManager: {
|
|
23
|
+
isContainerAlive: mockIsContainerAlive,
|
|
24
|
+
hasBinaryInContainer: mockHasBinaryInContainer,
|
|
25
|
+
startContainer: mockStartContainer,
|
|
26
|
+
getContainerById: mockGetContainerById,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock fs operations for worktree guardrails (CLAUDE.md in .claude dirs)
|
|
31
|
+
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
|
32
|
+
const mockExistsSync = vi.hoisted(() => vi.fn((..._args: any[]) => false));
|
|
33
|
+
const mockReadFileSync = vi.hoisted(() => vi.fn((..._args: any[]) => ""));
|
|
34
|
+
const mockWriteFileSync = vi.hoisted(() => vi.fn());
|
|
35
|
+
const isMockedPath = vi.hoisted(() => (path: string): boolean => {
|
|
36
|
+
return path.includes(".claude") || path.startsWith("/tmp/worktrees/") || path.startsWith("/tmp/main-repo");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
40
|
+
const actual = (await importOriginal()) as any;
|
|
41
|
+
return {
|
|
42
|
+
...actual,
|
|
43
|
+
mkdirSync: (...args: any[]) => {
|
|
44
|
+
if (typeof args[0] === "string" && isMockedPath(args[0])) {
|
|
45
|
+
return mockMkdirSync(...args);
|
|
46
|
+
}
|
|
47
|
+
return actual.mkdirSync(...args);
|
|
48
|
+
},
|
|
49
|
+
existsSync: (...args: any[]) => {
|
|
50
|
+
if (typeof args[0] === "string" && isMockedPath(args[0])) {
|
|
51
|
+
return mockExistsSync(...args);
|
|
52
|
+
}
|
|
53
|
+
return actual.existsSync(...args);
|
|
54
|
+
},
|
|
55
|
+
readFileSync: (...args: any[]) => {
|
|
56
|
+
if (typeof args[0] === "string" && isMockedPath(args[0])) {
|
|
57
|
+
return mockReadFileSync(...args);
|
|
58
|
+
}
|
|
59
|
+
return actual.readFileSync(...args);
|
|
60
|
+
},
|
|
61
|
+
writeFileSync: (...args: any[]) => {
|
|
62
|
+
if (typeof args[0] === "string" && isMockedPath(args[0])) {
|
|
63
|
+
return mockWriteFileSync(...args);
|
|
64
|
+
}
|
|
65
|
+
return actual.writeFileSync(...args);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ─── Imports (after mocks) ───────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
import { SessionStore } from "./session-store.js";
|
|
73
|
+
import { CliLauncher } from "./cli-launcher.js";
|
|
74
|
+
import { companionBus } from "./event-bus.js";
|
|
75
|
+
|
|
76
|
+
// ─── Bun.spawn mock ─────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
let exitResolve: (code: number) => void;
|
|
79
|
+
|
|
80
|
+
function createMockProc(pid = 12345) {
|
|
81
|
+
let resolve: (code: number) => void;
|
|
82
|
+
const exitedPromise = new Promise<number>((r) => {
|
|
83
|
+
resolve = r;
|
|
84
|
+
});
|
|
85
|
+
exitResolve = resolve!;
|
|
86
|
+
return {
|
|
87
|
+
pid,
|
|
88
|
+
kill: vi.fn(),
|
|
89
|
+
exited: exitedPromise,
|
|
90
|
+
stdout: null,
|
|
91
|
+
stderr: null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createMockCodexProc(pid = 12345) {
|
|
96
|
+
let resolve: (code: number) => void;
|
|
97
|
+
const exitedPromise = new Promise<number>((r) => {
|
|
98
|
+
resolve = r;
|
|
99
|
+
});
|
|
100
|
+
exitResolve = resolve!;
|
|
101
|
+
return {
|
|
102
|
+
pid,
|
|
103
|
+
kill: vi.fn(),
|
|
104
|
+
exited: exitedPromise,
|
|
105
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
106
|
+
stdout: new ReadableStream<Uint8Array>(),
|
|
107
|
+
stderr: new ReadableStream<Uint8Array>(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createPendingCodexWsProxyProc(pid = 12345) {
|
|
112
|
+
let resolve: (code: number) => void;
|
|
113
|
+
const exitedPromise = new Promise<number>((r) => {
|
|
114
|
+
resolve = r;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Keep stdout open so CodexAdapter can wait for JSON-RPC responses without
|
|
118
|
+
// immediately failing initialization in tests that only care about launcher lifecycle.
|
|
119
|
+
const stdout = new ReadableStream<Uint8Array>({ start() {} });
|
|
120
|
+
const stderr = new ReadableStream<Uint8Array>({ start() {} });
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
proc: {
|
|
124
|
+
pid,
|
|
125
|
+
kill: vi.fn(),
|
|
126
|
+
exited: exitedPromise,
|
|
127
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
128
|
+
stdout,
|
|
129
|
+
stderr,
|
|
130
|
+
},
|
|
131
|
+
resolveExit: resolve!,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const mockSpawn = vi.fn();
|
|
136
|
+
const mockListen = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
|
|
137
|
+
vi.stubGlobal("Bun", { spawn: mockSpawn, listen: mockListen });
|
|
138
|
+
|
|
139
|
+
// ─── Test setup ──────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
let tempDir: string;
|
|
142
|
+
let store: SessionStore;
|
|
143
|
+
let launcher: CliLauncher;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
vi.clearAllMocks();
|
|
147
|
+
companionBus.clear();
|
|
148
|
+
delete process.env.COMPANION_CONTAINER_SDK_HOST;
|
|
149
|
+
delete process.env.COMPANION_FORCE_BYPASS_IN_CONTAINER;
|
|
150
|
+
// Default to stdio for most tests; WS launcher behavior is covered explicitly below.
|
|
151
|
+
process.env.COMPANION_CODEX_TRANSPORT = "stdio";
|
|
152
|
+
tempDir = mkdtempSync(join(tmpdir(), "launcher-test-"));
|
|
153
|
+
store = new SessionStore(tempDir);
|
|
154
|
+
launcher = new CliLauncher(3456);
|
|
155
|
+
launcher.setStore(store);
|
|
156
|
+
mockSpawn.mockReturnValue(createMockProc());
|
|
157
|
+
mockListen.mockImplementation(() => ({ stop: vi.fn() }));
|
|
158
|
+
mockResolveBinary.mockReturnValue("/usr/bin/claude");
|
|
159
|
+
mockGetContainerById.mockReturnValue(undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
delete process.env.COMPANION_CODEX_TRANSPORT;
|
|
164
|
+
delete process.env.COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS;
|
|
165
|
+
delete process.env.COMPANION_CODEX_PONG_TIMEOUT_MS;
|
|
166
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── launch ──────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("launch", () => {
|
|
172
|
+
it("creates a session with a UUID and starting state", () => {
|
|
173
|
+
const info = launcher.launch({ cwd: "/tmp/project" });
|
|
174
|
+
|
|
175
|
+
expect(info.sessionId).toBe("test-session-id");
|
|
176
|
+
expect(info.state).toBe("starting");
|
|
177
|
+
expect(info.cwd).toBe("/tmp/project");
|
|
178
|
+
expect(info.createdAt).toBeGreaterThan(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("spawns CLI with correct --sdk-url and flags", () => {
|
|
182
|
+
launcher.launch({ cwd: "/tmp/project" });
|
|
183
|
+
|
|
184
|
+
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
185
|
+
const [cmdAndArgs, options] = mockSpawn.mock.calls[0];
|
|
186
|
+
|
|
187
|
+
// Binary should be resolved via execSync
|
|
188
|
+
expect(cmdAndArgs[0]).toBe("/usr/bin/claude");
|
|
189
|
+
|
|
190
|
+
// Core required flags
|
|
191
|
+
expect(cmdAndArgs).toContain("--sdk-url");
|
|
192
|
+
expect(cmdAndArgs).toContain("ws://localhost:3456/ws/cli/test-session-id");
|
|
193
|
+
expect(cmdAndArgs).toContain("--print");
|
|
194
|
+
expect(cmdAndArgs).toContain("--output-format");
|
|
195
|
+
expect(cmdAndArgs).toContain("stream-json");
|
|
196
|
+
expect(cmdAndArgs).toContain("--input-format");
|
|
197
|
+
expect(cmdAndArgs).toContain("--include-partial-messages");
|
|
198
|
+
expect(cmdAndArgs).toContain("--verbose");
|
|
199
|
+
|
|
200
|
+
// Headless prompt
|
|
201
|
+
expect(cmdAndArgs).toContain("-p");
|
|
202
|
+
expect(cmdAndArgs).toContain("");
|
|
203
|
+
|
|
204
|
+
// Spawn options
|
|
205
|
+
expect(options.cwd).toBe("/tmp/project");
|
|
206
|
+
expect(options.stdout).toBe("pipe");
|
|
207
|
+
expect(options.stderr).toBe("pipe");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("passes --model when provided", () => {
|
|
211
|
+
launcher.launch({ model: "claude-opus-4-20250514", cwd: "/tmp" });
|
|
212
|
+
|
|
213
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
214
|
+
const modelIdx = cmdAndArgs.indexOf("--model");
|
|
215
|
+
expect(modelIdx).toBeGreaterThan(-1);
|
|
216
|
+
expect(cmdAndArgs[modelIdx + 1]).toBe("claude-opus-4-20250514");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("passes --permission-mode when provided", () => {
|
|
220
|
+
// Allow bypassPermissions through even when tests run as root
|
|
221
|
+
process.env.COMPANION_FORCE_BYPASS_AS_ROOT = "1";
|
|
222
|
+
try {
|
|
223
|
+
launcher.launch({ permissionMode: "bypassPermissions", cwd: "/tmp" });
|
|
224
|
+
|
|
225
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
226
|
+
const modeIdx = cmdAndArgs.indexOf("--permission-mode");
|
|
227
|
+
expect(modeIdx).toBeGreaterThan(-1);
|
|
228
|
+
expect(cmdAndArgs[modeIdx + 1]).toBe("bypassPermissions");
|
|
229
|
+
} finally {
|
|
230
|
+
delete process.env.COMPANION_FORCE_BYPASS_AS_ROOT;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("downgrades bypassPermissions to acceptEdits for containerized Claude sessions", () => {
|
|
235
|
+
launcher.launch({
|
|
236
|
+
cwd: "/tmp/project",
|
|
237
|
+
permissionMode: "bypassPermissions",
|
|
238
|
+
containerId: "abc123def456",
|
|
239
|
+
containerName: "companion-test",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
243
|
+
// With bash -lc wrapping, CLI args are in the last element as a single string
|
|
244
|
+
const bashCmd = cmdAndArgs[cmdAndArgs.length - 1];
|
|
245
|
+
expect(bashCmd).toContain("--permission-mode");
|
|
246
|
+
expect(bashCmd).toContain("acceptEdits");
|
|
247
|
+
expect(bashCmd).not.toContain("bypassPermissions");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("downgrades bypassPermissions to acceptEdits when host launcher runs as root", () => {
|
|
251
|
+
const originalGetuid = process.getuid;
|
|
252
|
+
Object.defineProperty(process, "getuid", {
|
|
253
|
+
value: () => 0,
|
|
254
|
+
configurable: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
launcher.launch({
|
|
259
|
+
cwd: "/tmp/project",
|
|
260
|
+
permissionMode: "bypassPermissions",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
264
|
+
const modeIdx = cmdAndArgs.indexOf("--permission-mode");
|
|
265
|
+
expect(modeIdx).toBeGreaterThan(-1);
|
|
266
|
+
expect(cmdAndArgs[modeIdx + 1]).toBe("acceptEdits");
|
|
267
|
+
} finally {
|
|
268
|
+
Object.defineProperty(process, "getuid", {
|
|
269
|
+
value: originalGetuid,
|
|
270
|
+
configurable: true,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("uses COMPANION_CONTAINER_SDK_HOST for containerized sdk-url when set", () => {
|
|
276
|
+
process.env.COMPANION_CONTAINER_SDK_HOST = "172.17.0.1";
|
|
277
|
+
launcher.launch({
|
|
278
|
+
cwd: "/tmp/project",
|
|
279
|
+
containerId: "abc123def456",
|
|
280
|
+
containerName: "companion-test",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
284
|
+
// With bash -lc wrapping, CLI args are in the last element as a single string
|
|
285
|
+
const bashCmd = cmdAndArgs[cmdAndArgs.length - 1];
|
|
286
|
+
expect(bashCmd).toContain("--sdk-url");
|
|
287
|
+
expect(bashCmd).toContain("ws://172.17.0.1:3456/ws/cli/test-session-id");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("passes --allowedTools for each tool", () => {
|
|
291
|
+
launcher.launch({
|
|
292
|
+
allowedTools: ["Read", "Write", "Bash"],
|
|
293
|
+
cwd: "/tmp",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
297
|
+
// Each tool gets its own --allowedTools flag
|
|
298
|
+
const toolFlags = cmdAndArgs.reduce(
|
|
299
|
+
(acc: string[], arg: string, i: number) => {
|
|
300
|
+
if (arg === "--allowedTools") acc.push(cmdAndArgs[i + 1]);
|
|
301
|
+
return acc;
|
|
302
|
+
},
|
|
303
|
+
[],
|
|
304
|
+
);
|
|
305
|
+
expect(toolFlags).toEqual(["Read", "Write", "Bash"]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("passes branching flags when resumeSessionAt/forkSession are provided", () => {
|
|
309
|
+
// These flags enable starting a new branch of work from a prior session point.
|
|
310
|
+
launcher.launch({
|
|
311
|
+
cwd: "/tmp",
|
|
312
|
+
resumeSessionAt: "prior-session-123",
|
|
313
|
+
forkSession: true,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
317
|
+
const resumeAtIdx = cmdAndArgs.indexOf("--resume-session-at");
|
|
318
|
+
expect(resumeAtIdx).toBeGreaterThan(-1);
|
|
319
|
+
expect(cmdAndArgs[resumeAtIdx + 1]).toBe("prior-session-123");
|
|
320
|
+
expect(cmdAndArgs).toContain("--fork-session");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("resolves binary path via resolveBinary when not absolute", () => {
|
|
324
|
+
mockResolveBinary.mockReturnValue("/usr/local/bin/claude-dev");
|
|
325
|
+
launcher.launch({ claudeBinary: "claude-dev", cwd: "/tmp" });
|
|
326
|
+
|
|
327
|
+
expect(mockResolveBinary).toHaveBeenCalledWith("claude-dev");
|
|
328
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
329
|
+
expect(cmdAndArgs[0]).toBe("/usr/local/bin/claude-dev");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("passes absolute binary path directly to resolveBinary", () => {
|
|
333
|
+
mockResolveBinary.mockReturnValue("/opt/bin/claude");
|
|
334
|
+
launcher.launch({
|
|
335
|
+
claudeBinary: "/opt/bin/claude",
|
|
336
|
+
cwd: "/tmp",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(mockResolveBinary).toHaveBeenCalledWith("/opt/bin/claude");
|
|
340
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
341
|
+
expect(cmdAndArgs[0]).toBe("/opt/bin/claude");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("sets state=exited and exitCode=127 when claude binary not found", () => {
|
|
345
|
+
mockResolveBinary.mockReturnValue(null);
|
|
346
|
+
|
|
347
|
+
const info = launcher.launch({ cwd: "/tmp" });
|
|
348
|
+
|
|
349
|
+
expect(info.state).toBe("exited");
|
|
350
|
+
expect(info.exitCode).toBe(127);
|
|
351
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("stores container metadata when containerId provided", () => {
|
|
355
|
+
const info = launcher.launch({
|
|
356
|
+
cwd: "/tmp/project",
|
|
357
|
+
containerId: "abc123def456",
|
|
358
|
+
containerName: "companion-session-1",
|
|
359
|
+
containerImage: "ubuntu:22.04",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(info.containerId).toBe("abc123def456");
|
|
363
|
+
expect(info.containerName).toBe("companion-session-1");
|
|
364
|
+
expect(info.containerImage).toBe("ubuntu:22.04");
|
|
365
|
+
expect(info.containerCwd).toBe("/workspace");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("stores explicit containerCwd when provided", () => {
|
|
369
|
+
mockSpawn.mockReturnValueOnce(createMockCodexProc());
|
|
370
|
+
const info = launcher.launch({
|
|
371
|
+
cwd: "/tmp/project",
|
|
372
|
+
backendType: "codex",
|
|
373
|
+
containerId: "abc123def456",
|
|
374
|
+
containerName: "companion-session-1",
|
|
375
|
+
containerImage: "ubuntu:22.04",
|
|
376
|
+
containerCwd: "/workspace/repo",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(info.containerCwd).toBe("/workspace/repo");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("uses docker exec -i with bash -lc for containerized Claude sessions", () => {
|
|
383
|
+
// bash -lc ensures ~/.bashrc is sourced so nvm-installed CLIs are on PATH
|
|
384
|
+
launcher.launch({
|
|
385
|
+
cwd: "/tmp/project",
|
|
386
|
+
containerId: "abc123def456",
|
|
387
|
+
containerName: "companion-session-1",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
391
|
+
expect(cmdAndArgs[0]).toBe("docker");
|
|
392
|
+
expect(cmdAndArgs[1]).toBe("exec");
|
|
393
|
+
expect(cmdAndArgs[2]).toBe("-i");
|
|
394
|
+
// Should wrap the CLI command in bash -lc for login shell PATH
|
|
395
|
+
expect(cmdAndArgs).toContain("bash");
|
|
396
|
+
expect(cmdAndArgs).toContain("-lc");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("sets session pid from spawned process", () => {
|
|
400
|
+
mockSpawn.mockReturnValue(createMockProc(99999));
|
|
401
|
+
const info = launcher.launch({ cwd: "/tmp" });
|
|
402
|
+
expect(info.pid).toBe(99999);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("unsets CLAUDECODE to avoid CLI nesting guard", () => {
|
|
406
|
+
launcher.launch({ cwd: "/tmp" });
|
|
407
|
+
|
|
408
|
+
const [, options] = mockSpawn.mock.calls[0];
|
|
409
|
+
expect(options.env.CLAUDECODE).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("merges custom env variables", () => {
|
|
413
|
+
launcher.launch({
|
|
414
|
+
cwd: "/tmp",
|
|
415
|
+
env: { MY_VAR: "hello" },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const [, options] = mockSpawn.mock.calls[0];
|
|
419
|
+
expect(options.env.MY_VAR).toBe("hello");
|
|
420
|
+
expect(options.env.CLAUDECODE).toBeUndefined();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("enables Codex web search when codexInternetAccess=true", () => {
|
|
424
|
+
// Use a fake path where no sibling `node` exists, so the spawn uses
|
|
425
|
+
// the codex binary directly (the explicit-node path is tested separately).
|
|
426
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
427
|
+
mockSpawn.mockReturnValueOnce(createMockCodexProc());
|
|
428
|
+
|
|
429
|
+
launcher.launch({
|
|
430
|
+
backendType: "codex",
|
|
431
|
+
cwd: "/tmp/project",
|
|
432
|
+
codexInternetAccess: true,
|
|
433
|
+
codexSandbox: "danger-full-access",
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const [cmdAndArgs, options] = mockSpawn.mock.calls[0];
|
|
437
|
+
expect(cmdAndArgs[0]).toBe("/opt/fake/codex");
|
|
438
|
+
expect(cmdAndArgs).toContain("app-server");
|
|
439
|
+
expect(cmdAndArgs).toContain("--enable");
|
|
440
|
+
expect(cmdAndArgs).toContain("multi_agent");
|
|
441
|
+
expect(cmdAndArgs).toContain("-c");
|
|
442
|
+
expect(cmdAndArgs).toContain("tools.webSearch=true");
|
|
443
|
+
expect(options.cwd).toBe("/tmp/project");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("disables Codex web search when codexInternetAccess=false", () => {
|
|
447
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
448
|
+
mockSpawn.mockReturnValueOnce(createMockCodexProc());
|
|
449
|
+
|
|
450
|
+
launcher.launch({
|
|
451
|
+
backendType: "codex",
|
|
452
|
+
cwd: "/tmp/project",
|
|
453
|
+
codexInternetAccess: false,
|
|
454
|
+
codexSandbox: "workspace-write",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
458
|
+
expect(cmdAndArgs).toContain("app-server");
|
|
459
|
+
expect(cmdAndArgs).toContain("--enable");
|
|
460
|
+
expect(cmdAndArgs).toContain("multi_agent");
|
|
461
|
+
expect(cmdAndArgs).toContain("-c");
|
|
462
|
+
expect(cmdAndArgs).toContain("tools.webSearch=false");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("spawns codex via sibling node binary to bypass shebang issues", () => {
|
|
466
|
+
// When a `node` binary exists next to the resolved `codex`, the launcher
|
|
467
|
+
// should invoke `node <codex-script>` directly instead of relying on
|
|
468
|
+
// the #!/usr/bin/env node shebang (which may resolve to system Node v12).
|
|
469
|
+
// Create a temp dir with both `codex` and `node` files to simulate nvm layout.
|
|
470
|
+
const tmpBinDir = mkdtempSync(join(tmpdir(), "codex-test-"));
|
|
471
|
+
const fakeCodex = join(tmpBinDir, "codex");
|
|
472
|
+
const fakeNode = join(tmpBinDir, "node");
|
|
473
|
+
const { writeFileSync: realWriteFileSync } = require("node:fs");
|
|
474
|
+
realWriteFileSync(fakeCodex, "#!/usr/bin/env node\n");
|
|
475
|
+
realWriteFileSync(fakeNode, "#!/bin/sh\n");
|
|
476
|
+
|
|
477
|
+
mockResolveBinary.mockReturnValue(fakeCodex);
|
|
478
|
+
mockSpawn.mockReturnValueOnce(createMockCodexProc());
|
|
479
|
+
|
|
480
|
+
launcher.launch({
|
|
481
|
+
backendType: "codex",
|
|
482
|
+
cwd: "/tmp/project",
|
|
483
|
+
codexSandbox: "workspace-write",
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
487
|
+
// Sibling node exists, so it should use explicit node invocation
|
|
488
|
+
expect(cmdAndArgs[0]).toBe(fakeNode);
|
|
489
|
+
// The codex script path should be arg 1
|
|
490
|
+
expect(cmdAndArgs[1]).toContain("codex");
|
|
491
|
+
expect(cmdAndArgs).toContain("app-server");
|
|
492
|
+
expect(cmdAndArgs).toContain("--enable");
|
|
493
|
+
expect(cmdAndArgs).toContain("multi_agent");
|
|
494
|
+
|
|
495
|
+
// Cleanup
|
|
496
|
+
rmSync(tmpBinDir, { recursive: true, force: true });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("sets state=exited and exitCode=127 when codex binary not found", () => {
|
|
500
|
+
mockResolveBinary.mockReturnValue(null);
|
|
501
|
+
|
|
502
|
+
const info = launcher.launch({
|
|
503
|
+
backendType: "codex",
|
|
504
|
+
cwd: "/tmp/project",
|
|
505
|
+
codexSandbox: "workspace-write",
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(info.state).toBe("exited");
|
|
509
|
+
expect(info.exitCode).toBe(127);
|
|
510
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ─── state management ────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
describe("state management", () => {
|
|
518
|
+
describe("markConnected", () => {
|
|
519
|
+
it("sets state to connected", () => {
|
|
520
|
+
launcher.launch({ cwd: "/tmp" });
|
|
521
|
+
launcher.markConnected("test-session-id");
|
|
522
|
+
|
|
523
|
+
const session = launcher.getSession("test-session-id");
|
|
524
|
+
expect(session?.state).toBe("connected");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("does nothing for unknown session", () => {
|
|
528
|
+
// Should not throw
|
|
529
|
+
launcher.markConnected("nonexistent");
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("setCLISessionId", () => {
|
|
534
|
+
it("stores the CLI session ID", () => {
|
|
535
|
+
launcher.launch({ cwd: "/tmp" });
|
|
536
|
+
launcher.setCLISessionId("test-session-id", "cli-internal-abc");
|
|
537
|
+
|
|
538
|
+
const session = launcher.getSession("test-session-id");
|
|
539
|
+
expect(session?.cliSessionId).toBe("cli-internal-abc");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("does nothing for unknown session", () => {
|
|
543
|
+
// Should not throw
|
|
544
|
+
launcher.setCLISessionId("nonexistent", "cli-id");
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe("isAlive", () => {
|
|
549
|
+
it("returns true for non-exited session", () => {
|
|
550
|
+
launcher.launch({ cwd: "/tmp" });
|
|
551
|
+
expect(launcher.isAlive("test-session-id")).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("returns false for exited session", async () => {
|
|
555
|
+
launcher.launch({ cwd: "/tmp" });
|
|
556
|
+
|
|
557
|
+
// Simulate process exit
|
|
558
|
+
exitResolve(0);
|
|
559
|
+
// Allow the .then callback in spawnCLI to run
|
|
560
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
561
|
+
|
|
562
|
+
expect(launcher.isAlive("test-session-id")).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("returns false for unknown session", () => {
|
|
566
|
+
expect(launcher.isAlive("nonexistent")).toBe(false);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe("listSessions", () => {
|
|
571
|
+
it("returns all sessions", () => {
|
|
572
|
+
// Because randomUUID is mocked to always return the same value,
|
|
573
|
+
// we need to test with a single launch. But we can verify the list.
|
|
574
|
+
launcher.launch({ cwd: "/tmp" });
|
|
575
|
+
const sessions = launcher.listSessions();
|
|
576
|
+
|
|
577
|
+
expect(sessions).toHaveLength(1);
|
|
578
|
+
expect(sessions[0].sessionId).toBe("test-session-id");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("returns empty array when no sessions exist", () => {
|
|
582
|
+
expect(launcher.listSessions()).toEqual([]);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe("getSession", () => {
|
|
587
|
+
it("returns a specific session", () => {
|
|
588
|
+
launcher.launch({ cwd: "/tmp/myproject" });
|
|
589
|
+
|
|
590
|
+
const session = launcher.getSession("test-session-id");
|
|
591
|
+
expect(session).toBeDefined();
|
|
592
|
+
expect(session?.cwd).toBe("/tmp/myproject");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("returns undefined for unknown session", () => {
|
|
596
|
+
expect(launcher.getSession("nonexistent")).toBeUndefined();
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("pruneExited", () => {
|
|
601
|
+
it("removes exited sessions and returns count", async () => {
|
|
602
|
+
launcher.launch({ cwd: "/tmp" });
|
|
603
|
+
|
|
604
|
+
// Simulate process exit
|
|
605
|
+
exitResolve(0);
|
|
606
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
607
|
+
|
|
608
|
+
expect(launcher.getSession("test-session-id")?.state).toBe("exited");
|
|
609
|
+
|
|
610
|
+
const pruned = launcher.pruneExited();
|
|
611
|
+
expect(pruned).toBe(1);
|
|
612
|
+
expect(launcher.listSessions()).toHaveLength(0);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("returns 0 when no sessions are exited", () => {
|
|
616
|
+
launcher.launch({ cwd: "/tmp" });
|
|
617
|
+
const pruned = launcher.pruneExited();
|
|
618
|
+
expect(pruned).toBe(0);
|
|
619
|
+
expect(launcher.listSessions()).toHaveLength(1);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("setArchived", () => {
|
|
624
|
+
it("sets the archived flag on a session", () => {
|
|
625
|
+
launcher.launch({ cwd: "/tmp" });
|
|
626
|
+
launcher.setArchived("test-session-id", true);
|
|
627
|
+
|
|
628
|
+
const session = launcher.getSession("test-session-id");
|
|
629
|
+
expect(session?.archived).toBe(true);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("can unset the archived flag", () => {
|
|
633
|
+
launcher.launch({ cwd: "/tmp" });
|
|
634
|
+
launcher.setArchived("test-session-id", true);
|
|
635
|
+
launcher.setArchived("test-session-id", false);
|
|
636
|
+
|
|
637
|
+
const session = launcher.getSession("test-session-id");
|
|
638
|
+
expect(session?.archived).toBe(false);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("does nothing for unknown session", () => {
|
|
642
|
+
// Should not throw
|
|
643
|
+
launcher.setArchived("nonexistent", true);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe("removeSession", () => {
|
|
648
|
+
it("deletes session from internal maps", () => {
|
|
649
|
+
launcher.launch({ cwd: "/tmp" });
|
|
650
|
+
expect(launcher.getSession("test-session-id")).toBeDefined();
|
|
651
|
+
|
|
652
|
+
launcher.removeSession("test-session-id");
|
|
653
|
+
expect(launcher.getSession("test-session-id")).toBeUndefined();
|
|
654
|
+
expect(launcher.listSessions()).toHaveLength(0);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("does nothing for unknown session", () => {
|
|
658
|
+
// Should not throw
|
|
659
|
+
launcher.removeSession("nonexistent");
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// ─── kill ────────────────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
describe("kill", () => {
|
|
667
|
+
it("sends SIGTERM via proc.kill", async () => {
|
|
668
|
+
launcher.launch({ cwd: "/tmp" });
|
|
669
|
+
|
|
670
|
+
// Grab the mock proc
|
|
671
|
+
const mockProc = mockSpawn.mock.results[0].value;
|
|
672
|
+
|
|
673
|
+
// Resolve the exit promise so kill() doesn't wait on the timeout
|
|
674
|
+
setTimeout(() => exitResolve(0), 5);
|
|
675
|
+
|
|
676
|
+
const result = await launcher.kill("test-session-id");
|
|
677
|
+
|
|
678
|
+
expect(result).toBe(true);
|
|
679
|
+
expect(mockProc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("marks session as exited", async () => {
|
|
683
|
+
launcher.launch({ cwd: "/tmp" });
|
|
684
|
+
|
|
685
|
+
setTimeout(() => exitResolve(0), 5);
|
|
686
|
+
await launcher.kill("test-session-id");
|
|
687
|
+
|
|
688
|
+
const session = launcher.getSession("test-session-id");
|
|
689
|
+
expect(session?.state).toBe("exited");
|
|
690
|
+
expect(session?.exitCode).toBe(-1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("returns false for unknown session", async () => {
|
|
694
|
+
const result = await launcher.kill("nonexistent");
|
|
695
|
+
expect(result).toBe(false);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// ─── relaunch ────────────────────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
describe("relaunch", () => {
|
|
702
|
+
it("kills old process and spawns new one with --resume", async () => {
|
|
703
|
+
// Create first proc whose exit resolves immediately when killed
|
|
704
|
+
let resolveFirst: (code: number) => void;
|
|
705
|
+
const firstProc = {
|
|
706
|
+
pid: 12345,
|
|
707
|
+
kill: vi.fn(() => { resolveFirst(0); }),
|
|
708
|
+
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
709
|
+
stdout: null,
|
|
710
|
+
stderr: null,
|
|
711
|
+
};
|
|
712
|
+
mockSpawn.mockReturnValueOnce(firstProc);
|
|
713
|
+
|
|
714
|
+
launcher.launch({ cwd: "/tmp/project", model: "claude-sonnet-4-6" });
|
|
715
|
+
launcher.setCLISessionId("test-session-id", "cli-resume-id");
|
|
716
|
+
|
|
717
|
+
// Second proc for the relaunch — never exits during test
|
|
718
|
+
const secondProc = createMockProc(54321);
|
|
719
|
+
mockSpawn.mockReturnValueOnce(secondProc);
|
|
720
|
+
|
|
721
|
+
const result = await launcher.relaunch("test-session-id");
|
|
722
|
+
expect(result).toEqual({ ok: true });
|
|
723
|
+
|
|
724
|
+
// Old process should have been killed
|
|
725
|
+
expect(firstProc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
726
|
+
|
|
727
|
+
// New process should be spawned with --resume
|
|
728
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
729
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[1];
|
|
730
|
+
expect(cmdAndArgs).toContain("--resume");
|
|
731
|
+
expect(cmdAndArgs).toContain("cli-resume-id");
|
|
732
|
+
|
|
733
|
+
// Session state should be reset to starting (set by relaunch before spawnCLI)
|
|
734
|
+
// Allow microtask queue to flush
|
|
735
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
736
|
+
const session = launcher.getSession("test-session-id");
|
|
737
|
+
expect(session?.state).toBe("starting");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("reuses launch env variables during relaunch", async () => {
|
|
741
|
+
let resolveFirst: (code: number) => void;
|
|
742
|
+
const firstProc = {
|
|
743
|
+
pid: 12345,
|
|
744
|
+
kill: vi.fn(() => { resolveFirst(0); }),
|
|
745
|
+
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
746
|
+
stdout: null,
|
|
747
|
+
stderr: null,
|
|
748
|
+
};
|
|
749
|
+
mockSpawn.mockReturnValueOnce(firstProc);
|
|
750
|
+
|
|
751
|
+
launcher.launch({
|
|
752
|
+
cwd: "/tmp/project",
|
|
753
|
+
containerId: "abc123def456",
|
|
754
|
+
containerName: "companion-test",
|
|
755
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: "tok-test" },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const secondProc = createMockProc(54321);
|
|
759
|
+
mockSpawn.mockReturnValueOnce(secondProc);
|
|
760
|
+
|
|
761
|
+
const result = await launcher.relaunch("test-session-id");
|
|
762
|
+
expect(result).toEqual({ ok: true });
|
|
763
|
+
|
|
764
|
+
const [relaunchCmd] = mockSpawn.mock.calls[1];
|
|
765
|
+
expect(relaunchCmd).toContain("-e");
|
|
766
|
+
expect(relaunchCmd).toContain("CLAUDE_CODE_OAUTH_TOKEN=tok-test");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("returns error for unknown session", async () => {
|
|
770
|
+
const result = await launcher.relaunch("nonexistent");
|
|
771
|
+
expect(result.ok).toBe(false);
|
|
772
|
+
expect(result.error).toContain("Session not found");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("returns error when container was removed externally", async () => {
|
|
776
|
+
// Launch a containerized session
|
|
777
|
+
launcher.launch({
|
|
778
|
+
cwd: "/tmp/project",
|
|
779
|
+
containerId: "abc123def456",
|
|
780
|
+
containerName: "companion-gone",
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Simulate container being removed
|
|
784
|
+
mockIsContainerAlive.mockReturnValueOnce("missing");
|
|
785
|
+
|
|
786
|
+
const result = await launcher.relaunch("test-session-id");
|
|
787
|
+
expect(result.ok).toBe(false);
|
|
788
|
+
expect(result.error).toContain("companion-gone");
|
|
789
|
+
expect(result.error).toContain("removed externally");
|
|
790
|
+
|
|
791
|
+
// Session should be marked as exited
|
|
792
|
+
const session = launcher.getSession("test-session-id");
|
|
793
|
+
expect(session?.state).toBe("exited");
|
|
794
|
+
expect(session?.exitCode).toBe(1);
|
|
795
|
+
|
|
796
|
+
// Should NOT have spawned a new process
|
|
797
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1); // only the initial launch
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("restarts stopped container before spawning CLI", async () => {
|
|
801
|
+
// Create initial proc that exits immediately when killed
|
|
802
|
+
let resolveFirst: (code: number) => void;
|
|
803
|
+
const firstProc = {
|
|
804
|
+
pid: 12345,
|
|
805
|
+
kill: vi.fn(() => { resolveFirst(0); }),
|
|
806
|
+
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
807
|
+
stdout: null,
|
|
808
|
+
stderr: null,
|
|
809
|
+
};
|
|
810
|
+
mockSpawn.mockReturnValueOnce(firstProc);
|
|
811
|
+
|
|
812
|
+
launcher.launch({
|
|
813
|
+
cwd: "/tmp/project",
|
|
814
|
+
containerId: "abc123def456",
|
|
815
|
+
containerName: "companion-stopped",
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// Container is stopped but can be restarted
|
|
819
|
+
mockIsContainerAlive.mockReturnValueOnce("stopped");
|
|
820
|
+
mockHasBinaryInContainer.mockReturnValueOnce(true);
|
|
821
|
+
|
|
822
|
+
const secondProc = createMockProc(54321);
|
|
823
|
+
mockSpawn.mockReturnValueOnce(secondProc);
|
|
824
|
+
|
|
825
|
+
const result = await launcher.relaunch("test-session-id");
|
|
826
|
+
expect(result).toEqual({ ok: true });
|
|
827
|
+
expect(mockStartContainer).toHaveBeenCalledWith("abc123def456");
|
|
828
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("returns error when stopped container cannot be restarted", async () => {
|
|
832
|
+
launcher.launch({
|
|
833
|
+
cwd: "/tmp/project",
|
|
834
|
+
containerId: "abc123def456",
|
|
835
|
+
containerName: "companion-dead",
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
mockIsContainerAlive.mockReturnValueOnce("stopped");
|
|
839
|
+
mockStartContainer.mockImplementationOnce(() => { throw new Error("container start failed"); });
|
|
840
|
+
|
|
841
|
+
const result = await launcher.relaunch("test-session-id");
|
|
842
|
+
expect(result.ok).toBe(false);
|
|
843
|
+
expect(result.error).toContain("companion-dead");
|
|
844
|
+
expect(result.error).toContain("stopped");
|
|
845
|
+
expect(result.error).toContain("container start failed");
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("returns error when CLI binary not found in container", async () => {
|
|
849
|
+
launcher.launch({
|
|
850
|
+
cwd: "/tmp/project",
|
|
851
|
+
containerId: "abc123def456",
|
|
852
|
+
containerName: "companion-nobin",
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
mockIsContainerAlive.mockReturnValueOnce("running");
|
|
856
|
+
mockHasBinaryInContainer.mockReturnValueOnce(false);
|
|
857
|
+
|
|
858
|
+
const result = await launcher.relaunch("test-session-id");
|
|
859
|
+
expect(result.ok).toBe(false);
|
|
860
|
+
expect(result.error).toContain("claude");
|
|
861
|
+
expect(result.error).toContain("not found");
|
|
862
|
+
expect(result.error).toContain("companion-nobin");
|
|
863
|
+
|
|
864
|
+
const session = launcher.getSession("test-session-id");
|
|
865
|
+
expect(session?.state).toBe("exited");
|
|
866
|
+
expect(session?.exitCode).toBe(127);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("skips container validation for non-containerized sessions", async () => {
|
|
870
|
+
// Create initial proc that exits when killed
|
|
871
|
+
let resolveFirst: (code: number) => void;
|
|
872
|
+
const firstProc = {
|
|
873
|
+
pid: 12345,
|
|
874
|
+
kill: vi.fn(() => { resolveFirst(0); }),
|
|
875
|
+
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
876
|
+
stdout: null,
|
|
877
|
+
stderr: null,
|
|
878
|
+
};
|
|
879
|
+
mockSpawn.mockReturnValueOnce(firstProc);
|
|
880
|
+
|
|
881
|
+
launcher.launch({ cwd: "/tmp/project" });
|
|
882
|
+
|
|
883
|
+
const secondProc = createMockProc(54321);
|
|
884
|
+
mockSpawn.mockReturnValueOnce(secondProc);
|
|
885
|
+
|
|
886
|
+
const result = await launcher.relaunch("test-session-id");
|
|
887
|
+
expect(result).toEqual({ ok: true });
|
|
888
|
+
|
|
889
|
+
// Container validation methods should NOT have been called
|
|
890
|
+
expect(mockIsContainerAlive).not.toHaveBeenCalled();
|
|
891
|
+
expect(mockHasBinaryInContainer).not.toHaveBeenCalled();
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// ─── codex websocket launcher ────────────────────────────────────────────────
|
|
896
|
+
|
|
897
|
+
describe("codex websocket launcher", () => {
|
|
898
|
+
it("spawns codex app-server and a node ws proxy, then attaches a CodexAdapter", async () => {
|
|
899
|
+
// Verify the WS transport path launches two subprocesses:
|
|
900
|
+
// 1) codex app-server --listen ...
|
|
901
|
+
// 2) a Node sidecar proxy that bridges stdio <-> WebSocket
|
|
902
|
+
process.env.COMPANION_CODEX_TRANSPORT = "ws";
|
|
903
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
904
|
+
|
|
905
|
+
const codexProc = createMockProc(2001);
|
|
906
|
+
const { proc: proxyProc } = createPendingCodexWsProxyProc(2002);
|
|
907
|
+
mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
|
|
908
|
+
|
|
909
|
+
const onAdapter = vi.fn();
|
|
910
|
+
companionBus.on("backend:codex-adapter-created", ({ sessionId, adapter }) => onAdapter(sessionId, adapter));
|
|
911
|
+
|
|
912
|
+
launcher.launch({
|
|
913
|
+
backendType: "codex",
|
|
914
|
+
cwd: "/tmp/project",
|
|
915
|
+
codexSandbox: "workspace-write",
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
919
|
+
|
|
920
|
+
expect(mockListen).toHaveBeenCalled();
|
|
921
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
922
|
+
|
|
923
|
+
const [codexCmd] = mockSpawn.mock.calls[0];
|
|
924
|
+
expect(codexCmd[0]).toBe("/opt/fake/codex");
|
|
925
|
+
expect(codexCmd).toContain("app-server");
|
|
926
|
+
expect(codexCmd).toContain("--enable");
|
|
927
|
+
expect(codexCmd).toContain("multi_agent");
|
|
928
|
+
expect(codexCmd).toContain("--listen");
|
|
929
|
+
expect(codexCmd).toContain("ws://127.0.0.1:4500");
|
|
930
|
+
|
|
931
|
+
const [proxyCmd, proxyOpts] = mockSpawn.mock.calls[1];
|
|
932
|
+
expect(proxyCmd[0]).toBe("node");
|
|
933
|
+
expect(proxyCmd[1]).toContain("codex-ws-proxy.cjs");
|
|
934
|
+
expect(proxyCmd[2]).toBe("ws://127.0.0.1:4500");
|
|
935
|
+
// Default connect timeout (30s) and pong timeout (30s) passed to proxy
|
|
936
|
+
expect(proxyCmd[3]).toBe("30000");
|
|
937
|
+
expect(proxyCmd[4]).toBe("30000");
|
|
938
|
+
expect(proxyOpts.stdin).toBe("pipe");
|
|
939
|
+
expect(proxyOpts.stdout).toBe("pipe");
|
|
940
|
+
expect(proxyOpts.stderr).toBe("pipe");
|
|
941
|
+
|
|
942
|
+
expect(onAdapter).toHaveBeenCalledTimes(1);
|
|
943
|
+
expect(onAdapter.mock.calls[0][0]).toBe("test-session-id");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("skips already-claimed ws ports when selecting Codex host listen port", async () => {
|
|
947
|
+
process.env.COMPANION_CODEX_TRANSPORT = "ws";
|
|
948
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
949
|
+
(launcher as any).claimedCodexWsPorts.add(4500);
|
|
950
|
+
|
|
951
|
+
const codexProc = createMockProc(2101);
|
|
952
|
+
const { proc: proxyProc } = createPendingCodexWsProxyProc(2102);
|
|
953
|
+
mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
|
|
954
|
+
|
|
955
|
+
launcher.launch({
|
|
956
|
+
backendType: "codex",
|
|
957
|
+
cwd: "/tmp/project",
|
|
958
|
+
codexSandbox: "workspace-write",
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
962
|
+
|
|
963
|
+
const [codexCmd] = mockSpawn.mock.calls[0];
|
|
964
|
+
expect(codexCmd).toContain("ws://127.0.0.1:4501");
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("passes custom connect and pong timeouts from env vars to the ws proxy", async () => {
|
|
968
|
+
// When COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS and COMPANION_CODEX_PONG_TIMEOUT_MS
|
|
969
|
+
// are set, those values should be forwarded as argv[3] and argv[4] to the proxy.
|
|
970
|
+
process.env.COMPANION_CODEX_TRANSPORT = "ws";
|
|
971
|
+
process.env.COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS = "60000";
|
|
972
|
+
process.env.COMPANION_CODEX_PONG_TIMEOUT_MS = "45000";
|
|
973
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
974
|
+
|
|
975
|
+
const codexProc = createMockProc(5001);
|
|
976
|
+
const { proc: proxyProc } = createPendingCodexWsProxyProc(5002);
|
|
977
|
+
mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
|
|
978
|
+
|
|
979
|
+
companionBus.on("backend:codex-adapter-created", vi.fn());
|
|
980
|
+
launcher.launch({
|
|
981
|
+
backendType: "codex",
|
|
982
|
+
cwd: "/tmp/project",
|
|
983
|
+
codexSandbox: "workspace-write",
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
987
|
+
|
|
988
|
+
const [proxyCmd] = mockSpawn.mock.calls[1];
|
|
989
|
+
expect(proxyCmd[3]).toBe("60000");
|
|
990
|
+
expect(proxyCmd[4]).toBe("45000");
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("relaunch kills the old codex process and ws proxy before spawning replacements", async () => {
|
|
994
|
+
// Verify the WS sidecar is treated as part of session lifecycle during relaunch.
|
|
995
|
+
process.env.COMPANION_CODEX_TRANSPORT = "ws";
|
|
996
|
+
mockResolveBinary.mockReturnValue("/opt/fake/codex");
|
|
997
|
+
|
|
998
|
+
let resolveCodex1!: (code: number) => void;
|
|
999
|
+
const codexProc1 = {
|
|
1000
|
+
pid: 3001,
|
|
1001
|
+
kill: vi.fn(() => resolveCodex1(0)),
|
|
1002
|
+
exited: new Promise<number>((r) => { resolveCodex1 = r; }),
|
|
1003
|
+
stdout: null,
|
|
1004
|
+
stderr: null,
|
|
1005
|
+
};
|
|
1006
|
+
const proxy1 = createPendingCodexWsProxyProc(3002);
|
|
1007
|
+
proxy1.proc.kill.mockImplementation(() => proxy1.resolveExit(0));
|
|
1008
|
+
|
|
1009
|
+
const codexProc2 = createMockProc(3003);
|
|
1010
|
+
const proxy2 = createPendingCodexWsProxyProc(3004);
|
|
1011
|
+
|
|
1012
|
+
mockSpawn
|
|
1013
|
+
.mockReturnValueOnce(codexProc1 as any)
|
|
1014
|
+
.mockReturnValueOnce(proxy1.proc as any)
|
|
1015
|
+
.mockReturnValueOnce(codexProc2 as any)
|
|
1016
|
+
.mockReturnValueOnce(proxy2.proc as any);
|
|
1017
|
+
|
|
1018
|
+
launcher.launch({
|
|
1019
|
+
backendType: "codex",
|
|
1020
|
+
cwd: "/tmp/project",
|
|
1021
|
+
codexSandbox: "workspace-write",
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1025
|
+
|
|
1026
|
+
const result = await launcher.relaunch("test-session-id");
|
|
1027
|
+
expect(result).toEqual({ ok: true });
|
|
1028
|
+
expect(codexProc1.kill).toHaveBeenCalledWith("SIGTERM");
|
|
1029
|
+
expect(proxy1.proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
1030
|
+
expect(mockSpawn).toHaveBeenCalledTimes(4);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("kill() returns true and kills the proxy when only a ws proxy remains", async () => {
|
|
1034
|
+
// Exercise the proxy-only branch introduced for WS cleanup robustness.
|
|
1035
|
+
launcher.launch({ cwd: "/tmp/project" });
|
|
1036
|
+
const proxyOnly = createPendingCodexWsProxyProc(4001);
|
|
1037
|
+
|
|
1038
|
+
(launcher as any).processes.delete("test-session-id");
|
|
1039
|
+
(launcher as any).codexWsProxies.set("test-session-id", proxyOnly.proc);
|
|
1040
|
+
|
|
1041
|
+
const result = await launcher.kill("test-session-id");
|
|
1042
|
+
expect(result).toBe(true);
|
|
1043
|
+
expect(proxyOnly.proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("containerized codex ws mode ignores detached launcher exit and uses proxy exit for session liveness", async () => {
|
|
1047
|
+
// In container WS mode, docker exec -d exits immediately after launching Codex.
|
|
1048
|
+
// The session must remain alive until the proxy (actual transport) exits.
|
|
1049
|
+
process.env.COMPANION_CODEX_TRANSPORT = "ws";
|
|
1050
|
+
mockGetContainerById.mockReturnValue({
|
|
1051
|
+
containerId: "abc123def456",
|
|
1052
|
+
name: "companion-codex",
|
|
1053
|
+
image: "the-companion:latest",
|
|
1054
|
+
portMappings: [{ containerPort: 4502, hostPort: 55021 }],
|
|
1055
|
+
hostCwd: "/tmp/project",
|
|
1056
|
+
containerCwd: "/workspace",
|
|
1057
|
+
state: "running",
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
let resolveLauncherProc!: (code: number) => void;
|
|
1061
|
+
const detachedLauncherProc = {
|
|
1062
|
+
pid: 5001,
|
|
1063
|
+
kill: vi.fn(),
|
|
1064
|
+
exited: new Promise<number>((r) => { resolveLauncherProc = r; }),
|
|
1065
|
+
stdout: null,
|
|
1066
|
+
stderr: null,
|
|
1067
|
+
};
|
|
1068
|
+
const proxy = createPendingCodexWsProxyProc(5002);
|
|
1069
|
+
|
|
1070
|
+
mockSpawn
|
|
1071
|
+
.mockReturnValueOnce(detachedLauncherProc as any)
|
|
1072
|
+
.mockReturnValueOnce(proxy.proc as any);
|
|
1073
|
+
|
|
1074
|
+
launcher.launch({
|
|
1075
|
+
backendType: "codex",
|
|
1076
|
+
cwd: "/tmp/project",
|
|
1077
|
+
codexSandbox: "workspace-write",
|
|
1078
|
+
containerId: "abc123def456",
|
|
1079
|
+
containerName: "companion-codex",
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1083
|
+
|
|
1084
|
+
const [codexCmd] = mockSpawn.mock.calls[0];
|
|
1085
|
+
const codexBashCmd = codexCmd[codexCmd.length - 1];
|
|
1086
|
+
expect(codexBashCmd).toContain("--enable");
|
|
1087
|
+
expect(codexBashCmd).toContain("multi_agent");
|
|
1088
|
+
expect(codexBashCmd).toContain("--listen");
|
|
1089
|
+
expect(codexBashCmd).toContain("ws://0.0.0.0:4502");
|
|
1090
|
+
|
|
1091
|
+
const [proxyCmd] = mockSpawn.mock.calls[1];
|
|
1092
|
+
expect(proxyCmd[2]).toBe("ws://127.0.0.1:55021");
|
|
1093
|
+
|
|
1094
|
+
resolveLauncherProc(0);
|
|
1095
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1096
|
+
|
|
1097
|
+
expect(launcher.getSession("test-session-id")?.state).not.toBe("exited");
|
|
1098
|
+
|
|
1099
|
+
proxy.resolveExit(7);
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1101
|
+
|
|
1102
|
+
const session = launcher.getSession("test-session-id");
|
|
1103
|
+
expect(session?.state).toBe("exited");
|
|
1104
|
+
expect(session?.exitCode).toBe(7);
|
|
1105
|
+
});
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// ─── persistence ─────────────────────────────────────────────────────────────
|
|
1109
|
+
|
|
1110
|
+
describe("persistence", () => {
|
|
1111
|
+
describe("restoreFromDisk", () => {
|
|
1112
|
+
it("recovers sessions from the store", () => {
|
|
1113
|
+
// Manually write launcher data to disk to simulate a previous run
|
|
1114
|
+
const savedSessions = [
|
|
1115
|
+
{
|
|
1116
|
+
sessionId: "restored-1",
|
|
1117
|
+
pid: 99999,
|
|
1118
|
+
state: "connected" as const,
|
|
1119
|
+
cwd: "/tmp/project",
|
|
1120
|
+
createdAt: Date.now(),
|
|
1121
|
+
cliSessionId: "cli-abc",
|
|
1122
|
+
},
|
|
1123
|
+
];
|
|
1124
|
+
store.saveLauncher(savedSessions);
|
|
1125
|
+
|
|
1126
|
+
// Mock process.kill(pid, 0) to succeed (process is alive)
|
|
1127
|
+
const origKill = process.kill;
|
|
1128
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
1129
|
+
pid: number,
|
|
1130
|
+
signal?: string | number,
|
|
1131
|
+
) => {
|
|
1132
|
+
if (signal === 0) return true;
|
|
1133
|
+
return origKill.call(process, pid, signal as any);
|
|
1134
|
+
}) as any);
|
|
1135
|
+
|
|
1136
|
+
const newLauncher = new CliLauncher(3456);
|
|
1137
|
+
newLauncher.setStore(store);
|
|
1138
|
+
const recovered = newLauncher.restoreFromDisk();
|
|
1139
|
+
|
|
1140
|
+
expect(recovered).toBe(1);
|
|
1141
|
+
|
|
1142
|
+
const session = newLauncher.getSession("restored-1");
|
|
1143
|
+
expect(session).toBeDefined();
|
|
1144
|
+
// Live PIDs get state reset to "starting" awaiting WS reconnect
|
|
1145
|
+
expect(session?.state).toBe("starting");
|
|
1146
|
+
expect(session?.cliSessionId).toBe("cli-abc");
|
|
1147
|
+
|
|
1148
|
+
killSpy.mockRestore();
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("marks dead PIDs as exited", () => {
|
|
1152
|
+
const savedSessions = [
|
|
1153
|
+
{
|
|
1154
|
+
sessionId: "dead-1",
|
|
1155
|
+
pid: 11111,
|
|
1156
|
+
state: "connected" as const,
|
|
1157
|
+
cwd: "/tmp/project",
|
|
1158
|
+
createdAt: Date.now(),
|
|
1159
|
+
},
|
|
1160
|
+
];
|
|
1161
|
+
store.saveLauncher(savedSessions);
|
|
1162
|
+
|
|
1163
|
+
// Mock process.kill(pid, 0) to throw (process is dead)
|
|
1164
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(((
|
|
1165
|
+
_pid: number,
|
|
1166
|
+
signal?: string | number,
|
|
1167
|
+
) => {
|
|
1168
|
+
if (signal === 0) throw new Error("ESRCH");
|
|
1169
|
+
return true;
|
|
1170
|
+
}) as any);
|
|
1171
|
+
|
|
1172
|
+
const newLauncher = new CliLauncher(3456);
|
|
1173
|
+
newLauncher.setStore(store);
|
|
1174
|
+
const recovered = newLauncher.restoreFromDisk();
|
|
1175
|
+
|
|
1176
|
+
// Dead sessions don't count as recovered
|
|
1177
|
+
expect(recovered).toBe(0);
|
|
1178
|
+
|
|
1179
|
+
const session = newLauncher.getSession("dead-1");
|
|
1180
|
+
expect(session).toBeDefined();
|
|
1181
|
+
expect(session?.state).toBe("exited");
|
|
1182
|
+
expect(session?.exitCode).toBe(-1);
|
|
1183
|
+
|
|
1184
|
+
killSpy.mockRestore();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("returns 0 when no store is set", () => {
|
|
1188
|
+
const newLauncher = new CliLauncher(3456);
|
|
1189
|
+
// No setStore call
|
|
1190
|
+
expect(newLauncher.restoreFromDisk()).toBe(0);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it("returns 0 when store has no launcher data", () => {
|
|
1194
|
+
const newLauncher = new CliLauncher(3456);
|
|
1195
|
+
newLauncher.setStore(store);
|
|
1196
|
+
// Store is empty, no launcher.json file
|
|
1197
|
+
expect(newLauncher.restoreFromDisk()).toBe(0);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it("recovers Docker WS sessions using container liveness instead of PID", () => {
|
|
1201
|
+
// Docker WS mode sessions have containerId + codexWsPort.
|
|
1202
|
+
// The stored PID is from `docker exec -d` which exits immediately,
|
|
1203
|
+
// so container liveness must be checked instead.
|
|
1204
|
+
const savedSessions = [
|
|
1205
|
+
{
|
|
1206
|
+
sessionId: "docker-ws-1",
|
|
1207
|
+
pid: 55555,
|
|
1208
|
+
state: "connected" as const,
|
|
1209
|
+
cwd: "/tmp/project",
|
|
1210
|
+
createdAt: Date.now(),
|
|
1211
|
+
containerId: "abc123",
|
|
1212
|
+
codexWsPort: 32819,
|
|
1213
|
+
},
|
|
1214
|
+
];
|
|
1215
|
+
store.saveLauncher(savedSessions);
|
|
1216
|
+
|
|
1217
|
+
mockIsContainerAlive.mockReturnValueOnce("running");
|
|
1218
|
+
|
|
1219
|
+
const newLauncher = new CliLauncher(3456);
|
|
1220
|
+
newLauncher.setStore(store);
|
|
1221
|
+
const recovered = newLauncher.restoreFromDisk();
|
|
1222
|
+
|
|
1223
|
+
expect(recovered).toBe(1);
|
|
1224
|
+
expect(mockIsContainerAlive).toHaveBeenCalledWith("abc123");
|
|
1225
|
+
|
|
1226
|
+
const session = newLauncher.getSession("docker-ws-1");
|
|
1227
|
+
expect(session).toBeDefined();
|
|
1228
|
+
expect(session?.state).toBe("starting");
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it("marks Docker WS sessions as exited when container is stopped", () => {
|
|
1232
|
+
const savedSessions = [
|
|
1233
|
+
{
|
|
1234
|
+
sessionId: "docker-ws-dead",
|
|
1235
|
+
pid: 66666,
|
|
1236
|
+
state: "connected" as const,
|
|
1237
|
+
cwd: "/tmp/project",
|
|
1238
|
+
createdAt: Date.now(),
|
|
1239
|
+
containerId: "dead-container",
|
|
1240
|
+
codexWsPort: 32820,
|
|
1241
|
+
},
|
|
1242
|
+
];
|
|
1243
|
+
store.saveLauncher(savedSessions);
|
|
1244
|
+
|
|
1245
|
+
mockIsContainerAlive.mockReturnValueOnce("stopped");
|
|
1246
|
+
|
|
1247
|
+
const newLauncher = new CliLauncher(3456);
|
|
1248
|
+
newLauncher.setStore(store);
|
|
1249
|
+
const recovered = newLauncher.restoreFromDisk();
|
|
1250
|
+
|
|
1251
|
+
expect(recovered).toBe(0);
|
|
1252
|
+
expect(mockIsContainerAlive).toHaveBeenCalledWith("dead-container");
|
|
1253
|
+
|
|
1254
|
+
const session = newLauncher.getSession("docker-ws-dead");
|
|
1255
|
+
expect(session).toBeDefined();
|
|
1256
|
+
expect(session?.state).toBe("exited");
|
|
1257
|
+
expect(session?.exitCode).toBe(-1);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it("preserves already-exited sessions from disk", () => {
|
|
1261
|
+
const savedSessions = [
|
|
1262
|
+
{
|
|
1263
|
+
sessionId: "already-exited",
|
|
1264
|
+
pid: 22222,
|
|
1265
|
+
state: "exited" as const,
|
|
1266
|
+
exitCode: 0,
|
|
1267
|
+
cwd: "/tmp/project",
|
|
1268
|
+
createdAt: Date.now(),
|
|
1269
|
+
},
|
|
1270
|
+
];
|
|
1271
|
+
store.saveLauncher(savedSessions);
|
|
1272
|
+
|
|
1273
|
+
const newLauncher = new CliLauncher(3456);
|
|
1274
|
+
newLauncher.setStore(store);
|
|
1275
|
+
const recovered = newLauncher.restoreFromDisk();
|
|
1276
|
+
|
|
1277
|
+
// Already-exited sessions are loaded but not "recovered"
|
|
1278
|
+
expect(recovered).toBe(0);
|
|
1279
|
+
const session = newLauncher.getSession("already-exited");
|
|
1280
|
+
expect(session).toBeDefined();
|
|
1281
|
+
expect(session?.state).toBe("exited");
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// ─── getStartingSessions ─────────────────────────────────────────────────────
|
|
1287
|
+
|
|
1288
|
+
describe("getStartingSessions", () => {
|
|
1289
|
+
it("returns only sessions in starting state", () => {
|
|
1290
|
+
launcher.launch({ cwd: "/tmp" });
|
|
1291
|
+
|
|
1292
|
+
const starting = launcher.getStartingSessions();
|
|
1293
|
+
expect(starting).toHaveLength(1);
|
|
1294
|
+
expect(starting[0].state).toBe("starting");
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("excludes sessions that have been connected", () => {
|
|
1298
|
+
launcher.launch({ cwd: "/tmp" });
|
|
1299
|
+
launcher.markConnected("test-session-id");
|
|
1300
|
+
|
|
1301
|
+
const starting = launcher.getStartingSessions();
|
|
1302
|
+
expect(starting).toHaveLength(0);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("returns empty array when no sessions exist", () => {
|
|
1306
|
+
expect(launcher.getStartingSessions()).toEqual([]);
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
// ─── isCmdScript platform guard ───────────────────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
describe("isCmdScript platform guard", () => {
|
|
1313
|
+
const originalPlatform = process.platform;
|
|
1314
|
+
|
|
1315
|
+
afterEach(() => {
|
|
1316
|
+
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
it("wraps .cmd binary with cmd.exe /c on win32", () => {
|
|
1320
|
+
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
|
1321
|
+
mockResolveBinary.mockReturnValue("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd");
|
|
1322
|
+
|
|
1323
|
+
launcher.launch({ cwd: "/tmp" });
|
|
1324
|
+
|
|
1325
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
1326
|
+
// On Windows, .cmd files should be spawned via cmd.exe /c
|
|
1327
|
+
expect(cmdAndArgs[0]).toBe("cmd.exe");
|
|
1328
|
+
expect(cmdAndArgs[1]).toBe("/c");
|
|
1329
|
+
expect(cmdAndArgs[2]).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd");
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it("does not wrap .cmd binary with cmd.exe on non-Windows", () => {
|
|
1333
|
+
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
|
1334
|
+
mockResolveBinary.mockReturnValue("/usr/local/bin/claude.cmd");
|
|
1335
|
+
|
|
1336
|
+
launcher.launch({ cwd: "/tmp" });
|
|
1337
|
+
|
|
1338
|
+
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
1339
|
+
// On non-Windows, .cmd files should be spawned directly (no cmd.exe wrapping)
|
|
1340
|
+
expect(cmdAndArgs[0]).toBe("/usr/local/bin/claude.cmd");
|
|
1341
|
+
expect(cmdAndArgs[0]).not.toBe("cmd.exe");
|
|
1342
|
+
});
|
|
1343
|
+
});
|