@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,513 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Mock sandbox-manager ─────────────────────────────────────────────────
4
+ vi.mock("../sandbox-manager.js", () => ({
5
+ listSandboxes: vi.fn(() => []),
6
+ getSandbox: vi.fn(() => null),
7
+ createSandbox: vi.fn(),
8
+ updateSandbox: vi.fn(),
9
+ deleteSandbox: vi.fn(() => false),
10
+ }));
11
+
12
+ // ─── Mock container-manager ───────────────────────────────────────────────
13
+ vi.mock("../container-manager.js", () => ({
14
+ containerManager: {
15
+ checkDocker: vi.fn(() => true),
16
+ createContainer: vi.fn(() => ({ containerId: "test-container-123", name: "companion-test" })),
17
+ copyWorkspaceToContainer: vi.fn(async () => {}),
18
+ execInContainerAsync: vi.fn(async () => ({ exitCode: 0, output: "ok\n" })),
19
+ removeContainer: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ // ─── Mock image-pull-manager ──────────────────────────────────────────────
24
+ vi.mock("../image-pull-manager.js", () => ({
25
+ imagePullManager: {
26
+ isReady: vi.fn(() => true),
27
+ },
28
+ }));
29
+
30
+ import { Hono } from "hono";
31
+ import * as sandboxManager from "../sandbox-manager.js";
32
+ import { containerManager } from "../container-manager.js";
33
+ import { imagePullManager } from "../image-pull-manager.js";
34
+ import { registerSandboxRoutes } from "./sandbox-routes.js";
35
+
36
+ // ─── Test setup ───────────────────────────────────────────────────────────
37
+
38
+ let app: Hono;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+
43
+ app = new Hono().basePath("/api");
44
+ registerSandboxRoutes(app);
45
+ });
46
+
47
+ // ─── Helpers ──────────────────────────────────────────────────────────────
48
+
49
+ /** Minimal sandbox fixture matching the CompanionSandbox shape. */
50
+ function makeSandbox(overrides: Record<string, unknown> = {}) {
51
+ return {
52
+ name: "My Sandbox",
53
+ slug: "my-sandbox",
54
+ createdAt: 1000,
55
+ updatedAt: 2000,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+ // GET /api/sandboxes
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+
64
+ describe("GET /api/sandboxes", () => {
65
+ it("returns an empty list when no sandboxes exist", async () => {
66
+ // Validates that the endpoint returns 200 with an empty array
67
+ // when there are no sandboxes on disk.
68
+ vi.mocked(sandboxManager.listSandboxes).mockReturnValue([]);
69
+
70
+ const res = await app.request("/api/sandboxes");
71
+
72
+ expect(res.status).toBe(200);
73
+ expect(await res.json()).toEqual([]);
74
+ });
75
+
76
+ it("returns a list of sandboxes", async () => {
77
+ // Validates that multiple sandboxes are returned correctly.
78
+ const sandboxes = [
79
+ makeSandbox(),
80
+ makeSandbox({ slug: "second", name: "Second" }),
81
+ ];
82
+ vi.mocked(sandboxManager.listSandboxes).mockReturnValue(sandboxes as any);
83
+
84
+ const res = await app.request("/api/sandboxes");
85
+
86
+ expect(res.status).toBe(200);
87
+ const json = await res.json();
88
+ expect(json).toHaveLength(2);
89
+ expect(json[0].slug).toBe("my-sandbox");
90
+ expect(json[1].slug).toBe("second");
91
+ });
92
+
93
+ it("returns 500 when listSandboxes throws", async () => {
94
+ // Validates that internal errors in sandbox-manager are surfaced
95
+ // as 500 responses with the error message in the body.
96
+ vi.mocked(sandboxManager.listSandboxes).mockImplementation(() => {
97
+ throw new Error("disk failure");
98
+ });
99
+
100
+ const res = await app.request("/api/sandboxes");
101
+
102
+ expect(res.status).toBe(500);
103
+ const json = await res.json();
104
+ expect(json.error).toBe("disk failure");
105
+ });
106
+ });
107
+
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+ // GET /api/sandboxes/:slug
110
+ // ═══════════════════════════════════════════════════════════════════════════
111
+
112
+ describe("GET /api/sandboxes/:slug", () => {
113
+ it("returns the sandbox when it exists", async () => {
114
+ // Validates that a single sandbox is returned by slug and that
115
+ // getSandbox is called with the correct slug parameter.
116
+ const sandbox = makeSandbox();
117
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
118
+
119
+ const res = await app.request("/api/sandboxes/my-sandbox");
120
+
121
+ expect(res.status).toBe(200);
122
+ expect(await res.json()).toEqual(sandbox);
123
+ expect(sandboxManager.getSandbox).toHaveBeenCalledWith("my-sandbox");
124
+ });
125
+
126
+ it("returns 404 when the sandbox does not exist", async () => {
127
+ // Validates that requesting a non-existent slug returns 404
128
+ // with an appropriate error message.
129
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(null);
130
+
131
+ const res = await app.request("/api/sandboxes/missing");
132
+
133
+ expect(res.status).toBe(404);
134
+ const json = await res.json();
135
+ expect(json.error).toMatch(/not found/i);
136
+ });
137
+ });
138
+
139
+ // ═══════════════════════════════════════════════════════════════════════════
140
+ // POST /api/sandboxes
141
+ // ═══════════════════════════════════════════════════════════════════════════
142
+
143
+ describe("POST /api/sandboxes", () => {
144
+ it("creates a sandbox with name and initScript and returns 201", async () => {
145
+ // Validates that a new sandbox is created with optional initScript
146
+ // and that the response status is 201 (Created).
147
+ const created = makeSandbox({
148
+ initScript: "npm install",
149
+ });
150
+ vi.mocked(sandboxManager.createSandbox).mockReturnValue(created as any);
151
+
152
+ const res = await app.request("/api/sandboxes", {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({
156
+ name: "My Sandbox",
157
+ initScript: "npm install",
158
+ }),
159
+ });
160
+
161
+ expect(res.status).toBe(201);
162
+ expect(await res.json()).toEqual(created);
163
+ // Verify createSandbox was called with name and options object
164
+ expect(sandboxManager.createSandbox).toHaveBeenCalledWith("My Sandbox", {
165
+ initScript: "npm install",
166
+ });
167
+ });
168
+
169
+ it("returns 400 when createSandbox throws a validation error", async () => {
170
+ // Validates that errors thrown by createSandbox (e.g. duplicate slug)
171
+ // are surfaced as 400 responses with the error message.
172
+ vi.mocked(sandboxManager.createSandbox).mockImplementation(() => {
173
+ throw new Error('A sandbox with a similar name already exists ("my-sandbox")');
174
+ });
175
+
176
+ const res = await app.request("/api/sandboxes", {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ name: "My Sandbox" }),
180
+ });
181
+
182
+ expect(res.status).toBe(400);
183
+ const json = await res.json();
184
+ expect(json.error).toMatch(/already exists/i);
185
+ });
186
+
187
+ it("returns 400 when name is missing", async () => {
188
+ // Validates that omitting the required "name" field causes a 400 error.
189
+ // The sandbox-manager throws "Sandbox name is required" for empty names.
190
+ vi.mocked(sandboxManager.createSandbox).mockImplementation(() => {
191
+ throw new Error("Sandbox name is required");
192
+ });
193
+
194
+ const res = await app.request("/api/sandboxes", {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({}),
198
+ });
199
+
200
+ expect(res.status).toBe(400);
201
+ const json = await res.json();
202
+ expect(json.error).toBe("Sandbox name is required");
203
+ });
204
+ });
205
+
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+ // PUT /api/sandboxes/:slug
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+
210
+ describe("PUT /api/sandboxes/:slug", () => {
211
+ it("updates an existing sandbox", async () => {
212
+ // Validates that an existing sandbox can be updated with new fields
213
+ // and that updateSandbox is called with the correct slug and update payload.
214
+ const updated = makeSandbox({ name: "Updated Name" });
215
+ vi.mocked(sandboxManager.updateSandbox).mockReturnValue(updated as any);
216
+
217
+ const res = await app.request("/api/sandboxes/my-sandbox", {
218
+ method: "PUT",
219
+ headers: { "Content-Type": "application/json" },
220
+ body: JSON.stringify({ name: "Updated Name", initScript: "echo hi" }),
221
+ });
222
+
223
+ expect(res.status).toBe(200);
224
+ expect(await res.json()).toEqual(updated);
225
+ expect(sandboxManager.updateSandbox).toHaveBeenCalledWith(
226
+ "my-sandbox",
227
+ expect.objectContaining({ name: "Updated Name", initScript: "echo hi" }),
228
+ );
229
+ });
230
+
231
+ it("returns 404 when the sandbox does not exist", async () => {
232
+ // Validates that updating a non-existent sandbox returns 404.
233
+ // updateSandbox returns null when the slug is not found.
234
+ vi.mocked(sandboxManager.updateSandbox).mockReturnValue(null);
235
+
236
+ const res = await app.request("/api/sandboxes/missing", {
237
+ method: "PUT",
238
+ headers: { "Content-Type": "application/json" },
239
+ body: JSON.stringify({ name: "X" }),
240
+ });
241
+
242
+ expect(res.status).toBe(404);
243
+ const json = await res.json();
244
+ expect(json.error).toMatch(/not found/i);
245
+ });
246
+
247
+ it("returns 400 when updateSandbox throws a slug collision error", async () => {
248
+ // Validates that renaming a sandbox to a name that collides with
249
+ // an existing slug results in a 400 error.
250
+ vi.mocked(sandboxManager.updateSandbox).mockImplementation(() => {
251
+ throw new Error('A sandbox with a similar name already exists ("other-sandbox")');
252
+ });
253
+
254
+ const res = await app.request("/api/sandboxes/my-sandbox", {
255
+ method: "PUT",
256
+ headers: { "Content-Type": "application/json" },
257
+ body: JSON.stringify({ name: "Other Sandbox" }),
258
+ });
259
+
260
+ expect(res.status).toBe(400);
261
+ const json = await res.json();
262
+ expect(json.error).toMatch(/already exists/i);
263
+ });
264
+ });
265
+
266
+ // ═══════════════════════════════════════════════════════════════════════════
267
+ // DELETE /api/sandboxes/:slug
268
+ // ═══════════════════════════════════════════════════════════════════════════
269
+
270
+ describe("DELETE /api/sandboxes/:slug", () => {
271
+ it("deletes a sandbox and returns ok", async () => {
272
+ // Validates successful deletion returns { ok: true } and that
273
+ // deleteSandbox is called with the correct slug.
274
+ vi.mocked(sandboxManager.deleteSandbox).mockReturnValue(true);
275
+
276
+ const res = await app.request("/api/sandboxes/my-sandbox", {
277
+ method: "DELETE",
278
+ });
279
+
280
+ expect(res.status).toBe(200);
281
+ expect(await res.json()).toEqual({ ok: true });
282
+ expect(sandboxManager.deleteSandbox).toHaveBeenCalledWith("my-sandbox");
283
+ });
284
+
285
+ it("returns 404 when the sandbox does not exist", async () => {
286
+ // Validates that deleting a non-existent sandbox returns 404.
287
+ vi.mocked(sandboxManager.deleteSandbox).mockReturnValue(false);
288
+
289
+ const res = await app.request("/api/sandboxes/missing", {
290
+ method: "DELETE",
291
+ });
292
+
293
+ expect(res.status).toBe(404);
294
+ const json = await res.json();
295
+ expect(json.error).toMatch(/not found/i);
296
+ });
297
+ });
298
+
299
+ // ═══════════════════════════════════════════════════════════════════════════
300
+ // POST /api/sandboxes/:slug/test-init
301
+ // ═══════════════════════════════════════════════════════════════════════════
302
+
303
+ describe("POST /api/sandboxes/:slug/test-init", () => {
304
+ it("executes the init script in an ephemeral container and returns success", async () => {
305
+ // Happy path: sandbox exists, has init script, Docker available, image ready.
306
+ // Should create container, copy workspace, exec script, cleanup.
307
+ const sandbox = makeSandbox({ initScript: "echo hello" });
308
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
309
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
310
+ vi.mocked(imagePullManager.isReady).mockReturnValue(true);
311
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({
312
+ exitCode: 0,
313
+ output: "hello\n",
314
+ });
315
+
316
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
317
+ method: "POST",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify({ cwd: "/home/user/project" }),
320
+ });
321
+
322
+ expect(res.status).toBe(200);
323
+ const json = await res.json();
324
+ expect(json.success).toBe(true);
325
+ expect(json.exitCode).toBe(0);
326
+ expect(json.output).toBe("hello\n");
327
+
328
+ // Container should be cleaned up
329
+ expect(containerManager.removeContainer).toHaveBeenCalled();
330
+ });
331
+
332
+ it("returns 404 when sandbox not found", async () => {
333
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(null);
334
+
335
+ const res = await app.request("/api/sandboxes/missing/test-init", {
336
+ method: "POST",
337
+ headers: { "Content-Type": "application/json" },
338
+ body: JSON.stringify({ cwd: "/tmp" }),
339
+ });
340
+
341
+ expect(res.status).toBe(404);
342
+ });
343
+
344
+ it("returns 400 when sandbox has no init script", async () => {
345
+ // A sandbox without an init script cannot be tested
346
+ const sandbox = makeSandbox(); // no initScript
347
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
348
+
349
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ cwd: "/tmp" }),
353
+ });
354
+
355
+ expect(res.status).toBe(400);
356
+ const json = await res.json();
357
+ expect(json.error).toMatch(/no init script/i);
358
+ });
359
+
360
+ it("returns 400 when cwd is missing", async () => {
361
+ const sandbox = makeSandbox({ initScript: "echo test" });
362
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
363
+
364
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
365
+ method: "POST",
366
+ headers: { "Content-Type": "application/json" },
367
+ body: JSON.stringify({}),
368
+ });
369
+
370
+ expect(res.status).toBe(400);
371
+ const json = await res.json();
372
+ expect(json.error).toMatch(/cwd/i);
373
+ });
374
+
375
+ it("returns 503 when Docker is not available", async () => {
376
+ const sandbox = makeSandbox({ initScript: "echo test" });
377
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
378
+ vi.mocked(containerManager.checkDocker).mockReturnValue(false);
379
+
380
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
381
+ method: "POST",
382
+ headers: { "Content-Type": "application/json" },
383
+ body: JSON.stringify({ cwd: "/tmp" }),
384
+ });
385
+
386
+ expect(res.status).toBe(503);
387
+ const json = await res.json();
388
+ expect(json.error).toMatch(/docker/i);
389
+ });
390
+
391
+ it("returns 503 when Docker image is not ready", async () => {
392
+ const sandbox = makeSandbox({ initScript: "echo test" });
393
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
394
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
395
+ vi.mocked(imagePullManager.isReady).mockReturnValue(false);
396
+
397
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
398
+ method: "POST",
399
+ headers: { "Content-Type": "application/json" },
400
+ body: JSON.stringify({ cwd: "/tmp" }),
401
+ });
402
+
403
+ expect(res.status).toBe(503);
404
+ const json = await res.json();
405
+ expect(json.error).toMatch(/not available/i);
406
+ });
407
+
408
+ it("returns failure when init script exits with non-zero code", async () => {
409
+ // The init script failed — report the exit code and captured output
410
+ const sandbox = makeSandbox({ initScript: "exit 1" });
411
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
412
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
413
+ vi.mocked(imagePullManager.isReady).mockReturnValue(true);
414
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({
415
+ exitCode: 1,
416
+ output: "command not found\n",
417
+ });
418
+
419
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
420
+ method: "POST",
421
+ headers: { "Content-Type": "application/json" },
422
+ body: JSON.stringify({ cwd: "/tmp" }),
423
+ });
424
+
425
+ expect(res.status).toBe(200);
426
+ const json = await res.json();
427
+ expect(json.success).toBe(false);
428
+ expect(json.exitCode).toBe(1);
429
+ expect(json.output).toContain("command not found");
430
+
431
+ // Container should still be cleaned up
432
+ expect(containerManager.removeContainer).toHaveBeenCalled();
433
+ });
434
+
435
+ it("cleans up container even when execInContainerAsync throws", async () => {
436
+ // Ensures the finally block removes the container on unexpected errors
437
+ const sandbox = makeSandbox({ initScript: "echo crash" });
438
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
439
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
440
+ vi.mocked(imagePullManager.isReady).mockReturnValue(true);
441
+ vi.mocked(containerManager.execInContainerAsync).mockRejectedValue(
442
+ new Error("Container crashed"),
443
+ );
444
+
445
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
446
+ method: "POST",
447
+ headers: { "Content-Type": "application/json" },
448
+ body: JSON.stringify({ cwd: "/tmp" }),
449
+ });
450
+
451
+ expect(res.status).toBe(500);
452
+ const json = await res.json();
453
+ expect(json.success).toBe(false);
454
+ expect(json.output).toBe("Container crashed");
455
+
456
+ // Container should be cleaned up even on error
457
+ expect(containerManager.removeContainer).toHaveBeenCalled();
458
+ });
459
+
460
+ it("uses body initScript over stored initScript when provided", async () => {
461
+ // The endpoint accepts an optional initScript body param so the frontend
462
+ // can test unsaved draft content without persisting first.
463
+ const sandbox = makeSandbox({ initScript: "stored script" });
464
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
465
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
466
+ vi.mocked(imagePullManager.isReady).mockReturnValue(true);
467
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({
468
+ exitCode: 0,
469
+ output: "draft ok\n",
470
+ });
471
+
472
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
473
+ method: "POST",
474
+ headers: { "Content-Type": "application/json" },
475
+ body: JSON.stringify({ cwd: "/tmp", initScript: "echo draft" }),
476
+ });
477
+
478
+ expect(res.status).toBe(200);
479
+ // Should exec the body initScript, not the stored one
480
+ expect(containerManager.execInContainerAsync).toHaveBeenCalledWith(
481
+ "test-container-123",
482
+ ["sh", "-lc", "echo draft"],
483
+ expect.any(Object),
484
+ );
485
+ });
486
+
487
+ it("normalizes cwd to prevent path traversal", async () => {
488
+ // The cwd should be resolved to an absolute path to collapse
489
+ // traversal sequences like ../../etc.
490
+ const sandbox = makeSandbox({ initScript: "echo test" });
491
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue(sandbox as any);
492
+ vi.mocked(containerManager.checkDocker).mockReturnValue(true);
493
+ vi.mocked(imagePullManager.isReady).mockReturnValue(true);
494
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({
495
+ exitCode: 0,
496
+ output: "ok\n",
497
+ });
498
+
499
+ const res = await app.request("/api/sandboxes/my-sandbox/test-init", {
500
+ method: "POST",
501
+ headers: { "Content-Type": "application/json" },
502
+ body: JSON.stringify({ cwd: "/home/user/../../../etc" }),
503
+ });
504
+
505
+ expect(res.status).toBe(200);
506
+ // The cwd passed to createContainer should be the resolved path
507
+ expect(containerManager.createContainer).toHaveBeenCalledWith(
508
+ expect.any(String),
509
+ "/etc",
510
+ expect.any(Object),
511
+ );
512
+ });
513
+ });
@@ -0,0 +1,127 @@
1
+ import { resolve } from "node:path";
2
+ import type { Hono } from "hono";
3
+ import * as sandboxManager from "../sandbox-manager.js";
4
+ import { containerManager, type ContainerConfig } from "../container-manager.js";
5
+ import { imagePullManager } from "../image-pull-manager.js";
6
+
7
+ export function registerSandboxRoutes(
8
+ api: Hono,
9
+ ): void {
10
+ api.get("/sandboxes", (c) => {
11
+ try {
12
+ return c.json(sandboxManager.listSandboxes());
13
+ } catch (e: unknown) {
14
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
15
+ }
16
+ });
17
+
18
+ api.get("/sandboxes/:slug", (c) => {
19
+ const sandbox = sandboxManager.getSandbox(c.req.param("slug"));
20
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
21
+ return c.json(sandbox);
22
+ });
23
+
24
+ api.post("/sandboxes", async (c) => {
25
+ const body = await c.req.json().catch(() => ({}));
26
+ try {
27
+ const sandbox = sandboxManager.createSandbox(body.name, {
28
+ initScript: body.initScript,
29
+ });
30
+ return c.json(sandbox, 201);
31
+ } catch (e: unknown) {
32
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
33
+ }
34
+ });
35
+
36
+ api.put("/sandboxes/:slug", async (c) => {
37
+ const slug = c.req.param("slug");
38
+ const body = await c.req.json().catch(() => ({}));
39
+ try {
40
+ const sandbox = sandboxManager.updateSandbox(slug, {
41
+ name: body.name,
42
+ initScript: body.initScript,
43
+ });
44
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
45
+ return c.json(sandbox);
46
+ } catch (e: unknown) {
47
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
48
+ }
49
+ });
50
+
51
+ api.delete("/sandboxes/:slug", (c) => {
52
+ try {
53
+ const deleted = sandboxManager.deleteSandbox(c.req.param("slug"));
54
+ if (!deleted) return c.json({ error: "Sandbox not found" }, 404);
55
+ return c.json({ ok: true });
56
+ } catch (e: unknown) {
57
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
58
+ }
59
+ });
60
+
61
+ // Test the init script of a sandbox in an ephemeral container.
62
+ // Accepts an optional `initScript` body param to test unsaved content
63
+ // without persisting it first. Falls back to the stored script.
64
+ api.post("/sandboxes/:slug/test-init", async (c) => {
65
+ const slug = c.req.param("slug");
66
+ const body = await c.req.json().catch(() => ({}));
67
+ const rawCwd = body.cwd;
68
+
69
+ const sandbox = sandboxManager.getSandbox(slug);
70
+ if (!sandbox) return c.json({ error: "Sandbox not found" }, 404);
71
+
72
+ // Prefer body initScript (unsaved draft) over stored value
73
+ const initScript = (typeof body.initScript === "string" ? body.initScript : sandbox.initScript ?? "").trim();
74
+ if (!initScript) return c.json({ error: "No init script configured for this sandbox" }, 400);
75
+ if (!rawCwd) return c.json({ error: "Working directory (cwd) is required" }, 400);
76
+
77
+ // Require an absolute path from the caller, then normalize
78
+ const cwdStr = String(rawCwd);
79
+ if (!cwdStr.startsWith("/")) return c.json({ error: "Working directory must be an absolute path" }, 400);
80
+ const cwd = resolve(cwdStr);
81
+
82
+ if (!containerManager.checkDocker()) return c.json({ error: "Docker is not available" }, 503);
83
+
84
+ const effectiveImage = "the-companion:latest";
85
+ if (!imagePullManager.isReady(effectiveImage)) {
86
+ return c.json({ error: `Docker image ${effectiveImage} is not available. Pull it first.` }, 503);
87
+ }
88
+
89
+ const tempId = `t${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
90
+ let containerId: string | undefined;
91
+
92
+ try {
93
+ const config: ContainerConfig = {
94
+ image: effectiveImage,
95
+ ports: [],
96
+ };
97
+ const containerInfo = containerManager.createContainer(tempId, cwd, config);
98
+ containerId = containerInfo.containerId;
99
+
100
+ await containerManager.copyWorkspaceToContainer(containerId, cwd);
101
+
102
+ const initTimeout = Number(process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
103
+ const result = await containerManager.execInContainerAsync(
104
+ containerId,
105
+ ["sh", "-lc", initScript],
106
+ { timeout: initTimeout },
107
+ );
108
+
109
+ const output = result.output.length > 2000
110
+ ? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
111
+ : result.output;
112
+
113
+ return c.json({
114
+ success: result.exitCode === 0,
115
+ exitCode: result.exitCode,
116
+ output,
117
+ });
118
+ } catch (e: unknown) {
119
+ const msg = e instanceof Error ? e.message : String(e);
120
+ return c.json({ success: false, exitCode: -1, output: msg }, 500);
121
+ } finally {
122
+ if (containerId) {
123
+ try { containerManager.removeContainer(tempId); } catch { /* best effort cleanup */ }
124
+ }
125
+ }
126
+ });
127
+ }