@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,264 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { createToken, verifyToken, managedAuth } from "./managed-auth.js";
4
+
5
+ const TEST_SECRET = "test-secret-key-for-hmac-256-signing";
6
+
7
+ describe("managed-auth token utilities", () => {
8
+ describe("createToken + verifyToken", () => {
9
+ it("creates a valid token that can be verified", async () => {
10
+ const token = await createToken(TEST_SECRET, 60);
11
+ expect(typeof token).toBe("string");
12
+ expect(token.split(".")).toHaveLength(2);
13
+
14
+ const valid = await verifyToken(token, TEST_SECRET);
15
+ expect(valid).toBe(true);
16
+ });
17
+
18
+ it("rejects tokens signed with a different secret", async () => {
19
+ const token = await createToken(TEST_SECRET, 60);
20
+ const valid = await verifyToken(token, "wrong-secret");
21
+ expect(valid).toBe(false);
22
+ });
23
+
24
+ it("rejects expired tokens", async () => {
25
+ // Create a token that expires in -1 seconds (already expired)
26
+ const token = await createToken(TEST_SECRET, -1);
27
+ const valid = await verifyToken(token, TEST_SECRET);
28
+ expect(valid).toBe(false);
29
+ });
30
+
31
+ it("rejects malformed tokens", async () => {
32
+ expect(await verifyToken("not-a-token", TEST_SECRET)).toBe(false);
33
+ expect(await verifyToken("a.b.c", TEST_SECRET)).toBe(false);
34
+ expect(await verifyToken("", TEST_SECRET)).toBe(false);
35
+ });
36
+
37
+ it("rejects tokens with tampered payload", async () => {
38
+ const token = await createToken(TEST_SECRET, 60);
39
+ const [, sig] = token.split(".");
40
+ // Replace payload with different data — signature won't match
41
+ const tamperedPayload = btoa(JSON.stringify({ exp: 9999999999 }))
42
+ .replace(/\+/g, "-")
43
+ .replace(/\//g, "_")
44
+ .replace(/=+$/, "");
45
+ const valid = await verifyToken(`${tamperedPayload}.${sig}`, TEST_SECRET);
46
+ expect(valid).toBe(false);
47
+ });
48
+
49
+ it("uses custom TTL for token expiration", async () => {
50
+ // Create a token with a very long TTL
51
+ const token = await createToken(TEST_SECRET, 3600);
52
+ const valid = await verifyToken(token, TEST_SECRET);
53
+ expect(valid).toBe(true);
54
+ });
55
+ });
56
+ });
57
+
58
+ describe("managed-auth middleware", () => {
59
+ const savedEnv: Record<string, string | undefined> = {};
60
+
61
+ beforeEach(() => {
62
+ savedEnv.COMPANION_AUTH_ENABLED = process.env.COMPANION_AUTH_ENABLED;
63
+ savedEnv.COMPANION_AUTH_SECRET = process.env.COMPANION_AUTH_SECRET;
64
+ savedEnv.COMPANION_LOGIN_URL = process.env.COMPANION_LOGIN_URL;
65
+ });
66
+
67
+ afterEach(() => {
68
+ for (const [key, val] of Object.entries(savedEnv)) {
69
+ if (val === undefined) delete process.env[key];
70
+ else process.env[key] = val;
71
+ }
72
+ });
73
+
74
+ /**
75
+ * Helper: creates a test Hono app with managedAuth middleware
76
+ * and a catch-all route that returns 200 if reached.
77
+ */
78
+ function createTestApp() {
79
+ const app = new Hono();
80
+ app.use("/*", managedAuth);
81
+ app.all("/*", (c) => c.json({ ok: true }));
82
+ return app;
83
+ }
84
+
85
+ it("enforces auth even without COMPANION_AUTH_ENABLED (enable decision is in index.ts)", async () => {
86
+ // The middleware is always active when registered — the enable/disable
87
+ // decision moved to index.ts which only registers it when appropriate.
88
+ delete process.env.COMPANION_AUTH_ENABLED;
89
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
90
+ const app = createTestApp();
91
+
92
+ const res = await app.request("/api/sessions");
93
+ expect(res.status).toBe(401);
94
+ });
95
+
96
+ it("bypasses auth for /health endpoint", async () => {
97
+ process.env.COMPANION_AUTH_ENABLED = "1";
98
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
99
+ const app = createTestApp();
100
+
101
+ const res = await app.request("/health");
102
+ expect(res.status).toBe(200);
103
+ });
104
+
105
+ it("bypasses auth for /ws/cli/ paths", async () => {
106
+ process.env.COMPANION_AUTH_ENABLED = "1";
107
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
108
+ const app = createTestApp();
109
+
110
+ const res = await app.request("/ws/cli/abc-123");
111
+ expect(res.status).toBe(200);
112
+ });
113
+
114
+ it("returns 401 when no token is provided and no login URL is set", async () => {
115
+ process.env.COMPANION_AUTH_ENABLED = "1";
116
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
117
+ delete process.env.COMPANION_LOGIN_URL;
118
+ const app = createTestApp();
119
+
120
+ const res = await app.request("/api/sessions");
121
+ expect(res.status).toBe(401);
122
+ const body = await res.json();
123
+ expect(body.error).toBe("Unauthorized");
124
+ });
125
+
126
+ it("redirects when no token is provided and login URL is set", async () => {
127
+ process.env.COMPANION_AUTH_ENABLED = "1";
128
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
129
+ process.env.COMPANION_LOGIN_URL = "https://login.example.com";
130
+ const app = createTestApp();
131
+
132
+ const res = await app.request("/api/sessions", { redirect: "manual" });
133
+ expect(res.status).toBe(302);
134
+ expect(res.headers.get("location")).toBe("https://login.example.com");
135
+ });
136
+
137
+ it("returns 500 when COMPANION_AUTH_SECRET is missing", async () => {
138
+ process.env.COMPANION_AUTH_ENABLED = "1";
139
+ delete process.env.COMPANION_AUTH_SECRET;
140
+ const app = createTestApp();
141
+
142
+ // Provide a token so it gets past the "no token" check
143
+ const res = await app.request("/api/sessions?token=fake.token");
144
+ expect(res.status).toBe(500);
145
+ const body = await res.json();
146
+ expect(body.error).toBe("Server misconfigured");
147
+ });
148
+
149
+ it("allows access with a valid token in query param", async () => {
150
+ process.env.COMPANION_AUTH_ENABLED = "1";
151
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
152
+ const app = createTestApp();
153
+
154
+ const token = await createToken(TEST_SECRET, 60);
155
+ const res = await app.request(`/api/sessions?token=${token}`);
156
+ expect(res.status).toBe(200);
157
+ expect(await res.json()).toEqual({ ok: true });
158
+ expect(res.headers.get("set-cookie")).toContain("companion_token=");
159
+ });
160
+
161
+ it("sets a Secure auth cookie for HTTPS requests", async () => {
162
+ process.env.COMPANION_AUTH_ENABLED = "1";
163
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
164
+ const app = createTestApp();
165
+
166
+ const token = await createToken(TEST_SECRET, 60);
167
+ const res = await app.request(`https://instance.example.com/api/sessions?token=${token}`);
168
+
169
+ expect(res.status).toBe(200);
170
+ expect(res.headers.get("set-cookie")).toContain("Secure");
171
+ });
172
+
173
+ it("omits Secure on auth cookie for direct HTTP instance URLs", async () => {
174
+ process.env.COMPANION_AUTH_ENABLED = "1";
175
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
176
+ const app = createTestApp();
177
+
178
+ const token = await createToken(TEST_SECRET, 60);
179
+ const res = await app.request(`http://5.161.114.105/?token=${token}`);
180
+
181
+ expect(res.status).toBe(200);
182
+ expect(res.headers.get("set-cookie")).not.toContain("Secure");
183
+ });
184
+
185
+ it("persists query-token auth as cookie for follow-up requests", async () => {
186
+ process.env.COMPANION_AUTH_ENABLED = "1";
187
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
188
+ const app = createTestApp();
189
+
190
+ const token = await createToken(TEST_SECRET, 60);
191
+ const first = await app.request(`/api/sessions?token=${token}`);
192
+ expect(first.status).toBe(200);
193
+
194
+ const setCookie = first.headers.get("set-cookie");
195
+ expect(setCookie).toBeTruthy();
196
+
197
+ const cookiePair = setCookie!.split(";")[0];
198
+ const second = await app.request("/api/sessions", {
199
+ headers: { cookie: cookiePair },
200
+ });
201
+ expect(second.status).toBe(200);
202
+ expect(await second.json()).toEqual({ ok: true });
203
+ });
204
+
205
+ it("allows access with a valid token in cookie", async () => {
206
+ process.env.COMPANION_AUTH_ENABLED = "1";
207
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
208
+ const app = createTestApp();
209
+
210
+ const token = await createToken(TEST_SECRET, 60);
211
+ const res = await app.request("/api/sessions", {
212
+ headers: { cookie: `companion_token=${token}` },
213
+ });
214
+ expect(res.status).toBe(200);
215
+ expect(await res.json()).toEqual({ ok: true });
216
+ });
217
+
218
+ it("rejects an invalid token", async () => {
219
+ process.env.COMPANION_AUTH_ENABLED = "1";
220
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
221
+ delete process.env.COMPANION_LOGIN_URL;
222
+ const app = createTestApp();
223
+
224
+ const res = await app.request("/api/sessions?token=bad.token");
225
+ expect(res.status).toBe(401);
226
+ });
227
+
228
+ it("rejects an expired token", async () => {
229
+ process.env.COMPANION_AUTH_ENABLED = "1";
230
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
231
+ delete process.env.COMPANION_LOGIN_URL;
232
+ const app = createTestApp();
233
+
234
+ const token = await createToken(TEST_SECRET, -1);
235
+ const res = await app.request(`/api/sessions?token=${token}`);
236
+ expect(res.status).toBe(401);
237
+ });
238
+
239
+ it("redirects with invalid token when login URL is set", async () => {
240
+ process.env.COMPANION_AUTH_ENABLED = "1";
241
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
242
+ process.env.COMPANION_LOGIN_URL = "https://login.example.com";
243
+ const app = createTestApp();
244
+
245
+ const res = await app.request("/api/sessions?token=bad.token", {
246
+ redirect: "manual",
247
+ });
248
+ expect(res.status).toBe(302);
249
+ expect(res.headers.get("location")).toBe("https://login.example.com");
250
+ });
251
+
252
+ it("prefers query param over cookie when both are present", async () => {
253
+ process.env.COMPANION_AUTH_ENABLED = "1";
254
+ process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
255
+ const app = createTestApp();
256
+
257
+ const validToken = await createToken(TEST_SECRET, 60);
258
+ // Query has valid token, cookie has bad token — query wins.
259
+ const res = await app.request(`/api/sessions?token=${validToken}`, {
260
+ headers: { cookie: "companion_token=bad.token" },
261
+ });
262
+ expect(res.status).toBe(200);
263
+ });
264
+ });
@@ -0,0 +1,195 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { Context } from "hono";
3
+
4
+ /**
5
+ * Auth middleware for managed Companion Cloud instances.
6
+ *
7
+ * Only active when COMPANION_AUTH_ENABLED=1. Validates a JWT from a cookie
8
+ * or query parameter, signed by the control plane using COMPANION_AUTH_SECRET.
9
+ *
10
+ * Skipped paths:
11
+ * - /ws/cli/* — internal CLI WebSocket (Claude Code connects from within the machine)
12
+ * - /health — monitoring endpoint used by control plane health checks
13
+ */
14
+ export const managedAuth = createMiddleware(async (c: Context, next) => {
15
+ // This middleware is only registered by index.ts when managed auth is
16
+ // enabled (COMPANION_AUTH_ENABLED=1 or COMPANION_AUTH_SECRET is set).
17
+ // No redundant env check needed here.
18
+
19
+ const path = c.req.path;
20
+
21
+ // Internal paths that bypass auth
22
+ if (path.startsWith("/ws/cli/") || path === "/health") return next();
23
+
24
+ const cookieToken = getCookie(c, "companion_token");
25
+ const queryToken = c.req.query("token");
26
+ // Give explicit URL token precedence so reconnect links can always override
27
+ // stale/expired cookies in the browser.
28
+ const token = queryToken || cookieToken;
29
+
30
+ if (!token) {
31
+ return redirectOrUnauthorized(c);
32
+ }
33
+
34
+ const secret = process.env.COMPANION_AUTH_SECRET;
35
+ if (!secret) {
36
+ console.error("[managed-auth] COMPANION_AUTH_SECRET is not set");
37
+ return c.json({ error: "Server misconfigured" }, 500);
38
+ }
39
+
40
+ const valid = await verifyToken(token, secret);
41
+ if (!valid) {
42
+ return redirectOrUnauthorized(c);
43
+ }
44
+
45
+ // When auth arrives via URL query once, persist it to a cookie so static
46
+ // assets and subsequent API calls are authenticated without ?token=...
47
+ if (queryToken && queryToken !== cookieToken) {
48
+ setAuthCookie(c, queryToken);
49
+ }
50
+
51
+ return next();
52
+ });
53
+
54
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
55
+
56
+ function getCookie(c: Context, name: string): string | undefined {
57
+ const header = c.req.header("cookie");
58
+ if (!header) return undefined;
59
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
60
+ return match?.[1];
61
+ }
62
+
63
+ function setAuthCookie(c: Context, token: string): void {
64
+ const encoded = encodeURIComponent(token);
65
+ const secure = shouldUseSecureCookie(c) ? "; Secure" : "";
66
+ c.header(
67
+ "Set-Cookie",
68
+ `companion_token=${encoded}; Path=/; HttpOnly${secure}; SameSite=Lax; Max-Age=900`,
69
+ );
70
+ }
71
+
72
+ function shouldUseSecureCookie(c: Context): boolean {
73
+ const forwardedProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
74
+ if (forwardedProto) return forwardedProto === "https";
75
+
76
+ try {
77
+ return new URL(c.req.url).protocol === "https:";
78
+ } catch {
79
+ return true;
80
+ }
81
+ }
82
+
83
+ function redirectOrUnauthorized(c: Context): Response {
84
+ const loginUrl = process.env.COMPANION_LOGIN_URL;
85
+ if (loginUrl) {
86
+ return c.redirect(loginUrl);
87
+ }
88
+ return c.json({ error: "Unauthorized" }, 401);
89
+ }
90
+
91
+ /**
92
+ * Verify a JWT-like HMAC-SHA256 token.
93
+ * Token format: base64url(payload).base64url(signature)
94
+ * Payload: { exp: number } (Unix seconds)
95
+ */
96
+ export async function verifyToken(
97
+ token: string,
98
+ secret: string,
99
+ ): Promise<boolean> {
100
+ const parts = token.split(".");
101
+ if (parts.length !== 2) return false;
102
+
103
+ const [payloadB64, signatureB64] = parts;
104
+
105
+ // Verify signature using HMAC-SHA256
106
+ const encoder = new TextEncoder();
107
+ const key = await crypto.subtle.importKey(
108
+ "raw",
109
+ encoder.encode(secret),
110
+ { name: "HMAC", hash: "SHA-256" },
111
+ false,
112
+ ["sign"],
113
+ );
114
+
115
+ const expectedSig = await crypto.subtle.sign(
116
+ "HMAC",
117
+ key,
118
+ encoder.encode(payloadB64),
119
+ );
120
+
121
+ const expectedB64 = base64UrlEncode(new Uint8Array(expectedSig));
122
+ if (!timingSafeEqual(expectedB64, signatureB64)) return false;
123
+
124
+ // Check expiration
125
+ try {
126
+ const payload = JSON.parse(
127
+ new TextDecoder().decode(base64UrlDecode(payloadB64)),
128
+ );
129
+ if (typeof payload.exp === "number" && payload.exp < Date.now() / 1000) {
130
+ return false;
131
+ }
132
+ } catch {
133
+ return false;
134
+ }
135
+
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Create a signed token for the control plane to issue.
141
+ * Exported for use by the control plane's token endpoint.
142
+ */
143
+ export async function createToken(
144
+ secret: string,
145
+ ttlSeconds = 900, // 15 minutes
146
+ ): Promise<string> {
147
+ const payload = { exp: Math.floor(Date.now() / 1000) + ttlSeconds };
148
+ const payloadB64 = base64UrlEncode(
149
+ new TextEncoder().encode(JSON.stringify(payload)),
150
+ );
151
+
152
+ const key = await crypto.subtle.importKey(
153
+ "raw",
154
+ new TextEncoder().encode(secret),
155
+ { name: "HMAC", hash: "SHA-256" },
156
+ false,
157
+ ["sign"],
158
+ );
159
+
160
+ const sig = await crypto.subtle.sign(
161
+ "HMAC",
162
+ key,
163
+ new TextEncoder().encode(payloadB64),
164
+ );
165
+
166
+ return `${payloadB64}.${base64UrlEncode(new Uint8Array(sig))}`;
167
+ }
168
+
169
+ // ─── Base64url ───────────────────────────────────────────────────────────────
170
+
171
+ function base64UrlEncode(data: Uint8Array): string {
172
+ let binary = "";
173
+ for (const byte of data) binary += String.fromCharCode(byte);
174
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
175
+ }
176
+
177
+ function base64UrlDecode(str: string): Uint8Array {
178
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
179
+ const binary = atob(padded);
180
+ const bytes = new Uint8Array(binary.length);
181
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
182
+ return bytes;
183
+ }
184
+
185
+ /**
186
+ * Constant-time string comparison to prevent timing attacks.
187
+ */
188
+ function timingSafeEqual(a: string, b: string): boolean {
189
+ if (a.length !== b.length) return false;
190
+ let result = 0;
191
+ for (let i = 0; i < a.length; i++) {
192
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
193
+ }
194
+ return result === 0;
195
+ }