@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,721 @@
1
+ // Tests for the Linear Agent SDK webhook and OAuth routes.
2
+ // Covers webhook signature verification, event dispatch, OAuth callback,
3
+ // authorization URL generation, status endpoint, and disconnect flow.
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
6
+ import { Hono } from "hono";
7
+
8
+ // Mock linear-agent module
9
+ vi.mock("../linear-agent.js", () => ({
10
+ verifyWebhookSignature: vi.fn(),
11
+ isLinearOAuthConfigured: vi.fn(),
12
+ getOAuthAuthorizeUrl: vi.fn(),
13
+ exchangeCodeForTokens: vi.fn(),
14
+ validateOAuthState: vi.fn(),
15
+ }));
16
+
17
+ // Mock agent-store
18
+ vi.mock("../agent-store.js", () => ({
19
+ listAgents: vi.fn(),
20
+ }));
21
+
22
+ // Mock linear-staging module
23
+ vi.mock("../linear-staging.js", () => ({
24
+ createSlot: vi.fn(),
25
+ getSlot: vi.fn(),
26
+ deleteSlot: vi.fn(),
27
+ updateSlotTokens: vi.fn(),
28
+ }));
29
+
30
+ // Mock settings-manager
31
+ vi.mock("../settings-manager.js", () => ({
32
+ getSettings: vi.fn().mockReturnValue({
33
+ publicUrl: "https://companion.example.com",
34
+ linearOAuthClientId: "client-id",
35
+ linearOAuthClientSecret: "client-secret",
36
+ linearOAuthWebhookSecret: "webhook-secret",
37
+ linearOAuthAccessToken: "access-token",
38
+ }),
39
+ updateSettings: vi.fn(),
40
+ }));
41
+
42
+ import * as linearAgent from "../linear-agent.js";
43
+ import * as settingsManager from "../settings-manager.js";
44
+ import * as agentStore from "../agent-store.js";
45
+ import * as staging from "../linear-staging.js";
46
+ import {
47
+ registerLinearAgentWebhookRoute,
48
+ registerLinearAgentProtectedRoutes,
49
+ } from "./linear-agent-routes.js";
50
+
51
+ // ─── Test helpers ────────────────────────────────────────────────────────────
52
+
53
+ function createMockBridge() {
54
+ return {
55
+ handleEvent: vi.fn().mockResolvedValue(undefined),
56
+ } as unknown as import("../linear-agent-bridge.js").LinearAgentBridge;
57
+ }
58
+
59
+ function createApp() {
60
+ const app = new Hono();
61
+ const bridge = createMockBridge();
62
+ registerLinearAgentWebhookRoute(app, bridge);
63
+ registerLinearAgentProtectedRoutes(app);
64
+ return { app, bridge };
65
+ }
66
+
67
+ const testAgent = {
68
+ id: "agent-1",
69
+ name: "Linear Bot",
70
+ enabled: true,
71
+ triggers: {
72
+ linear: {
73
+ enabled: true,
74
+ oauthClientId: "test-client-id",
75
+ webhookSecret: "test-webhook-secret",
76
+ },
77
+ },
78
+ };
79
+
80
+ const validPayload = {
81
+ type: "AgentSessionEvent",
82
+ action: "created",
83
+ oauthClientId: "test-client-id",
84
+ agentSession: {
85
+ id: "session-123",
86
+ status: "pending",
87
+ createdAt: "2026-01-01T00:00:00Z",
88
+ updatedAt: "2026-01-01T00:00:00Z",
89
+ },
90
+ promptContext: "Fix the bug",
91
+ };
92
+
93
+ // ─── Webhook endpoint tests ─────────────────────────────────────────────────
94
+
95
+ describe("POST /linear/agent-webhook", () => {
96
+ let app: Hono;
97
+ let bridge: ReturnType<typeof createMockBridge>;
98
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
99
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
100
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
101
+
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
105
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
106
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
107
+ ({ app, bridge } = createApp());
108
+ });
109
+
110
+ afterEach(() => {
111
+ vi.restoreAllMocks();
112
+ });
113
+
114
+ it("returns 401 when webhook signature is invalid", async () => {
115
+ // Agent must be found first (per-agent lookup), then signature check fails
116
+ vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
117
+ vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(false);
118
+
119
+ const res = await app.request("/linear/agent-webhook", {
120
+ method: "POST",
121
+ body: JSON.stringify(validPayload),
122
+ headers: { "Content-Type": "application/json", "linear-signature": "bad-sig" },
123
+ });
124
+
125
+ expect(res.status).toBe(401);
126
+ const body = await res.json();
127
+ expect(body.error).toBe("Invalid signature");
128
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
129
+ expect.stringContaining("Invalid webhook signature"),
130
+ );
131
+ });
132
+
133
+ it("returns 400 for invalid JSON body", async () => {
134
+ // JSON parsing now happens before signature verification
135
+ const res = await app.request("/linear/agent-webhook", {
136
+ method: "POST",
137
+ body: "not-json{{",
138
+ headers: { "Content-Type": "text/plain", "linear-signature": "valid-sig" },
139
+ });
140
+
141
+ expect(res.status).toBe(400);
142
+ const body = await res.json();
143
+ expect(body.error).toBe("Invalid JSON");
144
+ });
145
+
146
+ it("dispatches AgentSessionEvent to bridge and returns 200", async () => {
147
+ vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
148
+ vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(true);
149
+
150
+ const res = await app.request("/linear/agent-webhook", {
151
+ method: "POST",
152
+ body: JSON.stringify(validPayload),
153
+ headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
154
+ });
155
+
156
+ expect(res.status).toBe(200);
157
+ const body = await res.json();
158
+ expect(body.ok).toBe(true);
159
+
160
+ // Wait a tick for the async dispatch
161
+ await new Promise((r) => setTimeout(r, 10));
162
+ expect(bridge.handleEvent).toHaveBeenCalledWith(validPayload);
163
+ expect(consoleLogSpy).toHaveBeenCalledWith(
164
+ expect.stringContaining("Accepted AgentSessionEvent"),
165
+ );
166
+ });
167
+
168
+ it("ignores non-AgentSessionEvent types", async () => {
169
+ // Type check happens before agent lookup, so no agent mock needed
170
+ const res = await app.request("/linear/agent-webhook", {
171
+ method: "POST",
172
+ body: JSON.stringify({ type: "Issue", action: "created", data: {} }),
173
+ headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
174
+ });
175
+
176
+ expect(res.status).toBe(200);
177
+ const body = await res.json();
178
+ expect(body.ignored).toBe(true);
179
+ expect(bridge.handleEvent).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("accepts x-linear-signature header as fallback", async () => {
183
+ vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
184
+ vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(true);
185
+
186
+ const res = await app.request("/linear/agent-webhook", {
187
+ method: "POST",
188
+ body: JSON.stringify(validPayload),
189
+ headers: { "Content-Type": "application/json", "x-linear-signature": "valid-sig" },
190
+ });
191
+
192
+ expect(res.status).toBe(200);
193
+ // verifyWebhookSignature now takes (webhookSecret, rawBody, signature)
194
+ expect(linearAgent.verifyWebhookSignature).toHaveBeenCalledWith(
195
+ "test-webhook-secret",
196
+ expect.any(String),
197
+ "valid-sig",
198
+ );
199
+ });
200
+
201
+ it("returns 404 when no agent matches the oauthClientId", async () => {
202
+ // No agents configured — should return 404
203
+ vi.mocked(agentStore.listAgents).mockReturnValue([]);
204
+
205
+ const res = await app.request("/linear/agent-webhook", {
206
+ method: "POST",
207
+ body: JSON.stringify(validPayload),
208
+ headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
209
+ });
210
+
211
+ expect(res.status).toBe(404);
212
+ const body = await res.json();
213
+ expect(body.error).toContain("No agent configured");
214
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
215
+ expect.stringContaining("No agent found for oauthClientId"),
216
+ );
217
+ });
218
+
219
+ it("sanitizes user-controlled fields before logging webhook diagnostics", async () => {
220
+ vi.mocked(agentStore.listAgents).mockReturnValue([]);
221
+
222
+ const maliciousPayload = {
223
+ ...validPayload,
224
+ action: "created\nforged",
225
+ oauthClientId: "evil\n[linear-agent-routes] Accepted AgentSessionEvent",
226
+ agentSession: {
227
+ ...validPayload.agentSession,
228
+ id: "session-123\tforged",
229
+ },
230
+ };
231
+
232
+ const res = await app.request("/linear/agent-webhook", {
233
+ method: "POST",
234
+ body: JSON.stringify(maliciousPayload),
235
+ headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
236
+ });
237
+
238
+ expect(res.status).toBe(404);
239
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
240
+ "[linear-agent-routes] No agent found for oauthClientId: evil_[linear-agent-routes] Accepted AgentSessionEvent action=created_forged sessionId=session-123_forged",
241
+ );
242
+ });
243
+ });
244
+
245
+ describe("console spy cleanup", () => {
246
+ it("restores console spies before later describe blocks run", () => {
247
+ // Regression test: webhook tests install console spies, but later describes
248
+ // should still see the original console implementations.
249
+ expect(vi.isMockFunction(console.log)).toBe(false);
250
+ expect(vi.isMockFunction(console.warn)).toBe(false);
251
+ expect(vi.isMockFunction(console.error)).toBe(false);
252
+ });
253
+ });
254
+
255
+ // ─── OAuth callback tests ───────────────────────────────────────────────────
256
+
257
+ describe("GET /linear/oauth/callback", () => {
258
+ let app: Hono;
259
+
260
+ beforeEach(() => {
261
+ vi.clearAllMocks();
262
+ ({ app } = createApp());
263
+ });
264
+
265
+ it("redirects with error when error parameter is present", async () => {
266
+ const res = await app.request("/linear/oauth/callback?error=access_denied");
267
+
268
+ expect(res.status).toBe(302);
269
+ const location = res.headers.get("location");
270
+ expect(location).toContain("oauth_error=access_denied");
271
+ });
272
+
273
+ it("redirects with error when no code parameter", async () => {
274
+ const res = await app.request("/linear/oauth/callback");
275
+
276
+ expect(res.status).toBe(302);
277
+ const location = res.headers.get("location");
278
+ expect(location).toContain("oauth_error=no_code");
279
+ });
280
+
281
+ it("redirects with error when state is missing (CSRF protection)", async () => {
282
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: false });
283
+ const res = await app.request("/linear/oauth/callback?code=auth-code-123");
284
+
285
+ expect(res.status).toBe(302);
286
+ const location = res.headers.get("location");
287
+ expect(location).toContain("oauth_error=invalid_state");
288
+ });
289
+
290
+ it("redirects with error when state is invalid (CSRF protection)", async () => {
291
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: false });
292
+
293
+ const res = await app.request("/linear/oauth/callback?code=auth-code-123&state=bad-state");
294
+
295
+ expect(res.status).toBe(302);
296
+ const location = res.headers.get("location");
297
+ expect(location).toContain("oauth_error=invalid_state");
298
+ });
299
+
300
+ it("exchanges code for tokens and redirects on success", async () => {
301
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: true });
302
+ vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue({
303
+ accessToken: "new-access",
304
+ refreshToken: "new-refresh",
305
+ });
306
+
307
+ const res = await app.request("/linear/oauth/callback?code=auth-code-123&state=valid-state");
308
+
309
+ expect(res.status).toBe(302);
310
+ const location = res.headers.get("location");
311
+ expect(location).toContain("oauth_success=true");
312
+
313
+ // exchangeCodeForTokens now receives credentials object as first arg
314
+ expect(linearAgent.exchangeCodeForTokens).toHaveBeenCalledWith(
315
+ { clientId: "client-id", clientSecret: "client-secret" },
316
+ "auth-code-123",
317
+ expect.stringContaining("/api/linear/oauth/callback"),
318
+ );
319
+
320
+ // Should persist tokens to global staging
321
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith({
322
+ linearOAuthAccessToken: "new-access",
323
+ linearOAuthRefreshToken: "new-refresh",
324
+ });
325
+ });
326
+
327
+ it("redirects with error when token exchange fails", async () => {
328
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: true });
329
+ vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue(null);
330
+
331
+ const res = await app.request("/linear/oauth/callback?code=bad-code&state=valid-state");
332
+
333
+ expect(res.status).toBe(302);
334
+ const location = res.headers.get("location");
335
+ expect(location).toContain("oauth_error=token_exchange_failed");
336
+ });
337
+ });
338
+
339
+ // ─── OAuth authorize URL endpoint ───────────────────────────────────────────
340
+
341
+ describe("GET /linear/oauth/authorize-url", () => {
342
+ let app: Hono;
343
+
344
+ beforeEach(() => {
345
+ vi.clearAllMocks();
346
+ ({ app } = createApp());
347
+ });
348
+
349
+ it("returns authorization URL when configured", async () => {
350
+ vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue({
351
+ url: "https://linear.app/oauth/authorize?client_id=test&state=abc123",
352
+ state: "abc123",
353
+ });
354
+
355
+ const res = await app.request("/linear/oauth/authorize-url");
356
+
357
+ expect(res.status).toBe(200);
358
+ const body = await res.json();
359
+ expect(body.url).toContain("linear.app/oauth/authorize");
360
+
361
+ // getOAuthAuthorizeUrl receives clientId, redirectUri, and an options object
362
+ expect(linearAgent.getOAuthAuthorizeUrl).toHaveBeenCalledWith(
363
+ "client-id",
364
+ expect.stringContaining("/api/linear/oauth/callback"),
365
+ { returnTo: undefined, stagingId: undefined },
366
+ );
367
+ });
368
+
369
+ it("returns 400 when OAuth client ID is not configured", async () => {
370
+ vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue(null);
371
+
372
+ const res = await app.request("/linear/oauth/authorize-url");
373
+
374
+ expect(res.status).toBe(400);
375
+ const body = await res.json();
376
+ expect(body.error).toContain("not configured");
377
+ });
378
+ });
379
+
380
+ // ─── OAuth status endpoint ──────────────────────────────────────────────────
381
+
382
+ describe("GET /linear/oauth/status", () => {
383
+ let app: Hono;
384
+
385
+ beforeEach(() => {
386
+ vi.clearAllMocks();
387
+ ({ app } = createApp());
388
+ });
389
+
390
+ it("returns OAuth configuration status", async () => {
391
+ vi.mocked(linearAgent.isLinearOAuthConfigured).mockReturnValue(true);
392
+
393
+ const res = await app.request("/linear/oauth/status");
394
+
395
+ expect(res.status).toBe(200);
396
+ const body = await res.json();
397
+ expect(body.configured).toBe(true);
398
+ expect(body.hasClientId).toBe(true);
399
+ expect(body.hasClientSecret).toBe(true);
400
+ expect(body.hasWebhookSecret).toBe(true);
401
+ expect(body.hasAccessToken).toBe(true);
402
+
403
+ // isLinearOAuthConfigured now receives credentials object
404
+ expect(linearAgent.isLinearOAuthConfigured).toHaveBeenCalledWith({
405
+ clientId: "client-id",
406
+ clientSecret: "client-secret",
407
+ accessToken: "access-token",
408
+ });
409
+ });
410
+ });
411
+
412
+ // ─── OAuth disconnect endpoint ──────────────────────────────────────────────
413
+
414
+ describe("POST /linear/oauth/disconnect", () => {
415
+ let app: Hono;
416
+
417
+ beforeEach(() => {
418
+ vi.clearAllMocks();
419
+ ({ app } = createApp());
420
+ });
421
+
422
+ it("clears OAuth tokens and returns success", async () => {
423
+ const res = await app.request("/linear/oauth/disconnect", { method: "POST" });
424
+
425
+ expect(res.status).toBe(200);
426
+ const body = await res.json();
427
+ expect(body.ok).toBe(true);
428
+
429
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith({
430
+ linearOAuthAccessToken: "",
431
+ linearOAuthRefreshToken: "",
432
+ });
433
+ });
434
+ });
435
+
436
+ // ─── Staging slot CRUD tests ────────────────────────────────────────────────
437
+
438
+ describe("POST /linear/oauth/staging", () => {
439
+ let app: Hono;
440
+
441
+ beforeEach(() => {
442
+ vi.clearAllMocks();
443
+ ({ app } = createApp());
444
+ });
445
+
446
+ it("creates a staging slot and returns the stagingId", async () => {
447
+ // createSlot should return a hex ID when given valid credentials
448
+ vi.mocked(staging.createSlot).mockReturnValue("abcd1234abcd1234abcd1234abcd1234");
449
+
450
+ const res = await app.request("/linear/oauth/staging", {
451
+ method: "POST",
452
+ body: JSON.stringify({
453
+ clientId: "my-client-id",
454
+ clientSecret: "my-client-secret",
455
+ webhookSecret: "my-webhook-secret",
456
+ }),
457
+ headers: { "Content-Type": "application/json" },
458
+ });
459
+
460
+ expect(res.status).toBe(200);
461
+ const body = await res.json();
462
+ expect(body.stagingId).toBe("abcd1234abcd1234abcd1234abcd1234");
463
+
464
+ // Verify createSlot was called with the provided credentials
465
+ expect(staging.createSlot).toHaveBeenCalledWith({
466
+ clientId: "my-client-id",
467
+ clientSecret: "my-client-secret",
468
+ webhookSecret: "my-webhook-secret",
469
+ });
470
+ });
471
+
472
+ it("returns 400 when required fields are missing", async () => {
473
+ // Missing webhookSecret — should be rejected before createSlot is called
474
+ const res = await app.request("/linear/oauth/staging", {
475
+ method: "POST",
476
+ body: JSON.stringify({
477
+ clientId: "my-client-id",
478
+ clientSecret: "my-client-secret",
479
+ // webhookSecret intentionally omitted
480
+ }),
481
+ headers: { "Content-Type": "application/json" },
482
+ });
483
+
484
+ expect(res.status).toBe(400);
485
+ const body = await res.json();
486
+ expect(body.error).toContain("required");
487
+ expect(staging.createSlot).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it("returns 400 when all fields are empty strings", async () => {
491
+ // All fields present but empty — should be rejected after trimming
492
+ const res = await app.request("/linear/oauth/staging", {
493
+ method: "POST",
494
+ body: JSON.stringify({
495
+ clientId: " ",
496
+ clientSecret: "",
497
+ webhookSecret: "",
498
+ }),
499
+ headers: { "Content-Type": "application/json" },
500
+ });
501
+
502
+ expect(res.status).toBe(400);
503
+ const body = await res.json();
504
+ expect(body.error).toContain("required");
505
+ expect(staging.createSlot).not.toHaveBeenCalled();
506
+ });
507
+ });
508
+
509
+ describe("GET /linear/oauth/staging/:id/status", () => {
510
+ let app: Hono;
511
+
512
+ beforeEach(() => {
513
+ vi.clearAllMocks();
514
+ ({ app } = createApp());
515
+ });
516
+
517
+ it("returns full status for an existing staging slot", async () => {
518
+ // Simulate a slot that has completed OAuth (has accessToken)
519
+ vi.mocked(staging.getSlot).mockReturnValue({
520
+ id: "abcd1234abcd1234abcd1234abcd1234",
521
+ clientId: "my-client-id",
522
+ clientSecret: "my-client-secret",
523
+ webhookSecret: "my-webhook-secret",
524
+ accessToken: "token-abc",
525
+ refreshToken: "refresh-abc",
526
+ createdAt: Date.now(),
527
+ });
528
+
529
+ const res = await app.request("/linear/oauth/staging/abcd1234abcd1234abcd1234abcd1234/status");
530
+
531
+ expect(res.status).toBe(200);
532
+ const body = await res.json();
533
+ expect(body.exists).toBe(true);
534
+ expect(body.hasAccessToken).toBe(true);
535
+ expect(body.hasClientId).toBe(true);
536
+ expect(body.hasClientSecret).toBe(true);
537
+ });
538
+
539
+ it("returns exists:false for a non-existent or expired slot", async () => {
540
+ // getSlot returns null when the slot doesn't exist or has expired
541
+ vi.mocked(staging.getSlot).mockReturnValue(null);
542
+
543
+ const res = await app.request("/linear/oauth/staging/deadbeefdeadbeefdeadbeefdeadbeef/status");
544
+
545
+ expect(res.status).toBe(200);
546
+ const body = await res.json();
547
+ expect(body.exists).toBe(false);
548
+ expect(body.hasAccessToken).toBe(false);
549
+ expect(body.hasClientId).toBe(false);
550
+ expect(body.hasClientSecret).toBe(false);
551
+ });
552
+ });
553
+
554
+ describe("DELETE /linear/oauth/staging/:id", () => {
555
+ let app: Hono;
556
+
557
+ beforeEach(() => {
558
+ vi.clearAllMocks();
559
+ ({ app } = createApp());
560
+ });
561
+
562
+ it("deletes a staging slot and returns ok", async () => {
563
+ vi.mocked(staging.deleteSlot).mockReturnValue(true);
564
+
565
+ const res = await app.request("/linear/oauth/staging/abcd1234abcd1234abcd1234abcd1234", {
566
+ method: "DELETE",
567
+ });
568
+
569
+ expect(res.status).toBe(200);
570
+ const body = await res.json();
571
+ expect(body.ok).toBe(true);
572
+
573
+ expect(staging.deleteSlot).toHaveBeenCalledWith("abcd1234abcd1234abcd1234abcd1234");
574
+ });
575
+ });
576
+
577
+ // ─── OAuth callback with expired staging slot ───────────────────────────────
578
+
579
+ describe("GET /linear/oauth/callback — expired staging slot", () => {
580
+ let app: Hono;
581
+
582
+ beforeEach(() => {
583
+ vi.clearAllMocks();
584
+ ({ app } = createApp());
585
+ });
586
+
587
+ it("redirects with staging_slot_expired error when staging slot has expired", async () => {
588
+ // The state nonce is valid and contains a stagingId, but the slot has been
589
+ // deleted or expired (getSlot returns null). The callback should NOT fall
590
+ // back to global credentials — it should return an explicit error.
591
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({
592
+ valid: true,
593
+ stagingId: "abcd1234abcd1234abcd1234abcd1234",
594
+ returnTo: "/#/agents",
595
+ });
596
+ vi.mocked(staging.getSlot).mockReturnValue(null);
597
+
598
+ const res = await app.request(
599
+ "/linear/oauth/callback?code=auth-code-123&state=valid-state-with-staging",
600
+ );
601
+
602
+ expect(res.status).toBe(302);
603
+ const location = res.headers.get("location");
604
+ // Should redirect to the returnTo path with the staging_slot_expired error
605
+ expect(location).toContain("/#/agents");
606
+ expect(location).toContain("oauth_error=staging_slot_expired");
607
+
608
+ // Token exchange should never be attempted when the staging slot is expired
609
+ expect(linearAgent.exchangeCodeForTokens).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it("uses staging slot credentials when slot exists", async () => {
613
+ // When a stagingId is in the state and the slot is still alive, the callback
614
+ // should use the staging slot's credentials for token exchange and persist
615
+ // the tokens back to the slot via updateSlotTokens.
616
+ vi.mocked(linearAgent.validateOAuthState).mockReturnValue({
617
+ valid: true,
618
+ stagingId: "abcd1234abcd1234abcd1234abcd1234",
619
+ returnTo: "/#/agents",
620
+ });
621
+ vi.mocked(staging.getSlot).mockReturnValue({
622
+ id: "abcd1234abcd1234abcd1234abcd1234",
623
+ clientId: "staging-client-id",
624
+ clientSecret: "staging-client-secret",
625
+ webhookSecret: "staging-webhook-secret",
626
+ accessToken: "",
627
+ refreshToken: "",
628
+ createdAt: Date.now(),
629
+ });
630
+ vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue({
631
+ accessToken: "new-staging-access",
632
+ refreshToken: "new-staging-refresh",
633
+ });
634
+
635
+ const res = await app.request(
636
+ "/linear/oauth/callback?code=auth-code-456&state=valid-state-with-staging",
637
+ );
638
+
639
+ expect(res.status).toBe(302);
640
+ const location = res.headers.get("location");
641
+ expect(location).toContain("/#/agents");
642
+ expect(location).toContain("oauth_success=true");
643
+
644
+ // Should use the staging slot's credentials, not global settings
645
+ expect(linearAgent.exchangeCodeForTokens).toHaveBeenCalledWith(
646
+ { clientId: "staging-client-id", clientSecret: "staging-client-secret" },
647
+ "auth-code-456",
648
+ expect.stringContaining("/api/linear/oauth/callback"),
649
+ );
650
+
651
+ // Tokens should be persisted to the staging slot, not global settings
652
+ expect(staging.updateSlotTokens).toHaveBeenCalledWith(
653
+ "abcd1234abcd1234abcd1234abcd1234",
654
+ { accessToken: "new-staging-access", refreshToken: "new-staging-refresh" },
655
+ );
656
+ // Global settings should NOT be updated
657
+ expect(settingsManager.updateSettings).not.toHaveBeenCalled();
658
+ });
659
+ });
660
+
661
+ // ─── OAuth authorize URL with staging slot ──────────────────────────────────
662
+
663
+ describe("GET /linear/oauth/authorize-url — with stagingId", () => {
664
+ let app: Hono;
665
+
666
+ beforeEach(() => {
667
+ vi.clearAllMocks();
668
+ ({ app } = createApp());
669
+ });
670
+
671
+ it("uses the staging slot's clientId when stagingId is provided", async () => {
672
+ // When the authorize-url request includes a stagingId, the route should look
673
+ // up the staging slot and use its clientId instead of the global setting.
674
+ vi.mocked(staging.getSlot).mockReturnValue({
675
+ id: "abcd1234abcd1234abcd1234abcd1234",
676
+ clientId: "staging-oauth-client-id",
677
+ clientSecret: "staging-oauth-client-secret",
678
+ webhookSecret: "staging-webhook-secret",
679
+ accessToken: "",
680
+ refreshToken: "",
681
+ createdAt: Date.now(),
682
+ });
683
+ vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue({
684
+ url: "https://linear.app/oauth/authorize?client_id=staging-oauth-client-id&state=xyz",
685
+ state: "xyz",
686
+ });
687
+
688
+ const res = await app.request(
689
+ "/linear/oauth/authorize-url?stagingId=abcd1234abcd1234abcd1234abcd1234",
690
+ );
691
+
692
+ expect(res.status).toBe(200);
693
+ const body = await res.json();
694
+ expect(body.url).toContain("linear.app/oauth/authorize");
695
+
696
+ // getOAuthAuthorizeUrl should receive the staging slot's clientId
697
+ expect(linearAgent.getOAuthAuthorizeUrl).toHaveBeenCalledWith(
698
+ "staging-oauth-client-id",
699
+ expect.stringContaining("/api/linear/oauth/callback"),
700
+ { returnTo: undefined, stagingId: "abcd1234abcd1234abcd1234abcd1234" },
701
+ );
702
+ });
703
+
704
+ it("returns 404 when staging slot doesn't exist (expired or deleted)", async () => {
705
+ // If stagingId is provided but the slot is expired/missing, the endpoint
706
+ // should return 404 immediately rather than generating a URL that will
707
+ // fail at callback time with staging_slot_expired.
708
+ vi.mocked(staging.getSlot).mockReturnValue(null);
709
+
710
+ const res = await app.request(
711
+ "/linear/oauth/authorize-url?stagingId=deadbeefdeadbeefdeadbeefdeadbeef",
712
+ );
713
+
714
+ expect(res.status).toBe(404);
715
+ const body = await res.json();
716
+ expect(body.error).toContain("Staging slot expired");
717
+
718
+ // Should not have attempted to generate an authorize URL
719
+ expect(linearAgent.getOAuthAuthorizeUrl).not.toHaveBeenCalled();
720
+ });
721
+ });