agent-relay 1.2.3 → 1.3.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 (200) hide show
  1. package/.trajectories/agent-relay-322-324.md +17 -0
  2. package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.json +49 -0
  3. package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.md +31 -0
  4. package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.json +125 -0
  5. package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.md +62 -0
  6. package/.trajectories/completed/2026-01/traj_33iuy72sezbk.json +49 -0
  7. package/.trajectories/completed/2026-01/traj_33iuy72sezbk.md +31 -0
  8. package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.json +77 -0
  9. package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.md +42 -0
  10. package/.trajectories/completed/2026-01/traj_6mieijqyvaag.json +77 -0
  11. package/.trajectories/completed/2026-01/traj_6mieijqyvaag.md +42 -0
  12. package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.json +77 -0
  13. package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.md +42 -0
  14. package/.trajectories/completed/2026-01/traj_94gnp3k30goq.json +66 -0
  15. package/.trajectories/completed/2026-01/traj_94gnp3k30goq.md +36 -0
  16. package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.json +40 -0
  17. package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.md +22 -0
  18. package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.json +121 -0
  19. package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.md +29 -0
  20. package/.trajectories/completed/2026-01/traj_fhx9irlckht6.json +53 -0
  21. package/.trajectories/completed/2026-01/traj_fhx9irlckht6.md +32 -0
  22. package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.json +101 -0
  23. package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.md +52 -0
  24. package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.json +49 -0
  25. package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.md +31 -0
  26. package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.json +65 -0
  27. package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.md +37 -0
  28. package/.trajectories/completed/2026-01/traj_lq450ly148uw.json +49 -0
  29. package/.trajectories/completed/2026-01/traj_lq450ly148uw.md +31 -0
  30. package/.trajectories/completed/2026-01/traj_multi_server_arch.md +101 -0
  31. package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.json +27 -0
  32. package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.md +14 -0
  33. package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.json +53 -0
  34. package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.md +32 -0
  35. package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.json +186 -0
  36. package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.md +86 -0
  37. package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.json +77 -0
  38. package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.md +42 -0
  39. package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.json +89 -0
  40. package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.md +47 -0
  41. package/.trajectories/completed/2026-01/traj_xy9vifpqet80.json +65 -0
  42. package/.trajectories/completed/2026-01/traj_xy9vifpqet80.md +37 -0
  43. package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.json +49 -0
  44. package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.md +31 -0
  45. package/.trajectories/consolidate-settings-panel.md +24 -0
  46. package/.trajectories/gh-cli-user-token.md +26 -0
  47. package/.trajectories/index.json +155 -1
  48. package/deploy/workspace/codex.config.toml +15 -0
  49. package/deploy/workspace/entrypoint.sh +167 -7
  50. package/deploy/workspace/git-credential-relay +17 -2
  51. package/dist/bridge/spawner.d.ts +7 -0
  52. package/dist/bridge/spawner.js +40 -9
  53. package/dist/bridge/types.d.ts +2 -0
  54. package/dist/cli/index.js +210 -168
  55. package/dist/cloud/api/admin.d.ts +8 -0
  56. package/dist/cloud/api/admin.js +212 -0
  57. package/dist/cloud/api/auth.js +8 -0
  58. package/dist/cloud/api/billing.d.ts +0 -10
  59. package/dist/cloud/api/billing.js +248 -58
  60. package/dist/cloud/api/codex-auth-helper.d.ts +10 -4
  61. package/dist/cloud/api/codex-auth-helper.js +215 -8
  62. package/dist/cloud/api/coordinators.js +402 -0
  63. package/dist/cloud/api/daemons.js +15 -11
  64. package/dist/cloud/api/git.js +104 -17
  65. package/dist/cloud/api/github-app.js +42 -8
  66. package/dist/cloud/api/nango-auth.js +297 -16
  67. package/dist/cloud/api/onboarding.js +97 -33
  68. package/dist/cloud/api/providers.js +12 -16
  69. package/dist/cloud/api/repos.js +200 -124
  70. package/dist/cloud/api/test-helpers.js +40 -0
  71. package/dist/cloud/api/usage.js +13 -0
  72. package/dist/cloud/api/webhooks.js +1 -1
  73. package/dist/cloud/api/workspaces.d.ts +18 -0
  74. package/dist/cloud/api/workspaces.js +945 -15
  75. package/dist/cloud/config.d.ts +8 -0
  76. package/dist/cloud/config.js +15 -0
  77. package/dist/cloud/db/drizzle.d.ts +5 -2
  78. package/dist/cloud/db/drizzle.js +27 -20
  79. package/dist/cloud/db/schema.d.ts +19 -51
  80. package/dist/cloud/db/schema.js +5 -4
  81. package/dist/cloud/index.d.ts +0 -1
  82. package/dist/cloud/index.js +0 -1
  83. package/dist/cloud/provisioner/index.d.ts +93 -1
  84. package/dist/cloud/provisioner/index.js +608 -63
  85. package/dist/cloud/server.js +156 -16
  86. package/dist/cloud/services/compute-enforcement.d.ts +57 -0
  87. package/dist/cloud/services/compute-enforcement.js +175 -0
  88. package/dist/cloud/services/index.d.ts +2 -0
  89. package/dist/cloud/services/index.js +4 -0
  90. package/dist/cloud/services/intro-expiration.d.ts +55 -0
  91. package/dist/cloud/services/intro-expiration.js +211 -0
  92. package/dist/cloud/services/nango.d.ts +14 -0
  93. package/dist/cloud/services/nango.js +74 -14
  94. package/dist/cloud/services/ssh-security.d.ts +31 -0
  95. package/dist/cloud/services/ssh-security.js +63 -0
  96. package/dist/continuity/manager.d.ts +5 -0
  97. package/dist/continuity/manager.js +56 -2
  98. package/dist/daemon/api.d.ts +2 -0
  99. package/dist/daemon/api.js +214 -5
  100. package/dist/daemon/cli-auth.d.ts +13 -1
  101. package/dist/daemon/cli-auth.js +166 -47
  102. package/dist/daemon/connection.d.ts +7 -1
  103. package/dist/daemon/connection.js +15 -0
  104. package/dist/daemon/orchestrator.d.ts +2 -0
  105. package/dist/daemon/orchestrator.js +26 -0
  106. package/dist/daemon/repo-manager.d.ts +116 -0
  107. package/dist/daemon/repo-manager.js +384 -0
  108. package/dist/daemon/router.d.ts +60 -1
  109. package/dist/daemon/router.js +281 -20
  110. package/dist/daemon/user-directory.d.ts +111 -0
  111. package/dist/daemon/user-directory.js +233 -0
  112. package/dist/dashboard/out/404.html +1 -1
  113. package/dist/dashboard/out/_next/static/T1tgCqVWHFIkV7ClEtzD7/_ssgManifest.js +1 -0
  114. package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +9 -0
  115. package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
  117. package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
  118. package/dist/dashboard/out/_next/static/chunks/899-bb19a9b3d9b39ea6.js +1 -0
  119. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-8939b0fc700f7eca.js +1 -0
  120. package/dist/dashboard/out/_next/static/chunks/app/app/page-5af1b6b439858aa6.js +1 -0
  121. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-f45ecbc3e06134fc.js +1 -0
  122. package/dist/dashboard/out/_next/static/chunks/app/history/{page-abb9ab2d329f56e9.js → page-8c8bed33beb2bf1c.js} +1 -1
  123. package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
  124. package/dist/dashboard/out/_next/static/chunks/app/login/{page-c22d080201cbd9fb.js → page-16f3b49e55b1e0ed.js} +1 -1
  125. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-ac39dc0cc3c26fa7.js +1 -0
  126. package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-4a5938c18a11a654.js} +1 -1
  127. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-982a7000fee44014.js +1 -0
  128. package/dist/dashboard/out/_next/static/chunks/app/providers/page-ac3a6ac433fd6001.js +1 -0
  129. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-09f9caae98a18c09.js +1 -0
  130. package/dist/dashboard/out/_next/static/chunks/app/signup/{page-68d34f50baa8ab6b.js → page-547dd0ca55ecd0ba.js} +1 -1
  131. package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
  132. package/dist/dashboard/out/_next/static/chunks/{main-app-6e8e8d3ef4e0192a.js → main-app-5d692157a8eb1fd9.js} +1 -1
  133. package/dist/dashboard/out/_next/static/css/85d2af9c7ac74d62.css +1 -0
  134. package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
  135. package/dist/dashboard/out/app/onboarding.html +1 -1
  136. package/dist/dashboard/out/app/onboarding.txt +3 -3
  137. package/dist/dashboard/out/app.html +1 -1
  138. package/dist/dashboard/out/app.txt +3 -3
  139. package/dist/dashboard/out/apple-icon.png +0 -0
  140. package/dist/dashboard/out/connect-repos.html +1 -1
  141. package/dist/dashboard/out/connect-repos.txt +3 -3
  142. package/dist/dashboard/out/history.html +1 -1
  143. package/dist/dashboard/out/history.txt +3 -3
  144. package/dist/dashboard/out/index.html +1 -1
  145. package/dist/dashboard/out/index.txt +3 -3
  146. package/dist/dashboard/out/login.html +2 -2
  147. package/dist/dashboard/out/login.txt +3 -3
  148. package/dist/dashboard/out/metrics.html +1 -1
  149. package/dist/dashboard/out/metrics.txt +3 -3
  150. package/dist/dashboard/out/pricing.html +2 -2
  151. package/dist/dashboard/out/pricing.txt +3 -3
  152. package/dist/dashboard/out/providers/setup/claude.html +1 -0
  153. package/dist/dashboard/out/providers/setup/claude.txt +8 -0
  154. package/dist/dashboard/out/providers/setup/codex.html +1 -0
  155. package/dist/dashboard/out/providers/setup/codex.txt +8 -0
  156. package/dist/dashboard/out/providers.html +1 -1
  157. package/dist/dashboard/out/providers.txt +3 -3
  158. package/dist/dashboard/out/signup.html +2 -2
  159. package/dist/dashboard/out/signup.txt +3 -3
  160. package/dist/dashboard-server/server.js +316 -12
  161. package/dist/dashboard-server/user-bridge.d.ts +103 -0
  162. package/dist/dashboard-server/user-bridge.js +189 -0
  163. package/dist/protocol/channels.d.ts +205 -0
  164. package/dist/protocol/channels.js +154 -0
  165. package/dist/protocol/types.d.ts +13 -1
  166. package/dist/resiliency/provider-context.js +2 -0
  167. package/dist/shared/cli-auth-config.d.ts +19 -0
  168. package/dist/shared/cli-auth-config.js +58 -2
  169. package/dist/utils/agent-config.js +1 -1
  170. package/dist/wrapper/auth-detection.d.ts +49 -0
  171. package/dist/wrapper/auth-detection.js +192 -0
  172. package/dist/wrapper/base-wrapper.d.ts +153 -0
  173. package/dist/wrapper/base-wrapper.js +393 -0
  174. package/dist/wrapper/client.d.ts +7 -1
  175. package/dist/wrapper/client.js +3 -0
  176. package/dist/wrapper/index.d.ts +1 -0
  177. package/dist/wrapper/index.js +4 -3
  178. package/dist/wrapper/pty-wrapper.d.ts +62 -84
  179. package/dist/wrapper/pty-wrapper.js +154 -180
  180. package/dist/wrapper/tmux-wrapper.d.ts +41 -66
  181. package/dist/wrapper/tmux-wrapper.js +90 -134
  182. package/package.json +4 -2
  183. package/scripts/postinstall.js +11 -155
  184. package/scripts/test-interactive-terminal.sh +248 -0
  185. package/dist/cloud/vault/index.d.ts +0 -76
  186. package/dist/cloud/vault/index.js +0 -219
  187. package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
  188. package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
  189. package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
  190. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-3fdfa60e53f2810d.js +0 -1
  191. package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
  192. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-3538dfe0ffe984b8.js +0 -1
  193. package/dist/dashboard/out/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
  194. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
  195. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-b08ed1c34d14434a.js +0 -1
  196. package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
  197. package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
  198. package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
  199. package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
  200. /package/dist/dashboard/out/_next/static/{wPgKJtcOmTFLpUncDg16A → T1tgCqVWHFIkV7ClEtzD7}/_buildManifest.js +0 -0
@@ -27,22 +27,56 @@ githubAppRouter.get('/status', (_req, res) => {
27
27
  });
28
28
  /**
29
29
  * GET /api/github-app/repos
30
- * List repositories the user has connected via Nango
30
+ * List repositories the user has access to
31
+ *
32
+ * First tries database (populated by GitHub App OAuth).
33
+ * If empty, queries GitHub directly via user OAuth connection.
31
34
  */
32
35
  githubAppRouter.get('/repos', async (req, res) => {
33
36
  const userId = req.session.userId;
34
37
  try {
35
- const repos = await db.repositories.findByUserId(userId);
38
+ // Try database first (from GitHub App OAuth flow)
39
+ const dbRepos = await db.repositories.findByUserId(userId);
40
+ if (dbRepos.length > 0) {
41
+ // Return repos from database
42
+ return res.json({
43
+ repositories: dbRepos.map((r) => ({
44
+ id: r.id,
45
+ fullName: r.githubFullName,
46
+ isPrivate: r.isPrivate,
47
+ defaultBranch: r.defaultBranch,
48
+ syncStatus: r.syncStatus,
49
+ hasNangoConnection: !!r.nangoConnectionId,
50
+ lastSyncedAt: r.lastSyncedAt,
51
+ })),
52
+ source: 'database',
53
+ });
54
+ }
55
+ // Database empty - query GitHub directly via user OAuth
56
+ const user = await db.users.findById(userId);
57
+ if (!user?.nangoConnectionId) {
58
+ return res.json({
59
+ repositories: [],
60
+ source: 'none',
61
+ hint: 'User not connected to GitHub',
62
+ });
63
+ }
64
+ console.log(`[github-app/repos] Database empty, querying GitHub for user ${user.githubUsername}`);
65
+ const { repositories } = await nangoService.listUserAccessibleRepos(user.nangoConnectionId, {
66
+ perPage: 100,
67
+ type: 'all',
68
+ });
36
69
  res.json({
37
- repositories: repos.map((r) => ({
38
- id: r.id,
39
- fullName: r.githubFullName,
70
+ repositories: repositories.map((r) => ({
71
+ id: null, // No database ID yet
72
+ fullName: r.fullName,
40
73
  isPrivate: r.isPrivate,
41
74
  defaultBranch: r.defaultBranch,
42
- syncStatus: r.syncStatus,
43
- hasNangoConnection: !!r.nangoConnectionId,
44
- lastSyncedAt: r.lastSyncedAt,
75
+ syncStatus: 'live', // Queried from GitHub, not cached
76
+ hasNangoConnection: true,
77
+ lastSyncedAt: null,
45
78
  })),
79
+ source: 'github-api',
46
80
  });
47
81
  }
48
82
  catch (error) {
@@ -160,27 +160,17 @@ nangoAuthRouter.get('/repo-status/:connectionId', requireAuth, async (req, res)
160
160
  * Handle Nango webhooks for auth and sync events
161
161
  */
162
162
  nangoAuthRouter.post('/webhook', async (req, res) => {
163
- // Use the preserved raw body from express.json verify callback
164
163
  const rawBody = req.rawBody || JSON.stringify(req.body);
165
- // Verify signature using the new verifyIncomingWebhookRequest method
164
+ // Verify webhook signature if present
166
165
  const hasSignature = req.headers['x-nango-signature'] || req.headers['x-nango-hmac-sha256'];
167
- const isDev = process.env.NODE_ENV !== 'production';
168
166
  if (hasSignature) {
169
167
  if (!nangoService.verifyWebhookSignature(rawBody, req.headers)) {
170
168
  console.error('[nango-webhook] Invalid signature');
171
169
  return res.status(401).json({ error: 'Invalid signature' });
172
170
  }
173
- console.log('[nango-webhook] Signature verified');
174
- }
175
- else if (!isDev) {
176
- console.error('[nango-webhook] Missing signature in production');
177
- return res.status(401).json({ error: 'Missing signature' });
178
- }
179
- else {
180
- console.warn('[nango-webhook] Skipping signature verification in development (no signature)');
181
171
  }
182
172
  const payload = req.body;
183
- console.log(`[nango-webhook] Received ${payload.type} event`, JSON.stringify(payload, null, 2));
173
+ console.log(`[nango-webhook] Received ${payload.type} event`);
184
174
  try {
185
175
  switch (payload.type) {
186
176
  case 'auth':
@@ -190,8 +180,7 @@ nangoAuthRouter.post('/webhook', async (req, res) => {
190
180
  console.log('[nango-webhook] Sync event received');
191
181
  break;
192
182
  case 'forward':
193
- // Nango forwards events from providers - typically not needed for our flow
194
- console.log('[nango-webhook] Forward event from provider (ignored)');
183
+ await handleForwardWebhook(payload);
195
184
  break;
196
185
  default:
197
186
  console.log(`[nango-webhook] Unhandled event type: ${payload.type}`);
@@ -216,6 +205,72 @@ async function handleAuthWebhook(payload) {
216
205
  await handleRepoAuthWebhook(connectionId, endUser);
217
206
  }
218
207
  }
208
+ /**
209
+ * Check user's repo access and auto-add them to workspaces
210
+ * Uses GitHub user OAuth to query accessible repos and persists them to database
211
+ */
212
+ async function checkAndAutoAddToWorkspaces(userId, connectionId) {
213
+ try {
214
+ const user = await db.users.findById(userId);
215
+ if (!user)
216
+ return;
217
+ console.log(`[nango-webhook] Checking workspace auto-add for ${user.githubUsername}`);
218
+ // Query repos the user has access to via GitHub OAuth
219
+ const { repositories } = await nangoService.listUserAccessibleRepos(connectionId, {
220
+ perPage: 100,
221
+ type: 'all',
222
+ });
223
+ const workspacesToJoin = new Set();
224
+ // Check for workspace memberships - only persist repos that match existing workspaces
225
+ for (const repo of repositories) {
226
+ // Check if any user has this repo linked to a workspace
227
+ const allRepoRecords = await db.repositories.findByGithubFullName(repo.fullName);
228
+ let matchedWorkspaceId = null;
229
+ for (const record of allRepoRecords) {
230
+ if (record.workspaceId) {
231
+ workspacesToJoin.add(record.workspaceId);
232
+ matchedWorkspaceId = record.workspaceId; // Save the workspaceId to copy
233
+ }
234
+ }
235
+ // Only persist repos that are linked to workspaces
236
+ if (matchedWorkspaceId) {
237
+ await db.repositories.upsert({
238
+ userId: user.id,
239
+ githubFullName: repo.fullName,
240
+ githubId: repo.id,
241
+ isPrivate: repo.isPrivate,
242
+ defaultBranch: repo.defaultBranch,
243
+ nangoConnectionId: connectionId,
244
+ workspaceId: matchedWorkspaceId, // Copy the workspaceId
245
+ syncStatus: 'synced',
246
+ lastSyncedAt: new Date(),
247
+ });
248
+ }
249
+ }
250
+ // Auto-add user to workspaces
251
+ for (const workspaceId of workspacesToJoin) {
252
+ const existingMembership = await db.workspaceMembers.findMembership(workspaceId, userId);
253
+ if (!existingMembership) {
254
+ const workspace = await db.workspaces.findById(workspaceId);
255
+ if (workspace) {
256
+ console.log(`[nango-webhook] Auto-adding ${user.githubUsername} to workspace ${workspace.name}`);
257
+ await db.workspaceMembers.addMember({
258
+ workspaceId,
259
+ userId,
260
+ role: 'member',
261
+ invitedBy: workspace.userId,
262
+ });
263
+ await db.workspaceMembers.acceptInvite(workspaceId, userId);
264
+ }
265
+ }
266
+ }
267
+ console.log(`[nango-webhook] Synced ${repositories.length} repos, auto-added ${user.githubUsername} to ${workspacesToJoin.size} workspaces`);
268
+ }
269
+ catch (error) {
270
+ console.error(`[nango-webhook] Error checking workspace auto-add:`, error);
271
+ // Non-fatal - don't throw
272
+ }
273
+ }
219
274
  /**
220
275
  * Handle GitHub login webhook
221
276
  *
@@ -246,6 +301,8 @@ async function handleLoginWebhook(connectionId, _endUser) {
246
301
  email: newUser.email || undefined,
247
302
  });
248
303
  console.log(`[nango-webhook] New user created: ${githubUser.login}`);
304
+ // Check for auto-add to workspaces based on repo access
305
+ await checkAndAutoAddToWorkspaces(newUser.id, connectionId);
249
306
  return;
250
307
  }
251
308
  // SCENARIO 2: Returning user with existing connection - delete temp connection
@@ -269,6 +326,8 @@ async function handleLoginWebhook(connectionId, _endUser) {
269
326
  console.error(`[nango-webhook] Failed to delete temp connection:`, error);
270
327
  // Non-fatal - continue anyway
271
328
  }
329
+ // Check for auto-add using permanent connection
330
+ await checkAndAutoAddToWorkspaces(existingUser.id, existingUser.nangoConnectionId);
272
331
  return;
273
332
  }
274
333
  // SCENARIO 3: Existing user, first connection (or same connection)
@@ -284,6 +343,192 @@ async function handleLoginWebhook(connectionId, _endUser) {
284
343
  id: existingUser.id,
285
344
  email: existingUser.email || undefined,
286
345
  });
346
+ // Check for auto-add to workspaces
347
+ await checkAndAutoAddToWorkspaces(existingUser.id, connectionId);
348
+ }
349
+ /**
350
+ * Handle Nango forward webhook (GitHub events forwarded by Nango)
351
+ */
352
+ async function handleForwardWebhook(payload) {
353
+ const githubPayload = payload.payload;
354
+ console.log(`[nango-webhook] Forward event: action=${githubPayload.action} from ${payload.providerConfigKey}`);
355
+ // Only process GitHub App events
356
+ if (payload.providerConfigKey !== NANGO_INTEGRATIONS.GITHUB_APP) {
357
+ console.log('[nango-webhook] Ignoring forward event from non-GitHub-App integration');
358
+ return;
359
+ }
360
+ try {
361
+ // Determine event type from payload structure
362
+ if (githubPayload.installation && githubPayload.action === 'created' && githubPayload.repositories) {
363
+ // Installation created event
364
+ await handleInstallationForward(githubPayload, payload.connectionId);
365
+ }
366
+ else if (githubPayload.repositories_added || githubPayload.repositories_removed) {
367
+ // Installation repositories added/removed
368
+ await handleInstallationRepositoriesForward(githubPayload, payload.connectionId);
369
+ }
370
+ else {
371
+ console.log(`[nango-webhook] Unhandled forward event structure: action=${githubPayload.action}`);
372
+ }
373
+ }
374
+ catch (error) {
375
+ console.error(`[nango-webhook] Error processing forward event:`, error);
376
+ throw error;
377
+ }
378
+ }
379
+ /**
380
+ * Handle GitHub installation events forwarded by Nango
381
+ */
382
+ async function handleInstallationForward(body, connectionId) {
383
+ const { action, installation, repositories, sender } = body;
384
+ if (!installation || !sender)
385
+ return;
386
+ const installationId = String(installation.id);
387
+ console.log(`[nango-webhook] Installation ${action}: ${installation.account.login} (${installationId})`);
388
+ if (action === 'created') {
389
+ // Find user by GitHub ID
390
+ const user = await db.users.findByGithubId(String(sender.id));
391
+ // Create/update installation record
392
+ await db.githubInstallations.upsert({
393
+ installationId,
394
+ accountType: installation.account.type.toLowerCase(),
395
+ accountLogin: installation.account.login,
396
+ accountId: String(installation.account.id),
397
+ installedById: user?.id ?? null,
398
+ permissions: installation.permissions,
399
+ events: installation.events,
400
+ });
401
+ // Sync repositories if provided
402
+ if (repositories && user) {
403
+ const dbInstallation = await db.githubInstallations.findByInstallationId(installationId);
404
+ if (dbInstallation) {
405
+ const workspacesToJoin = new Set();
406
+ for (const repo of repositories) {
407
+ const syncedRepo = await db.repositories.upsert({
408
+ userId: user.id,
409
+ githubFullName: repo.full_name,
410
+ githubId: repo.id,
411
+ isPrivate: repo.private,
412
+ installationId: dbInstallation.id,
413
+ nangoConnectionId: connectionId,
414
+ syncStatus: 'synced',
415
+ lastSyncedAt: new Date(),
416
+ });
417
+ // Check if repo is part of an existing workspace
418
+ // Look for ANY user's record of this repo that has a workspaceId
419
+ if (syncedRepo.workspaceId) {
420
+ workspacesToJoin.add(syncedRepo.workspaceId);
421
+ }
422
+ else {
423
+ // Check if other users have this repo linked to a workspace
424
+ const allRepoRecords = await db.repositories.findByGithubFullName(repo.full_name);
425
+ for (const otherRecord of allRepoRecords) {
426
+ if (otherRecord.workspaceId && otherRecord.userId !== user.id) {
427
+ workspacesToJoin.add(otherRecord.workspaceId);
428
+ }
429
+ }
430
+ }
431
+ }
432
+ // Auto-join user to workspaces for repos they have access to
433
+ for (const workspaceId of workspacesToJoin) {
434
+ const existingMembership = await db.workspaceMembers.findMembership(workspaceId, user.id);
435
+ if (!existingMembership) {
436
+ const workspace = await db.workspaces.findById(workspaceId);
437
+ if (workspace) {
438
+ console.log(`[nango-webhook] Auto-adding ${user.githubUsername} to workspace ${workspace.name}`);
439
+ await db.workspaceMembers.addMember({
440
+ workspaceId,
441
+ userId: user.id,
442
+ role: 'member',
443
+ invitedBy: workspace.userId,
444
+ });
445
+ await db.workspaceMembers.acceptInvite(workspaceId, user.id);
446
+ }
447
+ }
448
+ }
449
+ console.log(`[nango-webhook] Installation created for ${installation.account.login}, auto-joined ${workspacesToJoin.size} workspaces`);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Handle installation_repositories events forwarded by Nango
456
+ */
457
+ async function handleInstallationRepositoriesForward(body, connectionId) {
458
+ const { action, installation, repositories_added, repositories_removed, sender } = body;
459
+ if (!installation || !sender)
460
+ return;
461
+ const installationId = String(installation.id);
462
+ console.log(`[nango-webhook] Repositories ${action} for ${installation.account.login}`);
463
+ // Find installation in database
464
+ const dbInstallation = await db.githubInstallations.findByInstallationId(installationId);
465
+ if (!dbInstallation) {
466
+ console.error(`[nango-webhook] Installation ${installationId} not found in database`);
467
+ return;
468
+ }
469
+ // Find user who triggered this
470
+ const user = await db.users.findByGithubId(String(sender.id));
471
+ if (!user) {
472
+ console.error(`[nango-webhook] User ${sender.login} not found in database`);
473
+ return;
474
+ }
475
+ if (action === 'added' && repositories_added) {
476
+ const workspacesToJoin = new Set();
477
+ for (const repo of repositories_added) {
478
+ const syncedRepo = await db.repositories.upsert({
479
+ userId: user.id,
480
+ githubFullName: repo.full_name,
481
+ githubId: repo.id,
482
+ isPrivate: repo.private,
483
+ installationId: dbInstallation.id,
484
+ nangoConnectionId: connectionId,
485
+ syncStatus: 'synced',
486
+ lastSyncedAt: new Date(),
487
+ });
488
+ // Check if repo is part of an existing workspace
489
+ // Look for ANY user's record of this repo that has a workspaceId
490
+ if (syncedRepo.workspaceId) {
491
+ workspacesToJoin.add(syncedRepo.workspaceId);
492
+ }
493
+ else {
494
+ // Check if other users have this repo linked to a workspace
495
+ const allRepoRecords = await db.repositories.findByGithubFullName(repo.full_name);
496
+ for (const otherRecord of allRepoRecords) {
497
+ if (otherRecord.workspaceId && otherRecord.userId !== user.id) {
498
+ workspacesToJoin.add(otherRecord.workspaceId);
499
+ }
500
+ }
501
+ }
502
+ }
503
+ // Auto-join user to workspaces for repos they have access to
504
+ for (const workspaceId of workspacesToJoin) {
505
+ const existingMembership = await db.workspaceMembers.findMembership(workspaceId, user.id);
506
+ if (!existingMembership) {
507
+ const workspace = await db.workspaces.findById(workspaceId);
508
+ if (workspace) {
509
+ console.log(`[nango-webhook] Auto-adding ${user.githubUsername} to workspace ${workspace.name}`);
510
+ await db.workspaceMembers.addMember({
511
+ workspaceId,
512
+ userId: user.id,
513
+ role: 'member',
514
+ invitedBy: workspace.userId,
515
+ });
516
+ await db.workspaceMembers.acceptInvite(workspaceId, user.id);
517
+ }
518
+ }
519
+ }
520
+ console.log(`[nango-webhook] Added ${repositories_added.length} repositories, auto-joined ${workspacesToJoin.size} workspaces`);
521
+ }
522
+ if (action === 'removed' && repositories_removed) {
523
+ for (const repo of repositories_removed) {
524
+ const repos = await db.repositories.findByUserId(user.id);
525
+ const existingRepo = repos.find(r => r.githubFullName === repo.full_name);
526
+ if (existingRepo) {
527
+ await db.repositories.updateSyncStatus(existingRepo.id, 'access_removed');
528
+ }
529
+ }
530
+ console.log(`[nango-webhook] Removed access to ${repositories_removed.length} repositories`);
531
+ }
287
532
  }
288
533
  /**
289
534
  * Handle GitHub App OAuth webhook (repo access)
@@ -339,9 +584,11 @@ async function handleRepoAuthWebhook(connectionId, endUser) {
339
584
  }
340
585
  // Fetch repos the user has access to
341
586
  const { repositories: repos } = await nangoService.listGithubAppRepos(connectionId);
587
+ // Track workspaces to auto-join
588
+ const workspacesToJoin = new Set();
342
589
  // Sync repos to database
343
590
  for (const repo of repos) {
344
- await db.repositories.upsert({
591
+ const syncedRepo = await db.repositories.upsert({
345
592
  userId: user.id,
346
593
  githubFullName: repo.full_name,
347
594
  githubId: repo.id,
@@ -352,10 +599,44 @@ async function handleRepoAuthWebhook(connectionId, endUser) {
352
599
  syncStatus: 'synced',
353
600
  lastSyncedAt: new Date(),
354
601
  });
602
+ // Check if this repo is part of an existing workspace
603
+ // Look for ANY user's record of this repo that has a workspaceId
604
+ if (syncedRepo.workspaceId) {
605
+ workspacesToJoin.add(syncedRepo.workspaceId);
606
+ }
607
+ else {
608
+ // Check if other users have this repo linked to a workspace
609
+ const allRepoRecords = await db.repositories.findByGithubFullName(repo.full_name);
610
+ for (const otherRecord of allRepoRecords) {
611
+ if (otherRecord.workspaceId && otherRecord.userId !== user.id) {
612
+ workspacesToJoin.add(otherRecord.workspaceId);
613
+ }
614
+ }
615
+ }
616
+ }
617
+ // Auto-join user to workspaces for repos they have access to
618
+ for (const workspaceId of workspacesToJoin) {
619
+ // Check if already a member
620
+ const existingMembership = await db.workspaceMembers.findMembership(workspaceId, user.id);
621
+ if (!existingMembership) {
622
+ // Get workspace owner to use as invitedBy
623
+ const workspace = await db.workspaces.findById(workspaceId);
624
+ if (workspace) {
625
+ console.log(`[nango-webhook] Auto-adding ${user.githubUsername} to workspace ${workspace.name}`);
626
+ await db.workspaceMembers.addMember({
627
+ workspaceId,
628
+ userId: user.id,
629
+ role: 'member',
630
+ invitedBy: workspace.userId, // Workspace owner invited them
631
+ });
632
+ // Auto-accept since they have GitHub repo access
633
+ await db.workspaceMembers.acceptInvite(workspaceId, user.id);
634
+ }
635
+ }
355
636
  }
356
637
  // Clear any pending installation request
357
638
  await db.users.clearPendingInstallationRequest(user.id);
358
- console.log(`[nango-webhook] Synced ${repos.length} repos for ${user.githubUsername} (installation: ${githubInstallationId || 'unknown'})`);
639
+ console.log(`[nango-webhook] Synced ${repos.length} repos for ${user.githubUsername} (installation: ${githubInstallationId || 'unknown'}), auto-joined ${workspacesToJoin.size} workspaces`);
359
640
  // Note: We intentionally do NOT auto-provision workspaces here.
360
641
  // Users should go through the onboarding flow at /app to:
361
642
  // 1. Name their workspace
@@ -13,7 +13,6 @@ import { Router } from 'express';
13
13
  import * as crypto from 'crypto';
14
14
  import { requireAuth } from './auth.js';
15
15
  import { db } from '../db/index.js';
16
- import { vault } from '../vault/index.js';
17
16
  // Import for local use
18
17
  import { CLI_AUTH_CONFIG, runCLIAuthViaPTY, stripAnsiCodes, matchesSuccessPattern, findMatchingPrompt, validateProviderConfig, validateAllProviderConfigs, getSupportedProviders, } from './cli-pty-runner.js';
19
18
  // Re-export from shared module for backward compatibility
@@ -56,9 +55,12 @@ onboardingRouter.post('/cli/:provider/start', async (req, res) => {
56
55
  console.log('[onboarding] Route handler entered! provider:', req.params.provider);
57
56
  const { provider } = req.params;
58
57
  const userId = req.session.userId;
59
- const { workspaceId, useDeviceFlow } = req.body; // Optional: specific workspace, device flow option
60
- console.log('[onboarding] userId:', userId, 'workspaceId:', workspaceId, 'useDeviceFlow:', useDeviceFlow);
58
+ const { workspaceId, useDeviceFlow: requestedDeviceFlow } = req.body; // Optional: specific workspace, device flow option
59
+ // Device flow is only used if explicitly requested by the client
60
+ // Standard flow: user runs `codex-auth` CLI locally to capture OAuth callback and forward to cloud
61
61
  const config = CLI_AUTH_CONFIG[provider];
62
+ const useDeviceFlow = requestedDeviceFlow ?? false;
63
+ console.log('[onboarding] userId:', userId, 'workspaceId:', workspaceId, 'useDeviceFlow:', useDeviceFlow);
62
64
  if (!config) {
63
65
  return res.status(400).json({
64
66
  error: 'Provider not supported for CLI auth',
@@ -153,6 +155,7 @@ onboardingRouter.post('/cli/:provider/start', async (req, res) => {
153
155
  status: session.status,
154
156
  authUrl: session.authUrl,
155
157
  workspaceId: workspace.id,
158
+ useDeviceFlow, // Tell dashboard whether device flow is being used (no CLI helper needed)
156
159
  message: session.authUrl ? 'Open the auth URL to complete login' : 'Auth session starting, poll for status',
157
160
  });
158
161
  }
@@ -185,6 +188,8 @@ onboardingRouter.get('/cli/:provider/status/:sessionId', async (req, res) => {
185
188
  session.status = workspaceStatus.status || session.status;
186
189
  session.authUrl = workspaceStatus.authUrl || session.authUrl;
187
190
  session.error = workspaceStatus.error;
191
+ session.errorHint = workspaceStatus.errorHint;
192
+ session.recoverable = workspaceStatus.recoverable;
188
193
  }
189
194
  }
190
195
  catch (err) {
@@ -195,6 +200,8 @@ onboardingRouter.get('/cli/:provider/status/:sessionId', async (req, res) => {
195
200
  status: session.status,
196
201
  authUrl: session.authUrl,
197
202
  error: session.error,
203
+ errorHint: session.errorHint,
204
+ recoverable: session.recoverable,
198
205
  });
199
206
  });
200
207
  /**
@@ -220,7 +227,7 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req, res) =>
220
227
  try {
221
228
  let accessToken = token || session.token;
222
229
  let refreshToken = session.refreshToken;
223
- let tokenExpiresAt = session.tokenExpiresAt;
230
+ let _tokenExpiresAt = session.tokenExpiresAt;
224
231
  // If using workspace delegation, forward complete request first
225
232
  if (session.workspaceUrl && session.workspaceSessionId) {
226
233
  // Forward authCode to workspace if provided (for Codex-style redirects)
@@ -241,25 +248,52 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req, res) =>
241
248
  }
242
249
  session.status = 'success';
243
250
  }
244
- // Fetch credentials from workspace
251
+ // Fetch credentials from workspace with retry
252
+ // Credentials may not be immediately available after OAuth completes
245
253
  if (!accessToken) {
246
- try {
247
- const credsResponse = await fetch(`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`);
248
- if (credsResponse.ok) {
249
- const creds = await credsResponse.json();
250
- accessToken = creds.token;
251
- refreshToken = creds.refreshToken;
252
- if (creds.expiresAt) {
253
- tokenExpiresAt = new Date(creds.expiresAt);
254
+ const MAX_CREDS_RETRIES = 5;
255
+ const CREDS_RETRY_DELAY = 1000; // 1 second between retries
256
+ for (let attempt = 1; attempt <= MAX_CREDS_RETRIES; attempt++) {
257
+ try {
258
+ console.log(`[onboarding] Fetching credentials from workspace (attempt ${attempt}/${MAX_CREDS_RETRIES})`);
259
+ const credsResponse = await fetch(`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`);
260
+ if (credsResponse.ok) {
261
+ const creds = await credsResponse.json();
262
+ accessToken = creds.token;
263
+ refreshToken = creds.refreshToken;
264
+ if (creds.tokenExpiresAt) {
265
+ _tokenExpiresAt = new Date(creds.tokenExpiresAt);
266
+ }
267
+ console.log('[onboarding] Fetched credentials from workspace:', {
268
+ hasToken: !!accessToken,
269
+ hasRefreshToken: !!refreshToken,
270
+ attempt,
271
+ });
272
+ break; // Success, exit retry loop
273
+ }
274
+ // Check if it's an error state (not just "not ready yet")
275
+ const errorBody = await credsResponse.json().catch(() => ({}));
276
+ if (errorBody.status === 'error') {
277
+ // Auth failed, don't retry
278
+ console.error('[onboarding] Auth failed in workspace:', errorBody);
279
+ return res.status(400).json({
280
+ error: errorBody.error || 'Authentication failed',
281
+ errorHint: errorBody.errorHint,
282
+ recoverable: errorBody.recoverable,
283
+ });
284
+ }
285
+ // If not ready yet and we have more retries, wait and try again
286
+ if (attempt < MAX_CREDS_RETRIES) {
287
+ console.log(`[onboarding] Credentials not ready yet, retrying in ${CREDS_RETRY_DELAY}ms...`);
288
+ await new Promise(resolve => setTimeout(resolve, CREDS_RETRY_DELAY));
289
+ }
290
+ }
291
+ catch (err) {
292
+ console.error(`[onboarding] Failed to get credentials from workspace (attempt ${attempt}):`, err);
293
+ if (attempt < MAX_CREDS_RETRIES) {
294
+ await new Promise(resolve => setTimeout(resolve, CREDS_RETRY_DELAY));
254
295
  }
255
- console.log('[onboarding] Fetched credentials from workspace:', {
256
- hasToken: !!accessToken,
257
- hasRefreshToken: !!refreshToken,
258
- });
259
296
  }
260
- }
261
- catch (err) {
262
- console.error('[onboarding] Failed to get credentials from workspace:', err);
263
297
  }
264
298
  }
265
299
  }
@@ -268,13 +302,11 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req, res) =>
268
302
  error: 'No token found. Please complete authentication or paste your token.',
269
303
  });
270
304
  }
271
- // Store in vault with refresh token and expiry
272
- await vault.storeCredential({
305
+ // Mark provider as connected (tokens are not stored centrally - CLI tools
306
+ // authenticate directly on workspace instances)
307
+ await db.credentials.upsert({
273
308
  userId,
274
309
  provider,
275
- accessToken,
276
- refreshToken,
277
- tokenExpiresAt,
278
310
  scopes: getProviderScopes(provider),
279
311
  });
280
312
  // Clean up session
@@ -297,8 +329,8 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req, res) =>
297
329
  onboardingRouter.post('/cli/:provider/code/:sessionId', async (req, res) => {
298
330
  const { provider, sessionId } = req.params;
299
331
  const userId = req.session.userId;
300
- const { code } = req.body;
301
- console.log('[onboarding] Auth code submission request:', { provider, sessionId, codeLength: code?.length });
332
+ const { code, state } = req.body; // state is optional, used for Codex OAuth
333
+ console.log('[onboarding] Auth code submission request:', { provider, sessionId, codeLength: code?.length, hasState: !!state });
302
334
  if (!code || typeof code !== 'string') {
303
335
  return res.status(400).json({ error: 'Auth code is required' });
304
336
  }
@@ -324,7 +356,7 @@ onboardingRouter.post('/cli/:provider/code/:sessionId', async (req, res) => {
324
356
  const codeResponse = await fetch(targetUrl, {
325
357
  method: 'POST',
326
358
  headers: { 'Content-Type': 'application/json' },
327
- body: JSON.stringify({ code }),
359
+ body: JSON.stringify({ code, state }), // Forward state for Codex CSRF validation
328
360
  });
329
361
  console.log('[onboarding] Workspace response:', { status: codeResponse.status });
330
362
  if (codeResponse.ok) {
@@ -381,6 +413,37 @@ onboardingRouter.post('/cli/:provider/cancel/:sessionId', async (req, res) => {
381
413
  }
382
414
  res.json({ success: true });
383
415
  });
416
+ /**
417
+ * POST /api/onboarding/mark-connected/:provider
418
+ * Mark a provider as connected without storing a token.
419
+ * Used by terminal-based setup where the CLI stores credentials locally.
420
+ */
421
+ onboardingRouter.post('/mark-connected/:provider', async (req, res) => {
422
+ const { provider } = req.params;
423
+ const userId = req.session.userId;
424
+ // Validate provider
425
+ const validProviders = ['anthropic', 'openai', 'google', 'github'];
426
+ if (!validProviders.includes(provider)) {
427
+ return res.status(400).json({ error: 'Invalid provider' });
428
+ }
429
+ try {
430
+ // Mark provider as connected (tokens are stored by CLI on workspace)
431
+ await db.credentials.upsert({
432
+ userId,
433
+ provider,
434
+ scopes: getProviderScopes(provider),
435
+ });
436
+ console.log(`[onboarding] Marked ${provider} as connected for user ${userId}`);
437
+ res.json({
438
+ success: true,
439
+ message: `${provider} connected successfully`,
440
+ });
441
+ }
442
+ catch (error) {
443
+ console.error(`Error marking ${provider} as connected:`, error);
444
+ res.status(500).json({ error: 'Failed to mark provider as connected' });
445
+ }
446
+ });
384
447
  /**
385
448
  * POST /api/onboarding/token/:provider
386
449
  * Directly store a token (for manual paste flow)
@@ -398,22 +461,23 @@ onboardingRouter.post('/token/:provider', async (req, res) => {
398
461
  if (!isValid) {
399
462
  return res.status(400).json({ error: 'Invalid token' });
400
463
  }
401
- // Store in vault
402
- await vault.storeCredential({
464
+ // Mark provider as connected (tokens are not stored centrally - CLI tools
465
+ // authenticate directly on workspace instances)
466
+ await db.credentials.upsert({
403
467
  userId,
404
468
  provider,
405
- accessToken: token,
406
469
  scopes: getProviderScopes(provider),
407
470
  providerAccountEmail: email,
408
471
  });
409
472
  res.json({
410
473
  success: true,
411
474
  message: `${provider} connected successfully`,
475
+ note: 'Token validated. Configure this on your workspace for usage.',
412
476
  });
413
477
  }
414
478
  catch (error) {
415
- console.error(`Error storing token for ${provider}:`, error);
416
- res.status(500).json({ error: 'Failed to store token' });
479
+ console.error(`Error storing provider connection for ${provider}:`, error);
480
+ res.status(500).json({ error: 'Failed to store provider connection' });
417
481
  }
418
482
  });
419
483
  /**