@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,365 @@
1
+ /**
2
+ * Tests for linear-oauth-connections.ts — file-based CRUD store for
3
+ * Linear OAuth connections.
4
+ *
5
+ * Validates:
6
+ * - CRUD: create, read, update, delete
7
+ * - List and lookup operations
8
+ * - findOAuthConnectionByClientId lookup
9
+ * - sanitizeOAuthConnection masks secrets
10
+ * - Auto-derived status from accessToken
11
+ * - _resetForTest clears state
12
+ * - Persistence to disk (write + reload)
13
+ * - Invalid/corrupt JSON file handling
14
+ */
15
+ import { describe, it, expect, beforeEach, afterAll } from "vitest";
16
+ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import {
21
+ listOAuthConnections,
22
+ getOAuthConnection,
23
+ findOAuthConnectionByClientId,
24
+ createOAuthConnection,
25
+ updateOAuthConnection,
26
+ deleteOAuthConnection,
27
+ sanitizeOAuthConnection,
28
+ _resetForTest,
29
+ type LinearOAuthConnection,
30
+ } from "./linear-oauth-connections.js";
31
+
32
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
33
+
34
+ const TEST_DIR = join(tmpdir(), `companion-oauth-test-${Date.now()}`);
35
+ const TEST_FILE = join(TEST_DIR, "linear-oauth-connections.json");
36
+
37
+ beforeEach(() => {
38
+ // Reset state and point at a temp file per test
39
+ _resetForTest(TEST_FILE);
40
+ // Ensure clean directory
41
+ if (existsSync(TEST_DIR)) {
42
+ rmSync(TEST_DIR, { recursive: true });
43
+ }
44
+ mkdirSync(TEST_DIR, { recursive: true });
45
+ });
46
+
47
+ afterAll(() => {
48
+ // Clean up temp directory
49
+ if (existsSync(TEST_DIR)) {
50
+ rmSync(TEST_DIR, { recursive: true });
51
+ }
52
+ });
53
+
54
+ // =============================================================================
55
+ // Tests
56
+ // =============================================================================
57
+
58
+ describe("linear-oauth-connections", () => {
59
+ // ─── List ─────────────────────────────────────────────────────────────────
60
+
61
+ it("returns empty array when no connections exist", () => {
62
+ const conns = listOAuthConnections();
63
+ expect(conns).toEqual([]);
64
+ });
65
+
66
+ // ─── Create ───────────────────────────────────────────────────────────────
67
+
68
+ it("creates a connection with all required fields", () => {
69
+ const conn = createOAuthConnection({
70
+ name: "My App",
71
+ oauthClientId: "client-123",
72
+ oauthClientSecret: "secret-456",
73
+ webhookSecret: "webhook-789",
74
+ });
75
+
76
+ expect(conn.id).toBeTruthy();
77
+ expect(conn.name).toBe("My App");
78
+ expect(conn.oauthClientId).toBe("client-123");
79
+ expect(conn.oauthClientSecret).toBe("secret-456");
80
+ expect(conn.webhookSecret).toBe("webhook-789");
81
+ expect(conn.accessToken).toBe("");
82
+ expect(conn.refreshToken).toBe("");
83
+ expect(conn.status).toBe("disconnected");
84
+ expect(conn.createdAt).toBeGreaterThan(0);
85
+ expect(conn.updatedAt).toBeGreaterThan(0);
86
+ });
87
+
88
+ it("trims whitespace from inputs", () => {
89
+ const conn = createOAuthConnection({
90
+ name: " Trimmed App ",
91
+ oauthClientId: " cid ",
92
+ oauthClientSecret: " csec ",
93
+ webhookSecret: " wsec ",
94
+ });
95
+
96
+ expect(conn.name).toBe("Trimmed App");
97
+ expect(conn.oauthClientId).toBe("cid");
98
+ expect(conn.oauthClientSecret).toBe("csec");
99
+ expect(conn.webhookSecret).toBe("wsec");
100
+ });
101
+
102
+ it("sets status to 'connected' when accessToken is provided", () => {
103
+ const conn = createOAuthConnection({
104
+ name: "With Token",
105
+ oauthClientId: "cid",
106
+ oauthClientSecret: "csec",
107
+ webhookSecret: "wsec",
108
+ accessToken: "tok-123",
109
+ });
110
+
111
+ expect(conn.status).toBe("connected");
112
+ expect(conn.accessToken).toBe("tok-123");
113
+ });
114
+
115
+ // ─── Get ──────────────────────────────────────────────────────────────────
116
+
117
+ it("retrieves a connection by ID", () => {
118
+ const created = createOAuthConnection({
119
+ name: "Findable",
120
+ oauthClientId: "cid",
121
+ oauthClientSecret: "csec",
122
+ webhookSecret: "wsec",
123
+ });
124
+
125
+ const found = getOAuthConnection(created.id);
126
+ expect(found).not.toBeNull();
127
+ expect(found!.name).toBe("Findable");
128
+ });
129
+
130
+ it("returns null for non-existent ID", () => {
131
+ expect(getOAuthConnection("non-existent-id")).toBeNull();
132
+ });
133
+
134
+ // ─── Find by client ID ────────────────────────────────────────────────────
135
+
136
+ it("finds a connection by oauthClientId", () => {
137
+ createOAuthConnection({
138
+ name: "Target",
139
+ oauthClientId: "unique-client-id",
140
+ oauthClientSecret: "csec",
141
+ webhookSecret: "wsec",
142
+ });
143
+
144
+ const found = findOAuthConnectionByClientId("unique-client-id");
145
+ expect(found).not.toBeNull();
146
+ expect(found!.name).toBe("Target");
147
+ });
148
+
149
+ it("returns null when no matching oauthClientId", () => {
150
+ expect(findOAuthConnectionByClientId("nope")).toBeNull();
151
+ });
152
+
153
+ // ─── Update ───────────────────────────────────────────────────────────────
154
+
155
+ it("updates connection fields", () => {
156
+ const conn = createOAuthConnection({
157
+ name: "Old Name",
158
+ oauthClientId: "cid",
159
+ oauthClientSecret: "csec",
160
+ webhookSecret: "wsec",
161
+ });
162
+
163
+ const updated = updateOAuthConnection(conn.id, { name: "New Name" });
164
+ expect(updated).not.toBeNull();
165
+ expect(updated!.name).toBe("New Name");
166
+ expect(updated!.oauthClientId).toBe("cid"); // unchanged
167
+ });
168
+
169
+ it("auto-derives connected status when accessToken is set", () => {
170
+ const conn = createOAuthConnection({
171
+ name: "App",
172
+ oauthClientId: "cid",
173
+ oauthClientSecret: "csec",
174
+ webhookSecret: "wsec",
175
+ });
176
+ expect(conn.status).toBe("disconnected");
177
+
178
+ const updated = updateOAuthConnection(conn.id, { accessToken: "tok" });
179
+ expect(updated!.status).toBe("connected");
180
+ });
181
+
182
+ it("auto-derives disconnected status when accessToken is cleared", () => {
183
+ const conn = createOAuthConnection({
184
+ name: "App",
185
+ oauthClientId: "cid",
186
+ oauthClientSecret: "csec",
187
+ webhookSecret: "wsec",
188
+ accessToken: "tok",
189
+ });
190
+ expect(conn.status).toBe("connected");
191
+
192
+ const updated = updateOAuthConnection(conn.id, { accessToken: "" });
193
+ expect(updated!.status).toBe("disconnected");
194
+ });
195
+
196
+ it("allows explicit status override", () => {
197
+ const conn = createOAuthConnection({
198
+ name: "App",
199
+ oauthClientId: "cid",
200
+ oauthClientSecret: "csec",
201
+ webhookSecret: "wsec",
202
+ });
203
+
204
+ const updated = updateOAuthConnection(conn.id, { status: "connected" });
205
+ expect(updated!.status).toBe("connected");
206
+ });
207
+
208
+ it("returns null when updating non-existent ID", () => {
209
+ expect(updateOAuthConnection("nope", { name: "x" })).toBeNull();
210
+ });
211
+
212
+ // ─── Delete ───────────────────────────────────────────────────────────────
213
+
214
+ it("deletes a connection and returns true", () => {
215
+ const conn = createOAuthConnection({
216
+ name: "Deletable",
217
+ oauthClientId: "cid",
218
+ oauthClientSecret: "csec",
219
+ webhookSecret: "wsec",
220
+ });
221
+
222
+ expect(deleteOAuthConnection(conn.id)).toBe(true);
223
+ expect(getOAuthConnection(conn.id)).toBeNull();
224
+ expect(listOAuthConnections()).toHaveLength(0);
225
+ });
226
+
227
+ it("returns false when deleting non-existent ID", () => {
228
+ expect(deleteOAuthConnection("nope")).toBe(false);
229
+ });
230
+
231
+ // ─── List ─────────────────────────────────────────────────────────────────
232
+
233
+ it("lists all connections", () => {
234
+ createOAuthConnection({ name: "A", oauthClientId: "a", oauthClientSecret: "s", webhookSecret: "w" });
235
+ createOAuthConnection({ name: "B", oauthClientId: "b", oauthClientSecret: "s", webhookSecret: "w" });
236
+
237
+ const all = listOAuthConnections();
238
+ expect(all).toHaveLength(2);
239
+ expect(all.map((c) => c.name)).toEqual(["A", "B"]);
240
+ });
241
+
242
+ it("returns a copy (mutations don't affect store)", () => {
243
+ createOAuthConnection({ name: "Safe", oauthClientId: "cid", oauthClientSecret: "s", webhookSecret: "w" });
244
+ const list = listOAuthConnections();
245
+ list.pop();
246
+ expect(listOAuthConnections()).toHaveLength(1);
247
+ });
248
+
249
+ // ─── Sanitize ─────────────────────────────────────────────────────────────
250
+
251
+ it("masks secrets in sanitized output", () => {
252
+ const conn = createOAuthConnection({
253
+ name: "Sensitive",
254
+ oauthClientId: "visible-client-id",
255
+ oauthClientSecret: "super-secret",
256
+ webhookSecret: "wh-secret",
257
+ accessToken: "at-secret",
258
+ });
259
+
260
+ const sanitized = sanitizeOAuthConnection(conn);
261
+
262
+ // Should include these public fields
263
+ expect(sanitized.id).toBe(conn.id);
264
+ expect(sanitized.name).toBe("Sensitive");
265
+ expect(sanitized.oauthClientId).toBe("visible-client-id");
266
+ expect(sanitized.status).toBe("connected");
267
+ expect(sanitized.createdAt).toBe(conn.createdAt);
268
+ expect(sanitized.updatedAt).toBe(conn.updatedAt);
269
+
270
+ // Should have boolean flags instead of actual secrets
271
+ expect(sanitized.hasAccessToken).toBe(true);
272
+ expect(sanitized.hasClientSecret).toBe(true);
273
+ expect(sanitized.hasWebhookSecret).toBe(true);
274
+
275
+ // Should NOT contain the actual secrets
276
+ const raw = sanitized as unknown as Record<string, unknown>;
277
+ expect(raw["oauthClientSecret"]).toBeUndefined();
278
+ expect(raw["webhookSecret"]).toBeUndefined();
279
+ expect(raw["accessToken"]).toBeUndefined();
280
+ expect(raw["refreshToken"]).toBeUndefined();
281
+ });
282
+
283
+ it("reports false flags when secrets are empty", () => {
284
+ const conn = createOAuthConnection({
285
+ name: "No Secrets",
286
+ oauthClientId: "cid",
287
+ oauthClientSecret: "csec",
288
+ webhookSecret: "wsec",
289
+ });
290
+
291
+ const sanitized = sanitizeOAuthConnection(conn);
292
+ expect(sanitized.hasAccessToken).toBe(false); // no accessToken provided
293
+ });
294
+
295
+ // ─── Persistence ──────────────────────────────────────────────────────────
296
+
297
+ it("persists to disk and reloads correctly", () => {
298
+ // Create a connection
299
+ const conn = createOAuthConnection({
300
+ name: "Persistent",
301
+ oauthClientId: "persist-cid",
302
+ oauthClientSecret: "persist-csec",
303
+ webhookSecret: "persist-wsec",
304
+ });
305
+
306
+ // Verify file exists
307
+ expect(existsSync(TEST_FILE)).toBe(true);
308
+
309
+ // Reset state (simulates server restart) and reload
310
+ _resetForTest(TEST_FILE);
311
+ const reloaded = listOAuthConnections();
312
+ expect(reloaded).toHaveLength(1);
313
+ expect(reloaded[0].id).toBe(conn.id);
314
+ expect(reloaded[0].name).toBe("Persistent");
315
+ });
316
+
317
+ it("handles corrupt JSON file gracefully", () => {
318
+ // Write invalid JSON to the file
319
+ writeFileSync(TEST_FILE, "not valid json{{{", "utf-8");
320
+
321
+ _resetForTest(TEST_FILE);
322
+ const conns = listOAuthConnections();
323
+ expect(conns).toEqual([]);
324
+ });
325
+
326
+ it("handles non-array JSON file gracefully", () => {
327
+ // Write valid JSON but not an array
328
+ writeFileSync(TEST_FILE, JSON.stringify({ foo: "bar" }), "utf-8");
329
+
330
+ _resetForTest(TEST_FILE);
331
+ const conns = listOAuthConnections();
332
+ expect(conns).toEqual([]);
333
+ });
334
+
335
+ it("filters out malformed entries from JSON file", () => {
336
+ // Write array with mix of valid and invalid entries
337
+ const data = [
338
+ { id: "valid", oauthClientId: "cid", name: "Valid", oauthClientSecret: "", webhookSecret: "", accessToken: "", refreshToken: "", status: "disconnected", createdAt: 1, updatedAt: 1 },
339
+ { noId: true }, // missing id
340
+ null, // null entry
341
+ "string entry", // not an object
342
+ ];
343
+ writeFileSync(TEST_FILE, JSON.stringify(data), "utf-8");
344
+
345
+ _resetForTest(TEST_FILE);
346
+ const conns = listOAuthConnections();
347
+ expect(conns).toHaveLength(1);
348
+ expect(conns[0].name).toBe("Valid");
349
+ });
350
+
351
+ // ─── _resetForTest ────────────────────────────────────────────────────────
352
+
353
+ it("clears state when _resetForTest is called", () => {
354
+ createOAuthConnection({ name: "Will Reset", oauthClientId: "cid", oauthClientSecret: "s", webhookSecret: "w" });
355
+ expect(listOAuthConnections()).toHaveLength(1);
356
+
357
+ _resetForTest(TEST_FILE);
358
+ // Next list call will attempt to read from TEST_FILE which no longer has this connection
359
+ // But since we created a connection above, the file DOES have it.
360
+ // So reset and use a non-existent file to prove state is cleared
361
+ const emptyFile = join(TEST_DIR, "empty.json");
362
+ _resetForTest(emptyFile);
363
+ expect(listOAuthConnections()).toHaveLength(0);
364
+ });
365
+ });
@@ -0,0 +1,294 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { randomUUID } from "node:crypto";
9
+ import { COMPANION_HOME } from "./paths.js";
10
+
11
+ // ─── Types ───────────────────────────────────────────────────────────────────
12
+
13
+ export interface LinearOAuthConnection {
14
+ id: string;
15
+ name: string;
16
+ oauthClientId: string;
17
+ oauthClientSecret: string;
18
+ webhookSecret: string;
19
+ accessToken: string;
20
+ refreshToken: string;
21
+ status: "connected" | "disconnected";
22
+ createdAt: number;
23
+ updatedAt: number;
24
+ }
25
+
26
+ /** Sanitized version for API responses (secrets masked). */
27
+ export interface LinearOAuthConnectionSummary {
28
+ id: string;
29
+ name: string;
30
+ oauthClientId: string;
31
+ status: "connected" | "disconnected";
32
+ hasAccessToken: boolean;
33
+ hasClientSecret: boolean;
34
+ hasWebhookSecret: boolean;
35
+ createdAt: number;
36
+ updatedAt: number;
37
+ }
38
+
39
+ // ─── Paths ───────────────────────────────────────────────────────────────────
40
+
41
+ const DEFAULT_PATH = join(COMPANION_HOME, "linear-oauth-connections.json");
42
+
43
+ // ─── Store ───────────────────────────────────────────────────────────────────
44
+
45
+ let connections: LinearOAuthConnection[] = [];
46
+ let loaded = false;
47
+ let filePath = DEFAULT_PATH;
48
+
49
+ function ensureLoaded(): void {
50
+ if (loaded) return;
51
+ try {
52
+ if (existsSync(filePath)) {
53
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
54
+ if (Array.isArray(raw)) {
55
+ connections = raw.filter(
56
+ (c: unknown): c is LinearOAuthConnection =>
57
+ typeof c === "object" &&
58
+ c !== null &&
59
+ typeof (c as LinearOAuthConnection).id === "string" &&
60
+ typeof (c as LinearOAuthConnection).oauthClientId === "string",
61
+ );
62
+ } else {
63
+ connections = [];
64
+ }
65
+ }
66
+ } catch {
67
+ connections = [];
68
+ }
69
+ loaded = true;
70
+
71
+ // Auto-migrate from agents with inline credentials + global settings
72
+ migrateFromAgents();
73
+ }
74
+
75
+ // ─── Migration ───────────────────────────────────────────────────────────────
76
+
77
+ interface MigrationSettings {
78
+ linearOAuthClientId: string;
79
+ linearOAuthClientSecret: string;
80
+ linearOAuthWebhookSecret: string;
81
+ linearOAuthAccessToken: string;
82
+ linearOAuthRefreshToken: string;
83
+ [key: string]: unknown;
84
+ }
85
+
86
+ interface MigrationDeps {
87
+ listAgents: () => Array<{ id: string; name: string; triggers?: { linear?: Record<string, unknown> } }>;
88
+ updateAgent: (id: string, patch: Record<string, unknown>) => void;
89
+ getSettings: () => MigrationSettings;
90
+ }
91
+
92
+ /**
93
+ * One-time migration: if no OAuth connections exist, extract inline credentials
94
+ * from agents and global settings into standalone OAuth connections.
95
+ * Deduplicates by oauthClientId so multiple agents sharing the same app
96
+ * get a single connection.
97
+ *
98
+ * Accepts optional deps parameter for testability.
99
+ */
100
+ export function migrateFromAgents(deps?: MigrationDeps): void {
101
+ if (connections.length > 0) return;
102
+
103
+ let resolvedDeps: MigrationDeps;
104
+ if (deps) {
105
+ resolvedDeps = deps;
106
+ } else {
107
+ // Lazy import to avoid circular dependency at module load time
108
+ try {
109
+ const agentStoreModule = require("./agent-store.js") as typeof import("./agent-store.js");
110
+ const settingsModule = require("./settings-manager.js") as typeof import("./settings-manager.js");
111
+ resolvedDeps = {
112
+ listAgents: agentStoreModule.listAgents as MigrationDeps["listAgents"],
113
+ updateAgent: agentStoreModule.updateAgent as MigrationDeps["updateAgent"],
114
+ getSettings: settingsModule.getSettings as unknown as MigrationDeps["getSettings"],
115
+ };
116
+ } catch {
117
+ return; // Can't migrate without dependencies
118
+ }
119
+ }
120
+
121
+ const agents = resolvedDeps.listAgents();
122
+ const seenClientIds = new Set<string>();
123
+
124
+ for (const agent of agents) {
125
+ const linear = agent.triggers?.linear;
126
+ const oauthClientId = linear?.oauthClientId as string | undefined;
127
+ if (!oauthClientId || seenClientIds.has(oauthClientId)) continue;
128
+ seenClientIds.add(oauthClientId);
129
+
130
+ const now = Date.now();
131
+ const conn: LinearOAuthConnection = {
132
+ id: randomUUID(),
133
+ name: `${agent.name} OAuth App`,
134
+ oauthClientId,
135
+ oauthClientSecret: (linear?.oauthClientSecret as string) || "",
136
+ webhookSecret: (linear?.webhookSecret as string) || "",
137
+ accessToken: (linear?.accessToken as string) || "",
138
+ refreshToken: (linear?.refreshToken as string) || "",
139
+ status: linear?.accessToken ? "connected" : "disconnected",
140
+ createdAt: now,
141
+ updatedAt: now,
142
+ };
143
+ connections.push(conn);
144
+
145
+ // Update all agents with this clientId to reference the new connection
146
+ for (const a of agents) {
147
+ if ((a.triggers?.linear?.oauthClientId as string) === oauthClientId) {
148
+ resolvedDeps.updateAgent(a.id, {
149
+ triggers: {
150
+ ...a.triggers,
151
+ linear: {
152
+ ...a.triggers!.linear,
153
+ oauthConnectionId: conn.id,
154
+ },
155
+ },
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ // Also migrate from global settings if present
162
+ const settings = resolvedDeps.getSettings();
163
+ if (settings.linearOAuthClientId && !seenClientIds.has(settings.linearOAuthClientId)) {
164
+ const now = Date.now();
165
+ connections.push({
166
+ id: randomUUID(),
167
+ name: "Default OAuth App",
168
+ oauthClientId: settings.linearOAuthClientId,
169
+ oauthClientSecret: settings.linearOAuthClientSecret,
170
+ webhookSecret: settings.linearOAuthWebhookSecret,
171
+ accessToken: settings.linearOAuthAccessToken,
172
+ refreshToken: settings.linearOAuthRefreshToken,
173
+ status: settings.linearOAuthAccessToken ? "connected" : "disconnected",
174
+ createdAt: now,
175
+ updatedAt: now,
176
+ });
177
+ }
178
+
179
+ if (connections.length > 0) {
180
+ persist();
181
+ console.log(`[linear-oauth-connections] Migrated ${connections.length} OAuth connection(s) from agents/settings`);
182
+ }
183
+ }
184
+
185
+ function persist(): void {
186
+ mkdirSync(dirname(filePath), { recursive: true });
187
+ writeFileSync(filePath, JSON.stringify(connections, null, 2), "utf-8");
188
+ }
189
+
190
+ // ─── Public API ──────────────────────────────────────────────────────────────
191
+
192
+ export function listOAuthConnections(): LinearOAuthConnection[] {
193
+ ensureLoaded();
194
+ return [...connections];
195
+ }
196
+
197
+ export function getOAuthConnection(id: string): LinearOAuthConnection | null {
198
+ ensureLoaded();
199
+ return connections.find((c) => c.id === id) ?? null;
200
+ }
201
+
202
+ /** Look up an OAuth connection by its Linear OAuth app client ID. */
203
+ export function findOAuthConnectionByClientId(
204
+ oauthClientId: string,
205
+ ): LinearOAuthConnection | null {
206
+ ensureLoaded();
207
+ return connections.find((c) => c.oauthClientId === oauthClientId) ?? null;
208
+ }
209
+
210
+ export function createOAuthConnection(data: {
211
+ name: string;
212
+ oauthClientId: string;
213
+ oauthClientSecret: string;
214
+ webhookSecret: string;
215
+ accessToken?: string;
216
+ refreshToken?: string;
217
+ }): LinearOAuthConnection {
218
+ ensureLoaded();
219
+ const now = Date.now();
220
+ const conn: LinearOAuthConnection = {
221
+ id: randomUUID(),
222
+ name: data.name.trim(),
223
+ oauthClientId: data.oauthClientId.trim(),
224
+ oauthClientSecret: data.oauthClientSecret.trim(),
225
+ webhookSecret: data.webhookSecret.trim(),
226
+ accessToken: data.accessToken?.trim() || "",
227
+ refreshToken: data.refreshToken?.trim() || "",
228
+ status: data.accessToken?.trim() ? "connected" : "disconnected",
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ };
232
+ connections.push(conn);
233
+ persist();
234
+ return conn;
235
+ }
236
+
237
+ export function updateOAuthConnection(
238
+ id: string,
239
+ patch: Partial<Omit<LinearOAuthConnection, "id" | "createdAt">>,
240
+ ): LinearOAuthConnection | null {
241
+ ensureLoaded();
242
+ const conn = connections.find((c) => c.id === id);
243
+ if (!conn) return null;
244
+
245
+ if (patch.name !== undefined) conn.name = patch.name.trim();
246
+ if (patch.oauthClientId !== undefined) conn.oauthClientId = patch.oauthClientId.trim();
247
+ if (patch.oauthClientSecret !== undefined) conn.oauthClientSecret = patch.oauthClientSecret.trim();
248
+ if (patch.webhookSecret !== undefined) conn.webhookSecret = patch.webhookSecret.trim();
249
+ if (patch.accessToken !== undefined) conn.accessToken = patch.accessToken.trim();
250
+ if (patch.refreshToken !== undefined) conn.refreshToken = patch.refreshToken.trim();
251
+ if (patch.status !== undefined) {
252
+ conn.status = patch.status;
253
+ } else if (patch.accessToken !== undefined) {
254
+ // Auto-derive status from accessToken presence
255
+ conn.status = patch.accessToken.trim() ? "connected" : "disconnected";
256
+ }
257
+ conn.updatedAt = Date.now();
258
+
259
+ persist();
260
+ return conn;
261
+ }
262
+
263
+ export function deleteOAuthConnection(id: string): boolean {
264
+ ensureLoaded();
265
+ const idx = connections.findIndex((c) => c.id === id);
266
+ if (idx === -1) return false;
267
+ connections.splice(idx, 1);
268
+ persist();
269
+ return true;
270
+ }
271
+
272
+ /** Sanitize an OAuth connection for API responses (mask secrets). */
273
+ export function sanitizeOAuthConnection(
274
+ conn: LinearOAuthConnection,
275
+ ): LinearOAuthConnectionSummary {
276
+ return {
277
+ id: conn.id,
278
+ name: conn.name,
279
+ oauthClientId: conn.oauthClientId,
280
+ status: conn.status,
281
+ hasAccessToken: !!conn.accessToken,
282
+ hasClientSecret: !!conn.oauthClientSecret,
283
+ hasWebhookSecret: !!conn.webhookSecret,
284
+ createdAt: conn.createdAt,
285
+ updatedAt: conn.updatedAt,
286
+ };
287
+ }
288
+
289
+ /** Reset internal state and optionally set a custom file path (for testing). */
290
+ export function _resetForTest(customPath?: string): void {
291
+ connections = [];
292
+ loaded = false;
293
+ filePath = customPath || DEFAULT_PATH;
294
+ }