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
@@ -20,6 +20,408 @@ const coordinatorWriteRoutes = [
20
20
  coordinatorWriteRoutes.forEach(route => {
21
21
  coordinatorsRouter.use(route, checkCoordinatorAccess);
22
22
  });
23
+ // ============================================================================
24
+ // Project Group CRUD Routes
25
+ // These must come BEFORE the /:groupId/coordinator routes
26
+ // ============================================================================
27
+ /**
28
+ * GET /api/project-groups
29
+ * List all project groups for the authenticated user
30
+ */
31
+ coordinatorsRouter.get('/', async (req, res) => {
32
+ const userId = req.session.userId;
33
+ try {
34
+ const result = await db.projectGroups.findAllWithRepositories(userId);
35
+ res.json({
36
+ groups: result.groups.map(group => ({
37
+ id: group.id,
38
+ name: group.name,
39
+ description: group.description,
40
+ color: group.color,
41
+ icon: group.icon,
42
+ coordinatorAgent: group.coordinatorAgent,
43
+ sortOrder: group.sortOrder,
44
+ repositoryCount: group.repositories.length,
45
+ repositories: group.repositories.map(repo => ({
46
+ id: repo.id,
47
+ githubFullName: repo.githubFullName,
48
+ defaultBranch: repo.defaultBranch,
49
+ isPrivate: repo.isPrivate,
50
+ })),
51
+ createdAt: group.createdAt,
52
+ updatedAt: group.updatedAt,
53
+ })),
54
+ ungroupedRepositories: result.ungroupedRepositories.map(repo => ({
55
+ id: repo.id,
56
+ githubFullName: repo.githubFullName,
57
+ defaultBranch: repo.defaultBranch,
58
+ isPrivate: repo.isPrivate,
59
+ workspaceId: repo.workspaceId,
60
+ })),
61
+ });
62
+ }
63
+ catch (error) {
64
+ console.error('Error listing project groups:', error);
65
+ res.status(500).json({ error: 'Failed to list project groups' });
66
+ }
67
+ });
68
+ /**
69
+ * POST /api/project-groups
70
+ * Create a new project group
71
+ */
72
+ coordinatorsRouter.post('/', async (req, res) => {
73
+ const userId = req.session.userId;
74
+ const { name, description, color, icon, repositoryIds } = req.body;
75
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
76
+ return res.status(400).json({ error: 'Name is required' });
77
+ }
78
+ if (name.length > 255) {
79
+ return res.status(400).json({ error: 'Name must be 255 characters or less' });
80
+ }
81
+ try {
82
+ // Check for duplicate name
83
+ const existing = await db.projectGroups.findByName(userId, name.trim());
84
+ if (existing) {
85
+ return res.status(409).json({ error: 'A project group with this name already exists' });
86
+ }
87
+ // Create the group
88
+ const group = await db.projectGroups.create({
89
+ userId,
90
+ name: name.trim(),
91
+ description: description?.trim() || null,
92
+ color: color || null,
93
+ icon: icon || null,
94
+ coordinatorAgent: { enabled: false },
95
+ sortOrder: 0,
96
+ });
97
+ // Assign repositories to the group if provided
98
+ if (repositoryIds && Array.isArray(repositoryIds) && repositoryIds.length > 0) {
99
+ // Verify all repositories belong to the user
100
+ const userRepos = await db.repositories.findByUserId(userId);
101
+ const userRepoIds = new Set(userRepos.map(r => r.id));
102
+ for (const repoId of repositoryIds) {
103
+ if (!userRepoIds.has(repoId)) {
104
+ return res.status(400).json({
105
+ error: `Repository ${repoId} not found or not owned by user`,
106
+ });
107
+ }
108
+ }
109
+ // Assign repositories to the group
110
+ for (const repoId of repositoryIds) {
111
+ await db.repositories.assignToGroup(repoId, group.id);
112
+ }
113
+ }
114
+ // Fetch the group with repositories for response
115
+ const groupWithRepos = await db.projectGroups.findWithRepositories(group.id);
116
+ res.status(201).json({
117
+ success: true,
118
+ group: {
119
+ id: groupWithRepos.id,
120
+ name: groupWithRepos.name,
121
+ description: groupWithRepos.description,
122
+ color: groupWithRepos.color,
123
+ icon: groupWithRepos.icon,
124
+ coordinatorAgent: groupWithRepos.coordinatorAgent,
125
+ repositories: groupWithRepos.repositories.map(repo => ({
126
+ id: repo.id,
127
+ githubFullName: repo.githubFullName,
128
+ defaultBranch: repo.defaultBranch,
129
+ isPrivate: repo.isPrivate,
130
+ })),
131
+ createdAt: groupWithRepos.createdAt,
132
+ updatedAt: groupWithRepos.updatedAt,
133
+ },
134
+ });
135
+ }
136
+ catch (error) {
137
+ console.error('Error creating project group:', error);
138
+ res.status(500).json({ error: 'Failed to create project group' });
139
+ }
140
+ });
141
+ /**
142
+ * GET /api/project-groups/:id
143
+ * Get a specific project group with its repositories
144
+ */
145
+ coordinatorsRouter.get('/:id', async (req, res) => {
146
+ const userId = req.session.userId;
147
+ const { id } = req.params;
148
+ // Skip if this looks like a coordinator route
149
+ if (id === 'coordinators') {
150
+ return res.status(404).json({ error: 'Not found' });
151
+ }
152
+ try {
153
+ const group = await db.projectGroups.findWithRepositories(id);
154
+ if (!group) {
155
+ return res.status(404).json({ error: 'Project group not found' });
156
+ }
157
+ if (group.userId !== userId) {
158
+ return res.status(403).json({ error: 'Unauthorized' });
159
+ }
160
+ res.json({
161
+ id: group.id,
162
+ name: group.name,
163
+ description: group.description,
164
+ color: group.color,
165
+ icon: group.icon,
166
+ coordinatorAgent: group.coordinatorAgent,
167
+ sortOrder: group.sortOrder,
168
+ repositories: group.repositories.map(repo => ({
169
+ id: repo.id,
170
+ githubFullName: repo.githubFullName,
171
+ defaultBranch: repo.defaultBranch,
172
+ isPrivate: repo.isPrivate,
173
+ syncStatus: repo.syncStatus,
174
+ lastSyncedAt: repo.lastSyncedAt,
175
+ workspaceId: repo.workspaceId,
176
+ })),
177
+ createdAt: group.createdAt,
178
+ updatedAt: group.updatedAt,
179
+ });
180
+ }
181
+ catch (error) {
182
+ console.error('Error getting project group:', error);
183
+ res.status(500).json({ error: 'Failed to get project group' });
184
+ }
185
+ });
186
+ /**
187
+ * PATCH /api/project-groups/:id
188
+ * Update a project group's metadata
189
+ */
190
+ coordinatorsRouter.patch('/:id', async (req, res) => {
191
+ const userId = req.session.userId;
192
+ const { id } = req.params;
193
+ const { name, description, color, icon } = req.body;
194
+ try {
195
+ const group = await db.projectGroups.findById(id);
196
+ if (!group) {
197
+ return res.status(404).json({ error: 'Project group not found' });
198
+ }
199
+ if (group.userId !== userId) {
200
+ return res.status(403).json({ error: 'Unauthorized' });
201
+ }
202
+ // Build update object with only provided fields
203
+ const updates = {};
204
+ if (name !== undefined) {
205
+ if (typeof name !== 'string' || name.trim().length === 0) {
206
+ return res.status(400).json({ error: 'Name cannot be empty' });
207
+ }
208
+ if (name.length > 255) {
209
+ return res.status(400).json({ error: 'Name must be 255 characters or less' });
210
+ }
211
+ // Check for duplicate name (excluding current group)
212
+ const existing = await db.projectGroups.findByName(userId, name.trim());
213
+ if (existing && existing.id !== id) {
214
+ return res.status(409).json({ error: 'A project group with this name already exists' });
215
+ }
216
+ updates.name = name.trim();
217
+ }
218
+ if (description !== undefined) {
219
+ updates.description = description?.trim() || null;
220
+ }
221
+ if (color !== undefined) {
222
+ // Validate hex color format if provided
223
+ if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) {
224
+ return res.status(400).json({ error: 'Color must be a valid hex color (e.g., #3B82F6)' });
225
+ }
226
+ updates.color = color || null;
227
+ }
228
+ if (icon !== undefined) {
229
+ updates.icon = icon || null;
230
+ }
231
+ if (Object.keys(updates).length === 0) {
232
+ return res.status(400).json({ error: 'No valid fields to update' });
233
+ }
234
+ await db.projectGroups.update(id, updates);
235
+ // Fetch updated group
236
+ const updatedGroup = await db.projectGroups.findWithRepositories(id);
237
+ res.json({
238
+ success: true,
239
+ group: {
240
+ id: updatedGroup.id,
241
+ name: updatedGroup.name,
242
+ description: updatedGroup.description,
243
+ color: updatedGroup.color,
244
+ icon: updatedGroup.icon,
245
+ coordinatorAgent: updatedGroup.coordinatorAgent,
246
+ repositories: updatedGroup.repositories.map(repo => ({
247
+ id: repo.id,
248
+ githubFullName: repo.githubFullName,
249
+ defaultBranch: repo.defaultBranch,
250
+ isPrivate: repo.isPrivate,
251
+ })),
252
+ updatedAt: updatedGroup.updatedAt,
253
+ },
254
+ });
255
+ }
256
+ catch (error) {
257
+ console.error('Error updating project group:', error);
258
+ res.status(500).json({ error: 'Failed to update project group' });
259
+ }
260
+ });
261
+ /**
262
+ * DELETE /api/project-groups/:id
263
+ * Delete a project group (repositories are unassigned, not deleted)
264
+ */
265
+ coordinatorsRouter.delete('/:id', async (req, res) => {
266
+ const userId = req.session.userId;
267
+ const { id } = req.params;
268
+ try {
269
+ const group = await db.projectGroups.findById(id);
270
+ if (!group) {
271
+ return res.status(404).json({ error: 'Project group not found' });
272
+ }
273
+ if (group.userId !== userId) {
274
+ return res.status(403).json({ error: 'Unauthorized' });
275
+ }
276
+ // Stop coordinator if running
277
+ if (group.coordinatorAgent?.enabled) {
278
+ try {
279
+ const coordinatorService = getCoordinatorService();
280
+ await coordinatorService.stop(id);
281
+ }
282
+ catch (err) {
283
+ console.warn('Error stopping coordinator during group deletion:', err);
284
+ }
285
+ }
286
+ // Delete the group (repositories will have projectGroupId set to null due to ON DELETE SET NULL)
287
+ await db.projectGroups.delete(id);
288
+ res.json({
289
+ success: true,
290
+ message: 'Project group deleted',
291
+ });
292
+ }
293
+ catch (error) {
294
+ console.error('Error deleting project group:', error);
295
+ res.status(500).json({ error: 'Failed to delete project group' });
296
+ }
297
+ });
298
+ /**
299
+ * POST /api/project-groups/:id/repositories
300
+ * Add repositories to a project group
301
+ */
302
+ coordinatorsRouter.post('/:id/repositories', async (req, res) => {
303
+ const userId = req.session.userId;
304
+ const { id } = req.params;
305
+ const { repositoryIds } = req.body;
306
+ if (!repositoryIds || !Array.isArray(repositoryIds) || repositoryIds.length === 0) {
307
+ return res.status(400).json({ error: 'repositoryIds array is required' });
308
+ }
309
+ try {
310
+ const group = await db.projectGroups.findById(id);
311
+ if (!group) {
312
+ return res.status(404).json({ error: 'Project group not found' });
313
+ }
314
+ if (group.userId !== userId) {
315
+ return res.status(403).json({ error: 'Unauthorized' });
316
+ }
317
+ // Verify all repositories belong to the user
318
+ const userRepos = await db.repositories.findByUserId(userId);
319
+ const userRepoIds = new Set(userRepos.map(r => r.id));
320
+ for (const repoId of repositoryIds) {
321
+ if (!userRepoIds.has(repoId)) {
322
+ return res.status(400).json({
323
+ error: `Repository ${repoId} not found or not owned by user`,
324
+ });
325
+ }
326
+ }
327
+ // Assign repositories to the group
328
+ for (const repoId of repositoryIds) {
329
+ await db.repositories.assignToGroup(repoId, id);
330
+ }
331
+ // Fetch updated group
332
+ const updatedGroup = await db.projectGroups.findWithRepositories(id);
333
+ res.json({
334
+ success: true,
335
+ group: {
336
+ id: updatedGroup.id,
337
+ name: updatedGroup.name,
338
+ repositories: updatedGroup.repositories.map(repo => ({
339
+ id: repo.id,
340
+ githubFullName: repo.githubFullName,
341
+ defaultBranch: repo.defaultBranch,
342
+ isPrivate: repo.isPrivate,
343
+ })),
344
+ },
345
+ });
346
+ }
347
+ catch (error) {
348
+ console.error('Error adding repositories to group:', error);
349
+ res.status(500).json({ error: 'Failed to add repositories to group' });
350
+ }
351
+ });
352
+ /**
353
+ * DELETE /api/project-groups/:id/repositories/:repoId
354
+ * Remove a repository from a project group
355
+ */
356
+ coordinatorsRouter.delete('/:id/repositories/:repoId', async (req, res) => {
357
+ const userId = req.session.userId;
358
+ const { id, repoId } = req.params;
359
+ try {
360
+ const group = await db.projectGroups.findById(id);
361
+ if (!group) {
362
+ return res.status(404).json({ error: 'Project group not found' });
363
+ }
364
+ if (group.userId !== userId) {
365
+ return res.status(403).json({ error: 'Unauthorized' });
366
+ }
367
+ // Verify repository exists and belongs to this group
368
+ const repo = await db.repositories.findById(repoId);
369
+ if (!repo) {
370
+ return res.status(404).json({ error: 'Repository not found' });
371
+ }
372
+ if (repo.userId !== userId) {
373
+ return res.status(403).json({ error: 'Unauthorized' });
374
+ }
375
+ if (repo.projectGroupId !== id) {
376
+ return res.status(400).json({ error: 'Repository is not in this group' });
377
+ }
378
+ // Remove repository from group (set projectGroupId to null)
379
+ await db.repositories.assignToGroup(repoId, null);
380
+ res.json({
381
+ success: true,
382
+ message: 'Repository removed from group',
383
+ });
384
+ }
385
+ catch (error) {
386
+ console.error('Error removing repository from group:', error);
387
+ res.status(500).json({ error: 'Failed to remove repository from group' });
388
+ }
389
+ });
390
+ /**
391
+ * PUT /api/project-groups/reorder
392
+ * Reorder project groups
393
+ */
394
+ coordinatorsRouter.put('/reorder', async (req, res) => {
395
+ const userId = req.session.userId;
396
+ const { orderedIds } = req.body;
397
+ if (!orderedIds || !Array.isArray(orderedIds)) {
398
+ return res.status(400).json({ error: 'orderedIds array is required' });
399
+ }
400
+ try {
401
+ // Verify all groups belong to user
402
+ const userGroups = await db.projectGroups.findByUserId(userId);
403
+ const userGroupIds = new Set(userGroups.map(g => g.id));
404
+ for (const groupId of orderedIds) {
405
+ if (!userGroupIds.has(groupId)) {
406
+ return res.status(400).json({
407
+ error: `Group ${groupId} not found or not owned by user`,
408
+ });
409
+ }
410
+ }
411
+ await db.projectGroups.reorder(userId, orderedIds);
412
+ res.json({
413
+ success: true,
414
+ message: 'Groups reordered',
415
+ });
416
+ }
417
+ catch (error) {
418
+ console.error('Error reordering project groups:', error);
419
+ res.status(500).json({ error: 'Failed to reorder project groups' });
420
+ }
421
+ });
422
+ // ============================================================================
423
+ // Coordinator Agent Routes
424
+ // ============================================================================
23
425
  /**
24
426
  * GET /api/project-groups/:groupId/coordinator
25
427
  * Get coordinator agent configuration
@@ -12,7 +12,6 @@ import { Router } from 'express';
12
12
  import { randomBytes, createHash } from 'crypto';
13
13
  import { requireAuth } from './auth.js';
14
14
  import { db } from '../db/index.js';
15
- import { vault } from '../vault/index.js';
16
15
  export const daemonsRouter = Router();
17
16
  /**
18
17
  * Generate a secure API key
@@ -201,21 +200,26 @@ daemonsRouter.post('/heartbeat', requireDaemonAuth, async (req, res) => {
201
200
  });
202
201
  /**
203
202
  * GET /api/daemons/credentials
204
- * Get credentials for the daemon's user (syncs cloud credentials to local)
203
+ * Get credentials for the daemon's user
204
+ *
205
+ * Note: Tokens are no longer stored centrally. CLI tools authenticate directly
206
+ * on workspace/local instances. This endpoint returns connected provider info only.
205
207
  */
206
208
  daemonsRouter.get('/credentials', requireDaemonAuth, async (req, res) => {
207
209
  const daemon = req.daemon;
208
210
  try {
209
- // Get all decrypted credentials for the user via vault
210
- const credentialsMap = await vault.getUserCredentials(daemon.userId);
211
- // Convert Map to array format for API response
212
- const credentials = Array.from(credentialsMap.entries()).map(([provider, cred]) => ({
213
- provider,
214
- accessToken: cred.accessToken,
215
- tokenType: 'bearer',
216
- expiresAt: cred.tokenExpiresAt,
211
+ // Get connected providers for this user (no tokens stored centrally)
212
+ const credentials = await db.credentials.findByUserId(daemon.userId);
213
+ // Return provider info without tokens
214
+ const providers = credentials.map((cred) => ({
215
+ provider: cred.provider,
216
+ providerAccountEmail: cred.providerAccountEmail,
217
+ connectedAt: cred.createdAt,
217
218
  }));
218
- res.json({ credentials });
219
+ res.json({
220
+ providers,
221
+ note: 'Tokens are authenticated locally on workspace instances via CLI.',
222
+ });
219
223
  }
220
224
  catch (error) {
221
225
  console.error('Error fetching credentials:', error);
@@ -23,20 +23,34 @@ function generateExpectedToken(workspaceId) {
23
23
  /**
24
24
  * Verify workspace access token
25
25
  * Workspaces authenticate with a secret passed at provisioning time
26
+ *
27
+ * Returns:
28
+ * - { valid: true } if token matches
29
+ * - { valid: false, reason: string } if token is invalid or missing
26
30
  */
27
31
  function verifyWorkspaceToken(req, workspaceId) {
28
32
  const authHeader = req.get('authorization');
29
- if (!authHeader?.startsWith('Bearer ')) {
30
- return false;
33
+ if (!authHeader) {
34
+ return { valid: false, reason: 'No Authorization header. WORKSPACE_TOKEN may not be set in the container.' };
35
+ }
36
+ if (!authHeader.startsWith('Bearer ')) {
37
+ return { valid: false, reason: 'Invalid Authorization header format. Expected: Bearer <token>' };
31
38
  }
32
39
  const providedToken = authHeader.slice(7);
40
+ if (!providedToken) {
41
+ return { valid: false, reason: 'Empty bearer token provided.' };
42
+ }
33
43
  const expectedToken = generateExpectedToken(workspaceId);
34
44
  // Use timing-safe comparison to prevent timing attacks
35
45
  try {
36
- return crypto.timingSafeEqual(Buffer.from(providedToken), Buffer.from(expectedToken));
46
+ const isValid = crypto.timingSafeEqual(Buffer.from(providedToken), Buffer.from(expectedToken));
47
+ if (!isValid) {
48
+ return { valid: false, reason: 'Token mismatch. Workspace may need reprovisioning or SESSION_SECRET changed.' };
49
+ }
50
+ return { valid: true };
37
51
  }
38
52
  catch {
39
- return false;
53
+ return { valid: false, reason: 'Token comparison failed (length mismatch). Workspace may need reprovisioning.' };
40
54
  }
41
55
  }
42
56
  /**
@@ -57,29 +71,67 @@ gitRouter.get('/token', async (req, res) => {
57
71
  return res.status(400).json({ error: 'workspaceId is required' });
58
72
  }
59
73
  // Verify the request is from a valid workspace
60
- if (!verifyWorkspaceToken(req, workspaceId)) {
61
- return res.status(401).json({ error: 'Invalid workspace token' });
74
+ const tokenVerification = verifyWorkspaceToken(req, workspaceId);
75
+ if (!tokenVerification.valid) {
76
+ console.warn(`[git] Token verification failed for workspace ${workspaceId.substring(0, 8)}: ${tokenVerification.reason}`);
77
+ return res.status(401).json({
78
+ error: 'Invalid workspace token',
79
+ code: 'INVALID_WORKSPACE_TOKEN',
80
+ hint: tokenVerification.reason,
81
+ });
62
82
  }
63
83
  try {
64
84
  // Get workspace to find the user
65
85
  const workspace = await db.workspaces.findById(workspaceId);
66
86
  if (!workspace) {
67
- return res.status(404).json({ error: 'Workspace not found' });
87
+ console.warn(`[git] Workspace not found: ${workspaceId}`);
88
+ return res.status(404).json({
89
+ error: 'Workspace not found',
90
+ code: 'WORKSPACE_NOT_FOUND',
91
+ hint: 'The workspace may have been deleted. Try reprovisioning.',
92
+ });
68
93
  }
69
94
  const userId = workspace.userId;
95
+ console.log(`[git] Token request for workspace ${workspaceId.substring(0, 8)}, user ${userId.substring(0, 8)}`);
70
96
  // Find a repository with a Nango connection for this user
71
97
  const repos = await db.repositories.findByUserId(userId);
72
98
  const repoWithConnection = repos.find(r => r.nangoConnectionId);
73
99
  if (!repoWithConnection?.nangoConnectionId) {
100
+ console.warn(`[git] No Nango connection found for user ${userId.substring(0, 8)}. Repos: ${repos.length}, with connections: ${repos.filter(r => r.nangoConnectionId).length}`);
74
101
  return res.status(404).json({
75
102
  error: 'No GitHub App connection found',
76
- hint: 'Connect a repository via the GitHub App to enable git operations',
103
+ code: 'NO_GITHUB_APP_CONNECTION',
104
+ hint: 'Install the GitHub App on your repositories at https://github.com/apps/agent-relay',
105
+ repoCount: repos.length,
77
106
  });
78
107
  }
108
+ console.log(`[git] Fetching token from Nango for connection ${repoWithConnection.nangoConnectionId.substring(0, 8)}...`);
79
109
  // Get fresh tokens from Nango (auto-refreshes if needed)
80
110
  // - installationToken: for git operations (clone, push, pull)
81
111
  // - userToken: for gh CLI operations (requires user context)
82
- const installationToken = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
112
+ let installationToken;
113
+ try {
114
+ installationToken = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
115
+ }
116
+ catch (nangoError) {
117
+ const errorMessage = nangoError instanceof Error ? nangoError.message : 'Unknown error';
118
+ console.error(`[git] Nango token fetch failed for connection ${repoWithConnection.nangoConnectionId}:`, errorMessage);
119
+ // Provide specific hints based on error type
120
+ if (errorMessage.includes('not found') || errorMessage.includes('404')) {
121
+ return res.status(500).json({
122
+ error: 'GitHub App connection expired or revoked',
123
+ code: 'NANGO_CONNECTION_EXPIRED',
124
+ hint: 'Reconnect your GitHub App at https://github.com/apps/agent-relay',
125
+ details: errorMessage,
126
+ });
127
+ }
128
+ return res.status(500).json({
129
+ error: 'Failed to fetch GitHub token from Nango',
130
+ code: 'NANGO_TOKEN_FETCH_FAILED',
131
+ hint: 'This may be a temporary issue. Try again in a few seconds.',
132
+ details: errorMessage,
133
+ });
134
+ }
83
135
  // Try to get user OAuth token from github-app-oauth connection_config first
84
136
  // Fall back to separate 'github' user connection if available
85
137
  let userToken = null;
@@ -100,6 +152,7 @@ gitRouter.get('/token', async (req, res) => {
100
152
  }
101
153
  // GitHub App installation tokens expire after 1 hour
102
154
  const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString(); // 55 min buffer
155
+ console.log(`[git] Token fetched successfully for workspace ${workspaceId.substring(0, 8)}`);
103
156
  res.json({
104
157
  token: installationToken,
105
158
  userToken, // For gh CLI - may be null if not available
@@ -108,8 +161,13 @@ gitRouter.get('/token', async (req, res) => {
108
161
  });
109
162
  }
110
163
  catch (error) {
111
- console.error('[git] Error getting token:', error);
112
- res.status(500).json({ error: 'Failed to get GitHub token' });
164
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
165
+ console.error('[git] Unexpected error getting token:', error);
166
+ res.status(500).json({
167
+ error: 'Failed to get GitHub token',
168
+ code: 'UNEXPECTED_ERROR',
169
+ details: errorMessage,
170
+ });
113
171
  }
114
172
  });
115
173
  /**
@@ -121,22 +179,46 @@ gitRouter.post('/token', async (req, res) => {
121
179
  if (!workspaceId || typeof workspaceId !== 'string') {
122
180
  return res.status(400).json({ error: 'workspaceId is required' });
123
181
  }
124
- if (!verifyWorkspaceToken(req, workspaceId)) {
125
- return res.status(401).json({ error: 'Invalid workspace token' });
182
+ const tokenVerification = verifyWorkspaceToken(req, workspaceId);
183
+ if (!tokenVerification.valid) {
184
+ console.warn(`[git] POST: Token verification failed for workspace ${workspaceId.substring(0, 8)}: ${tokenVerification.reason}`);
185
+ return res.status(401).json({
186
+ error: 'Invalid workspace token',
187
+ code: 'INVALID_WORKSPACE_TOKEN',
188
+ hint: tokenVerification.reason,
189
+ });
126
190
  }
127
191
  try {
128
192
  const workspace = await db.workspaces.findById(workspaceId);
129
193
  if (!workspace) {
130
- return res.status(404).json({ error: 'Workspace not found' });
194
+ console.warn(`[git] POST: Workspace not found: ${workspaceId}`);
195
+ return res.status(404).json({
196
+ error: 'Workspace not found',
197
+ code: 'WORKSPACE_NOT_FOUND',
198
+ });
131
199
  }
132
200
  const repos = await db.repositories.findByUserId(workspace.userId);
133
201
  const repoWithConnection = repos.find(r => r.nangoConnectionId);
134
202
  if (!repoWithConnection?.nangoConnectionId) {
203
+ console.warn(`[git] POST: No Nango connection for user ${workspace.userId.substring(0, 8)}`);
135
204
  return res.status(404).json({
136
205
  error: 'No GitHub App connection found',
206
+ code: 'NO_GITHUB_APP_CONNECTION',
207
+ });
208
+ }
209
+ let token;
210
+ try {
211
+ token = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
212
+ }
213
+ catch (nangoError) {
214
+ const errorMessage = nangoError instanceof Error ? nangoError.message : 'Unknown error';
215
+ console.error(`[git] POST: Nango token fetch failed:`, errorMessage);
216
+ return res.status(500).json({
217
+ error: 'Failed to fetch GitHub token',
218
+ code: 'NANGO_TOKEN_FETCH_FAILED',
219
+ details: errorMessage,
137
220
  });
138
221
  }
139
- const token = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
140
222
  const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString();
141
223
  res.json({
142
224
  token,
@@ -145,8 +227,13 @@ gitRouter.post('/token', async (req, res) => {
145
227
  });
146
228
  }
147
229
  catch (error) {
148
- console.error('[git] Error getting token:', error);
149
- res.status(500).json({ error: 'Failed to get GitHub token' });
230
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
231
+ console.error('[git] POST: Unexpected error:', error);
232
+ res.status(500).json({
233
+ error: 'Failed to get GitHub token',
234
+ code: 'UNEXPECTED_ERROR',
235
+ details: errorMessage,
236
+ });
150
237
  }
151
238
  });
152
239
  //# sourceMappingURL=git.js.map