@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,661 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — must be declared before any imports that reference them
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // Mock env-manager
8
+ vi.mock("./env-manager.js", () => ({
9
+ getEnv: vi.fn(() => null),
10
+ listEnvs: vi.fn(() => []),
11
+ }));
12
+
13
+ // Mock sandbox-manager
14
+ vi.mock("./sandbox-manager.js", () => ({
15
+ getSandbox: vi.fn(() => null),
16
+ listSandboxes: vi.fn(() => []),
17
+ }));
18
+
19
+ // Mock git-utils
20
+ vi.mock("./git-utils.js", () => ({
21
+ getRepoInfo: vi.fn(() => null),
22
+ gitFetch: vi.fn(() => ({ success: true, output: "" })),
23
+ gitPull: vi.fn(() => ({ success: true, output: "" })),
24
+ ensureWorktree: vi.fn(() => ({
25
+ worktreePath: "/tmp/worktree",
26
+ actualBranch: "feature-branch",
27
+ })),
28
+ checkoutOrCreateBranch: vi.fn(() => ({ created: false })),
29
+ }));
30
+
31
+ // Mock container-manager
32
+ vi.mock("./container-manager.js", () => ({
33
+ containerManager: {
34
+ createContainer: vi.fn(() => ({
35
+ containerId: "abc123",
36
+ name: "test-container",
37
+ image: "test-image",
38
+ portMappings: [],
39
+ hostCwd: "/workspace",
40
+ containerCwd: "/workspace",
41
+ state: "running",
42
+ })),
43
+ copyWorkspaceToContainer: vi.fn(async () => {}),
44
+ reseedGitAuth: vi.fn(),
45
+ gitOpsInContainer: vi.fn(() => ({
46
+ fetchOk: true,
47
+ checkoutOk: true,
48
+ pullOk: true,
49
+ errors: [],
50
+ })),
51
+ execInContainerAsync: vi.fn(async () => ({
52
+ exitCode: 0,
53
+ output: "ok",
54
+ })),
55
+ removeContainer: vi.fn(),
56
+ retrack: vi.fn(),
57
+ },
58
+ }));
59
+
60
+ // Mock claude-container-auth
61
+ vi.mock("./claude-container-auth.js", () => ({
62
+ hasContainerClaudeAuth: vi.fn(() => true),
63
+ }));
64
+
65
+ // Mock codex-container-auth
66
+ vi.mock("./codex-container-auth.js", () => ({
67
+ hasContainerCodexAuth: vi.fn(() => true),
68
+ }));
69
+
70
+ // Mock image-pull-manager
71
+ vi.mock("./image-pull-manager.js", () => ({
72
+ imagePullManager: {
73
+ isReady: vi.fn(() => true),
74
+ getState: vi.fn(() => ({ status: "ready" })),
75
+ ensureImage: vi.fn(),
76
+ waitForReady: vi.fn(async () => true),
77
+ onProgress: vi.fn(() => vi.fn()),
78
+ },
79
+ }));
80
+
81
+ // Mock linear-connections
82
+ vi.mock("./linear-connections.js", () => ({
83
+ getConnection: vi.fn(() => null),
84
+ }));
85
+
86
+ // Mock linear-prompt-builder
87
+ vi.mock("./linear-prompt-builder.js", () => ({
88
+ buildLinearSystemPrompt: vi.fn(() => "linear prompt"),
89
+ }));
90
+
91
+ // Mock commands-discovery
92
+ vi.mock("./commands-discovery.js", () => ({
93
+ discoverCommandsAndSkills: vi.fn(async () => ({
94
+ slash_commands: [],
95
+ skills: [],
96
+ })),
97
+ }));
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Imports (after mocks)
101
+ // ---------------------------------------------------------------------------
102
+
103
+ import {
104
+ executeSessionCreation,
105
+ SessionCreationError,
106
+ type SessionCreationDeps,
107
+ type ProgressCallback,
108
+ } from "./session-creation-service.js";
109
+ import * as envManager from "./env-manager.js";
110
+ import * as sandboxManager from "./sandbox-manager.js";
111
+ import * as gitUtils from "./git-utils.js";
112
+ import { containerManager } from "./container-manager.js";
113
+ import { hasContainerClaudeAuth } from "./claude-container-auth.js";
114
+ import { hasContainerCodexAuth } from "./codex-container-auth.js";
115
+ import { imagePullManager } from "./image-pull-manager.js";
116
+ import { getConnection } from "./linear-connections.js";
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Helpers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Minimal mock deps that satisfy SessionCreationDeps */
123
+ function makeDeps(): SessionCreationDeps {
124
+ return {
125
+ launcher: {
126
+ launch: vi.fn(() => ({
127
+ sessionId: "sess-1",
128
+ state: "starting",
129
+ cwd: "/workspace",
130
+ backendType: "claude",
131
+ createdAt: Date.now(),
132
+ })),
133
+ } as unknown as SessionCreationDeps["launcher"],
134
+ wsBridge: {
135
+ markContainerized: vi.fn(),
136
+ injectSystemPrompt: vi.fn(),
137
+ prePopulateCommands: vi.fn(),
138
+ } as unknown as SessionCreationDeps["wsBridge"],
139
+ worktreeTracker: {
140
+ addMapping: vi.fn(),
141
+ } as unknown as SessionCreationDeps["worktreeTracker"],
142
+ };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Tests
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe("executeSessionCreation", () => {
150
+ let deps: SessionCreationDeps;
151
+
152
+ beforeEach(() => {
153
+ vi.clearAllMocks();
154
+ deps = makeDeps();
155
+ });
156
+
157
+ // -- Happy path: minimal body creates a session --
158
+ it("creates a session with minimal body (defaults to claude backend)", async () => {
159
+ const result = await executeSessionCreation({ cwd: "/workspace" }, deps);
160
+
161
+ expect(result.session.sessionId).toBe("sess-1");
162
+ expect(result.session.state).toBe("starting");
163
+ expect(result.session.cwd).toBe("/workspace");
164
+ expect(deps.launcher.launch).toHaveBeenCalledOnce();
165
+ });
166
+
167
+ // -- Backend validation --
168
+ it("throws SessionCreationError for invalid backend", async () => {
169
+ await expect(
170
+ executeSessionCreation({ backend: "invalid" }, deps),
171
+ ).rejects.toThrow(SessionCreationError);
172
+
173
+ try {
174
+ await executeSessionCreation({ backend: "invalid" }, deps);
175
+ } catch (e) {
176
+ expect(e).toBeInstanceOf(SessionCreationError);
177
+ expect((e as SessionCreationError).statusCode).toBe(400);
178
+ }
179
+ });
180
+
181
+ // -- Environment resolution --
182
+ it("injects environment variables from envSlug", async () => {
183
+ vi.mocked(envManager.getEnv).mockReturnValueOnce({
184
+ slug: "test-env",
185
+ name: "Test Env",
186
+ variables: { MY_KEY: "my_value" },
187
+ createdAt: Date.now(),
188
+ updatedAt: Date.now(),
189
+ });
190
+
191
+ await executeSessionCreation(
192
+ { cwd: "/workspace", envSlug: "test-env" },
193
+ deps,
194
+ );
195
+
196
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
197
+ expect.objectContaining({
198
+ env: expect.objectContaining({ MY_KEY: "my_value" }),
199
+ }),
200
+ );
201
+ });
202
+
203
+ // -- Sandbox resolution --
204
+ it("throws 404 for missing sandbox when sandboxEnabled", async () => {
205
+ try {
206
+ await executeSessionCreation(
207
+ { cwd: "/workspace", sandboxEnabled: true, sandboxSlug: "missing" },
208
+ deps,
209
+ );
210
+ expect.unreachable("should have thrown");
211
+ } catch (e) {
212
+ expect(e).toBeInstanceOf(SessionCreationError);
213
+ expect((e as SessionCreationError).statusCode).toBe(404);
214
+ expect((e as SessionCreationError).step).toBe("resolving_env");
215
+ }
216
+ });
217
+
218
+ // -- Branch validation --
219
+ it("rejects invalid branch names", async () => {
220
+ try {
221
+ await executeSessionCreation(
222
+ { cwd: "/workspace", branch: "my branch; rm -rf /" },
223
+ deps,
224
+ );
225
+ expect.unreachable("should have thrown");
226
+ } catch (e) {
227
+ expect(e).toBeInstanceOf(SessionCreationError);
228
+ expect((e as SessionCreationError).statusCode).toBe(400);
229
+ expect((e as SessionCreationError).step).toBe("checkout_branch");
230
+ }
231
+ });
232
+
233
+ // -- Git operations: worktree path --
234
+ it("creates a worktree when useWorktree is true", async () => {
235
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
236
+ repoRoot: "/workspace",
237
+ defaultBranch: "main",
238
+ currentBranch: "main",
239
+ repoName: "workspace",
240
+ isWorktree: false,
241
+ });
242
+
243
+ await executeSessionCreation(
244
+ { cwd: "/workspace", branch: "feature", useWorktree: true },
245
+ deps,
246
+ );
247
+
248
+ expect(gitUtils.ensureWorktree).toHaveBeenCalledWith(
249
+ "/workspace",
250
+ "feature",
251
+ expect.objectContaining({ forceNew: true }),
252
+ );
253
+ });
254
+
255
+ // -- Git operations: checkout path --
256
+ it("checks out branch when useWorktree is false", async () => {
257
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
258
+ repoRoot: "/workspace",
259
+ defaultBranch: "main",
260
+ currentBranch: "main",
261
+ repoName: "workspace",
262
+ isWorktree: false,
263
+ });
264
+
265
+ await executeSessionCreation(
266
+ { cwd: "/workspace", branch: "feature" },
267
+ deps,
268
+ );
269
+
270
+ expect(gitUtils.checkoutOrCreateBranch).toHaveBeenCalledWith(
271
+ "/workspace",
272
+ "feature",
273
+ expect.objectContaining({ defaultBranch: "main" }),
274
+ );
275
+ expect(gitUtils.gitPull).toHaveBeenCalled();
276
+ });
277
+
278
+ // -- Container auth check: Claude --
279
+ it("throws 400 when containerized Claude has no auth", async () => {
280
+ vi.mocked(hasContainerClaudeAuth).mockReturnValueOnce(false);
281
+
282
+ try {
283
+ await executeSessionCreation(
284
+ { cwd: "/workspace", container: { image: "test:latest" } },
285
+ deps,
286
+ );
287
+ expect.unreachable("should have thrown");
288
+ } catch (e) {
289
+ expect(e).toBeInstanceOf(SessionCreationError);
290
+ expect((e as SessionCreationError).statusCode).toBe(400);
291
+ expect((e as SessionCreationError).message).toContain("ANTHROPIC_API_KEY");
292
+ }
293
+ });
294
+
295
+ // -- Container auth check: Codex --
296
+ it("throws 400 when containerized Codex has no auth", async () => {
297
+ vi.mocked(hasContainerCodexAuth).mockReturnValueOnce(false);
298
+
299
+ try {
300
+ await executeSessionCreation(
301
+ { cwd: "/workspace", backend: "codex", container: { image: "test:latest" } },
302
+ deps,
303
+ );
304
+ expect.unreachable("should have thrown");
305
+ } catch (e) {
306
+ expect(e).toBeInstanceOf(SessionCreationError);
307
+ expect((e as SessionCreationError).statusCode).toBe(400);
308
+ expect((e as SessionCreationError).message).toContain("OPENAI_API_KEY");
309
+ }
310
+ });
311
+
312
+ // -- Image pull failure --
313
+ it("throws 503 when image pull fails", async () => {
314
+ vi.mocked(imagePullManager.isReady).mockReturnValueOnce(false);
315
+ vi.mocked(imagePullManager.waitForReady).mockResolvedValueOnce(false);
316
+ vi.mocked(imagePullManager.getState).mockReturnValueOnce({
317
+ status: "error",
318
+ error: "pull failed",
319
+ } as any);
320
+
321
+ try {
322
+ await executeSessionCreation(
323
+ { cwd: "/workspace", container: { image: "broken:latest" } },
324
+ deps,
325
+ );
326
+ expect.unreachable("should have thrown");
327
+ } catch (e) {
328
+ expect(e).toBeInstanceOf(SessionCreationError);
329
+ expect((e as SessionCreationError).statusCode).toBe(503);
330
+ expect((e as SessionCreationError).step).toBe("pulling_image");
331
+ }
332
+ });
333
+
334
+ // -- Container create failure --
335
+ it("throws 503 when container creation fails", async () => {
336
+ vi.mocked(containerManager.createContainer).mockImplementationOnce(() => {
337
+ throw new Error("docker not found");
338
+ });
339
+
340
+ try {
341
+ await executeSessionCreation(
342
+ { cwd: "/workspace", container: { image: "test:latest" } },
343
+ deps,
344
+ );
345
+ expect.unreachable("should have thrown");
346
+ } catch (e) {
347
+ expect(e).toBeInstanceOf(SessionCreationError);
348
+ expect((e as SessionCreationError).statusCode).toBe(503);
349
+ expect((e as SessionCreationError).step).toBe("creating_container");
350
+ }
351
+ });
352
+
353
+ // -- Workspace copy failure triggers cleanup --
354
+ it("removes container when workspace copy fails", async () => {
355
+ vi.mocked(containerManager.copyWorkspaceToContainer).mockRejectedValueOnce(
356
+ new Error("copy failed"),
357
+ );
358
+
359
+ try {
360
+ await executeSessionCreation(
361
+ { cwd: "/workspace", container: { image: "test:latest" } },
362
+ deps,
363
+ );
364
+ expect.unreachable("should have thrown");
365
+ } catch (e) {
366
+ expect(e).toBeInstanceOf(SessionCreationError);
367
+ expect((e as SessionCreationError).statusCode).toBe(503);
368
+ expect((e as SessionCreationError).step).toBe("copying_workspace");
369
+ // Verify cleanup happened
370
+ expect(containerManager.removeContainer).toHaveBeenCalled();
371
+ }
372
+ });
373
+
374
+ // -- Container git checkout failure triggers cleanup --
375
+ it("removes container when in-container git checkout fails", async () => {
376
+ vi.mocked(containerManager.gitOpsInContainer).mockReturnValueOnce({
377
+ fetchOk: true,
378
+ checkoutOk: false,
379
+ pullOk: false,
380
+ errors: ["checkout error"],
381
+ } as any);
382
+
383
+ try {
384
+ await executeSessionCreation(
385
+ { cwd: "/workspace", container: { image: "test:latest" }, branch: "feature" },
386
+ deps,
387
+ );
388
+ expect.unreachable("should have thrown");
389
+ } catch (e) {
390
+ expect(e).toBeInstanceOf(SessionCreationError);
391
+ expect((e as SessionCreationError).statusCode).toBe(400);
392
+ expect((e as SessionCreationError).step).toBe("checkout_branch");
393
+ expect(containerManager.removeContainer).toHaveBeenCalled();
394
+ }
395
+ });
396
+
397
+ // -- Progress callback is invoked in order --
398
+ it("calls onProgress at each step in correct order", async () => {
399
+ const steps: string[] = [];
400
+ const onProgress: ProgressCallback = async (step, _label, status) => {
401
+ steps.push(`${step}:${status}`);
402
+ };
403
+
404
+ await executeSessionCreation({ cwd: "/workspace" }, deps, onProgress);
405
+
406
+ // Should have resolving_env (in_progress, done) and launching_cli (in_progress, done)
407
+ expect(steps).toContain("resolving_env:in_progress");
408
+ expect(steps).toContain("resolving_env:done");
409
+ expect(steps).toContain("launching_cli:in_progress");
410
+ expect(steps).toContain("launching_cli:done");
411
+
412
+ // resolving_env should come before launching_cli
413
+ const envIdx = steps.indexOf("resolving_env:in_progress");
414
+ const launchIdx = steps.indexOf("launching_cli:in_progress");
415
+ expect(envIdx).toBeLessThan(launchIdx);
416
+ });
417
+
418
+ // -- No progress callback does not throw --
419
+ it("works without progress callback (REST mode)", async () => {
420
+ const result = await executeSessionCreation({ cwd: "/workspace" }, deps);
421
+ expect(result.session.sessionId).toBe("sess-1");
422
+ });
423
+
424
+ // -- Linear connection injection --
425
+ it("injects LINEAR_API_KEY and system prompt when connection exists", async () => {
426
+ vi.mocked(getConnection).mockReturnValueOnce({
427
+ id: "conn-1",
428
+ name: "Test",
429
+ apiKey: "lin_api_test",
430
+ } as any);
431
+
432
+ await executeSessionCreation(
433
+ { cwd: "/workspace", linearConnectionId: "conn-1" },
434
+ deps,
435
+ );
436
+
437
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
438
+ expect.objectContaining({
439
+ env: expect.objectContaining({ LINEAR_API_KEY: "lin_api_test" }),
440
+ }),
441
+ );
442
+ // For claude backend, injectSystemPrompt should be called
443
+ expect(deps.wsBridge.injectSystemPrompt).toHaveBeenCalledWith(
444
+ "sess-1",
445
+ expect.any(String),
446
+ );
447
+ });
448
+
449
+ // -- Post-launch: container retracking --
450
+ it("retracks container and marks session as containerized after launch", async () => {
451
+ await executeSessionCreation(
452
+ { cwd: "/workspace", container: { image: "test:latest" } },
453
+ deps,
454
+ );
455
+
456
+ expect(containerManager.retrack).toHaveBeenCalledWith("abc123", "sess-1");
457
+ expect(deps.wsBridge.markContainerized).toHaveBeenCalledWith("sess-1", "/workspace");
458
+ });
459
+
460
+ // -- Post-launch: worktree tracking --
461
+ it("tracks worktree mapping after launch", async () => {
462
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
463
+ repoRoot: "/workspace",
464
+ defaultBranch: "main",
465
+ currentBranch: "main",
466
+ repoName: "workspace",
467
+ isWorktree: false,
468
+ });
469
+
470
+ await executeSessionCreation(
471
+ { cwd: "/workspace", branch: "feature", useWorktree: true },
472
+ deps,
473
+ );
474
+
475
+ expect(deps.worktreeTracker.addMapping).toHaveBeenCalledWith(
476
+ expect.objectContaining({
477
+ sessionId: "sess-1",
478
+ branch: "feature",
479
+ }),
480
+ );
481
+ });
482
+
483
+ // -- Post-launch: slash commands discovery --
484
+ it("pre-populates slash commands after launch", async () => {
485
+ await executeSessionCreation({ cwd: "/workspace" }, deps);
486
+
487
+ expect(deps.wsBridge.prePopulateCommands).toHaveBeenCalledWith(
488
+ "sess-1",
489
+ expect.any(Array),
490
+ expect.any(Array),
491
+ );
492
+ });
493
+
494
+ // -- Resume session --
495
+ it("passes resumeSessionAt and forkSession to launcher", async () => {
496
+ await executeSessionCreation(
497
+ { cwd: "/workspace", resumeSessionAt: "sess-old", forkSession: true },
498
+ deps,
499
+ );
500
+
501
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
502
+ expect.objectContaining({
503
+ resumeSessionAt: "sess-old",
504
+ forkSession: true,
505
+ }),
506
+ );
507
+ });
508
+
509
+ // -- Codex backend --
510
+ it("sets codex-specific launch options for codex backend", async () => {
511
+ await executeSessionCreation(
512
+ { cwd: "/workspace", backend: "codex" },
513
+ deps,
514
+ );
515
+
516
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
517
+ expect.objectContaining({
518
+ backendType: "codex",
519
+ codexInternetAccess: true,
520
+ codexSandbox: "danger-full-access",
521
+ }),
522
+ );
523
+ });
524
+
525
+ // -- Init script: success --
526
+ it("runs init script when sandbox has one configured", async () => {
527
+ vi.mocked(sandboxManager.getSandbox).mockReturnValueOnce({
528
+ slug: "test-sandbox",
529
+ name: "Test Sandbox",
530
+ initScript: "echo hello",
531
+ createdAt: Date.now(),
532
+ updatedAt: Date.now(),
533
+ } as any);
534
+
535
+ await executeSessionCreation(
536
+ { cwd: "/workspace", sandboxEnabled: true, sandboxSlug: "test-sandbox" },
537
+ deps,
538
+ );
539
+
540
+ // Init script should have been executed via execInContainerAsync
541
+ expect(containerManager.execInContainerAsync).toHaveBeenCalledWith(
542
+ "abc123",
543
+ ["sh", "-lc", "echo hello"],
544
+ expect.objectContaining({ timeout: expect.any(Number) }),
545
+ );
546
+ expect(deps.launcher.launch).toHaveBeenCalled();
547
+ });
548
+
549
+ // -- Init script: non-zero exit triggers cleanup --
550
+ it("cleans up container when init script fails with non-zero exit", async () => {
551
+ vi.mocked(sandboxManager.getSandbox).mockReturnValueOnce({
552
+ slug: "test-sandbox",
553
+ name: "Test Sandbox",
554
+ initScript: "exit 1",
555
+ createdAt: Date.now(),
556
+ updatedAt: Date.now(),
557
+ } as any);
558
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValueOnce({
559
+ exitCode: 1,
560
+ output: "script failed",
561
+ });
562
+
563
+ try {
564
+ await executeSessionCreation(
565
+ { cwd: "/workspace", sandboxEnabled: true, sandboxSlug: "test-sandbox" },
566
+ deps,
567
+ );
568
+ expect.unreachable("should have thrown");
569
+ } catch (e) {
570
+ expect(e).toBeInstanceOf(SessionCreationError);
571
+ expect((e as SessionCreationError).statusCode).toBe(503);
572
+ expect((e as SessionCreationError).step).toBe("running_init_script");
573
+ expect(containerManager.removeContainer).toHaveBeenCalled();
574
+ }
575
+ });
576
+
577
+ // -- Init script: exception triggers cleanup --
578
+ it("cleans up container when init script throws", async () => {
579
+ vi.mocked(sandboxManager.getSandbox).mockReturnValueOnce({
580
+ slug: "test-sandbox",
581
+ name: "Test Sandbox",
582
+ initScript: "echo boom",
583
+ createdAt: Date.now(),
584
+ updatedAt: Date.now(),
585
+ } as any);
586
+ vi.mocked(containerManager.execInContainerAsync).mockRejectedValueOnce(
587
+ new Error("exec timeout"),
588
+ );
589
+
590
+ try {
591
+ await executeSessionCreation(
592
+ { cwd: "/workspace", sandboxEnabled: true, sandboxSlug: "test-sandbox" },
593
+ deps,
594
+ );
595
+ expect.unreachable("should have thrown");
596
+ } catch (e) {
597
+ expect(e).toBeInstanceOf(SessionCreationError);
598
+ expect((e as SessionCreationError).statusCode).toBe(503);
599
+ expect((e as SessionCreationError).step).toBe("running_init_script");
600
+ expect(containerManager.removeContainer).toHaveBeenCalled();
601
+ }
602
+ });
603
+
604
+
605
+ // -- launcher.launch() failure cleans up container --
606
+ it("cleans up container when launcher.launch() throws", async () => {
607
+ // Set up a containerized session via container.image (triggers effectiveImage)
608
+ vi.mocked(hasContainerClaudeAuth).mockReturnValueOnce(true);
609
+
610
+ // Make launcher.launch() throw
611
+ deps.launcher.launch = vi.fn(() => {
612
+ throw new Error("spawn failed");
613
+ });
614
+
615
+ try {
616
+ await executeSessionCreation(
617
+ { backend: "claude", cwd: "/workspace", container: { image: "test:latest" } },
618
+ deps,
619
+ );
620
+ expect.unreachable("should have thrown");
621
+ } catch (e) {
622
+ expect(e).toBeInstanceOf(SessionCreationError);
623
+ expect((e as SessionCreationError).statusCode).toBe(503);
624
+ expect((e as SessionCreationError).step).toBe("launching_cli");
625
+ expect((e as SessionCreationError).message).toContain("spawn failed");
626
+ // Verify container cleanup
627
+ expect(containerManager.removeContainer).toHaveBeenCalled();
628
+ }
629
+ });
630
+
631
+ // -- cwd validation for containerized sessions --
632
+ it("throws 400 when cwd is missing for containerized session", async () => {
633
+ try {
634
+ await executeSessionCreation(
635
+ { container: { image: "test:latest" } },
636
+ deps,
637
+ );
638
+ expect.unreachable("should have thrown");
639
+ } catch (e) {
640
+ expect(e).toBeInstanceOf(SessionCreationError);
641
+ expect((e as SessionCreationError).statusCode).toBe(400);
642
+ expect((e as SessionCreationError).message).toContain("cwd");
643
+ }
644
+ });
645
+ });
646
+
647
+ describe("SessionCreationError", () => {
648
+ it("carries statusCode and step", () => {
649
+ const err = new SessionCreationError("test error", 404, "resolving_env");
650
+ expect(err.message).toBe("test error");
651
+ expect(err.statusCode).toBe(404);
652
+ expect(err.step).toBe("resolving_env");
653
+ expect(err.name).toBe("SessionCreationError");
654
+ });
655
+
656
+ it("defaults statusCode to 500", () => {
657
+ const err = new SessionCreationError("server error");
658
+ expect(err.statusCode).toBe(500);
659
+ expect(err.step).toBeUndefined();
660
+ });
661
+ });