@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,304 @@
1
+ // ─── Linear Agent Interaction SDK Routes ─────────────────────────────────────
2
+ // Webhook endpoint for AgentSessionEvent + OAuth callback for app installation.
3
+ // The webhook route and OAuth callback are registered BEFORE auth middleware —
4
+ // Linear handles its own signature verification via HMAC-SHA256 and the OAuth
5
+ // callback is a redirect from Linear's authorization flow (no auth token in URL).
6
+
7
+ import type { Hono } from "hono";
8
+ import type { LinearAgentBridge } from "../linear-agent-bridge.js";
9
+ import * as linearAgent from "../linear-agent.js";
10
+ import type { AgentSessionEventPayload } from "../linear-agent.js";
11
+ import * as agentStore from "../agent-store.js";
12
+ import * as staging from "../linear-staging.js";
13
+ import { getSettings, updateSettings } from "../settings-manager.js";
14
+ import { findOAuthConnectionByClientId } from "../linear-oauth-connections.js";
15
+
16
+ function sanitizeLogField(value: string | undefined): string {
17
+ return (value || "unknown").replace(/[\r\n\t]/g, "_").slice(0, 128);
18
+ }
19
+
20
+ /**
21
+ * Register the Linear Agent SDK pre-auth routes (before auth middleware).
22
+ * Includes the webhook (HMAC-SHA256 verified) and the OAuth callback
23
+ * (redirect from Linear — user has no auth token in the URL).
24
+ */
25
+ export function registerLinearAgentWebhookRoute(
26
+ api: Hono,
27
+ bridge: LinearAgentBridge,
28
+ ): void {
29
+ // Webhook endpoint — verified via HMAC-SHA256 signature (per-agent lookup)
30
+ api.post("/linear/agent-webhook", async (c) => {
31
+ const rawBody = await c.req.text();
32
+ const signature = c.req.header("linear-signature") ?? c.req.header("x-linear-signature");
33
+
34
+ // Parse payload first to extract oauthClientId for agent lookup
35
+ let payload: AgentSessionEventPayload;
36
+ try {
37
+ payload = JSON.parse(rawBody) as AgentSessionEventPayload;
38
+ } catch {
39
+ return c.json({ error: "Invalid JSON" }, 400);
40
+ }
41
+
42
+ // Only handle AgentSessionEvent
43
+ if (payload.type !== "AgentSessionEvent") {
44
+ return c.json({ ok: true, ignored: true });
45
+ }
46
+
47
+ const action = sanitizeLogField(payload.action);
48
+ const oauthClientId = sanitizeLogField(payload.oauthClientId);
49
+ const linearSessionId = sanitizeLogField(payload.agentSession?.id);
50
+
51
+ // Look up credentials: first try OAuth connection (new model), then inline (legacy)
52
+ const oauthConn = payload.oauthClientId ? findOAuthConnectionByClientId(payload.oauthClientId) : null;
53
+ let webhookSecret: string;
54
+ let agent: ReturnType<typeof agentStore.listAgents>[number] | undefined;
55
+
56
+ if (oauthConn) {
57
+ // New model: find agent referencing this connection
58
+ webhookSecret = oauthConn.webhookSecret;
59
+ agent = agentStore.listAgents().find(
60
+ (a) => a.enabled && a.triggers?.linear?.enabled
61
+ && a.triggers.linear.oauthConnectionId === oauthConn.id,
62
+ );
63
+ } else {
64
+ // Legacy: find agent by inline oauthClientId
65
+ agent = agentStore.listAgents().find(
66
+ (a) => a.enabled && a.triggers?.linear?.enabled
67
+ && a.triggers.linear.oauthClientId === payload.oauthClientId,
68
+ );
69
+ webhookSecret = agent?.triggers?.linear?.webhookSecret || "";
70
+ }
71
+
72
+ if (!agent) {
73
+ console.error(
74
+ `[linear-agent-routes] No agent found for oauthClientId: ${oauthClientId} action=${action} sessionId=${linearSessionId}`,
75
+ );
76
+ return c.json({ error: "No agent configured for this OAuth client" }, 404);
77
+ }
78
+ if (!linearAgent.verifyWebhookSignature(webhookSecret, rawBody, signature ?? null)) {
79
+ console.warn(
80
+ `[linear-agent-routes] Invalid webhook signature for oauthClientId: ${oauthClientId} action=${action} sessionId=${linearSessionId} agentId=${agent.id} signature=${signature ? "present" : "missing"}`,
81
+ );
82
+ return c.json({ error: "Invalid signature" }, 401);
83
+ }
84
+
85
+ console.log(
86
+ `[linear-agent-routes] Accepted AgentSessionEvent action=${action} sessionId=${linearSessionId} oauthClientId=${oauthClientId} agentId=${agent.id}`,
87
+ );
88
+
89
+ // Dispatch asynchronously — must return 200 within 5s
90
+ bridge.handleEvent(payload).catch((err) => {
91
+ console.error("[linear-agent-routes] Error handling event:", err);
92
+ });
93
+
94
+ return c.json({ ok: true });
95
+ });
96
+
97
+ // OAuth callback — redirect from Linear after authorization.
98
+ // Pre-auth because the browser lands here from Linear with no Companion auth token.
99
+ // Protected by the OAuth `state` nonce (CSRF prevention).
100
+ api.get("/linear/oauth/callback", async (c) => {
101
+ const code = c.req.query("code");
102
+ const state = c.req.query("state");
103
+ const error = c.req.query("error");
104
+
105
+ if (error) {
106
+ return c.redirect(`/#/settings/linear?oauth_error=${encodeURIComponent(error)}`);
107
+ }
108
+
109
+ if (!code) {
110
+ return c.redirect("/#/settings/linear?oauth_error=no_code");
111
+ }
112
+
113
+ // Validate the state nonce to prevent CSRF
114
+ const stateResult = linearAgent.validateOAuthState(state);
115
+ if (!stateResult.valid) {
116
+ return c.redirect("/#/settings/linear?oauth_error=invalid_state");
117
+ }
118
+
119
+ // Determine redirect target — validate returnTo is a safe relative hash-router path
120
+ // to prevent open redirects (state passes through the browser and could be tampered with)
121
+ const rawReturnTo = stateResult.returnTo;
122
+ const redirectBase = (rawReturnTo && /^\/?#\//.test(rawReturnTo)) ? rawReturnTo : "/#/settings/linear";
123
+
124
+ // Build redirect URI (must match what was sent in the authorize request)
125
+ const settings = getSettings();
126
+ const baseUrl = settings.publicUrl || `http://localhost:${process.env.PORT || 3456}`;
127
+ const redirectUri = `${baseUrl}/api/linear/oauth/callback`;
128
+
129
+ // Determine which credentials to use for token exchange:
130
+ // 1. OAuth connection (new model) — connectionId in state
131
+ // 2. Staging slot (wizard flow) — stagingId in state
132
+ // 3. Global settings (legacy fallback)
133
+ const { getOAuthConnection, updateOAuthConnection } = await import("../linear-oauth-connections.js");
134
+ const oauthConnection = stateResult.connectionId ? getOAuthConnection(stateResult.connectionId) : null;
135
+ const stagingSlot = stateResult.stagingId ? staging.getSlot(stateResult.stagingId) : null;
136
+
137
+ // If a connectionId was in the state but the connection is gone, fail
138
+ if (stateResult.connectionId && !oauthConnection) {
139
+ return c.redirect(`${redirectBase}?oauth_error=${encodeURIComponent("connection_not_found")}`);
140
+ }
141
+
142
+ // If a stagingId was in the state but the slot is gone (expired/deleted), don't silently
143
+ // fall back to global settings — that would use the wrong OAuth app's credentials
144
+ if (stateResult.stagingId && !stagingSlot) {
145
+ return c.redirect(`${redirectBase}?oauth_error=${encodeURIComponent("staging_slot_expired")}`);
146
+ }
147
+
148
+ const clientId = oauthConnection?.oauthClientId || stagingSlot?.clientId || settings.linearOAuthClientId;
149
+ const clientSecret = oauthConnection?.oauthClientSecret || stagingSlot?.clientSecret || settings.linearOAuthClientSecret;
150
+
151
+ const tokens = await linearAgent.exchangeCodeForTokens(
152
+ { clientId, clientSecret },
153
+ code,
154
+ redirectUri,
155
+ );
156
+ if (!tokens) {
157
+ return c.redirect(`${redirectBase}?oauth_error=token_exchange_failed`);
158
+ }
159
+
160
+ // Persist tokens to the appropriate store
161
+ if (stateResult.connectionId && oauthConnection) {
162
+ // New model: store tokens directly in the OAuth connection
163
+ updateOAuthConnection(stateResult.connectionId, {
164
+ accessToken: tokens.accessToken,
165
+ refreshToken: tokens.refreshToken,
166
+ status: "connected",
167
+ });
168
+ } else if (stateResult.stagingId) {
169
+ staging.updateSlotTokens(stateResult.stagingId, tokens);
170
+ } else {
171
+ updateSettings({
172
+ linearOAuthAccessToken: tokens.accessToken,
173
+ linearOAuthRefreshToken: tokens.refreshToken,
174
+ });
175
+ }
176
+
177
+ console.log("[linear-agent-routes] OAuth tokens obtained successfully");
178
+ return c.redirect(`${redirectBase}?oauth_success=true`);
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Register protected Linear Agent SDK routes (after auth middleware).
184
+ * Status + authorize URL + disconnect + staging slot endpoints.
185
+ */
186
+ export function registerLinearAgentProtectedRoutes(api: Hono): void {
187
+ // ── Staging slot CRUD ──────────────────────────────────────────────────
188
+
189
+ // Create a staging slot for the wizard flow
190
+ api.post("/linear/oauth/staging", async (c) => {
191
+ const body = await c.req.json() as {
192
+ clientId?: string;
193
+ clientSecret?: string;
194
+ webhookSecret?: string;
195
+ };
196
+
197
+ const clientId = (body.clientId || "").trim();
198
+ const clientSecret = (body.clientSecret || "").trim();
199
+ const webhookSecret = (body.webhookSecret || "").trim();
200
+
201
+ if (!clientId || !clientSecret || !webhookSecret) {
202
+ return c.json({ error: "clientId, clientSecret, and webhookSecret are required" }, 400);
203
+ }
204
+
205
+ const stagingId = staging.createSlot({ clientId, clientSecret, webhookSecret });
206
+ return c.json({ stagingId });
207
+ });
208
+
209
+ // Check staging slot status
210
+ api.get("/linear/oauth/staging/:id/status", (c) => {
211
+ const id = c.req.param("id");
212
+ const slot = staging.getSlot(id);
213
+ if (!slot) {
214
+ return c.json({ exists: false, hasAccessToken: false, hasClientId: false, hasClientSecret: false });
215
+ }
216
+ return c.json({
217
+ exists: true,
218
+ hasAccessToken: !!slot.accessToken,
219
+ hasClientId: !!slot.clientId,
220
+ hasClientSecret: !!slot.clientSecret,
221
+ });
222
+ });
223
+
224
+ // Delete a staging slot
225
+ api.delete("/linear/oauth/staging/:id", (c) => {
226
+ const id = c.req.param("id");
227
+ staging.deleteSlot(id);
228
+ return c.json({ ok: true });
229
+ });
230
+
231
+ // ── OAuth flow endpoints ───────────────────────────────────────────────
232
+
233
+ // Get OAuth authorize URL for installing the app
234
+ api.get("/linear/oauth/authorize-url", (c) => {
235
+ const settings = getSettings();
236
+ const baseUrl = settings.publicUrl || `http://localhost:${process.env.PORT || 3456}`;
237
+ const redirectUri = `${baseUrl}/api/linear/oauth/callback`;
238
+ const returnTo = c.req.query("returnTo");
239
+ const stagingId = c.req.query("stagingId");
240
+
241
+ // Validate returnTo is a safe relative hash-router path to prevent open redirects
242
+ const safeReturnTo = returnTo && /^\/?#\//.test(returnTo) ? returnTo : undefined;
243
+
244
+ // Use staging slot's clientId if provided, fall back to global settings
245
+ const slot = stagingId ? staging.getSlot(stagingId) : null;
246
+
247
+ // If a stagingId was provided but the slot is expired/missing, fail early
248
+ // rather than generating a URL that will fail at callback time
249
+ if (stagingId && !slot) {
250
+ return c.json({ error: "Staging slot expired or not found" }, 404);
251
+ }
252
+
253
+ const clientId = slot?.clientId || settings.linearOAuthClientId;
254
+
255
+ const result = linearAgent.getOAuthAuthorizeUrl(clientId, redirectUri, {
256
+ returnTo: safeReturnTo,
257
+ stagingId,
258
+ });
259
+
260
+ if (!result) {
261
+ return c.json({ error: "OAuth client ID not configured" }, 400);
262
+ }
263
+
264
+ return c.json({ url: result.url });
265
+ });
266
+
267
+ // Check OAuth configuration status (global or staging slot)
268
+ api.get("/linear/oauth/status", (c) => {
269
+ const stagingId = c.req.query("stagingId");
270
+
271
+ if (stagingId) {
272
+ const slot = staging.getSlot(stagingId);
273
+ return c.json({
274
+ configured: !!(slot?.clientId && slot?.clientSecret && slot?.accessToken),
275
+ hasClientId: !!slot?.clientId,
276
+ hasClientSecret: !!slot?.clientSecret,
277
+ hasWebhookSecret: !!slot?.webhookSecret,
278
+ hasAccessToken: !!slot?.accessToken,
279
+ });
280
+ }
281
+
282
+ const settings = getSettings();
283
+ return c.json({
284
+ configured: linearAgent.isLinearOAuthConfigured({
285
+ clientId: settings.linearOAuthClientId,
286
+ clientSecret: settings.linearOAuthClientSecret,
287
+ accessToken: settings.linearOAuthAccessToken,
288
+ }),
289
+ hasClientId: !!settings.linearOAuthClientId.trim(),
290
+ hasClientSecret: !!settings.linearOAuthClientSecret.trim(),
291
+ hasWebhookSecret: !!settings.linearOAuthWebhookSecret.trim(),
292
+ hasAccessToken: !!settings.linearOAuthAccessToken.trim(),
293
+ });
294
+ });
295
+
296
+ // Disconnect OAuth (clear tokens)
297
+ api.post("/linear/oauth/disconnect", (c) => {
298
+ updateSettings({
299
+ linearOAuthAccessToken: "",
300
+ linearOAuthRefreshToken: "",
301
+ });
302
+ return c.json({ ok: true });
303
+ });
304
+ }