@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,473 @@
1
+ // Tests for the Linear Agent Interaction SDK client module.
2
+ // Covers webhook verification, OAuth token management, GraphQL calls,
3
+ // activity posting, and configuration checks.
4
+
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+ import { createHmac } from "node:crypto";
7
+
8
+ import type { LinearOAuthCredentials } from "./linear-agent.js";
9
+ import {
10
+ verifyWebhookSignature,
11
+ isLinearOAuthConfigured,
12
+ getOAuthAuthorizeUrl,
13
+ exchangeCodeForTokens,
14
+ refreshAccessToken,
15
+ linearGraphQL,
16
+ postActivity,
17
+ updateSessionUrls,
18
+ updateSessionPlan,
19
+ generateOAuthState,
20
+ validateOAuthState,
21
+ } from "./linear-agent.js";
22
+
23
+ // Mock global fetch
24
+ const mockFetch = vi.fn();
25
+ vi.stubGlobal("fetch", mockFetch);
26
+
27
+ // Default test credentials matching the LinearOAuthCredentials interface
28
+ const testCreds: LinearOAuthCredentials = {
29
+ clientId: "client-id",
30
+ clientSecret: "client-secret",
31
+ webhookSecret: "webhook-secret",
32
+ accessToken: "access-token",
33
+ refreshToken: "refresh-token",
34
+ };
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ // ─── Webhook signature verification ──────────────────────────────────────────
41
+
42
+ describe("verifyWebhookSignature", () => {
43
+ it("returns true for valid HMAC-SHA256 signature", () => {
44
+ const body = '{"type":"AgentSessionEvent"}';
45
+ const signature = createHmac("sha256", "test-secret").update(body).digest("hex");
46
+
47
+ expect(verifyWebhookSignature("test-secret", body, signature)).toBe(true);
48
+ });
49
+
50
+ it("returns false for invalid signature", () => {
51
+ expect(verifyWebhookSignature("test-secret", "body", "bad-signature")).toBe(false);
52
+ });
53
+
54
+ it("returns false when webhook secret is not configured", () => {
55
+ expect(verifyWebhookSignature("", "body", "some-sig")).toBe(false);
56
+ });
57
+
58
+ it("returns false when signature is null", () => {
59
+ expect(verifyWebhookSignature("webhook-secret", "body", null)).toBe(false);
60
+ });
61
+
62
+ it("returns false for malformed hex signature (timing-safe compare failure)", () => {
63
+ // Non-hex string will cause Buffer.from to produce different length
64
+ expect(verifyWebhookSignature("test-secret", "body", "not-valid-hex!!")).toBe(false);
65
+ });
66
+ });
67
+
68
+ // ─── OAuth configuration checks ─────────────────────────────────────────────
69
+
70
+ describe("isLinearOAuthConfigured", () => {
71
+ it("returns true when all required fields are present", () => {
72
+ expect(isLinearOAuthConfigured(testCreds)).toBe(true);
73
+ });
74
+
75
+ it("returns false when client ID is missing", () => {
76
+ expect(isLinearOAuthConfigured({ ...testCreds, clientId: "" })).toBe(false);
77
+ });
78
+
79
+ it("returns false when access token is missing", () => {
80
+ expect(isLinearOAuthConfigured({ ...testCreds, accessToken: "" })).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("getOAuthAuthorizeUrl", () => {
85
+ it("returns authorization URL and state nonce with correct parameters", () => {
86
+ const result = getOAuthAuthorizeUrl("my-client-id", "http://localhost:3456/api/linear/oauth/callback");
87
+ expect(result).not.toBeNull();
88
+ expect(result!.url).toContain("linear.app/oauth/authorize");
89
+ expect(result!.url).toContain("client_id=my-client-id");
90
+ expect(result!.url).toContain("response_type=code");
91
+ expect(result!.url).toContain("actor=app");
92
+ expect(result!.url).toContain("app%3Amentionable");
93
+ expect(result!.url).toContain("state=");
94
+ expect(result!.state).toBeTruthy();
95
+ });
96
+
97
+ it("returns null when client ID is not configured", () => {
98
+ expect(getOAuthAuthorizeUrl("", "http://localhost/callback")).toBeNull();
99
+ });
100
+ });
101
+
102
+ // ─── OAuth state CSRF protection ─────────────────────────────────────────────
103
+
104
+ describe("OAuth state nonce (CSRF protection)", () => {
105
+ it("generates unique state nonces", () => {
106
+ const state1 = generateOAuthState();
107
+ const state2 = generateOAuthState();
108
+ expect(state1).not.toBe(state2);
109
+ expect(state1.length).toBe(48); // 24 bytes → 48 hex chars
110
+ });
111
+
112
+ it("validates a generated state nonce (single use)", () => {
113
+ const state = generateOAuthState();
114
+ expect(validateOAuthState(state)).toEqual({ valid: true });
115
+ // Second use should fail — consumed
116
+ expect(validateOAuthState(state)).toEqual({ valid: false });
117
+ });
118
+
119
+ it("rejects unknown state nonces", () => {
120
+ expect(validateOAuthState("unknown-nonce")).toEqual({ valid: false });
121
+ });
122
+
123
+ it("rejects null/undefined state", () => {
124
+ expect(validateOAuthState(null)).toEqual({ valid: false });
125
+ expect(validateOAuthState(undefined)).toEqual({ valid: false });
126
+ });
127
+
128
+ it("preserves returnTo path in state", () => {
129
+ const state = generateOAuthState({ returnTo: "/#/setup/linear-agent" });
130
+ const result = validateOAuthState(state);
131
+ expect(result).toEqual({ valid: true, returnTo: "/#/setup/linear-agent" });
132
+ });
133
+
134
+ it("works without returnTo", () => {
135
+ const state = generateOAuthState();
136
+ const result = validateOAuthState(state);
137
+ expect(result).toEqual({ valid: true });
138
+ });
139
+
140
+ it("preserves stagingId in state round-trip", () => {
141
+ const state = generateOAuthState({ stagingId: "abc123def456" });
142
+ const result = validateOAuthState(state);
143
+ expect(result).toEqual({ valid: true, stagingId: "abc123def456" });
144
+ });
145
+
146
+ it("preserves both stagingId and returnTo in state round-trip", () => {
147
+ const state = generateOAuthState({ stagingId: "slot-42", returnTo: "/#/agents" });
148
+ const result = validateOAuthState(state);
149
+ expect(result).toEqual({ valid: true, stagingId: "slot-42", returnTo: "/#/agents" });
150
+ });
151
+
152
+ it("returns stagingId as undefined when not provided", () => {
153
+ const state = generateOAuthState({ returnTo: "/#/settings" });
154
+ const result = validateOAuthState(state);
155
+ expect(result.valid).toBe(true);
156
+ expect(result.stagingId).toBeUndefined();
157
+ expect(result.returnTo).toBe("/#/settings");
158
+ });
159
+ });
160
+
161
+ // ─── Token exchange ─────────────────────────────────────────────────────────
162
+
163
+ describe("exchangeCodeForTokens", () => {
164
+ it("exchanges authorization code for tokens", async () => {
165
+ mockFetch.mockResolvedValueOnce({
166
+ ok: true,
167
+ json: () => Promise.resolve({
168
+ access_token: "new-access",
169
+ refresh_token: "new-refresh",
170
+ expires_in: 86400,
171
+ scope: "read,write",
172
+ }),
173
+ });
174
+
175
+ const result = await exchangeCodeForTokens(
176
+ { clientId: testCreds.clientId, clientSecret: testCreds.clientSecret },
177
+ "auth-code",
178
+ "http://localhost/callback",
179
+ );
180
+
181
+ expect(result).toEqual({ accessToken: "new-access", refreshToken: "new-refresh" });
182
+ expect(mockFetch).toHaveBeenCalledWith("https://api.linear.app/oauth/token", expect.objectContaining({
183
+ method: "POST",
184
+ }));
185
+ });
186
+
187
+ it("returns null when client credentials are missing", async () => {
188
+ const result = await exchangeCodeForTokens(
189
+ { clientId: "", clientSecret: testCreds.clientSecret },
190
+ "code",
191
+ "http://localhost/callback",
192
+ );
193
+ expect(result).toBeNull();
194
+ });
195
+
196
+ it("returns null when token exchange fails", async () => {
197
+ mockFetch.mockResolvedValueOnce({
198
+ ok: false,
199
+ status: 400,
200
+ text: () => Promise.resolve("Bad Request"),
201
+ });
202
+
203
+ const result = await exchangeCodeForTokens(
204
+ { clientId: testCreds.clientId, clientSecret: testCreds.clientSecret },
205
+ "bad-code",
206
+ "http://localhost/callback",
207
+ );
208
+ expect(result).toBeNull();
209
+ });
210
+
211
+ it("returns null when fetch throws a network error", async () => {
212
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
213
+
214
+ const result = await exchangeCodeForTokens(
215
+ { clientId: testCreds.clientId, clientSecret: testCreds.clientSecret },
216
+ "code",
217
+ "http://localhost/callback",
218
+ );
219
+ expect(result).toBeNull();
220
+ });
221
+ });
222
+
223
+ // ─── Token refresh ──────────────────────────────────────────────────────────
224
+
225
+ describe("refreshAccessToken", () => {
226
+ it("refreshes token and invokes onTokensRefreshed callback", async () => {
227
+ mockFetch.mockResolvedValueOnce({
228
+ ok: true,
229
+ json: () => Promise.resolve({
230
+ access_token: "refreshed-access",
231
+ refresh_token: "refreshed-refresh",
232
+ expires_in: 86400,
233
+ }),
234
+ });
235
+
236
+ const onTokensRefreshed = vi.fn();
237
+ const result = await refreshAccessToken(testCreds, onTokensRefreshed);
238
+
239
+ expect(result).toBe("refreshed-access");
240
+ expect(onTokensRefreshed).toHaveBeenCalledWith({
241
+ accessToken: "refreshed-access",
242
+ refreshToken: "refreshed-refresh",
243
+ });
244
+ });
245
+
246
+ it("returns null when refresh credentials are missing", async () => {
247
+ const result = await refreshAccessToken({ ...testCreds, refreshToken: "" });
248
+ expect(result).toBeNull();
249
+ });
250
+
251
+ it("returns null when refresh request fails", async () => {
252
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
253
+
254
+ const result = await refreshAccessToken(testCreds);
255
+ expect(result).toBeNull();
256
+ });
257
+
258
+ it("keeps old refresh token if new one is not provided", async () => {
259
+ mockFetch.mockResolvedValueOnce({
260
+ ok: true,
261
+ json: () => Promise.resolve({
262
+ access_token: "new-access",
263
+ // No refresh_token in response
264
+ expires_in: 86400,
265
+ }),
266
+ });
267
+
268
+ const onTokensRefreshed = vi.fn();
269
+ await refreshAccessToken(
270
+ { ...testCreds, refreshToken: "old-refresh" },
271
+ onTokensRefreshed,
272
+ );
273
+
274
+ expect(onTokensRefreshed).toHaveBeenCalledWith({
275
+ accessToken: "new-access",
276
+ refreshToken: "old-refresh",
277
+ });
278
+ });
279
+ });
280
+
281
+ // ─── GraphQL helper ─────────────────────────────────────────────────────────
282
+
283
+ describe("linearGraphQL", () => {
284
+ it("sends authenticated GraphQL request and returns data", async () => {
285
+ mockFetch.mockResolvedValueOnce({
286
+ ok: true,
287
+ status: 200,
288
+ json: () => Promise.resolve({ data: { viewer: { id: "user-1" } } }),
289
+ });
290
+
291
+ const result = await linearGraphQL(testCreds, "{ viewer { id } }");
292
+
293
+ expect(result).toEqual({ data: { viewer: { id: "user-1" } } });
294
+ expect(mockFetch).toHaveBeenCalledWith("https://api.linear.app/graphql", expect.objectContaining({
295
+ method: "POST",
296
+ headers: expect.objectContaining({
297
+ Authorization: "Bearer access-token",
298
+ }),
299
+ }));
300
+ });
301
+
302
+ it("throws when no access token is configured", async () => {
303
+ await expect(
304
+ linearGraphQL({ ...testCreds, accessToken: "" }, "{ viewer { id } }")
305
+ ).rejects.toThrow("Linear OAuth not configured");
306
+ });
307
+
308
+ it("throws on non-OK response without 401", async () => {
309
+ mockFetch.mockResolvedValueOnce({
310
+ ok: false,
311
+ status: 500,
312
+ text: () => Promise.resolve("Internal Server Error"),
313
+ });
314
+
315
+ await expect(linearGraphQL(testCreds, "{ viewer { id } }")).rejects.toThrow(
316
+ "Linear API error 500"
317
+ );
318
+ });
319
+
320
+ it("auto-refreshes token on 401 and retries", async () => {
321
+ // First call: 401
322
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
323
+ // Token refresh call
324
+ mockFetch.mockResolvedValueOnce({
325
+ ok: true,
326
+ json: () => Promise.resolve({
327
+ access_token: "new-token",
328
+ refresh_token: "new-refresh",
329
+ expires_in: 86400,
330
+ }),
331
+ });
332
+ // Retry with new token: success
333
+ mockFetch.mockResolvedValueOnce({
334
+ ok: true,
335
+ status: 200,
336
+ json: () => Promise.resolve({ data: { viewer: { id: "user-1" } } }),
337
+ });
338
+
339
+ const result = await linearGraphQL(testCreds, "{ viewer { id } }");
340
+
341
+ expect(result).toEqual({ data: { viewer: { id: "user-1" } } });
342
+ // Should have made 3 fetch calls: initial GraphQL, token refresh, retry GraphQL
343
+ expect(mockFetch).toHaveBeenCalledTimes(3);
344
+ });
345
+
346
+ it("persists refreshed tokens via callback when a 401 is recovered", async () => {
347
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
348
+ mockFetch.mockResolvedValueOnce({
349
+ ok: true,
350
+ json: () => Promise.resolve({
351
+ access_token: "new-token",
352
+ refresh_token: "new-refresh",
353
+ expires_in: 86400,
354
+ }),
355
+ });
356
+ mockFetch.mockResolvedValueOnce({
357
+ ok: true,
358
+ status: 200,
359
+ json: () => Promise.resolve({ data: { viewer: { id: "user-1" } } }),
360
+ });
361
+
362
+ const onTokensRefreshed = vi.fn();
363
+ await linearGraphQL(testCreds, "{ viewer { id } }", undefined, onTokensRefreshed);
364
+
365
+ expect(onTokensRefreshed).toHaveBeenCalledWith({
366
+ accessToken: "new-token",
367
+ refreshToken: "new-refresh",
368
+ });
369
+ });
370
+ });
371
+
372
+ // ─── Activity posting ───────────────────────────────────────────────────────
373
+
374
+ describe("postActivity", () => {
375
+ it("sends agentActivityCreate mutation with correct input", async () => {
376
+ mockFetch.mockResolvedValueOnce({
377
+ ok: true,
378
+ status: 200,
379
+ json: () => Promise.resolve({ data: { agentActivityCreate: { success: true } } }),
380
+ });
381
+
382
+ await postActivity(testCreds, "session-123", { type: "thought", body: "Thinking...", ephemeral: true });
383
+
384
+ const fetchBody = JSON.parse(mockFetch.mock.calls[0][1].body);
385
+ expect(fetchBody.variables.input).toEqual({
386
+ agentSessionId: "session-123",
387
+ content: { type: "thought", body: "Thinking...", ephemeral: true },
388
+ });
389
+ });
390
+
391
+ it("logs error when activity creation returns errors", async () => {
392
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
393
+
394
+ mockFetch.mockResolvedValueOnce({
395
+ ok: true,
396
+ status: 200,
397
+ json: () => Promise.resolve({ errors: [{ message: "Session not found" }] }),
398
+ });
399
+
400
+ await postActivity(testCreds, "bad-session", { type: "response", body: "Done" });
401
+
402
+ expect(consoleSpy).toHaveBeenCalledWith(
403
+ "[linear-agent] Activity creation failed:",
404
+ "Session not found"
405
+ );
406
+ consoleSpy.mockRestore();
407
+ });
408
+
409
+ it("passes token refresh callback through to linearGraphQL", async () => {
410
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
411
+ mockFetch.mockResolvedValueOnce({
412
+ ok: true,
413
+ json: () => Promise.resolve({
414
+ access_token: "new-access",
415
+ refresh_token: "new-refresh",
416
+ expires_in: 86400,
417
+ }),
418
+ });
419
+ mockFetch.mockResolvedValueOnce({
420
+ ok: true,
421
+ status: 200,
422
+ json: () => Promise.resolve({ data: { agentActivityCreate: { success: true } } }),
423
+ });
424
+
425
+ const onTokensRefreshed = vi.fn();
426
+ await postActivity(testCreds, "session-123", { type: "response", body: "Done" }, onTokensRefreshed);
427
+
428
+ expect(onTokensRefreshed).toHaveBeenCalledWith({
429
+ accessToken: "new-access",
430
+ refreshToken: "new-refresh",
431
+ });
432
+ });
433
+ });
434
+
435
+ // ─── Session updates ────────────────────────────────────────────────────────
436
+
437
+ describe("updateSessionUrls", () => {
438
+ it("sends agentSessionUpdate mutation with external URLs", async () => {
439
+ mockFetch.mockResolvedValueOnce({
440
+ ok: true,
441
+ status: 200,
442
+ json: () => Promise.resolve({ data: { agentSessionUpdate: { success: true } } }),
443
+ });
444
+
445
+ await updateSessionUrls(testCreds, "session-123", [
446
+ { label: "Companion", url: "http://localhost:3456/#/session/abc" },
447
+ ]);
448
+
449
+ const fetchBody = JSON.parse(mockFetch.mock.calls[0][1].body);
450
+ expect(fetchBody.variables.input.externalUrls).toEqual([
451
+ { label: "Companion", url: "http://localhost:3456/#/session/abc" },
452
+ ]);
453
+ });
454
+ });
455
+
456
+ describe("updateSessionPlan", () => {
457
+ it("sends agentSessionUpdate mutation with plan items", async () => {
458
+ mockFetch.mockResolvedValueOnce({
459
+ ok: true,
460
+ status: 200,
461
+ json: () => Promise.resolve({ data: { agentSessionUpdate: { success: true } } }),
462
+ });
463
+
464
+ const plan = [
465
+ { content: "Analyze issue", status: "completed" as const },
466
+ { content: "Fix bug", status: "inProgress" as const },
467
+ ];
468
+ await updateSessionPlan(testCreds, "session-123", plan);
469
+
470
+ const fetchBody = JSON.parse(mockFetch.mock.calls[0][1].body);
471
+ expect(fetchBody.variables.input.plan).toEqual(plan);
472
+ });
473
+ });