@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,333 @@
1
+ /**
2
+ * Tests for NoVncProxy — WebSocket relay between browser and container's websockify.
3
+ *
4
+ * Validates:
5
+ * - handleOpen: no container → closes with 1011
6
+ * - handleOpen: no port mapping → closes with 1011
7
+ * - handleOpen: successful upstream connection + message relay (both directions)
8
+ * - handleMessage: relays binary and text frames to upstream
9
+ * - handleMessage: no-op when pair not found or upstream not open
10
+ * - handleClose: closes upstream and cleans up pair
11
+ * - Upstream close propagates to browser socket
12
+ * - Upstream error propagates to browser socket
13
+ */
14
+ import { describe, it, expect, vi, beforeEach } from "vitest";
15
+
16
+ // ── Mock container-manager ──────────────────────────────────────────────────
17
+ const mockGetContainer = vi.hoisted(() => vi.fn());
18
+
19
+ vi.mock("./container-manager.js", () => ({
20
+ containerManager: {
21
+ getContainer: mockGetContainer,
22
+ },
23
+ }));
24
+
25
+ // ── Mock WebSocket ──────────────────────────────────────────────────────────
26
+ // The NoVncProxy creates native WebSocket instances to connect to the container.
27
+ // We intercept these with a fake implementation that captures event listeners.
28
+
29
+ interface FakeWebSocketInstance {
30
+ binaryType: string;
31
+ readyState: number;
32
+ listeners: Record<string, Array<(ev: unknown) => void>>;
33
+ send: ReturnType<typeof vi.fn>;
34
+ close: ReturnType<typeof vi.fn>;
35
+ addEventListener: (type: string, cb: (ev: unknown) => void) => void;
36
+ }
37
+
38
+ let lastCreatedUpstream: FakeWebSocketInstance | null = null;
39
+
40
+ class FakeWebSocket {
41
+ static OPEN = 1;
42
+ static CLOSED = 3;
43
+
44
+ binaryType = "blob";
45
+ readyState = 0; // CONNECTING
46
+ listeners: Record<string, Array<(ev: unknown) => void>> = {};
47
+ send = vi.fn();
48
+ close = vi.fn();
49
+
50
+ constructor(
51
+ public url: string,
52
+ public protocols?: string | string[],
53
+ ) {
54
+ // Store for test access
55
+ lastCreatedUpstream = this as unknown as FakeWebSocketInstance;
56
+ }
57
+
58
+ addEventListener(type: string, cb: (ev: unknown) => void) {
59
+ if (!this.listeners[type]) this.listeners[type] = [];
60
+ this.listeners[type].push(cb);
61
+ }
62
+ }
63
+
64
+ // Patch global WebSocket so NoVncProxy uses our fake
65
+ vi.stubGlobal("WebSocket", FakeWebSocket);
66
+
67
+ import { NoVncProxy } from "./novnc-proxy.js";
68
+
69
+ // ── Helpers ─────────────────────────────────────────────────────────────────
70
+
71
+ /** Creates a mock ServerWebSocket that behaves like Bun's ServerWebSocket. */
72
+ function makeBrowserWs() {
73
+ return {
74
+ data: { kind: "novnc" as const, sessionId: "s1" },
75
+ send: vi.fn(),
76
+ close: vi.fn(),
77
+ } as unknown as import("bun").ServerWebSocket<import("./ws-bridge-types.js").SocketData>;
78
+ }
79
+
80
+ function fireUpstreamEvent(upstream: FakeWebSocketInstance, type: string, payload?: unknown) {
81
+ for (const cb of upstream.listeners[type] ?? []) {
82
+ cb(payload);
83
+ }
84
+ }
85
+
86
+ // ── Tests ───────────────────────────────────────────────────────────────────
87
+
88
+ describe("NoVncProxy", () => {
89
+ let proxy: NoVncProxy;
90
+
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ lastCreatedUpstream = null;
94
+ proxy = new NoVncProxy();
95
+ });
96
+
97
+ // ── handleOpen ──────────────────────────────────────────────────────────
98
+
99
+ it("closes browser socket with 1011 when container not found", () => {
100
+ mockGetContainer.mockReturnValue(undefined);
101
+ const ws = makeBrowserWs();
102
+
103
+ proxy.handleOpen(ws, "s1");
104
+
105
+ expect(ws.close).toHaveBeenCalledWith(1011, "Container not found");
106
+ // No upstream should have been created
107
+ expect(lastCreatedUpstream).toBeNull();
108
+ });
109
+
110
+ it("closes browser socket with 1011 when noVNC port not mapped", () => {
111
+ mockGetContainer.mockReturnValue({
112
+ portMappings: [{ containerPort: 3000, hostPort: 49100 }],
113
+ });
114
+ const ws = makeBrowserWs();
115
+
116
+ proxy.handleOpen(ws, "s1");
117
+
118
+ expect(ws.close).toHaveBeenCalledWith(1011, "noVNC port not mapped");
119
+ expect(lastCreatedUpstream).toBeNull();
120
+ });
121
+
122
+ it("connects to upstream websockify when container and port mapping exist", () => {
123
+ mockGetContainer.mockReturnValue({
124
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
125
+ });
126
+ const ws = makeBrowserWs();
127
+
128
+ proxy.handleOpen(ws, "s1");
129
+
130
+ expect(lastCreatedUpstream).not.toBeNull();
131
+ expect(lastCreatedUpstream!.binaryType).toBe("arraybuffer");
132
+ // Upstream URL should use the host port from the mapping
133
+ expect((lastCreatedUpstream as unknown as FakeWebSocket).url).toBe(
134
+ "ws://127.0.0.1:49200",
135
+ );
136
+ });
137
+
138
+ it("relays ArrayBuffer messages from upstream to browser socket", () => {
139
+ mockGetContainer.mockReturnValue({
140
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
141
+ });
142
+ const ws = makeBrowserWs();
143
+
144
+ proxy.handleOpen(ws, "s1");
145
+ const upstream = lastCreatedUpstream!;
146
+
147
+ // Simulate upstream sending an ArrayBuffer (binary VNC frame)
148
+ const buf = new ArrayBuffer(4);
149
+ fireUpstreamEvent(upstream, "message", { data: buf });
150
+
151
+ expect(ws.send).toHaveBeenCalledTimes(1);
152
+ // Should send as Uint8Array wrapping the ArrayBuffer
153
+ const sent = (ws.send as ReturnType<typeof vi.fn>).mock.calls[0][0];
154
+ expect(sent).toBeInstanceOf(Uint8Array);
155
+ });
156
+
157
+ it("relays text messages from upstream to browser socket", () => {
158
+ mockGetContainer.mockReturnValue({
159
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
160
+ });
161
+ const ws = makeBrowserWs();
162
+
163
+ proxy.handleOpen(ws, "s1");
164
+ const upstream = lastCreatedUpstream!;
165
+
166
+ // Simulate upstream sending a text message
167
+ fireUpstreamEvent(upstream, "message", { data: "hello" });
168
+
169
+ expect(ws.send).toHaveBeenCalledWith("hello");
170
+ });
171
+
172
+ it("cleans up and closes browser socket when upstream closes", () => {
173
+ mockGetContainer.mockReturnValue({
174
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
175
+ });
176
+ const ws = makeBrowserWs();
177
+
178
+ proxy.handleOpen(ws, "s1");
179
+ const upstream = lastCreatedUpstream!;
180
+
181
+ fireUpstreamEvent(upstream, "close");
182
+
183
+ expect(ws.close).toHaveBeenCalled();
184
+ // Pair should be removed — subsequent handleMessage should be no-op
185
+ proxy.handleMessage(ws, "test");
186
+ });
187
+
188
+ it("cleans up and closes browser socket on upstream error", () => {
189
+ mockGetContainer.mockReturnValue({
190
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
191
+ });
192
+ const ws = makeBrowserWs();
193
+
194
+ proxy.handleOpen(ws, "s1");
195
+ const upstream = lastCreatedUpstream!;
196
+
197
+ fireUpstreamEvent(upstream, "error", new Error("connection refused"));
198
+
199
+ expect(ws.close).toHaveBeenCalledWith(1011, "Upstream connection failed");
200
+ });
201
+
202
+ // ── handleMessage ───────────────────────────────────────────────────────
203
+
204
+ it("relays text messages from browser to upstream", () => {
205
+ mockGetContainer.mockReturnValue({
206
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
207
+ });
208
+ const ws = makeBrowserWs();
209
+
210
+ proxy.handleOpen(ws, "s1");
211
+ const upstream = lastCreatedUpstream!;
212
+ upstream.readyState = WebSocket.OPEN;
213
+
214
+ proxy.handleMessage(ws, "browser-text");
215
+
216
+ expect(upstream.send).toHaveBeenCalledWith("browser-text");
217
+ });
218
+
219
+ it("relays Buffer messages from browser to upstream", () => {
220
+ mockGetContainer.mockReturnValue({
221
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
222
+ });
223
+ const ws = makeBrowserWs();
224
+
225
+ proxy.handleOpen(ws, "s1");
226
+ const upstream = lastCreatedUpstream!;
227
+ upstream.readyState = WebSocket.OPEN;
228
+
229
+ const buf = Buffer.from([0x01, 0x02, 0x03]);
230
+ proxy.handleMessage(ws, buf);
231
+
232
+ // After dead-code removal, Buffer input is always converted to Uint8Array
233
+ expect(upstream.send).toHaveBeenCalledWith(new Uint8Array(buf));
234
+ });
235
+
236
+ it("is a no-op when pair not found", () => {
237
+ const ws = makeBrowserWs();
238
+ // No handleOpen called — no pair exists
239
+ proxy.handleMessage(ws, "test");
240
+ // Should not throw
241
+ });
242
+
243
+ it("is a no-op when upstream is not open", () => {
244
+ mockGetContainer.mockReturnValue({
245
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
246
+ });
247
+ const ws = makeBrowserWs();
248
+
249
+ proxy.handleOpen(ws, "s1");
250
+ const upstream = lastCreatedUpstream!;
251
+ upstream.readyState = 0; // CONNECTING, not OPEN
252
+
253
+ proxy.handleMessage(ws, "test");
254
+
255
+ expect(upstream.send).not.toHaveBeenCalled();
256
+ });
257
+
258
+ // ── handleClose ─────────────────────────────────────────────────────────
259
+
260
+ it("closes upstream and cleans up when browser socket closes", () => {
261
+ mockGetContainer.mockReturnValue({
262
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
263
+ });
264
+ const ws = makeBrowserWs();
265
+
266
+ proxy.handleOpen(ws, "s1");
267
+ const upstream = lastCreatedUpstream!;
268
+
269
+ proxy.handleClose(ws);
270
+
271
+ expect(upstream.close).toHaveBeenCalled();
272
+ // Pair should be removed — calling again should be no-op
273
+ proxy.handleClose(ws);
274
+ });
275
+
276
+ it("is a no-op when pair not found on close", () => {
277
+ const ws = makeBrowserWs();
278
+ // No handleOpen called
279
+ proxy.handleClose(ws);
280
+ // Should not throw
281
+ });
282
+
283
+ // ── Edge cases ──────────────────────────────────────────────────────────
284
+
285
+ it("handles browser ws.send throwing when upstream relays message", () => {
286
+ mockGetContainer.mockReturnValue({
287
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
288
+ });
289
+ const ws = makeBrowserWs();
290
+ (ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
291
+ throw new Error("socket closed");
292
+ });
293
+
294
+ proxy.handleOpen(ws, "s1");
295
+ const upstream = lastCreatedUpstream!;
296
+
297
+ // Should not throw — the error is caught internally
298
+ fireUpstreamEvent(upstream, "message", { data: "test" });
299
+ });
300
+
301
+ it("handles upstream.send throwing when browser relays message", () => {
302
+ mockGetContainer.mockReturnValue({
303
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
304
+ });
305
+ const ws = makeBrowserWs();
306
+
307
+ proxy.handleOpen(ws, "s1");
308
+ const upstream = lastCreatedUpstream!;
309
+ upstream.readyState = WebSocket.OPEN;
310
+ upstream.send.mockImplementation(() => {
311
+ throw new Error("socket closed");
312
+ });
313
+
314
+ // Should not throw — the error is caught internally
315
+ proxy.handleMessage(ws, "test");
316
+ });
317
+
318
+ it("handles upstream.close throwing on browser close", () => {
319
+ mockGetContainer.mockReturnValue({
320
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
321
+ });
322
+ const ws = makeBrowserWs();
323
+
324
+ proxy.handleOpen(ws, "s1");
325
+ const upstream = lastCreatedUpstream!;
326
+ upstream.close.mockImplementation(() => {
327
+ throw new Error("already closed");
328
+ });
329
+
330
+ // Should not throw — the error is caught internally
331
+ proxy.handleClose(ws);
332
+ });
333
+ });
@@ -0,0 +1,99 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { SocketData } from "./ws-bridge-types.js";
3
+ import { containerManager } from "./container-manager.js";
4
+
5
+ const NOVNC_CONTAINER_PORT = 6080;
6
+
7
+ interface ProxyPair {
8
+ browserWs: ServerWebSocket<SocketData>;
9
+ upstreamWs: WebSocket;
10
+ }
11
+
12
+ /**
13
+ * Proxies noVNC WebSocket traffic between the user's browser and the
14
+ * container's websockify server. This allows the noVNC client to connect
15
+ * through the companion's single port instead of requiring direct access
16
+ * to the container's mapped port.
17
+ */
18
+ export class NoVncProxy {
19
+ private pairs = new Map<ServerWebSocket<SocketData>, ProxyPair>();
20
+
21
+ handleOpen(ws: ServerWebSocket<SocketData>, sessionId: string): void {
22
+ const container = containerManager.getContainer(sessionId);
23
+ if (!container) {
24
+ console.warn(`[novnc-proxy] No container found for session ${sessionId}`);
25
+ ws.close(1011, "Container not found");
26
+ return;
27
+ }
28
+
29
+ const portMapping = container.portMappings.find(
30
+ (p) => p.containerPort === NOVNC_CONTAINER_PORT,
31
+ );
32
+ if (!portMapping) {
33
+ console.warn(`[novnc-proxy] No noVNC port mapping for session ${sessionId}`);
34
+ ws.close(1011, "noVNC port not mapped");
35
+ return;
36
+ }
37
+
38
+ // Connect to the container's websockify server
39
+ const upstreamUrl = `ws://127.0.0.1:${portMapping.hostPort}`;
40
+ const upstream = new WebSocket(upstreamUrl, ["binary"]);
41
+ upstream.binaryType = "arraybuffer";
42
+
43
+ const pair: ProxyPair = { browserWs: ws, upstreamWs: upstream };
44
+ this.pairs.set(ws, pair);
45
+
46
+ upstream.addEventListener("open", () => {
47
+ console.log(`[novnc-proxy] Upstream connected for session ${sessionId}`);
48
+ });
49
+
50
+ upstream.addEventListener("message", (event) => {
51
+ try {
52
+ if (event.data instanceof ArrayBuffer) {
53
+ ws.send(new Uint8Array(event.data));
54
+ } else {
55
+ ws.send(event.data);
56
+ }
57
+ } catch {
58
+ // Browser socket may have closed
59
+ }
60
+ });
61
+
62
+ upstream.addEventListener("close", () => {
63
+ this.pairs.delete(ws);
64
+ try { ws.close(); } catch { /* already closed */ }
65
+ });
66
+
67
+ upstream.addEventListener("error", (err) => {
68
+ console.error(`[novnc-proxy] Upstream error for session ${sessionId}:`, err);
69
+ this.pairs.delete(ws);
70
+ try { ws.close(1011, "Upstream connection failed"); } catch { /* already closed */ }
71
+ });
72
+ }
73
+
74
+ handleMessage(ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
75
+ const pair = this.pairs.get(ws);
76
+ if (!pair) return;
77
+
78
+ const { upstreamWs } = pair;
79
+ if (upstreamWs.readyState !== WebSocket.OPEN) return;
80
+
81
+ try {
82
+ upstreamWs.send(msg instanceof Buffer ? new Uint8Array(msg) : msg);
83
+ } catch {
84
+ // Upstream may have closed
85
+ }
86
+ }
87
+
88
+ handleClose(ws: ServerWebSocket<SocketData>): void {
89
+ const pair = this.pairs.get(ws);
90
+ if (!pair) return;
91
+
92
+ this.pairs.delete(ws);
93
+ try {
94
+ pair.upstreamWs.close();
95
+ } catch {
96
+ // Already closed
97
+ }
98
+ }
99
+ }