@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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,1211 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockExecSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => ""));
4
+ const mockExistsSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => false));
5
+ const mockWriteFileSync = vi.hoisted(() => vi.fn());
6
+ const mockReadFileSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => ""));
7
+ const mockMkdirSync = vi.hoisted(() => vi.fn());
8
+ const mockRmSync = vi.hoisted(() => vi.fn());
9
+ const mockSpawn = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("node:child_process", () => ({
12
+ execSync: mockExecSync,
13
+ }));
14
+
15
+ vi.mock("node:fs", async (importOriginal) => {
16
+ const actual = (await importOriginal()) as Record<string, unknown>;
17
+ return {
18
+ ...actual,
19
+ existsSync: mockExistsSync,
20
+ writeFileSync: mockWriteFileSync,
21
+ readFileSync: mockReadFileSync,
22
+ mkdirSync: mockMkdirSync,
23
+ rmSync: mockRmSync,
24
+ };
25
+ });
26
+
27
+ import { ContainerManager } from "./container-manager.js";
28
+
29
+ function createMockProc(exitCode: number, stderrText = "") {
30
+ return {
31
+ exited: Promise.resolve(exitCode),
32
+ stderr: new ReadableStream<Uint8Array>({
33
+ start(controller) {
34
+ controller.enqueue(new TextEncoder().encode(stderrText));
35
+ controller.close();
36
+ },
37
+ }),
38
+ kill: vi.fn(),
39
+ };
40
+ }
41
+
42
+ vi.stubGlobal("Bun", { spawn: mockSpawn });
43
+
44
+ describe("ContainerManager git auth seeding", () => {
45
+ beforeEach(() => {
46
+ mockExecSync.mockReset();
47
+ mockExistsSync.mockReset();
48
+ // Default: existsSync returns false (no host files)
49
+ mockExistsSync.mockReturnValue(false);
50
+ });
51
+
52
+ it("always configures gh as git credential helper when host token lookup fails", () => {
53
+ // Regression guard: copied gh auth files in the container are still valid even
54
+ // when `gh auth token` cannot read host keychain state.
55
+ mockExecSync.mockImplementation((...args: unknown[]) => {
56
+ const cmd = String(args[0] ?? "");
57
+ if (cmd.includes("gh auth token")) throw new Error("host token unavailable");
58
+ return "";
59
+ });
60
+
61
+ const manager = new ContainerManager();
62
+ manager.reseedGitAuth("container123");
63
+
64
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
65
+ expect(commands.some((cmd) => cmd.includes("gh auth setup-git"))).toBe(true);
66
+ expect(commands.some((cmd) => cmd.includes("gh auth login --with-token"))).toBe(false);
67
+ });
68
+
69
+ it("logs in with host token before running gh auth setup-git when token exists", () => {
70
+ // Ordering matters: authenticate first, then wire git credential helper.
71
+ mockExecSync.mockImplementation((...args: unknown[]) => {
72
+ const cmd = String(args[0] ?? "");
73
+ if (cmd.includes("gh auth token")) return "ghp_test_token";
74
+ return "";
75
+ });
76
+
77
+ const manager = new ContainerManager();
78
+ manager.reseedGitAuth("container123");
79
+
80
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
81
+ const loginIndex = commands.findIndex((cmd) => cmd.includes("gh auth login --with-token"));
82
+ const setupGitIndex = commands.findIndex((cmd) => cmd.includes("gh auth setup-git"));
83
+
84
+ expect(loginIndex).toBeGreaterThan(-1);
85
+ expect(setupGitIndex).toBeGreaterThan(-1);
86
+ expect(loginIndex).toBeLessThan(setupGitIndex);
87
+ });
88
+ });
89
+
90
+ describe("ContainerManager git identity seeding from host .gitconfig", () => {
91
+ beforeEach(() => {
92
+ mockExecSync.mockReset();
93
+ mockExistsSync.mockReset();
94
+ mockExistsSync.mockReturnValue(false);
95
+ });
96
+
97
+ it("copies user.name and user.email from /companion-host-gitconfig into container global config", () => {
98
+ // The host .gitconfig is mounted read-only at /companion-host-gitconfig.
99
+ // seedGitAuth should read identity from that file and write it into the
100
+ // container's writable /root/.gitconfig via git config --global.
101
+ mockExecSync.mockImplementation((...args: unknown[]) => {
102
+ const cmd = String(args[0] ?? "");
103
+ if (cmd.includes("gh auth token")) throw new Error("no token");
104
+ return "";
105
+ });
106
+
107
+ const manager = new ContainerManager();
108
+ manager.reseedGitAuth("container123");
109
+
110
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
111
+ // The seeding command should reference the staged host gitconfig path
112
+ const identityCmd = commands.find((cmd) => cmd.includes("companion-host-gitconfig"));
113
+ expect(identityCmd).toBeDefined();
114
+ // It should use git config -f to read from the mounted file
115
+ expect(identityCmd).toContain("git config -f /companion-host-gitconfig user.name");
116
+ expect(identityCmd).toContain("git config -f /companion-host-gitconfig user.email");
117
+ // It should write user.name and user.email via git config --global
118
+ expect(identityCmd).toContain("git config --global user.name");
119
+ expect(identityCmd).toContain("git config --global user.email");
120
+ });
121
+
122
+ it("disables gpgsign in writable global config (not the read-only mount)", () => {
123
+ // With the host .gitconfig mounted at /companion-host-gitconfig instead
124
+ // of /root/.gitconfig, git config --global writes succeed in the container.
125
+ mockExecSync.mockImplementation((...args: unknown[]) => {
126
+ const cmd = String(args[0] ?? "");
127
+ if (cmd.includes("gh auth token")) throw new Error("no token");
128
+ return "";
129
+ });
130
+
131
+ const manager = new ContainerManager();
132
+ manager.reseedGitAuth("container123");
133
+
134
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
135
+ expect(commands.some((cmd) => cmd.includes("git config --global commit.gpgsign false"))).toBe(true);
136
+ });
137
+
138
+ it("marks /workspace as a safe directory to avoid dubious ownership errors", () => {
139
+ // The workspace volume may be owned by a different uid (e.g. ubuntu)
140
+ // than the container user (root), triggering git's ownership check.
141
+ mockExecSync.mockImplementation((...args: unknown[]) => {
142
+ const cmd = String(args[0] ?? "");
143
+ if (cmd.includes("gh auth token")) throw new Error("no token");
144
+ return "";
145
+ });
146
+
147
+ const manager = new ContainerManager();
148
+ manager.reseedGitAuth("container123");
149
+
150
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
151
+ expect(commands.some((cmd) => cmd.includes("safe.directory /workspace"))).toBe(true);
152
+ });
153
+ });
154
+
155
+ describe("ContainerManager Codex file seeding", () => {
156
+ beforeEach(() => {
157
+ mockExecSync.mockReset();
158
+ mockExistsSync.mockReset();
159
+ mockExistsSync.mockReturnValue(false);
160
+ });
161
+
162
+ it("seeds Codex auth files when /companion-host-codex is available", () => {
163
+ // seedCodexFiles is called internally during createContainer and startContainer.
164
+ // Since we can't call createContainer in a unit test (it needs docker), we
165
+ // test the seeding indirectly via a restart (startContainer).
166
+ // However startContainer also calls docker start, so we test via the public
167
+ // reseedGitAuth path which triggers seedGitAuth but not seedCodexFiles.
168
+ // Instead, verify the command is issued during a docker exec mock.
169
+ mockExecSync.mockImplementation((..._args: unknown[]) => "");
170
+
171
+ const manager = new ContainerManager();
172
+ // Access private method via bracket notation for testing
173
+ (manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container456");
174
+
175
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
176
+ // Should attempt to copy Codex files from bind mount
177
+ expect(commands.some((cmd) =>
178
+ cmd.includes("/companion-host-codex") && cmd.includes("/root/.codex"),
179
+ )).toBe(true);
180
+ });
181
+
182
+ it("copies auth.json, config.toml, and directory seeds for Codex", () => {
183
+ mockExecSync.mockImplementation((..._args: unknown[]) => "");
184
+
185
+ const manager = new ContainerManager();
186
+ (manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container789");
187
+
188
+ const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
189
+ const seedCmd = commands.find((cmd) => cmd.includes("companion-host-codex"));
190
+ expect(seedCmd).toBeDefined();
191
+ // Verify it copies the expected files
192
+ expect(seedCmd).toContain("auth.json");
193
+ expect(seedCmd).toContain("config.toml");
194
+ expect(seedCmd).toContain("models_cache.json");
195
+ // Verify it copies directories
196
+ expect(seedCmd).toContain("skills");
197
+ expect(seedCmd).toContain("prompts");
198
+ expect(seedCmd).toContain("rules");
199
+ });
200
+
201
+ it("does not fail when seedCodexFiles encounters an error", () => {
202
+ // seedCodexFiles is best-effort and should not throw
203
+ mockExecSync.mockImplementation(() => {
204
+ throw new Error("container not running");
205
+ });
206
+
207
+ const manager = new ContainerManager();
208
+ expect(() => {
209
+ (manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container999");
210
+ }).not.toThrow();
211
+ });
212
+ });
213
+
214
+ describe("ContainerManager workspace copy", () => {
215
+ beforeEach(() => {
216
+ mockSpawn.mockReset();
217
+ });
218
+
219
+ it("uses tar stream + docker exec to copy workspace content", async () => {
220
+ // Validates the fast path used for large workspaces (especially on macOS):
221
+ // tar the host directory and stream directly into /workspace in-container.
222
+ mockSpawn.mockReturnValue(createMockProc(0));
223
+
224
+ const manager = new ContainerManager();
225
+ await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace")).resolves.toBeUndefined();
226
+
227
+ expect(mockSpawn).toHaveBeenCalledOnce();
228
+ const [args, options] = mockSpawn.mock.calls[0] as [string[], Record<string, unknown>];
229
+
230
+ expect(args[0]).toBe("bash");
231
+ expect(args[1]).toBe("-lc");
232
+ expect(args[2]).toContain("set -o pipefail");
233
+ expect(args[2]).toContain("COPYFILE_DISABLE=1 tar -C /tmp/my-workspace -cf - .");
234
+ expect(args[2]).toContain("docker exec -i container123 tar -xf - -C /workspace");
235
+ expect(options.stdout).toBe("pipe");
236
+ expect(options.stderr).toBe("pipe");
237
+ });
238
+
239
+ it("throws a descriptive error when copy command fails", async () => {
240
+ // Ensures stderr from the tar/docker pipeline is surfaced to users.
241
+ mockSpawn.mockReturnValue(createMockProc(2, "tar: write error"));
242
+
243
+ const manager = new ContainerManager();
244
+ await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace"))
245
+ .rejects.toThrow("workspace copy failed (exit 2): tar: write error");
246
+ });
247
+ });
248
+
249
+ describe("ContainerManager gitOpsInContainer", () => {
250
+ beforeEach(() => {
251
+ mockExecSync.mockReset();
252
+ });
253
+
254
+ it("runs fetch, checkout, and pull in sequence and reports success", () => {
255
+ // All git commands succeed inside the container
256
+ mockExecSync.mockReturnValue("");
257
+
258
+ const manager = new ContainerManager();
259
+ const result = manager.gitOpsInContainer("cid-123", {
260
+ branch: "feat/new",
261
+ currentBranch: "main",
262
+ defaultBranch: "main",
263
+ });
264
+
265
+ expect(result.fetchOk).toBe(true);
266
+ expect(result.checkoutOk).toBe(true);
267
+ expect(result.pullOk).toBe(true);
268
+ expect(result.errors).toHaveLength(0);
269
+
270
+ // Verify commands were executed in the container
271
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
272
+ expect(cmds.some((c) => c.includes("git fetch --prune"))).toBe(true);
273
+ expect(cmds.some((c) => c.includes("git checkout"))).toBe(true);
274
+ expect(cmds.some((c) => c.includes("git pull"))).toBe(true);
275
+ });
276
+
277
+ it("treats fetch failure as non-fatal and continues with checkout/pull", () => {
278
+ // git fetch fails but checkout and pull succeed
279
+ mockExecSync.mockImplementation((...args: unknown[]) => {
280
+ const cmd = String(args[0] ?? "");
281
+ if (cmd.includes("git fetch")) throw new Error("network unreachable");
282
+ return "";
283
+ });
284
+
285
+ const manager = new ContainerManager();
286
+ const result = manager.gitOpsInContainer("cid-123", {
287
+ branch: "feat/new",
288
+ currentBranch: "main",
289
+ });
290
+
291
+ expect(result.fetchOk).toBe(false);
292
+ expect(result.checkoutOk).toBe(true);
293
+ expect(result.pullOk).toBe(true);
294
+ expect(result.errors).toHaveLength(1);
295
+ expect(result.errors[0]).toContain("fetch:");
296
+ });
297
+
298
+ it("reports checkout failure when branch does not exist and createBranch is false", () => {
299
+ mockExecSync.mockImplementation((...args: unknown[]) => {
300
+ const cmd = String(args[0] ?? "");
301
+ if (cmd.includes("git checkout")) throw new Error("pathspec did not match");
302
+ return "";
303
+ });
304
+
305
+ const manager = new ContainerManager();
306
+ const result = manager.gitOpsInContainer("cid-123", {
307
+ branch: "nonexistent",
308
+ currentBranch: "main",
309
+ });
310
+
311
+ expect(result.checkoutOk).toBe(false);
312
+ expect(result.errors.some((e) => e.includes("does not exist"))).toBe(true);
313
+ });
314
+
315
+ it("creates a new branch when checkout fails and createBranch is true", () => {
316
+ // The simple checkout should fail, then the "checkout -b" fallback should succeed.
317
+ mockExecSync.mockImplementation((...args: unknown[]) => {
318
+ const cmd = String(args[0] ?? "");
319
+ // Match simple checkout (no -b flag) — the -b flag follows "checkout" directly
320
+ if (cmd.includes("git checkout") && !cmd.includes("checkout -b")) {
321
+ throw new Error("pathspec did not match");
322
+ }
323
+ return "";
324
+ });
325
+
326
+ const manager = new ContainerManager();
327
+ const result = manager.gitOpsInContainer("cid-123", {
328
+ branch: "feat/new",
329
+ currentBranch: "main",
330
+ createBranch: true,
331
+ defaultBranch: "main",
332
+ });
333
+
334
+ expect(result.checkoutOk).toBe(true);
335
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
336
+ expect(cmds.some((c) => c.includes("checkout -b"))).toBe(true);
337
+ });
338
+
339
+ it("treats pull failure as non-fatal", () => {
340
+ mockExecSync.mockImplementation((...args: unknown[]) => {
341
+ const cmd = String(args[0] ?? "");
342
+ if (cmd.includes("git pull")) throw new Error("no tracking info");
343
+ return "";
344
+ });
345
+
346
+ const manager = new ContainerManager();
347
+ const result = manager.gitOpsInContainer("cid-123", {
348
+ branch: "feat/new",
349
+ currentBranch: "main",
350
+ });
351
+
352
+ expect(result.pullOk).toBe(false);
353
+ expect(result.checkoutOk).toBe(true);
354
+ expect(result.errors.some((e) => e.includes("pull:"))).toBe(true);
355
+ });
356
+
357
+ it("skips checkout when currentBranch matches requested branch", () => {
358
+ mockExecSync.mockReturnValue("");
359
+
360
+ const manager = new ContainerManager();
361
+ const result = manager.gitOpsInContainer("cid-123", {
362
+ branch: "main",
363
+ currentBranch: "main",
364
+ });
365
+
366
+ expect(result.checkoutOk).toBe(true);
367
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
368
+ // Should not have any checkout command
369
+ expect(cmds.some((c) => c.includes("git checkout"))).toBe(false);
370
+ // But should still fetch and pull
371
+ expect(cmds.some((c) => c.includes("git fetch"))).toBe(true);
372
+ expect(cmds.some((c) => c.includes("git pull"))).toBe(true);
373
+ });
374
+ });
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Docker daemon checks
378
+ // ---------------------------------------------------------------------------
379
+
380
+ describe("ContainerManager checkDocker", () => {
381
+ beforeEach(() => {
382
+ mockExecSync.mockReset();
383
+ });
384
+
385
+ it("returns true when docker info succeeds", () => {
386
+ mockExecSync.mockReturnValue("24.0.7");
387
+ const manager = new ContainerManager();
388
+ expect(manager.checkDocker()).toBe(true);
389
+ });
390
+
391
+ it("returns false when docker info fails", () => {
392
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
393
+ const manager = new ContainerManager();
394
+ expect(manager.checkDocker()).toBe(false);
395
+ });
396
+ });
397
+
398
+ describe("ContainerManager getDockerVersion", () => {
399
+ beforeEach(() => {
400
+ mockExecSync.mockReset();
401
+ });
402
+
403
+ it("returns version string on success", () => {
404
+ mockExecSync.mockReturnValue("24.0.7");
405
+ const manager = new ContainerManager();
406
+ expect(manager.getDockerVersion()).toBe("24.0.7");
407
+ });
408
+
409
+ it("returns null on failure", () => {
410
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
411
+ const manager = new ContainerManager();
412
+ expect(manager.getDockerVersion()).toBeNull();
413
+ });
414
+ });
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Image operations
418
+ // ---------------------------------------------------------------------------
419
+
420
+ describe("ContainerManager listImages", () => {
421
+ beforeEach(() => {
422
+ mockExecSync.mockReset();
423
+ });
424
+
425
+ it("returns parsed image list", () => {
426
+ mockExecSync.mockReturnValue("node:22\nubuntu:latest\npython:3.12");
427
+ const manager = new ContainerManager();
428
+ expect(manager.listImages()).toEqual(["node:22", "python:3.12", "ubuntu:latest"]);
429
+ });
430
+
431
+ it("filters out <none> entries", () => {
432
+ mockExecSync.mockReturnValue("<none>:latest\nnode:22");
433
+ const manager = new ContainerManager();
434
+ expect(manager.listImages()).toEqual(["node:22"]);
435
+ });
436
+
437
+ it("returns empty array when docker command fails", () => {
438
+ mockExecSync.mockImplementation(() => { throw new Error("fail"); });
439
+ const manager = new ContainerManager();
440
+ expect(manager.listImages()).toEqual([]);
441
+ });
442
+
443
+ it("returns empty array when output is empty", () => {
444
+ mockExecSync.mockReturnValue("");
445
+ const manager = new ContainerManager();
446
+ expect(manager.listImages()).toEqual([]);
447
+ });
448
+ });
449
+
450
+ describe("ContainerManager imageExists", () => {
451
+ beforeEach(() => {
452
+ mockExecSync.mockReset();
453
+ });
454
+
455
+ it("returns true when image inspect succeeds", () => {
456
+ mockExecSync.mockReturnValue("[]");
457
+ const manager = new ContainerManager();
458
+ expect(manager.imageExists("node:22")).toBe(true);
459
+ });
460
+
461
+ it("returns false when image inspect fails", () => {
462
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
463
+ const manager = new ContainerManager();
464
+ expect(manager.imageExists("nonexistent:latest")).toBe(false);
465
+ });
466
+ });
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Container execution
470
+ // ---------------------------------------------------------------------------
471
+
472
+ describe("ContainerManager execInContainer", () => {
473
+ beforeEach(() => {
474
+ mockExecSync.mockReset();
475
+ });
476
+
477
+ it("runs docker exec with properly escaped args", () => {
478
+ mockExecSync.mockReturnValue("hello world");
479
+ const manager = new ContainerManager();
480
+ const result = manager.execInContainer("abc123", ["sh", "-c", "echo hello"]);
481
+ expect(result).toBe("hello world");
482
+ const cmd = String(mockExecSync.mock.calls[0]?.[0] ?? "");
483
+ expect(cmd).toContain("docker exec");
484
+ expect(cmd).toContain("abc123");
485
+ });
486
+
487
+ it("throws on invalid container ID", () => {
488
+ const manager = new ContainerManager();
489
+ expect(() => manager.execInContainer("../evil", ["ls"])).toThrow("Invalid container ID");
490
+ });
491
+
492
+ it("throws on container ID starting with hyphen", () => {
493
+ const manager = new ContainerManager();
494
+ expect(() => manager.execInContainer("-bad", ["ls"])).toThrow("Invalid container ID");
495
+ });
496
+ });
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Container tracking (retrack, getContainer, getContainerById, listContainers)
500
+ // ---------------------------------------------------------------------------
501
+
502
+ describe("ContainerManager tracking", () => {
503
+ beforeEach(() => {
504
+ mockExecSync.mockReset();
505
+ mockExistsSync.mockReset();
506
+ mockExistsSync.mockReturnValue(false);
507
+ });
508
+
509
+ it("retrack moves container to new session key", () => {
510
+ // Manually set up a container in the manager's internal map via restoreContainer
511
+ mockExecSync.mockReturnValue("true"); // docker inspect returns "true" (running)
512
+ const manager = new ContainerManager();
513
+ const info = {
514
+ containerId: "abc123def456",
515
+ name: "companion-abc123de",
516
+ image: "node:22",
517
+ portMappings: [],
518
+ hostCwd: "/tmp",
519
+ containerCwd: "/workspace",
520
+ state: "running" as const,
521
+ };
522
+ manager.restoreContainer("old-session", info);
523
+ expect(manager.getContainer("old-session")).toBeDefined();
524
+
525
+ manager.retrack("abc123def456", "new-session");
526
+ expect(manager.getContainer("old-session")).toBeUndefined();
527
+ expect(manager.getContainer("new-session")).toBeDefined();
528
+ });
529
+
530
+ it("retrack is a no-op when containerId is not tracked", () => {
531
+ const manager = new ContainerManager();
532
+ // Should not throw
533
+ manager.retrack("nonexistent", "new-session");
534
+ expect(manager.listContainers()).toHaveLength(0);
535
+ });
536
+
537
+ it("getContainerById finds container by docker ID", () => {
538
+ mockExecSync.mockReturnValue("true");
539
+ const manager = new ContainerManager();
540
+ const info = {
541
+ containerId: "abc123def456",
542
+ name: "companion-abc123de",
543
+ image: "node:22",
544
+ portMappings: [],
545
+ hostCwd: "/tmp",
546
+ containerCwd: "/workspace",
547
+ state: "running" as const,
548
+ };
549
+ manager.restoreContainer("sess-1", info);
550
+ expect(manager.getContainerById("abc123def456")).toBeDefined();
551
+ expect(manager.getContainerById("nonexistent")).toBeUndefined();
552
+ });
553
+
554
+ it("listContainers returns all tracked containers", () => {
555
+ mockExecSync.mockReturnValue("true");
556
+ const manager = new ContainerManager();
557
+ expect(manager.listContainers()).toHaveLength(0);
558
+
559
+ manager.restoreContainer("s1", {
560
+ containerId: "c1", name: "n1", image: "i1",
561
+ portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
562
+ });
563
+ manager.restoreContainer("s2", {
564
+ containerId: "c2", name: "n2", image: "i2",
565
+ portMappings: [], hostCwd: "/b", containerCwd: "/workspace", state: "running",
566
+ });
567
+ expect(manager.listContainers()).toHaveLength(2);
568
+ });
569
+ });
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // removeContainer
573
+ // ---------------------------------------------------------------------------
574
+
575
+ describe("ContainerManager removeContainer", () => {
576
+ beforeEach(() => {
577
+ mockExecSync.mockReset();
578
+ mockExistsSync.mockReset();
579
+ mockExistsSync.mockReturnValue(false);
580
+ });
581
+
582
+ it("removes container and volume from docker and internal map", () => {
583
+ // Set up a tracked container
584
+ mockExecSync.mockReturnValue("true");
585
+ const manager = new ContainerManager();
586
+ manager.restoreContainer("sess-1", {
587
+ containerId: "abc123", name: "companion-abc", image: "node:22",
588
+ portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
589
+ state: "running", volumeName: "companion-ws-abc",
590
+ });
591
+ expect(manager.getContainer("sess-1")).toBeDefined();
592
+
593
+ // Reset so we can track removal calls
594
+ mockExecSync.mockReset();
595
+ mockExecSync.mockReturnValue("");
596
+ manager.removeContainer("sess-1");
597
+
598
+ expect(manager.getContainer("sess-1")).toBeUndefined();
599
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
600
+ expect(cmds.some((c) => c.includes("docker rm -f"))).toBe(true);
601
+ expect(cmds.some((c) => c.includes("docker volume rm"))).toBe(true);
602
+ });
603
+
604
+ it("is a no-op when session is not tracked", () => {
605
+ const manager = new ContainerManager();
606
+ // Should not throw
607
+ manager.removeContainer("nonexistent");
608
+ });
609
+
610
+ it("continues cleanup even when docker rm fails", () => {
611
+ mockExecSync.mockReturnValue("true");
612
+ const manager = new ContainerManager();
613
+ manager.restoreContainer("sess-1", {
614
+ containerId: "abc123", name: "companion-abc", image: "node:22",
615
+ portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
616
+ state: "running", volumeName: "vol-1",
617
+ });
618
+ // Make docker rm fail but volume rm succeed
619
+ mockExecSync.mockReset();
620
+ mockExecSync.mockImplementation((...args: unknown[]) => {
621
+ const cmd = String(args[0] ?? "");
622
+ if (cmd.includes("docker rm")) throw new Error("rm failed");
623
+ return "";
624
+ });
625
+ // Should not throw — removal is best-effort
626
+ manager.removeContainer("sess-1");
627
+ expect(manager.getContainer("sess-1")).toBeUndefined();
628
+ });
629
+ });
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // isContainerAlive
633
+ // ---------------------------------------------------------------------------
634
+
635
+ describe("ContainerManager isContainerAlive", () => {
636
+ beforeEach(() => {
637
+ mockExecSync.mockReset();
638
+ });
639
+
640
+ it("returns 'running' when docker inspect shows true", () => {
641
+ mockExecSync.mockReturnValue("true");
642
+ const manager = new ContainerManager();
643
+ expect(manager.isContainerAlive("abc123")).toBe("running");
644
+ });
645
+
646
+ it("returns 'stopped' when docker inspect shows false", () => {
647
+ mockExecSync.mockReturnValue("false");
648
+ const manager = new ContainerManager();
649
+ expect(manager.isContainerAlive("abc123")).toBe("stopped");
650
+ });
651
+
652
+ it("returns 'missing' when docker inspect throws", () => {
653
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
654
+ const manager = new ContainerManager();
655
+ expect(manager.isContainerAlive("abc123")).toBe("missing");
656
+ });
657
+ });
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // hasBinaryInContainer
661
+ // ---------------------------------------------------------------------------
662
+
663
+ describe("ContainerManager hasBinaryInContainer", () => {
664
+ beforeEach(() => {
665
+ mockExecSync.mockReset();
666
+ });
667
+
668
+ it("returns true when which finds the binary", () => {
669
+ mockExecSync.mockReturnValue("/usr/bin/node");
670
+ const manager = new ContainerManager();
671
+ expect(manager.hasBinaryInContainer("abc123", "node")).toBe(true);
672
+ });
673
+
674
+ it("returns false when which fails", () => {
675
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
676
+ const manager = new ContainerManager();
677
+ expect(manager.hasBinaryInContainer("abc123", "nonexistent")).toBe(false);
678
+ });
679
+ });
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // startContainer
683
+ // ---------------------------------------------------------------------------
684
+
685
+ describe("ContainerManager startContainer", () => {
686
+ beforeEach(() => {
687
+ mockExecSync.mockReset();
688
+ mockExistsSync.mockReset();
689
+ mockExistsSync.mockReturnValue(false);
690
+ });
691
+
692
+ it("runs docker start and re-seeds auth files", () => {
693
+ // startContainer calls docker start, seedAuthFiles, seedCodexFiles, seedGitAuth
694
+ mockExecSync.mockReturnValue("");
695
+ const manager = new ContainerManager();
696
+ manager.startContainer("abc123");
697
+
698
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
699
+ expect(cmds[0]).toContain("docker start");
700
+ // Should have multiple docker exec calls for seeding
701
+ expect(cmds.length).toBeGreaterThan(1);
702
+ });
703
+
704
+ it("throws on invalid container ID", () => {
705
+ const manager = new ContainerManager();
706
+ expect(() => manager.startContainer("../evil")).toThrow("Invalid container ID");
707
+ });
708
+ });
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // restoreContainer
712
+ // ---------------------------------------------------------------------------
713
+
714
+ describe("ContainerManager restoreContainer", () => {
715
+ beforeEach(() => {
716
+ mockExecSync.mockReset();
717
+ });
718
+
719
+ it("tracks a running container", () => {
720
+ mockExecSync.mockReturnValue("true");
721
+ const manager = new ContainerManager();
722
+ const info = {
723
+ containerId: "abc123", name: "test", image: "node:22",
724
+ portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
725
+ state: "stopped" as const,
726
+ };
727
+ const ok = manager.restoreContainer("sess-1", info);
728
+ expect(ok).toBe(true);
729
+ expect(info.state).toBe("running");
730
+ expect(manager.getContainer("sess-1")).toBe(info);
731
+ });
732
+
733
+ it("tracks a stopped container", () => {
734
+ mockExecSync.mockReturnValue("false");
735
+ const manager = new ContainerManager();
736
+ const info = {
737
+ containerId: "abc123", name: "test", image: "node:22",
738
+ portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
739
+ state: "running" as const,
740
+ };
741
+ const ok = manager.restoreContainer("sess-1", info);
742
+ expect(ok).toBe(true);
743
+ expect(info.state).toBe("stopped");
744
+ });
745
+
746
+ it("returns false when container no longer exists", () => {
747
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
748
+ const manager = new ContainerManager();
749
+ const info = {
750
+ containerId: "abc123", name: "test", image: "node:22",
751
+ portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
752
+ state: "running" as const,
753
+ };
754
+ const ok = manager.restoreContainer("sess-1", info);
755
+ expect(ok).toBe(false);
756
+ expect(manager.getContainer("sess-1")).toBeUndefined();
757
+ });
758
+ });
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // persistState / restoreState
762
+ // ---------------------------------------------------------------------------
763
+
764
+ describe("ContainerManager persistState", () => {
765
+ beforeEach(() => {
766
+ mockExecSync.mockReset();
767
+ mockWriteFileSync.mockReset();
768
+ mockExistsSync.mockReset();
769
+ mockExistsSync.mockReturnValue(false);
770
+ });
771
+
772
+ it("writes tracked containers to disk as JSON", () => {
773
+ mockExecSync.mockReturnValue("true");
774
+ const manager = new ContainerManager();
775
+ manager.restoreContainer("sess-1", {
776
+ containerId: "c1", name: "n1", image: "i1",
777
+ portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
778
+ });
779
+
780
+ manager.persistState("/tmp/state.json");
781
+
782
+ expect(mockWriteFileSync).toHaveBeenCalledOnce();
783
+ const [path, content] = mockWriteFileSync.mock.calls[0] as [string, string, string];
784
+ expect(path).toBe("/tmp/state.json");
785
+ const parsed = JSON.parse(content);
786
+ expect(parsed).toHaveLength(1);
787
+ expect(parsed[0].sessionId).toBe("sess-1");
788
+ });
789
+
790
+ it("excludes removed containers from persisted state", () => {
791
+ mockExecSync.mockReturnValue("true");
792
+ const manager = new ContainerManager();
793
+ manager.restoreContainer("sess-1", {
794
+ containerId: "c1", name: "n1", image: "i1",
795
+ portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
796
+ });
797
+ // Remove the container so state = "removed"
798
+ mockExecSync.mockReset();
799
+ mockExecSync.mockReturnValue("");
800
+ manager.removeContainer("sess-1");
801
+
802
+ mockWriteFileSync.mockReset();
803
+ manager.persistState("/tmp/state.json");
804
+
805
+ const [, content] = mockWriteFileSync.mock.calls[0] as [string, string, string];
806
+ expect(JSON.parse(content)).toHaveLength(0);
807
+ });
808
+
809
+ it("does not throw when write fails", () => {
810
+ mockWriteFileSync.mockImplementation(() => { throw new Error("EACCES"); });
811
+ const manager = new ContainerManager();
812
+ expect(() => manager.persistState("/tmp/state.json")).not.toThrow();
813
+ });
814
+ });
815
+
816
+ describe("ContainerManager restoreState", () => {
817
+ beforeEach(() => {
818
+ mockExecSync.mockReset();
819
+ mockReadFileSync.mockReset();
820
+ mockExistsSync.mockReset();
821
+ });
822
+
823
+ it("returns 0 when file does not exist", () => {
824
+ mockExistsSync.mockReturnValue(false);
825
+ const manager = new ContainerManager();
826
+ expect(manager.restoreState("/tmp/state.json")).toBe(0);
827
+ });
828
+
829
+ it("restores containers from disk", () => {
830
+ mockExistsSync.mockReturnValue(true);
831
+ mockExecSync.mockReturnValue("true"); // container is running
832
+ mockReadFileSync.mockReturnValue(JSON.stringify([
833
+ { sessionId: "s1", info: { containerId: "c1", name: "n1", image: "i1", portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running" } },
834
+ ]));
835
+
836
+ const manager = new ContainerManager();
837
+ const count = manager.restoreState("/tmp/state.json");
838
+ expect(count).toBe(1);
839
+ expect(manager.getContainer("s1")).toBeDefined();
840
+ });
841
+
842
+ it("returns 0 when file is corrupt", () => {
843
+ mockExistsSync.mockReturnValue(true);
844
+ mockReadFileSync.mockReturnValue("not json");
845
+ const manager = new ContainerManager();
846
+ expect(manager.restoreState("/tmp/state.json")).toBe(0);
847
+ });
848
+ });
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // buildImage
852
+ // ---------------------------------------------------------------------------
853
+
854
+ describe("ContainerManager buildImage", () => {
855
+ beforeEach(() => {
856
+ mockExecSync.mockReset();
857
+ });
858
+
859
+ it("runs docker build and returns output", () => {
860
+ mockExecSync.mockReturnValue("Successfully built abc123");
861
+ const manager = new ContainerManager();
862
+ const output = manager.buildImage("/tmp/Dockerfile", "test:latest");
863
+ expect(output).toBe("Successfully built abc123");
864
+ const cmd = String(mockExecSync.mock.calls[0]?.[0] ?? "");
865
+ expect(cmd).toContain("docker build");
866
+ expect(cmd).toContain("-t test:latest");
867
+ });
868
+
869
+ it("throws with descriptive error on build failure", () => {
870
+ mockExecSync.mockImplementation(() => { throw new Error("build error"); });
871
+ const manager = new ContainerManager();
872
+ expect(() => manager.buildImage("/tmp/Dockerfile")).toThrow("Failed to build image");
873
+ });
874
+ });
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // getRegistryImage (static)
878
+ // ---------------------------------------------------------------------------
879
+
880
+ describe("ContainerManager.getRegistryImage", () => {
881
+ it("returns registry path for the-companion:latest", () => {
882
+ const result = ContainerManager.getRegistryImage("the-companion:latest");
883
+ expect(result).toContain("stangirard/the-companion:latest");
884
+ });
885
+
886
+ it("returns null for non-default images", () => {
887
+ expect(ContainerManager.getRegistryImage("node:22")).toBeNull();
888
+ expect(ContainerManager.getRegistryImage("custom:v1")).toBeNull();
889
+ });
890
+ });
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // cleanupAll
894
+ // ---------------------------------------------------------------------------
895
+
896
+ describe("ContainerManager cleanupAll", () => {
897
+ beforeEach(() => {
898
+ mockExecSync.mockReset();
899
+ });
900
+
901
+ it("removes all tracked containers", () => {
902
+ mockExecSync.mockReturnValue("true"); // for restoreContainer
903
+ const manager = new ContainerManager();
904
+ manager.restoreContainer("s1", {
905
+ containerId: "c1", name: "n1", image: "i1",
906
+ portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
907
+ });
908
+ manager.restoreContainer("s2", {
909
+ containerId: "c2", name: "n2", image: "i2",
910
+ portMappings: [], hostCwd: "/b", containerCwd: "/workspace", state: "running",
911
+ });
912
+ expect(manager.listContainers()).toHaveLength(2);
913
+
914
+ mockExecSync.mockReset();
915
+ mockExecSync.mockReturnValue("");
916
+ manager.cleanupAll();
917
+ expect(manager.listContainers()).toHaveLength(0);
918
+ });
919
+ });
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // createContainer (full flow with mocked docker commands)
923
+ // ---------------------------------------------------------------------------
924
+
925
+ describe("ContainerManager createContainer", () => {
926
+ beforeEach(() => {
927
+ mockExecSync.mockReset();
928
+ mockExistsSync.mockReset();
929
+ mockExistsSync.mockReturnValue(false);
930
+ });
931
+
932
+ it("creates a container with volume, ports, and auth seeding", () => {
933
+ // Mock the sequence of docker commands:
934
+ // 1. docker volume create
935
+ // 2. docker create → returns container ID
936
+ // 3. docker start
937
+ // 4-N. seedAuthFiles, seedCodexFiles, seedGitAuth (all docker exec)
938
+ // last. docker port → returns port mapping
939
+ let callCount = 0;
940
+ mockExecSync.mockImplementation((...args: unknown[]) => {
941
+ callCount++;
942
+ const cmd = String(args[0] ?? "");
943
+ if (cmd.includes("docker volume create")) return "companion-ws-test1234";
944
+ if (cmd.startsWith("docker create") || cmd.startsWith("'docker' 'create'") || cmd.includes("docker create")) return "abcdef1234567890";
945
+ if (cmd.includes("docker start")) return "";
946
+ if (cmd.includes("docker port")) return "0.0.0.0:49152";
947
+ if (cmd.includes("gh auth token")) throw new Error("no token");
948
+ return "";
949
+ });
950
+
951
+ const manager = new ContainerManager();
952
+ const info = manager.createContainer("test1234-5678-abcd", "/tmp/workspace", {
953
+ image: "node:22",
954
+ ports: [3000],
955
+ });
956
+
957
+ expect(info.containerId).toBe("abcdef1234567890");
958
+ expect(info.state).toBe("running");
959
+ expect(info.portMappings).toHaveLength(1);
960
+ expect(info.portMappings[0].hostPort).toBe(49152);
961
+ expect(info.portMappings[0].containerPort).toBe(3000);
962
+ expect(info.volumeName).toBe("companion-ws-test1234");
963
+ });
964
+
965
+ it("rejects invalid port numbers", () => {
966
+ const manager = new ContainerManager();
967
+ expect(() => manager.createContainer("sess-1", "/tmp", {
968
+ image: "node:22", ports: [0],
969
+ })).toThrow("Invalid port number: 0");
970
+
971
+ expect(() => manager.createContainer("sess-2", "/tmp", {
972
+ image: "node:22", ports: [99999],
973
+ })).toThrow("Invalid port number: 99999");
974
+ });
975
+
976
+ it("cleans up on creation failure", () => {
977
+ mockExecSync.mockImplementation((...args: unknown[]) => {
978
+ const cmd = String(args[0] ?? "");
979
+ if (cmd.includes("docker volume create")) return "vol-123";
980
+ if (cmd.includes("docker create")) throw new Error("image not found");
981
+ return "";
982
+ });
983
+
984
+ const manager = new ContainerManager();
985
+ expect(() => manager.createContainer("sess-1", "/tmp", {
986
+ image: "nonexistent:v1", ports: [],
987
+ })).toThrow("Failed to create container");
988
+ });
989
+
990
+ it("includes extra volumes and env vars in docker create args", () => {
991
+ let createCmd = "";
992
+ mockExecSync.mockImplementation((...args: unknown[]) => {
993
+ const cmd = String(args[0] ?? "");
994
+ if (cmd.includes("docker volume create")) return "vol-1";
995
+ if (cmd.includes("docker create")) { createCmd = cmd; return "cid123"; }
996
+ if (cmd.includes("docker start")) return "";
997
+ if (cmd.includes("docker port")) return "0.0.0.0:8080";
998
+ if (cmd.includes("gh auth token")) throw new Error("no");
999
+ return "";
1000
+ });
1001
+
1002
+ const manager = new ContainerManager();
1003
+ manager.createContainer("sess-1", "/tmp/ws", {
1004
+ image: "node:22",
1005
+ ports: [3000],
1006
+ volumes: ["/data:/data:ro"],
1007
+ env: { NODE_ENV: "production" },
1008
+ });
1009
+
1010
+ expect(createCmd).toContain("/data:/data:ro");
1011
+ expect(createCmd).toContain("NODE_ENV=production");
1012
+ });
1013
+
1014
+ it("mounts host .gitconfig when it exists", () => {
1015
+ let createCmd = "";
1016
+ mockExistsSync.mockImplementation((...args: unknown[]) => {
1017
+ const path = String(args[0] ?? "");
1018
+ return path.endsWith(".gitconfig");
1019
+ });
1020
+ mockExecSync.mockImplementation((...args: unknown[]) => {
1021
+ const cmd = String(args[0] ?? "");
1022
+ if (cmd.includes("docker volume create")) return "vol-1";
1023
+ if (cmd.includes("docker create")) { createCmd = cmd; return "cid123"; }
1024
+ if (cmd.includes("docker start")) return "";
1025
+ if (cmd.includes("docker port")) return "";
1026
+ if (cmd.includes("gh auth token")) throw new Error("no");
1027
+ return "";
1028
+ });
1029
+
1030
+ const manager = new ContainerManager();
1031
+ manager.createContainer("sess-1", "/tmp", { image: "node:22", ports: [] });
1032
+ expect(createCmd).toContain("companion-host-gitconfig");
1033
+ });
1034
+ });
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // seedAuthFiles (private, tested via startContainer which calls it)
1038
+ // ---------------------------------------------------------------------------
1039
+
1040
+ describe("ContainerManager seedAuthFiles via startContainer", () => {
1041
+ beforeEach(() => {
1042
+ mockExecSync.mockReset();
1043
+ mockExistsSync.mockReset();
1044
+ mockExistsSync.mockReturnValue(false);
1045
+ });
1046
+
1047
+ it("copies auth files from /companion-host-claude to /root/.claude", () => {
1048
+ mockExecSync.mockReturnValue("");
1049
+ const manager = new ContainerManager();
1050
+ manager.startContainer("abc123");
1051
+
1052
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
1053
+ // seedAuthFiles runs a docker exec with companion-host-claude
1054
+ expect(cmds.some((c) => c.includes("companion-host-claude") && c.includes("/root/.claude"))).toBe(true);
1055
+ });
1056
+ });
1057
+
1058
+ // ---------------------------------------------------------------------------
1059
+ // copyWorkspaceToContainer — validates container ID
1060
+ // ---------------------------------------------------------------------------
1061
+
1062
+ describe("ContainerManager copyWorkspaceToContainer validation", () => {
1063
+ it("rejects invalid container ID", async () => {
1064
+ const manager = new ContainerManager();
1065
+ await expect(manager.copyWorkspaceToContainer("../evil", "/tmp"))
1066
+ .rejects.toThrow("Invalid container ID");
1067
+ });
1068
+ });
1069
+
1070
+ // ---------------------------------------------------------------------------
1071
+ // pullImage
1072
+ // ---------------------------------------------------------------------------
1073
+
1074
+ describe("ContainerManager pullImage", () => {
1075
+ beforeEach(() => {
1076
+ mockSpawn.mockReset();
1077
+ mockExecSync.mockReset();
1078
+ });
1079
+
1080
+ it("returns true and tags image on successful pull", async () => {
1081
+ // Mock Bun.spawn to return a successful process with readable streams
1082
+ const mockStdout = new ReadableStream<Uint8Array>({
1083
+ start(controller) { controller.close(); },
1084
+ });
1085
+ const mockStderr = new ReadableStream<Uint8Array>({
1086
+ start(controller) { controller.close(); },
1087
+ });
1088
+ mockSpawn.mockReturnValue({
1089
+ stdout: mockStdout,
1090
+ stderr: mockStderr,
1091
+ exited: Promise.resolve(0),
1092
+ kill: vi.fn(),
1093
+ });
1094
+ mockExecSync.mockReturnValue("");
1095
+
1096
+ const manager = new ContainerManager();
1097
+ const result = await manager.pullImage("docker.io/stangirard/test:v1", "test:v1");
1098
+ expect(result).toBe(true);
1099
+ // Should tag the image
1100
+ const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
1101
+ expect(cmds.some((c) => c.includes("docker tag"))).toBe(true);
1102
+ });
1103
+
1104
+ it("returns false when pull fails with non-zero exit", async () => {
1105
+ const mockStdout = new ReadableStream<Uint8Array>({
1106
+ start(controller) { controller.close(); },
1107
+ });
1108
+ const mockStderr = new ReadableStream<Uint8Array>({
1109
+ start(controller) { controller.close(); },
1110
+ });
1111
+ mockSpawn.mockReturnValue({
1112
+ stdout: mockStdout,
1113
+ stderr: mockStderr,
1114
+ exited: Promise.resolve(1),
1115
+ kill: vi.fn(),
1116
+ });
1117
+
1118
+ const manager = new ContainerManager();
1119
+ const result = await manager.pullImage("nonexistent:v1", "nonexistent:v1");
1120
+ expect(result).toBe(false);
1121
+ });
1122
+
1123
+ it("skips tagging when remote and local names match", async () => {
1124
+ const mockStdout = new ReadableStream<Uint8Array>({
1125
+ start(controller) { controller.close(); },
1126
+ });
1127
+ const mockStderr = new ReadableStream<Uint8Array>({
1128
+ start(controller) { controller.close(); },
1129
+ });
1130
+ mockSpawn.mockReturnValue({
1131
+ stdout: mockStdout,
1132
+ stderr: mockStderr,
1133
+ exited: Promise.resolve(0),
1134
+ kill: vi.fn(),
1135
+ });
1136
+
1137
+ const manager = new ContainerManager();
1138
+ await manager.pullImage("node:22", "node:22");
1139
+ // Should NOT call docker tag since names match
1140
+ expect(mockExecSync).not.toHaveBeenCalled();
1141
+ });
1142
+ });
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // buildImageStreaming
1146
+ // ---------------------------------------------------------------------------
1147
+
1148
+ describe("ContainerManager buildImageStreaming", () => {
1149
+ beforeEach(() => {
1150
+ mockSpawn.mockReset();
1151
+ mockMkdirSync.mockReset();
1152
+ mockWriteFileSync.mockReset();
1153
+ mockRmSync.mockReset();
1154
+ });
1155
+
1156
+ it("returns success when build succeeds", async () => {
1157
+ const mockStdout = new ReadableStream<Uint8Array>({
1158
+ start(controller) {
1159
+ controller.enqueue(new TextEncoder().encode("Step 1/3\nStep 2/3\n"));
1160
+ controller.close();
1161
+ },
1162
+ });
1163
+ const mockStderr = new ReadableStream<Uint8Array>({
1164
+ start(controller) { controller.close(); },
1165
+ });
1166
+ mockSpawn.mockReturnValue({
1167
+ stdout: mockStdout,
1168
+ stderr: mockStderr,
1169
+ exited: Promise.resolve(0),
1170
+ kill: vi.fn(),
1171
+ });
1172
+
1173
+ const lines: string[] = [];
1174
+ const manager = new ContainerManager();
1175
+ const result = await manager.buildImageStreaming(
1176
+ "FROM node:22\nRUN echo hi",
1177
+ "test:v1",
1178
+ (line) => lines.push(line),
1179
+ );
1180
+ expect(result.success).toBe(true);
1181
+ expect(lines.length).toBeGreaterThan(0);
1182
+ // Should write Dockerfile to temp dir
1183
+ expect(mockMkdirSync).toHaveBeenCalled();
1184
+ expect(mockWriteFileSync).toHaveBeenCalled();
1185
+ // Should clean up temp dir
1186
+ expect(mockRmSync).toHaveBeenCalled();
1187
+ });
1188
+
1189
+ it("returns failure when build fails", async () => {
1190
+ const mockStdout = new ReadableStream<Uint8Array>({
1191
+ start(controller) { controller.close(); },
1192
+ });
1193
+ const mockStderr = new ReadableStream<Uint8Array>({
1194
+ start(controller) {
1195
+ controller.enqueue(new TextEncoder().encode("ERROR: invalid syntax\n"));
1196
+ controller.close();
1197
+ },
1198
+ });
1199
+ mockSpawn.mockReturnValue({
1200
+ stdout: mockStdout,
1201
+ stderr: mockStderr,
1202
+ exited: Promise.resolve(1),
1203
+ kill: vi.fn(),
1204
+ });
1205
+
1206
+ const manager = new ContainerManager();
1207
+ const result = await manager.buildImageStreaming("INVALID", "test:v1");
1208
+ expect(result.success).toBe(false);
1209
+ expect(result.log).toContain("ERROR: invalid syntax");
1210
+ });
1211
+ });