agent-relay 1.2.3 → 1.3.1

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 (189) 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/chunks/532-bace199897eeab37.js +9 -0
  114. package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
  115. package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
  117. package/dist/dashboard/out/_next/static/chunks/899-fc02ed79e3de4302.js +1 -0
  118. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/{page-3fdfa60e53f2810d.js → page-8553743baca53a00.js} +1 -1
  119. package/dist/dashboard/out/_next/static/chunks/app/app/page-c617745b81344f4f.js +1 -0
  120. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-f829604fb75a831a.js +1 -0
  121. package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-dc786c183425c2ac.js} +1 -1
  122. package/dist/dashboard/out/_next/static/chunks/app/providers/page-84322991d7244499.js +1 -0
  123. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-05606941a8e2be83.js +1 -0
  124. package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
  125. package/dist/dashboard/out/_next/static/css/48a8fbe3e659080e.css +1 -0
  126. package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
  127. package/dist/dashboard/out/_next/static/sDcbGRTYLcpPvyTs_rsNb/_ssgManifest.js +1 -0
  128. package/dist/dashboard/out/app/onboarding.html +1 -1
  129. package/dist/dashboard/out/app/onboarding.txt +3 -3
  130. package/dist/dashboard/out/app.html +1 -1
  131. package/dist/dashboard/out/app.txt +3 -3
  132. package/dist/dashboard/out/apple-icon.png +0 -0
  133. package/dist/dashboard/out/connect-repos.html +1 -1
  134. package/dist/dashboard/out/connect-repos.txt +2 -2
  135. package/dist/dashboard/out/history.html +1 -1
  136. package/dist/dashboard/out/history.txt +2 -2
  137. package/dist/dashboard/out/index.html +1 -1
  138. package/dist/dashboard/out/index.txt +3 -3
  139. package/dist/dashboard/out/login.html +2 -2
  140. package/dist/dashboard/out/login.txt +2 -2
  141. package/dist/dashboard/out/metrics.html +1 -1
  142. package/dist/dashboard/out/metrics.txt +3 -3
  143. package/dist/dashboard/out/pricing.html +2 -2
  144. package/dist/dashboard/out/pricing.txt +3 -3
  145. package/dist/dashboard/out/providers/setup/claude.html +1 -0
  146. package/dist/dashboard/out/providers/setup/claude.txt +8 -0
  147. package/dist/dashboard/out/providers/setup/codex.html +1 -0
  148. package/dist/dashboard/out/providers/setup/codex.txt +8 -0
  149. package/dist/dashboard/out/providers.html +1 -1
  150. package/dist/dashboard/out/providers.txt +3 -3
  151. package/dist/dashboard/out/signup.html +2 -2
  152. package/dist/dashboard/out/signup.txt +2 -2
  153. package/dist/dashboard-server/server.js +316 -12
  154. package/dist/dashboard-server/user-bridge.d.ts +103 -0
  155. package/dist/dashboard-server/user-bridge.js +189 -0
  156. package/dist/protocol/channels.d.ts +205 -0
  157. package/dist/protocol/channels.js +154 -0
  158. package/dist/protocol/types.d.ts +13 -1
  159. package/dist/resiliency/provider-context.js +2 -0
  160. package/dist/shared/cli-auth-config.d.ts +19 -0
  161. package/dist/shared/cli-auth-config.js +58 -2
  162. package/dist/utils/agent-config.js +1 -1
  163. package/dist/wrapper/auth-detection.d.ts +49 -0
  164. package/dist/wrapper/auth-detection.js +192 -0
  165. package/dist/wrapper/base-wrapper.d.ts +153 -0
  166. package/dist/wrapper/base-wrapper.js +393 -0
  167. package/dist/wrapper/client.d.ts +7 -1
  168. package/dist/wrapper/client.js +3 -0
  169. package/dist/wrapper/index.d.ts +1 -0
  170. package/dist/wrapper/index.js +4 -3
  171. package/dist/wrapper/pty-wrapper.d.ts +62 -84
  172. package/dist/wrapper/pty-wrapper.js +154 -180
  173. package/dist/wrapper/tmux-wrapper.d.ts +41 -66
  174. package/dist/wrapper/tmux-wrapper.js +90 -134
  175. package/package.json +4 -2
  176. package/scripts/postinstall.js +11 -155
  177. package/scripts/test-interactive-terminal.sh +248 -0
  178. package/dist/cloud/vault/index.d.ts +0 -76
  179. package/dist/cloud/vault/index.js +0 -219
  180. package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
  181. package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
  182. package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
  183. package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
  184. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
  185. package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
  186. package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
  187. package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
  188. package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
  189. /package/dist/dashboard/out/_next/static/{wPgKJtcOmTFLpUncDg16A → sDcbGRTYLcpPvyTs_rsNb}/_buildManifest.js +0 -0
@@ -8,13 +8,14 @@ import helmet from 'helmet';
8
8
  import crypto from 'crypto';
9
9
  import path from 'node:path';
10
10
  import http from 'node:http';
11
+ import fs from 'node:fs';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  import { createClient } from 'redis';
13
14
  import { RedisStore } from 'connect-redis';
14
15
  import { WebSocketServer, WebSocket } from 'ws';
15
16
  import { getConfig } from './config.js';
16
17
  import { runMigrations } from './db/index.js';
17
- import { getScalingOrchestrator } from './services/index.js';
18
+ import { getScalingOrchestrator, getComputeEnforcementService, getIntroExpirationService } from './services/index.js';
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = path.dirname(__filename);
20
21
  // API routers
@@ -35,7 +36,9 @@ import { githubAppRouter } from './api/github-app.js';
35
36
  import { nangoAuthRouter } from './api/nango-auth.js';
36
37
  import { gitRouter } from './api/git.js';
37
38
  import { codexAuthHelperRouter } from './api/codex-auth-helper.js';
39
+ import { adminRouter } from './api/admin.js';
38
40
  import { db } from './db/index.js';
41
+ import { validateSshSecurityConfig } from './services/ssh-security.js';
39
42
  /**
40
43
  * Proxy a request to the user's primary running workspace
41
44
  */
@@ -66,6 +69,8 @@ async function proxyToUserWorkspace(req, res, path) {
66
69
  }
67
70
  export async function createServer() {
68
71
  const config = getConfig();
72
+ // Validate security configuration at startup
73
+ validateSshSecurityConfig();
69
74
  const app = express();
70
75
  app.set('trust proxy', 1);
71
76
  // Redis client for sessions
@@ -226,27 +231,37 @@ export async function createServer() {
226
231
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
227
232
  });
228
233
  // API routes
229
- app.use('/api/auth', authRouter);
234
+ //
235
+ // IMPORTANT: Route order matters! Routes with non-session auth (webhooks, API keys, tokens)
236
+ // must be mounted BEFORE teamsRouter, which catches all /api/* with requireAuth.
237
+ //
238
+ // --- Routes with alternative auth (must be before teamsRouter) ---
239
+ app.use('/api/auth', authRouter); // Login endpoints (public)
240
+ app.use('/api/auth/nango', nangoAuthRouter); // Nango webhook (signature verification)
241
+ app.use('/api/auth/codex-helper', codexAuthHelperRouter);
242
+ app.use('/api/git', gitRouter); // Workspace token auth
243
+ app.use('/api/webhooks', webhooksRouter); // GitHub webhooks (signature verification)
244
+ app.use('/api/monitoring', monitoringRouter); // Daemon API key auth endpoints
245
+ app.use('/api/daemons', daemonsRouter); // Daemon API key auth endpoints
246
+ app.use('/api/admin', adminRouter); // Admin API secret auth
247
+ // --- Routes with session auth ---
230
248
  app.use('/api/providers', providersRouter);
231
249
  app.use('/api/workspaces', workspacesRouter);
232
250
  app.use('/api/repos', reposRouter);
233
251
  app.use('/api/onboarding', onboardingRouter);
234
- app.use('/api/teams', teamsRouter);
235
252
  app.use('/api/billing', billingRouter);
236
253
  app.use('/api/usage', usageRouter);
237
254
  app.use('/api/project-groups', coordinatorsRouter);
238
- app.use('/api/daemons', daemonsRouter);
239
- app.use('/api/monitoring', monitoringRouter);
240
- app.use('/api/webhooks', webhooksRouter);
241
255
  app.use('/api/github-app', githubAppRouter);
242
- app.use('/api/auth/nango', nangoAuthRouter);
243
- app.use('/api/auth/codex-helper', codexAuthHelperRouter);
244
- app.use('/api/git', gitRouter);
245
256
  // Test helper routes (only available in non-production)
257
+ // MUST be before teamsRouter to avoid auth interception
246
258
  if (process.env.NODE_ENV !== 'production') {
247
259
  app.use('/api/test', testHelpersRouter);
248
260
  console.log('[cloud] Test helper routes enabled (non-production mode)');
249
261
  }
262
+ // Teams router - MUST BE LAST among /api routes
263
+ // Handles /workspaces/:id/members and /invites with requireAuth on all routes
264
+ app.use('/api', teamsRouter);
250
265
  // Trajectory proxy routes - auto-detect user's workspace and forward
251
266
  // These are convenience routes so the dashboard doesn't need to know the workspace ID
252
267
  app.get('/api/trajectory', requireAuth, async (req, res) => {
@@ -264,17 +279,29 @@ export async function createServer() {
264
279
  // Serve static dashboard files (Next.js static export)
265
280
  // Path: dist/cloud/server.js -> ../../src/dashboard/out
266
281
  const dashboardPath = path.join(__dirname, '../../src/dashboard/out');
267
- // Serve static files with .html extension fallback for clean URLs
268
- // e.g., /signup will try /signup.html
269
- app.use(express.static(dashboardPath, { extensions: ['html'] }));
270
- // SPA fallback - serve index.html for all non-API routes that don't match static files
271
- // Express 5 requires named wildcard params instead of bare '*'
282
+ // Serve static files (JS, CSS, images, etc.)
283
+ app.use(express.static(dashboardPath));
284
+ // Handle clean URLs for Next.js static export
285
+ // When a directory exists (e.g., /app/), express.static won't serve app.html
286
+ // So we need to explicitly check for .html files
272
287
  app.get('/{*splat}', (req, res, next) => {
273
- // Don't serve index.html for API routes
288
+ // Don't handle API routes
274
289
  if (req.path.startsWith('/api/')) {
275
290
  return next();
276
291
  }
277
- res.sendFile(path.join(dashboardPath, 'index.html'));
292
+ // Clean the path (remove trailing slash)
293
+ const cleanPath = req.path.replace(/\/$/, '') || '/';
294
+ // Try to serve the corresponding .html file
295
+ const htmlFile = cleanPath === '/' ? 'index.html' : `${cleanPath}.html`;
296
+ const htmlPath = path.join(dashboardPath, htmlFile);
297
+ // Check if the HTML file exists
298
+ if (fs.existsSync(htmlPath)) {
299
+ res.sendFile(htmlPath);
300
+ }
301
+ else {
302
+ // Fallback to index.html for SPA-style routing
303
+ res.sendFile(path.join(dashboardPath, 'index.html'));
304
+ }
278
305
  });
279
306
  // Error handler
280
307
  app.use((err, req, res, _next) => {
@@ -287,6 +314,8 @@ export async function createServer() {
287
314
  // Server lifecycle
288
315
  let server = null;
289
316
  let scalingOrchestrator = null;
317
+ let computeEnforcement = null;
318
+ let introExpiration = null;
290
319
  // Create HTTP server for WebSocket upgrade handling
291
320
  const httpServer = http.createServer(app);
292
321
  // ===== Presence WebSocket =====
@@ -318,6 +347,77 @@ export async function createServer() {
318
347
  return false;
319
348
  }
320
349
  };
350
+ // WebSocket server for agent logs (proxied to workspace daemon)
351
+ const wssLogs = new WebSocketServer({ noServer: true, perMessageDeflate: false });
352
+ // Handle agent logs WebSocket connections
353
+ wssLogs.on('connection', async (clientWs, workspaceId, agentName) => {
354
+ console.log(`[ws/logs] Client connected for workspace=${workspaceId} agent=${agentName}`);
355
+ let daemonWs = null;
356
+ try {
357
+ // Find the workspace
358
+ const workspace = await db.workspaces.findById(workspaceId);
359
+ if (!workspace || !workspace.publicUrl) {
360
+ clientWs.send(JSON.stringify({ type: 'error', message: 'Workspace not found or not running' }));
361
+ clientWs.close();
362
+ return;
363
+ }
364
+ // Connect to workspace daemon WebSocket
365
+ // The workspace runs the dashboard server which expects /ws/logs path
366
+ const baseUrl = workspace.publicUrl.replace(/^http/, 'ws').replace(/\/$/, '');
367
+ const daemonWsUrl = `${baseUrl}/ws/logs/${encodeURIComponent(agentName)}`;
368
+ console.log(`[ws/logs] Connecting to daemon: ${daemonWsUrl}`);
369
+ daemonWs = new WebSocket(daemonWsUrl, { perMessageDeflate: false });
370
+ daemonWs.on('open', () => {
371
+ console.log(`[ws/logs] Connected to daemon for ${agentName}`);
372
+ // Note: No need to send subscribe message - the agent name in the URL path
373
+ // triggers auto-subscription in the dashboard server
374
+ });
375
+ daemonWs.on('message', (data) => {
376
+ // Forward daemon messages to client
377
+ if (clientWs.readyState === WebSocket.OPEN) {
378
+ clientWs.send(data.toString());
379
+ }
380
+ });
381
+ daemonWs.on('close', () => {
382
+ console.log(`[ws/logs] Daemon connection closed for ${agentName}`);
383
+ if (clientWs.readyState === WebSocket.OPEN) {
384
+ clientWs.close();
385
+ }
386
+ });
387
+ daemonWs.on('error', (err) => {
388
+ console.error(`[ws/logs] Daemon WebSocket error:`, err);
389
+ if (clientWs.readyState === WebSocket.OPEN) {
390
+ clientWs.send(JSON.stringify({ type: 'error', message: 'Daemon connection error' }));
391
+ clientWs.close();
392
+ }
393
+ });
394
+ // Forward client messages to daemon (for user input)
395
+ clientWs.on('message', (data) => {
396
+ if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
397
+ daemonWs.send(data.toString());
398
+ }
399
+ });
400
+ clientWs.on('close', () => {
401
+ console.log(`[ws/logs] Client disconnected for ${agentName}`);
402
+ if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
403
+ daemonWs.close();
404
+ }
405
+ });
406
+ clientWs.on('error', (err) => {
407
+ console.error(`[ws/logs] Client WebSocket error:`, err);
408
+ if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
409
+ daemonWs.close();
410
+ }
411
+ });
412
+ }
413
+ catch (err) {
414
+ console.error(`[ws/logs] Setup error:`, err);
415
+ if (clientWs.readyState === WebSocket.OPEN) {
416
+ clientWs.send(JSON.stringify({ type: 'error', message: 'Failed to connect to workspace' }));
417
+ clientWs.close();
418
+ }
419
+ }
420
+ });
321
421
  // Handle HTTP upgrade for WebSocket
322
422
  httpServer.on('upgrade', (request, socket, head) => {
323
423
  const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
@@ -326,6 +426,20 @@ export async function createServer() {
326
426
  wssPresence.emit('connection', ws, request);
327
427
  });
328
428
  }
429
+ else if (pathname.startsWith('/ws/logs/')) {
430
+ // Parse /ws/logs/:workspaceId/:agentName
431
+ const parts = pathname.split('/').filter(Boolean);
432
+ if (parts.length >= 4) {
433
+ const workspaceId = decodeURIComponent(parts[2]);
434
+ const agentName = decodeURIComponent(parts[3]);
435
+ wssLogs.handleUpgrade(request, socket, head, (ws) => {
436
+ wssLogs.emit('connection', ws, workspaceId, agentName);
437
+ });
438
+ }
439
+ else {
440
+ socket.destroy();
441
+ }
442
+ }
329
443
  else {
330
444
  // Unknown WebSocket path - destroy socket
331
445
  socket.destroy();
@@ -512,6 +626,24 @@ export async function createServer() {
512
626
  console.warn('[cloud] Failed to initialize scaling orchestrator:', error);
513
627
  // Non-fatal - server can run without auto-scaling
514
628
  }
629
+ // Start compute enforcement service (checks every 15 min)
630
+ try {
631
+ computeEnforcement = getComputeEnforcementService();
632
+ computeEnforcement.start();
633
+ console.log('[cloud] Compute enforcement service started');
634
+ }
635
+ catch (error) {
636
+ console.warn('[cloud] Failed to start compute enforcement:', error);
637
+ }
638
+ // Start intro expiration service (checks every hour for expired intro periods)
639
+ try {
640
+ introExpiration = getIntroExpirationService();
641
+ introExpiration.start();
642
+ console.log('[cloud] Intro expiration service started');
643
+ }
644
+ catch (error) {
645
+ console.warn('[cloud] Failed to start intro expiration:', error);
646
+ }
515
647
  }
516
648
  return new Promise((resolve) => {
517
649
  server = httpServer.listen(config.port, () => {
@@ -527,6 +659,14 @@ export async function createServer() {
527
659
  if (scalingOrchestrator) {
528
660
  await scalingOrchestrator.shutdown();
529
661
  }
662
+ // Stop compute enforcement service
663
+ if (computeEnforcement) {
664
+ computeEnforcement.stop();
665
+ }
666
+ // Stop intro expiration service
667
+ if (introExpiration) {
668
+ introExpiration.stop();
669
+ }
530
670
  // Close WebSocket server
531
671
  wssPresence.close();
532
672
  if (server) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Compute Enforcement Service
3
+ *
4
+ * Enforces compute hour limits for free tier users.
5
+ * Runs periodically to check usage and stop workspaces that have exceeded limits.
6
+ */
7
+ import { PlanType } from '../db/index.js';
8
+ export interface ComputeEnforcementConfig {
9
+ enabled: boolean;
10
+ checkIntervalMs: number;
11
+ warningThresholdPercent: number;
12
+ }
13
+ export interface EnforcementResult {
14
+ userId: string;
15
+ plan: PlanType;
16
+ computeHoursUsed: number;
17
+ computeHoursLimit: number;
18
+ action: 'none' | 'warning' | 'stopped';
19
+ workspacesStopped: string[];
20
+ }
21
+ export declare class ComputeEnforcementService {
22
+ private config;
23
+ private checkTimer;
24
+ private isRunning;
25
+ constructor(config?: Partial<ComputeEnforcementConfig>);
26
+ /**
27
+ * Start the enforcement service
28
+ */
29
+ start(): void;
30
+ /**
31
+ * Stop the enforcement service
32
+ */
33
+ stop(): void;
34
+ /**
35
+ * Run enforcement check for all free tier users
36
+ */
37
+ runEnforcement(): Promise<EnforcementResult[]>;
38
+ /**
39
+ * Enforce limits for a specific user
40
+ */
41
+ enforceUserLimits(userId: string): Promise<EnforcementResult>;
42
+ /**
43
+ * Manually trigger enforcement for a specific user
44
+ */
45
+ enforceUser(userId: string): Promise<EnforcementResult>;
46
+ /**
47
+ * Get service status
48
+ */
49
+ getStatus(): {
50
+ enabled: boolean;
51
+ isRunning: boolean;
52
+ checkIntervalMs: number;
53
+ };
54
+ }
55
+ export declare function getComputeEnforcementService(): ComputeEnforcementService;
56
+ export declare function createComputeEnforcementService(config?: Partial<ComputeEnforcementConfig>): ComputeEnforcementService;
57
+ //# sourceMappingURL=compute-enforcement.d.ts.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Compute Enforcement Service
3
+ *
4
+ * Enforces compute hour limits for free tier users.
5
+ * Runs periodically to check usage and stop workspaces that have exceeded limits.
6
+ */
7
+ import { db } from '../db/index.js';
8
+ import { getProvisioner } from '../provisioner/index.js';
9
+ import { getUserUsage, PLAN_LIMITS } from './planLimits.js';
10
+ const DEFAULT_CONFIG = {
11
+ enabled: true,
12
+ checkIntervalMs: 15 * 60 * 1000, // 15 minutes
13
+ warningThresholdPercent: 80,
14
+ };
15
+ export class ComputeEnforcementService {
16
+ config;
17
+ checkTimer = null;
18
+ isRunning = false;
19
+ constructor(config = {}) {
20
+ this.config = { ...DEFAULT_CONFIG, ...config };
21
+ }
22
+ /**
23
+ * Start the enforcement service
24
+ */
25
+ start() {
26
+ if (!this.config.enabled) {
27
+ console.log('[compute-enforcement] Service disabled');
28
+ return;
29
+ }
30
+ if (this.isRunning) {
31
+ console.warn('[compute-enforcement] Service already running');
32
+ return;
33
+ }
34
+ this.isRunning = true;
35
+ console.log(`[compute-enforcement] Started (checking every ${this.config.checkIntervalMs / 1000}s)`);
36
+ // Run immediately on start
37
+ this.runEnforcement().catch((err) => {
38
+ console.error('[compute-enforcement] Initial run failed:', err);
39
+ });
40
+ // Then run periodically
41
+ this.checkTimer = setInterval(() => {
42
+ this.runEnforcement().catch((err) => {
43
+ console.error('[compute-enforcement] Periodic run failed:', err);
44
+ });
45
+ }, this.config.checkIntervalMs);
46
+ }
47
+ /**
48
+ * Stop the enforcement service
49
+ */
50
+ stop() {
51
+ if (this.checkTimer) {
52
+ clearInterval(this.checkTimer);
53
+ this.checkTimer = null;
54
+ }
55
+ this.isRunning = false;
56
+ console.log('[compute-enforcement] Stopped');
57
+ }
58
+ /**
59
+ * Run enforcement check for all free tier users
60
+ */
61
+ async runEnforcement() {
62
+ const results = [];
63
+ try {
64
+ // Get all users on free tier
65
+ const freeUsers = await db.users.findByPlan('free');
66
+ console.log(`[compute-enforcement] Checking ${freeUsers.length} free tier users`);
67
+ for (const user of freeUsers) {
68
+ try {
69
+ const result = await this.enforceUserLimits(user.id);
70
+ results.push(result);
71
+ if (result.action !== 'none') {
72
+ console.log(`[compute-enforcement] User ${user.id.substring(0, 8)}: ${result.action} ` +
73
+ `(${result.computeHoursUsed.toFixed(2)}/${result.computeHoursLimit}h)`);
74
+ }
75
+ }
76
+ catch (err) {
77
+ console.error(`[compute-enforcement] Error for user ${user.id}:`, err);
78
+ }
79
+ }
80
+ const stopped = results.filter((r) => r.action === 'stopped').length;
81
+ const warned = results.filter((r) => r.action === 'warning').length;
82
+ if (stopped > 0 || warned > 0) {
83
+ console.log(`[compute-enforcement] Summary: ${stopped} stopped, ${warned} warned, ` +
84
+ `${results.length - stopped - warned} ok`);
85
+ }
86
+ }
87
+ catch (err) {
88
+ console.error('[compute-enforcement] Failed to run enforcement:', err);
89
+ }
90
+ return results;
91
+ }
92
+ /**
93
+ * Enforce limits for a specific user
94
+ */
95
+ async enforceUserLimits(userId) {
96
+ const user = await db.users.findById(userId);
97
+ const plan = user?.plan || 'free';
98
+ const limits = PLAN_LIMITS[plan];
99
+ const usage = await getUserUsage(userId);
100
+ const result = {
101
+ userId,
102
+ plan,
103
+ computeHoursUsed: usage.computeHoursThisMonth,
104
+ computeHoursLimit: limits.maxComputeHoursPerMonth,
105
+ action: 'none',
106
+ workspacesStopped: [],
107
+ };
108
+ // Skip if user has unlimited compute (paid plans may have high limits)
109
+ if (limits.maxComputeHoursPerMonth === Infinity) {
110
+ return result;
111
+ }
112
+ // Check if user has exceeded limit
113
+ if (usage.computeHoursThisMonth >= limits.maxComputeHoursPerMonth) {
114
+ // Stop all running workspaces
115
+ const workspaces = await db.workspaces.findByUserId(userId);
116
+ const runningWorkspaces = workspaces.filter((w) => w.status === 'running');
117
+ if (runningWorkspaces.length > 0) {
118
+ const provisioner = getProvisioner();
119
+ for (const workspace of runningWorkspaces) {
120
+ try {
121
+ await provisioner.stop(workspace.id);
122
+ result.workspacesStopped.push(workspace.id);
123
+ console.log(`[compute-enforcement] Stopped workspace ${workspace.id.substring(0, 8)} ` +
124
+ `for user ${userId.substring(0, 8)} (limit exceeded)`);
125
+ }
126
+ catch (err) {
127
+ console.error(`[compute-enforcement] Failed to stop workspace ${workspace.id}:`, err);
128
+ }
129
+ }
130
+ result.action = 'stopped';
131
+ // TODO: Send notification email to user
132
+ // await sendLimitReachedEmail(userId, usage.computeHoursThisMonth, limits.maxComputeHoursPerMonth);
133
+ }
134
+ }
135
+ else {
136
+ // Check if approaching limit (warning)
137
+ const usagePercent = (usage.computeHoursThisMonth / limits.maxComputeHoursPerMonth) * 100;
138
+ if (usagePercent >= this.config.warningThresholdPercent) {
139
+ result.action = 'warning';
140
+ // TODO: Send warning email to user (once per day)
141
+ // await sendLimitWarningEmail(userId, usage.computeHoursThisMonth, limits.maxComputeHoursPerMonth);
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ /**
147
+ * Manually trigger enforcement for a specific user
148
+ */
149
+ async enforceUser(userId) {
150
+ return this.enforceUserLimits(userId);
151
+ }
152
+ /**
153
+ * Get service status
154
+ */
155
+ getStatus() {
156
+ return {
157
+ enabled: this.config.enabled,
158
+ isRunning: this.isRunning,
159
+ checkIntervalMs: this.config.checkIntervalMs,
160
+ };
161
+ }
162
+ }
163
+ // Singleton instance
164
+ let _computeEnforcement = null;
165
+ export function getComputeEnforcementService() {
166
+ if (!_computeEnforcement) {
167
+ _computeEnforcement = new ComputeEnforcementService();
168
+ }
169
+ return _computeEnforcement;
170
+ }
171
+ export function createComputeEnforcementService(config = {}) {
172
+ _computeEnforcement = new ComputeEnforcementService(config);
173
+ return _computeEnforcement;
174
+ }
175
+ //# sourceMappingURL=compute-enforcement.js.map
@@ -9,4 +9,6 @@ export { CapacityManager, CapacityManagerConfig, WorkspaceCapacity, PlacementRec
9
9
  export { ScalingOrchestrator, OrchestratorConfig, ScalingEvent, getScalingOrchestrator, createScalingOrchestrator, } from './scaling-orchestrator.js';
10
10
  export { spawnCIFixAgent, notifyAgentOfCIFailure, completeFixAttempt, getFailureHistory, getPRFailureHistory, } from './ci-agent-spawner.js';
11
11
  export { handleMention, handleIssueAssignment, getPendingMentions, getPendingIssueAssignments, processPendingMentions, processPendingIssueAssignments, KNOWN_AGENTS, isKnownAgent, } from './mention-handler.js';
12
+ export { ComputeEnforcementService, ComputeEnforcementConfig, EnforcementResult, getComputeEnforcementService, createComputeEnforcementService, } from './compute-enforcement.js';
13
+ export { IntroExpirationService, IntroExpirationConfig, IntroStatus, ExpirationResult as IntroExpirationResult, INTRO_PERIOD_DAYS, getIntroStatus, getIntroExpirationService, startIntroExpirationService, stopIntroExpirationService, } from './intro-expiration.js';
12
14
  //# sourceMappingURL=index.d.ts.map
@@ -12,4 +12,8 @@ export { ScalingOrchestrator, getScalingOrchestrator, createScalingOrchestrator,
12
12
  export { spawnCIFixAgent, notifyAgentOfCIFailure, completeFixAttempt, getFailureHistory, getPRFailureHistory, } from './ci-agent-spawner.js';
13
13
  // Issue and mention handling
14
14
  export { handleMention, handleIssueAssignment, getPendingMentions, getPendingIssueAssignments, processPendingMentions, processPendingIssueAssignments, KNOWN_AGENTS, isKnownAgent, } from './mention-handler.js';
15
+ // Compute enforcement (free tier limits)
16
+ export { ComputeEnforcementService, getComputeEnforcementService, createComputeEnforcementService, } from './compute-enforcement.js';
17
+ // Intro expiration (auto-resize after free tier intro period)
18
+ export { IntroExpirationService, INTRO_PERIOD_DAYS, getIntroStatus, getIntroExpirationService, startIntroExpirationService, stopIntroExpirationService, } from './intro-expiration.js';
15
19
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Intro Expiration Service
3
+ *
4
+ * Handles auto-resize of workspaces when the free tier introductory period expires.
5
+ * Free users get Pro-level resources (2 CPU / 4GB) for the first 14 days,
6
+ * then get automatically downsized to standard free tier (1 CPU / 2GB).
7
+ */
8
+ export declare const INTRO_PERIOD_DAYS = 14;
9
+ export interface IntroExpirationConfig {
10
+ enabled: boolean;
11
+ checkIntervalMs: number;
12
+ }
13
+ export interface IntroStatus {
14
+ isIntroPeriod: boolean;
15
+ daysRemaining: number;
16
+ introPeriodDays: number;
17
+ expiresAt: Date | null;
18
+ }
19
+ export interface ExpirationResult {
20
+ userId: string;
21
+ workspaceId: string;
22
+ workspaceName: string;
23
+ action: 'resized' | 'skipped' | 'error';
24
+ reason?: string;
25
+ }
26
+ /**
27
+ * Get intro period status for a user
28
+ */
29
+ export declare function getIntroStatus(userCreatedAt: Date | string | null, plan: string): IntroStatus;
30
+ export declare class IntroExpirationService {
31
+ private config;
32
+ private checkTimer;
33
+ private isRunning;
34
+ constructor(config?: Partial<IntroExpirationConfig>);
35
+ /**
36
+ * Start the expiration service
37
+ */
38
+ start(): void;
39
+ /**
40
+ * Stop the expiration service
41
+ */
42
+ stop(): void;
43
+ /**
44
+ * Run expiration check for all free tier users with expired intro periods
45
+ */
46
+ runExpirationCheck(): Promise<ExpirationResult[]>;
47
+ /**
48
+ * Check and resize workspaces for a user whose intro period has expired
49
+ */
50
+ private checkAndResizeUserWorkspaces;
51
+ }
52
+ export declare function getIntroExpirationService(): IntroExpirationService;
53
+ export declare function startIntroExpirationService(): void;
54
+ export declare function stopIntroExpirationService(): void;
55
+ //# sourceMappingURL=intro-expiration.d.ts.map