@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,337 @@
1
+ // Tests for the Linear OAuth credential migration module.
2
+ // Verifies the one-time migration of global Linear OAuth credentials
3
+ // from settings.json to the first eligible agent (has linear trigger
4
+ // enabled but no per-agent oauthClientId yet).
5
+
6
+ import { describe, it, expect, vi, beforeEach } from "vitest";
7
+
8
+ import type { AgentConfig } from "./agent-types.js";
9
+
10
+ // ─── Mocks ──────────────────────────────────────────────────────────────────
11
+
12
+ // Mock the agent store: listAgents and updateAgent
13
+ vi.mock("./agent-store.js", () => ({
14
+ listAgents: vi.fn(() => []),
15
+ updateAgent: vi.fn(() => null),
16
+ }));
17
+
18
+ // Mock the settings manager: getSettings and updateSettings
19
+ vi.mock("./settings-manager.js", () => ({
20
+ getSettings: vi.fn(() => ({
21
+ linearOAuthClientId: "",
22
+ linearOAuthClientSecret: "",
23
+ linearOAuthWebhookSecret: "",
24
+ linearOAuthAccessToken: "",
25
+ linearOAuthRefreshToken: "",
26
+ })),
27
+ updateSettings: vi.fn(),
28
+ }));
29
+
30
+ import { migrateLinearCredentialsToAgents } from "./linear-credential-migration.js";
31
+ import * as agentStore from "./agent-store.js";
32
+ import { getSettings, updateSettings } from "./settings-manager.js";
33
+
34
+ // ─── Helpers ────────────────────────────────────────────────────────────────
35
+
36
+ /** Build a minimal AgentConfig for testing with optional overrides. */
37
+ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
38
+ return {
39
+ id: "test-agent",
40
+ version: 1,
41
+ name: "Test Agent",
42
+ description: "",
43
+ backendType: "claude",
44
+ model: "claude-sonnet-4-6",
45
+ permissionMode: "default",
46
+ cwd: "/tmp",
47
+ prompt: "do stuff",
48
+ enabled: true,
49
+ createdAt: Date.now(),
50
+ updatedAt: Date.now(),
51
+ totalRuns: 0,
52
+ consecutiveFailures: 0,
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ // ─── Tests ──────────────────────────────────────────────────────────────────
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ describe("migrateLinearCredentialsToAgents", () => {
64
+ // When no global OAuth client ID exists in settings, the function should
65
+ // return early without even querying for agents.
66
+ it("does nothing when no global OAuth credentials exist", () => {
67
+ vi.mocked(getSettings).mockReturnValue({
68
+ linearOAuthClientId: "",
69
+ linearOAuthClientSecret: "",
70
+ linearOAuthWebhookSecret: "",
71
+ linearOAuthAccessToken: "",
72
+ linearOAuthRefreshToken: "",
73
+ } as ReturnType<typeof getSettings>);
74
+
75
+ migrateLinearCredentialsToAgents();
76
+
77
+ // Should not attempt to list agents when there are no credentials to migrate
78
+ expect(agentStore.listAgents).not.toHaveBeenCalled();
79
+ expect(agentStore.updateAgent).not.toHaveBeenCalled();
80
+ expect(updateSettings).not.toHaveBeenCalled();
81
+ });
82
+
83
+ // When global credentials exist but no agent has linear enabled without
84
+ // existing oauthClientId, the function should log a message and return
85
+ // without modifying anything.
86
+ it("does nothing when no eligible agent is found", () => {
87
+ vi.mocked(getSettings).mockReturnValue({
88
+ linearOAuthClientId: "client-id-123",
89
+ linearOAuthClientSecret: "secret-456",
90
+ linearOAuthWebhookSecret: "webhook-789",
91
+ linearOAuthAccessToken: "access-token",
92
+ linearOAuthRefreshToken: "refresh-token",
93
+ } as ReturnType<typeof getSettings>);
94
+
95
+ // Agent has linear enabled but already has its own oauthClientId
96
+ const agentWithCreds = makeAgent({
97
+ id: "already-configured",
98
+ name: "Already Configured",
99
+ triggers: {
100
+ linear: {
101
+ enabled: true,
102
+ oauthClientId: "existing-client-id",
103
+ },
104
+ },
105
+ });
106
+
107
+ vi.mocked(agentStore.listAgents).mockReturnValue([agentWithCreds]);
108
+
109
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
110
+
111
+ migrateLinearCredentialsToAgents();
112
+
113
+ // Should have listed agents to search for eligible ones
114
+ expect(agentStore.listAgents).toHaveBeenCalled();
115
+ // But should not have updated any agent or cleared settings
116
+ expect(agentStore.updateAgent).not.toHaveBeenCalled();
117
+ expect(updateSettings).not.toHaveBeenCalled();
118
+ // Should log a staging message
119
+ expect(consoleSpy).toHaveBeenCalledWith(
120
+ expect.stringContaining("no Linear agent found to migrate to"),
121
+ );
122
+
123
+ consoleSpy.mockRestore();
124
+ });
125
+
126
+ // Happy path: global credentials exist and there is exactly one eligible
127
+ // agent (linear enabled, no oauthClientId). The function should copy all
128
+ // credentials to the agent and then clear the global settings.
129
+ it("migrates credentials to the first eligible agent", () => {
130
+ const globalCreds = {
131
+ linearOAuthClientId: "client-id-123",
132
+ linearOAuthClientSecret: "secret-456",
133
+ linearOAuthWebhookSecret: "webhook-789",
134
+ linearOAuthAccessToken: "access-token",
135
+ linearOAuthRefreshToken: "refresh-token",
136
+ };
137
+
138
+ vi.mocked(getSettings).mockReturnValue(
139
+ globalCreds as ReturnType<typeof getSettings>,
140
+ );
141
+
142
+ const eligibleAgent = makeAgent({
143
+ id: "linear-agent",
144
+ name: "My Linear Agent",
145
+ triggers: {
146
+ linear: {
147
+ enabled: true,
148
+ // No oauthClientId — eligible for migration
149
+ },
150
+ },
151
+ });
152
+
153
+ vi.mocked(agentStore.listAgents).mockReturnValue([eligibleAgent]);
154
+ vi.mocked(agentStore.updateAgent).mockReturnValue(eligibleAgent);
155
+
156
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
157
+
158
+ migrateLinearCredentialsToAgents();
159
+
160
+ // Should copy all global credentials to the agent's linear trigger config
161
+ expect(agentStore.updateAgent).toHaveBeenCalledWith("linear-agent", {
162
+ triggers: {
163
+ linear: {
164
+ enabled: true,
165
+ oauthClientId: "client-id-123",
166
+ oauthClientSecret: "secret-456",
167
+ webhookSecret: "webhook-789",
168
+ accessToken: "access-token",
169
+ refreshToken: "refresh-token",
170
+ },
171
+ },
172
+ });
173
+
174
+ // Should clear global credentials after successful migration
175
+ expect(updateSettings).toHaveBeenCalledWith({
176
+ linearOAuthClientId: "",
177
+ linearOAuthClientSecret: "",
178
+ linearOAuthWebhookSecret: "",
179
+ linearOAuthAccessToken: "",
180
+ linearOAuthRefreshToken: "",
181
+ });
182
+
183
+ // Should log success with agent name and ID
184
+ expect(consoleSpy).toHaveBeenCalledWith(
185
+ expect.stringContaining('Migrated global OAuth credentials to agent "My Linear Agent"'),
186
+ );
187
+
188
+ consoleSpy.mockRestore();
189
+ });
190
+
191
+ // When multiple agents have linear enabled, but the first one already has
192
+ // its own oauthClientId, the migration should skip it and migrate to the
193
+ // second agent that lacks credentials.
194
+ it("skips agents that already have oauthClientId and migrates to the next eligible one", () => {
195
+ vi.mocked(getSettings).mockReturnValue({
196
+ linearOAuthClientId: "global-client",
197
+ linearOAuthClientSecret: "global-secret",
198
+ linearOAuthWebhookSecret: "global-webhook",
199
+ linearOAuthAccessToken: "global-access",
200
+ linearOAuthRefreshToken: "global-refresh",
201
+ } as ReturnType<typeof getSettings>);
202
+
203
+ const agentWithCreds = makeAgent({
204
+ id: "agent-with-creds",
205
+ name: "Agent With Creds",
206
+ triggers: {
207
+ linear: {
208
+ enabled: true,
209
+ oauthClientId: "already-has-one",
210
+ },
211
+ },
212
+ });
213
+
214
+ const agentWithoutCreds = makeAgent({
215
+ id: "agent-without-creds",
216
+ name: "Agent Without Creds",
217
+ triggers: {
218
+ linear: {
219
+ enabled: true,
220
+ // No oauthClientId — this one should receive the migration
221
+ },
222
+ },
223
+ });
224
+
225
+ vi.mocked(agentStore.listAgents).mockReturnValue([
226
+ agentWithCreds,
227
+ agentWithoutCreds,
228
+ ]);
229
+ vi.mocked(agentStore.updateAgent).mockReturnValue(agentWithoutCreds);
230
+
231
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
232
+
233
+ migrateLinearCredentialsToAgents();
234
+
235
+ // Should migrate to the second agent (the one without existing credentials)
236
+ expect(agentStore.updateAgent).toHaveBeenCalledWith(
237
+ "agent-without-creds",
238
+ expect.objectContaining({
239
+ triggers: expect.objectContaining({
240
+ linear: expect.objectContaining({
241
+ oauthClientId: "global-client",
242
+ }),
243
+ }),
244
+ }),
245
+ );
246
+
247
+ // Should clear global settings after migration
248
+ expect(updateSettings).toHaveBeenCalled();
249
+
250
+ consoleSpy.mockRestore();
251
+ });
252
+
253
+ // An agent exists but its linear trigger is not enabled (enabled: false).
254
+ // It should not be considered eligible for migration.
255
+ it("skips agents without linear trigger enabled", () => {
256
+ vi.mocked(getSettings).mockReturnValue({
257
+ linearOAuthClientId: "client-id",
258
+ linearOAuthClientSecret: "secret",
259
+ linearOAuthWebhookSecret: "webhook",
260
+ linearOAuthAccessToken: "access",
261
+ linearOAuthRefreshToken: "refresh",
262
+ } as ReturnType<typeof getSettings>);
263
+
264
+ const disabledLinearAgent = makeAgent({
265
+ id: "disabled-linear",
266
+ name: "Disabled Linear Agent",
267
+ triggers: {
268
+ linear: {
269
+ enabled: false,
270
+ // No oauthClientId, but linear is disabled so it shouldn't qualify
271
+ },
272
+ },
273
+ });
274
+
275
+ // Also test an agent with no triggers at all
276
+ const noTriggersAgent = makeAgent({
277
+ id: "no-triggers",
278
+ name: "No Triggers Agent",
279
+ // No triggers property
280
+ });
281
+
282
+ vi.mocked(agentStore.listAgents).mockReturnValue([
283
+ disabledLinearAgent,
284
+ noTriggersAgent,
285
+ ]);
286
+
287
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
288
+
289
+ migrateLinearCredentialsToAgents();
290
+
291
+ // Neither agent should receive credentials
292
+ expect(agentStore.updateAgent).not.toHaveBeenCalled();
293
+ expect(updateSettings).not.toHaveBeenCalled();
294
+
295
+ // Should log the "no agent found" message
296
+ expect(consoleSpy).toHaveBeenCalledWith(
297
+ expect.stringContaining("no Linear agent found to migrate to"),
298
+ );
299
+
300
+ consoleSpy.mockRestore();
301
+ });
302
+
303
+ // If updateAgent returns null (store failure), the global credentials
304
+ // must NOT be cleared — otherwise the user's credentials are silently lost.
305
+ it("does NOT clear global credentials when updateAgent fails during migration", () => {
306
+ vi.mocked(getSettings).mockReturnValue({
307
+ linearOAuthClientId: "client-id",
308
+ linearOAuthClientSecret: "secret",
309
+ linearOAuthWebhookSecret: "webhook",
310
+ linearOAuthAccessToken: "access",
311
+ linearOAuthRefreshToken: "refresh",
312
+ } as ReturnType<typeof getSettings>);
313
+
314
+ const eligible = makeAgent({
315
+ id: "linear-agent",
316
+ name: "Linear Agent",
317
+ triggers: { linear: { enabled: true } },
318
+ });
319
+ vi.mocked(agentStore.listAgents).mockReturnValue([eligible]);
320
+ // Simulate a store failure
321
+ vi.mocked(agentStore.updateAgent).mockReturnValue(null);
322
+
323
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
324
+
325
+ migrateLinearCredentialsToAgents();
326
+
327
+ expect(agentStore.updateAgent).toHaveBeenCalled();
328
+ // Credentials must NOT be cleared if the agent write failed
329
+ expect(updateSettings).not.toHaveBeenCalled();
330
+ // Should log an error about the failure
331
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
332
+ expect.stringContaining("Failed to write credentials to agent"),
333
+ );
334
+
335
+ consoleErrorSpy.mockRestore();
336
+ });
337
+ });
@@ -0,0 +1,63 @@
1
+ // Linear OAuth Credential Migration
2
+ // One-time migration: copies global Linear OAuth credentials from settings.json
3
+ // to the first Linear agent that doesn't have per-agent credentials.
4
+ // This runs on server startup to handle the transition from global to per-agent storage.
5
+
6
+ import * as agentStore from "./agent-store.js";
7
+ import { getSettings, updateSettings } from "./settings-manager.js";
8
+
9
+ /** Migrate global Linear OAuth credentials to the first eligible agent.
10
+ * This is a one-time operation: once credentials are moved, global fields are cleared. */
11
+ export function migrateLinearCredentialsToAgents(): void {
12
+ const settings = getSettings();
13
+
14
+ // Nothing to migrate if no global OAuth client ID
15
+ if (!settings.linearOAuthClientId.trim()) return;
16
+
17
+ const agents = agentStore.listAgents();
18
+ const linearAgent = agents.find(
19
+ (a) => a.triggers?.linear?.enabled && !a.triggers.linear.oauthClientId
20
+ );
21
+
22
+ if (!linearAgent) {
23
+ console.log(
24
+ "[linear-migration] Global OAuth credentials exist but no Linear agent found to migrate to. Credentials will remain in global settings as staging."
25
+ );
26
+ return;
27
+ }
28
+
29
+ // Copy credentials to the agent
30
+ const triggers = linearAgent.triggers!;
31
+ const updated = agentStore.updateAgent(linearAgent.id, {
32
+ triggers: {
33
+ ...triggers,
34
+ linear: {
35
+ enabled: true,
36
+ ...triggers.linear,
37
+ oauthClientId: settings.linearOAuthClientId,
38
+ oauthClientSecret: settings.linearOAuthClientSecret,
39
+ webhookSecret: settings.linearOAuthWebhookSecret,
40
+ accessToken: settings.linearOAuthAccessToken,
41
+ refreshToken: settings.linearOAuthRefreshToken,
42
+ },
43
+ },
44
+ });
45
+
46
+ // Only clear global credentials after a confirmed successful write
47
+ if (!updated) {
48
+ console.error("[linear-migration] Failed to write credentials to agent — global credentials preserved");
49
+ return;
50
+ }
51
+
52
+ updateSettings({
53
+ linearOAuthClientId: "",
54
+ linearOAuthClientSecret: "",
55
+ linearOAuthWebhookSecret: "",
56
+ linearOAuthAccessToken: "",
57
+ linearOAuthRefreshToken: "",
58
+ });
59
+
60
+ console.log(
61
+ `[linear-migration] Migrated global OAuth credentials to agent "${linearAgent.name}" (${linearAgent.id})`
62
+ );
63
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tests for the migrateFromAgents() auto-migration in linear-oauth-connections.ts.
3
+ *
4
+ * Validates:
5
+ * - Agents with inline OAuth credentials get migrated to standalone connections
6
+ * - Global settings with OAuth credentials get migrated
7
+ * - Deduplication by oauthClientId (multiple agents sharing the same app)
8
+ * - Agents get updated with oauthConnectionId after migration
9
+ * - No migration when connections already exist
10
+ * - Agents without oauthClientId are skipped
11
+ * - Status correctly derived from accessToken presence
12
+ * - Migrated connections persist to disk
13
+ *
14
+ * Uses the exported `migrateFromAgents(deps)` with injected dependencies
15
+ * instead of relying on `require()` interception.
16
+ */
17
+ import { vi, describe, it, expect, beforeEach, afterAll } from "vitest";
18
+ import { mkdirSync, rmSync, existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+
22
+ import {
23
+ listOAuthConnections,
24
+ createOAuthConnection,
25
+ migrateFromAgents,
26
+ _resetForTest,
27
+ } from "./linear-oauth-connections.js";
28
+
29
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ const TEST_DIR = join(tmpdir(), `companion-oauth-migration-test-${Date.now()}`);
32
+ const TEST_FILE = join(TEST_DIR, "linear-oauth-connections.json");
33
+
34
+ const mockUpdateAgent = vi.fn();
35
+
36
+ function makeDeps(
37
+ agents: Array<Record<string, unknown>> = [],
38
+ settings: Record<string, string> = {},
39
+ ) {
40
+ return {
41
+ listAgents: () => agents as Array<{ id: string; name: string; triggers?: { linear?: Record<string, unknown> } }>,
42
+ updateAgent: mockUpdateAgent as (id: string, patch: Record<string, unknown>) => void,
43
+ getSettings: () => ({
44
+ linearOAuthClientId: "",
45
+ linearOAuthClientSecret: "",
46
+ linearOAuthWebhookSecret: "",
47
+ linearOAuthAccessToken: "",
48
+ linearOAuthRefreshToken: "",
49
+ ...settings,
50
+ }),
51
+ };
52
+ }
53
+
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ _resetForTest(TEST_FILE);
57
+ if (existsSync(TEST_DIR)) {
58
+ rmSync(TEST_DIR, { recursive: true });
59
+ }
60
+ mkdirSync(TEST_DIR, { recursive: true });
61
+ });
62
+
63
+ afterAll(() => {
64
+ if (existsSync(TEST_DIR)) {
65
+ rmSync(TEST_DIR, { recursive: true });
66
+ }
67
+ });
68
+
69
+ // =============================================================================
70
+ // Tests
71
+ // =============================================================================
72
+
73
+ describe("migrateFromAgents", () => {
74
+ it("migrates agents with inline OAuth credentials to standalone connections", () => {
75
+ const deps = makeDeps([
76
+ {
77
+ id: "agent-1",
78
+ name: "Linear Bot",
79
+ triggers: {
80
+ linear: {
81
+ enabled: true,
82
+ oauthClientId: "inline-cid",
83
+ oauthClientSecret: "inline-csec",
84
+ webhookSecret: "inline-wsec",
85
+ accessToken: "inline-tok",
86
+ refreshToken: "inline-ref",
87
+ },
88
+ },
89
+ },
90
+ ]);
91
+
92
+ migrateFromAgents(deps);
93
+ const conns = listOAuthConnections();
94
+
95
+ expect(conns).toHaveLength(1);
96
+ expect(conns[0].oauthClientId).toBe("inline-cid");
97
+ expect(conns[0].oauthClientSecret).toBe("inline-csec");
98
+ expect(conns[0].webhookSecret).toBe("inline-wsec");
99
+ expect(conns[0].accessToken).toBe("inline-tok");
100
+ expect(conns[0].status).toBe("connected");
101
+ expect(conns[0].name).toBe("Linear Bot OAuth App");
102
+
103
+ // Agent should be updated with oauthConnectionId
104
+ expect(mockUpdateAgent).toHaveBeenCalledWith(
105
+ "agent-1",
106
+ expect.objectContaining({
107
+ triggers: expect.objectContaining({
108
+ linear: expect.objectContaining({
109
+ oauthConnectionId: conns[0].id,
110
+ }),
111
+ }),
112
+ }),
113
+ );
114
+ });
115
+
116
+ it("deduplicates by oauthClientId when multiple agents share the same app", () => {
117
+ const deps = makeDeps([
118
+ {
119
+ id: "agent-1",
120
+ name: "Bot A",
121
+ triggers: {
122
+ linear: {
123
+ enabled: true,
124
+ oauthClientId: "shared-cid",
125
+ oauthClientSecret: "csec",
126
+ webhookSecret: "wsec",
127
+ accessToken: "tok",
128
+ },
129
+ },
130
+ },
131
+ {
132
+ id: "agent-2",
133
+ name: "Bot B",
134
+ triggers: {
135
+ linear: {
136
+ enabled: true,
137
+ oauthClientId: "shared-cid", // same clientId
138
+ oauthClientSecret: "csec",
139
+ webhookSecret: "wsec",
140
+ accessToken: "tok",
141
+ },
142
+ },
143
+ },
144
+ ]);
145
+
146
+ migrateFromAgents(deps);
147
+ const conns = listOAuthConnections();
148
+
149
+ // Should create only one connection (deduplication)
150
+ expect(conns).toHaveLength(1);
151
+
152
+ // Both agents should be updated with the same connection ID
153
+ expect(mockUpdateAgent).toHaveBeenCalledTimes(2);
154
+ expect(mockUpdateAgent).toHaveBeenCalledWith("agent-1", expect.anything());
155
+ expect(mockUpdateAgent).toHaveBeenCalledWith("agent-2", expect.anything());
156
+ });
157
+
158
+ it("migrates from global settings when no agent credentials exist", () => {
159
+ const deps = makeDeps([], {
160
+ linearOAuthClientId: "settings-cid",
161
+ linearOAuthClientSecret: "settings-csec",
162
+ linearOAuthWebhookSecret: "settings-wsec",
163
+ linearOAuthAccessToken: "settings-tok",
164
+ linearOAuthRefreshToken: "settings-ref",
165
+ });
166
+
167
+ migrateFromAgents(deps);
168
+ const conns = listOAuthConnections();
169
+
170
+ expect(conns).toHaveLength(1);
171
+ expect(conns[0].name).toBe("Default OAuth App");
172
+ expect(conns[0].oauthClientId).toBe("settings-cid");
173
+ expect(conns[0].status).toBe("connected");
174
+ });
175
+
176
+ it("skips migration when connections already exist", () => {
177
+ // Pre-create a connection
178
+ createOAuthConnection({
179
+ name: "Existing",
180
+ oauthClientId: "existing-cid",
181
+ oauthClientSecret: "csec",
182
+ webhookSecret: "wsec",
183
+ });
184
+
185
+ const deps = makeDeps([
186
+ {
187
+ id: "agent-1",
188
+ name: "Bot",
189
+ triggers: {
190
+ linear: {
191
+ enabled: true,
192
+ oauthClientId: "new-cid",
193
+ oauthClientSecret: "csec",
194
+ },
195
+ },
196
+ },
197
+ ]);
198
+
199
+ migrateFromAgents(deps);
200
+ const conns = listOAuthConnections();
201
+
202
+ // Should NOT create additional connections
203
+ expect(conns).toHaveLength(1);
204
+ expect(conns[0].oauthClientId).toBe("existing-cid");
205
+ expect(mockUpdateAgent).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it("skips agents without oauthClientId", () => {
209
+ const deps = makeDeps([
210
+ {
211
+ id: "agent-1",
212
+ name: "No OAuth",
213
+ triggers: { linear: { enabled: true } },
214
+ },
215
+ ]);
216
+
217
+ migrateFromAgents(deps);
218
+ expect(listOAuthConnections()).toHaveLength(0);
219
+ });
220
+
221
+ it("sets status to disconnected when agent has no accessToken", () => {
222
+ const deps = makeDeps([
223
+ {
224
+ id: "agent-1",
225
+ name: "Unconnected Bot",
226
+ triggers: {
227
+ linear: {
228
+ enabled: true,
229
+ oauthClientId: "cid",
230
+ oauthClientSecret: "csec",
231
+ },
232
+ },
233
+ },
234
+ ]);
235
+
236
+ migrateFromAgents(deps);
237
+ const conns = listOAuthConnections();
238
+
239
+ expect(conns).toHaveLength(1);
240
+ expect(conns[0].status).toBe("disconnected");
241
+ });
242
+
243
+ it("persists migrated connections to disk", () => {
244
+ const deps = makeDeps([
245
+ {
246
+ id: "agent-1",
247
+ name: "Persist Test",
248
+ triggers: {
249
+ linear: {
250
+ enabled: true,
251
+ oauthClientId: "persist-cid",
252
+ oauthClientSecret: "csec",
253
+ webhookSecret: "wsec",
254
+ },
255
+ },
256
+ },
257
+ ]);
258
+
259
+ migrateFromAgents(deps);
260
+ expect(existsSync(TEST_FILE)).toBe(true);
261
+
262
+ // Reload from disk and verify
263
+ _resetForTest(TEST_FILE);
264
+ const conns = listOAuthConnections();
265
+ expect(conns).toHaveLength(1);
266
+ expect(conns[0].oauthClientId).toBe("persist-cid");
267
+ });
268
+ });