agent-relay 2.0.16 → 2.0.18

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 (153) hide show
  1. package/deploy/workspace/entrypoint.sh +35 -19
  2. package/deploy/workspace/git-credential-relay +82 -7
  3. package/dist/dashboard/out/404.html +1 -1
  4. package/dist/dashboard/out/_next/static/chunks/320-402ffc8646b31da1.js +1 -0
  5. package/dist/dashboard/out/_next/static/chunks/83-26d2bde54616ee90.js +1 -0
  6. package/{packages/dashboard/ui-dist/_next/static/chunks/app/app/page-9d6bc8729b429956.js → dist/dashboard/out/_next/static/chunks/app/app/page-366fb7c078d4e9e0.js} +1 -1
  7. package/dist/dashboard/out/_next/static/chunks/app/complete-profile/page-dd64bbdf66b639cd.js +1 -0
  8. package/dist/dashboard/out/_next/static/chunks/app/login/page-435eceb0073be027.js +1 -0
  9. package/dist/dashboard/out/_next/static/chunks/app/{page-487fa38f041815c1.js → page-8119d4246743574e.js} +1 -1
  10. package/dist/dashboard/out/_next/static/chunks/app/signup/page-c7a0a28341365ae0.js +1 -0
  11. package/dist/dashboard/out/_next/static/chunks/{main-5a40a5ae29646e1b.js → main-311c3db74dcfadb7.js} +1 -1
  12. package/dist/dashboard/out/_next/static/css/{605dd4e30c91986f.css → 45361ce86b2847c4.css} +1 -1
  13. package/dist/dashboard/out/app/onboarding.html +1 -1
  14. package/dist/dashboard/out/app/onboarding.txt +1 -1
  15. package/dist/dashboard/out/app.html +1 -1
  16. package/dist/dashboard/out/app.txt +2 -2
  17. package/dist/dashboard/out/cloud/link.html +1 -1
  18. package/dist/dashboard/out/cloud/link.txt +1 -1
  19. package/dist/dashboard/out/complete-profile.html +5 -0
  20. package/dist/dashboard/out/complete-profile.txt +7 -0
  21. package/dist/dashboard/out/connect-repos.html +1 -1
  22. package/dist/dashboard/out/connect-repos.txt +1 -1
  23. package/dist/dashboard/out/history.html +1 -1
  24. package/dist/dashboard/out/history.txt +1 -1
  25. package/dist/dashboard/out/index.html +1 -1
  26. package/dist/dashboard/out/index.txt +2 -2
  27. package/dist/dashboard/out/login.html +2 -2
  28. package/dist/dashboard/out/login.txt +2 -2
  29. package/dist/dashboard/out/metrics.html +1 -1
  30. package/dist/dashboard/out/metrics.txt +1 -1
  31. package/dist/dashboard/out/pricing.html +2 -2
  32. package/dist/dashboard/out/pricing.txt +1 -1
  33. package/dist/dashboard/out/providers/setup/claude.html +1 -1
  34. package/dist/dashboard/out/providers/setup/claude.txt +1 -1
  35. package/dist/dashboard/out/providers/setup/codex.html +1 -1
  36. package/dist/dashboard/out/providers/setup/codex.txt +1 -1
  37. package/dist/dashboard/out/providers/setup/cursor.html +1 -1
  38. package/dist/dashboard/out/providers/setup/cursor.txt +1 -1
  39. package/dist/dashboard/out/providers.html +1 -1
  40. package/dist/dashboard/out/providers.txt +2 -2
  41. package/dist/dashboard/out/signup.html +2 -2
  42. package/dist/dashboard/out/signup.txt +2 -2
  43. package/dist/src/cli/index.js +3 -1
  44. package/package.json +22 -21
  45. package/packages/api-types/package.json +1 -1
  46. package/packages/bridge/package.json +8 -8
  47. package/packages/cloud/dist/api/auth.js +2 -0
  48. package/packages/cloud/dist/api/billing.js +4 -4
  49. package/packages/cloud/dist/api/email-auth.d.ts +11 -0
  50. package/packages/cloud/dist/api/email-auth.js +347 -0
  51. package/packages/cloud/dist/api/nango-auth.js +72 -5
  52. package/packages/cloud/dist/db/drizzle.d.ts +35 -1
  53. package/packages/cloud/dist/db/drizzle.js +136 -0
  54. package/packages/cloud/dist/db/index.d.ts +5 -4
  55. package/packages/cloud/dist/db/index.js +5 -3
  56. package/packages/cloud/dist/db/schema.d.ts +246 -2
  57. package/packages/cloud/dist/db/schema.js +39 -3
  58. package/packages/cloud/dist/provisioner/index.js +5 -1
  59. package/packages/cloud/dist/server.js +134 -24
  60. package/packages/cloud/dist/services/nango.d.ts +18 -0
  61. package/packages/cloud/dist/services/nango.js +32 -0
  62. package/packages/cloud/package.json +6 -6
  63. package/packages/config/package.json +2 -2
  64. package/packages/continuity/package.json +1 -1
  65. package/packages/daemon/package.json +12 -12
  66. package/packages/dashboard/dist/server.js +36 -7
  67. package/packages/dashboard/package.json +13 -13
  68. package/packages/dashboard/ui/app/complete-profile/page.tsx +204 -0
  69. package/packages/dashboard/ui/app/login/page.tsx +182 -38
  70. package/packages/dashboard/ui/app/signup/page.tsx +244 -54
  71. package/packages/dashboard/ui/lib/cloudApi.ts +1 -0
  72. package/packages/dashboard/ui/react-components/App.tsx +1 -1
  73. package/packages/dashboard/ui/react-components/ProviderAuthFlow.tsx +10 -0
  74. package/packages/dashboard/ui/react-components/RepoAccessPanel.tsx +160 -3
  75. package/packages/dashboard/ui-dist/404.html +1 -1
  76. package/packages/dashboard/ui-dist/_next/static/chunks/320-402ffc8646b31da1.js +1 -0
  77. package/packages/dashboard/ui-dist/_next/static/chunks/83-26d2bde54616ee90.js +1 -0
  78. package/{dist/dashboard/out/_next/static/chunks/app/app/page-9d6bc8729b429956.js → packages/dashboard/ui-dist/_next/static/chunks/app/app/page-366fb7c078d4e9e0.js} +1 -1
  79. package/packages/dashboard/ui-dist/_next/static/chunks/app/complete-profile/page-dd64bbdf66b639cd.js +1 -0
  80. package/packages/dashboard/ui-dist/_next/static/chunks/app/login/page-435eceb0073be027.js +1 -0
  81. package/packages/dashboard/ui-dist/_next/static/chunks/app/{page-487fa38f041815c1.js → page-8119d4246743574e.js} +1 -1
  82. package/packages/dashboard/ui-dist/_next/static/chunks/app/signup/page-c7a0a28341365ae0.js +1 -0
  83. package/packages/dashboard/ui-dist/_next/static/chunks/{main-5a40a5ae29646e1b.js → main-311c3db74dcfadb7.js} +1 -1
  84. package/packages/dashboard/ui-dist/_next/static/css/{605dd4e30c91986f.css → 45361ce86b2847c4.css} +1 -1
  85. package/packages/dashboard/ui-dist/app/onboarding.html +1 -1
  86. package/packages/dashboard/ui-dist/app/onboarding.txt +1 -1
  87. package/packages/dashboard/ui-dist/app.html +1 -1
  88. package/packages/dashboard/ui-dist/app.txt +2 -2
  89. package/packages/dashboard/ui-dist/cloud/link.html +1 -1
  90. package/packages/dashboard/ui-dist/cloud/link.txt +1 -1
  91. package/packages/dashboard/ui-dist/complete-profile.html +5 -0
  92. package/packages/dashboard/ui-dist/complete-profile.txt +7 -0
  93. package/packages/dashboard/ui-dist/connect-repos.html +1 -1
  94. package/packages/dashboard/ui-dist/connect-repos.txt +1 -1
  95. package/packages/dashboard/ui-dist/history.html +1 -1
  96. package/packages/dashboard/ui-dist/history.txt +1 -1
  97. package/packages/dashboard/ui-dist/index.html +1 -1
  98. package/packages/dashboard/ui-dist/index.txt +2 -2
  99. package/packages/dashboard/ui-dist/login.html +2 -2
  100. package/packages/dashboard/ui-dist/login.txt +2 -2
  101. package/packages/dashboard/ui-dist/metrics.html +1 -1
  102. package/packages/dashboard/ui-dist/metrics.txt +1 -1
  103. package/packages/dashboard/ui-dist/pricing.html +2 -2
  104. package/packages/dashboard/ui-dist/pricing.txt +1 -1
  105. package/packages/dashboard/ui-dist/providers/setup/claude.html +1 -1
  106. package/packages/dashboard/ui-dist/providers/setup/claude.txt +1 -1
  107. package/packages/dashboard/ui-dist/providers/setup/codex.html +1 -1
  108. package/packages/dashboard/ui-dist/providers/setup/codex.txt +1 -1
  109. package/packages/dashboard/ui-dist/providers/setup/cursor.html +1 -1
  110. package/packages/dashboard/ui-dist/providers/setup/cursor.txt +1 -1
  111. package/packages/dashboard/ui-dist/providers.html +1 -1
  112. package/packages/dashboard/ui-dist/providers.txt +2 -2
  113. package/packages/dashboard/ui-dist/signup.html +2 -2
  114. package/packages/dashboard/ui-dist/signup.txt +2 -2
  115. package/packages/dashboard-server/dist/server.js +36 -7
  116. package/packages/dashboard-server/package.json +12 -12
  117. package/packages/hooks/package.json +4 -4
  118. package/packages/mcp/package.json +2 -2
  119. package/packages/memory/package.json +2 -2
  120. package/packages/policy/package.json +2 -2
  121. package/packages/protocol/package.json +1 -1
  122. package/packages/resiliency/package.json +1 -1
  123. package/packages/sdk/package.json +2 -2
  124. package/packages/spawner/package.json +1 -1
  125. package/packages/state/package.json +1 -1
  126. package/packages/storage/package.json +2 -2
  127. package/packages/telemetry/package.json +1 -1
  128. package/packages/trajectory/package.json +2 -2
  129. package/packages/user-directory/package.json +2 -2
  130. package/packages/utils/package.json +1 -1
  131. package/packages/wrapper/dist/relay-pty-orchestrator.js +17 -3
  132. package/packages/wrapper/package.json +6 -6
  133. package/relay-snippets/agent-policy-snippet.md +40 -0
  134. package/relay-snippets/agent-relay-protocol.md +101 -0
  135. package/relay-snippets/agent-relay-snippet.md +177 -0
  136. package/SESSION_HANDOFF.md +0 -67
  137. package/dist/dashboard/out/_next/static/chunks/320-23e5ffe6aa7eb934.js +0 -1
  138. package/dist/dashboard/out/_next/static/chunks/83-4f08122d4e7e79a6.js +0 -1
  139. package/dist/dashboard/out/_next/static/chunks/app/login/page-a0ca6f7ca6a100b8.js +0 -1
  140. package/dist/dashboard/out/_next/static/chunks/app/signup/page-1ede2205b58649ca.js +0 -1
  141. package/packages/dashboard/ui-dist/_next/static/chunks/320-23e5ffe6aa7eb934.js +0 -1
  142. package/packages/dashboard/ui-dist/_next/static/chunks/83-4f08122d4e7e79a6.js +0 -1
  143. package/packages/dashboard/ui-dist/_next/static/chunks/app/login/page-a0ca6f7ca6a100b8.js +0 -1
  144. package/packages/dashboard/ui-dist/_next/static/chunks/app/signup/page-1ede2205b58649ca.js +0 -1
  145. package/test-push.txt +0 -1
  146. /package/dist/dashboard/out/_next/static/{itBGQ1M8yMA_hC42DKCqv → JIjqkuDKNeoSg7KaMMuhx}/_buildManifest.js +0 -0
  147. /package/dist/dashboard/out/_next/static/{itBGQ1M8yMA_hC42DKCqv → JIjqkuDKNeoSg7KaMMuhx}/_ssgManifest.js +0 -0
  148. /package/packages/dashboard/ui-dist/_next/static/{ML6Zby1B5OtZvl0Pa1zSZ → JIjqkuDKNeoSg7KaMMuhx}/_buildManifest.js +0 -0
  149. /package/packages/dashboard/ui-dist/_next/static/{ML6Zby1B5OtZvl0Pa1zSZ → JIjqkuDKNeoSg7KaMMuhx}/_ssgManifest.js +0 -0
  150. /package/packages/dashboard/ui-dist/_next/static/{Ni5Di0TB0PDcrvEYBFRKd → nmkOi7bqeDmLMoWBih8lz}/_buildManifest.js +0 -0
  151. /package/packages/dashboard/ui-dist/_next/static/{Ni5Di0TB0PDcrvEYBFRKd → nmkOi7bqeDmLMoWBih8lz}/_ssgManifest.js +0 -0
  152. /package/packages/dashboard/ui-dist/_next/static/{itBGQ1M8yMA_hC42DKCqv → wk_gKRNSPpWE-ZhGL6UMl}/_buildManifest.js +0 -0
  153. /package/packages/dashboard/ui-dist/_next/static/{itBGQ1M8yMA_hC42DKCqv → wk_gKRNSPpWE-ZhGL6UMl}/_ssgManifest.js +0 -0
@@ -12,9 +12,17 @@ import { relations } from 'drizzle-orm';
12
12
  // ============================================================================
13
13
  export const users = pgTable('users', {
14
14
  id: uuid('id').primaryKey().defaultRandom(),
15
- githubId: varchar('github_id', { length: 255 }).unique().notNull(),
16
- githubUsername: varchar('github_username', { length: 255 }).notNull(),
17
- email: varchar('email', { length: 255 }),
15
+ // GitHub OAuth fields (nullable for email-only users)
16
+ githubId: varchar('github_id', { length: 255 }).unique(),
17
+ githubUsername: varchar('github_username', { length: 255 }),
18
+ // Email authentication fields
19
+ email: varchar('email', { length: 255 }).unique(),
20
+ passwordHash: varchar('password_hash', { length: 255 }), // For email login
21
+ emailVerified: boolean('email_verified').notNull().default(false),
22
+ emailVerificationToken: varchar('email_verification_token', { length: 255 }),
23
+ emailVerificationExpires: timestamp('email_verification_expires'),
24
+ // Profile
25
+ displayName: varchar('display_name', { length: 255 }), // User-provided name for email users
18
26
  avatarUrl: varchar('avatar_url', { length: 512 }),
19
27
  plan: varchar('plan', { length: 50 }).notNull().default('free'),
20
28
  // Stripe billing
@@ -29,6 +37,7 @@ export const users = pgTable('users', {
29
37
  }, (table) => ({
30
38
  nangoConnectionIdx: index('idx_users_nango_connection').on(table.nangoConnectionId),
31
39
  incomingConnectionIdx: index('idx_users_incoming_connection').on(table.incomingConnectionId),
40
+ emailIdx: index('idx_users_email').on(table.email),
32
41
  }));
33
42
  export const usersRelations = relations(users, ({ many }) => ({
34
43
  credentials: many(credentials),
@@ -37,6 +46,33 @@ export const usersRelations = relations(users, ({ many }) => ({
37
46
  repositories: many(repositories),
38
47
  linkedDaemons: many(linkedDaemons),
39
48
  installedGitHubApps: many(githubInstallations),
49
+ emails: many(userEmails),
50
+ }));
51
+ // ============================================================================
52
+ // User Emails (GitHub-linked email addresses for account reconciliation)
53
+ // ============================================================================
54
+ export const userEmails = pgTable('user_emails', {
55
+ id: uuid('id').primaryKey().defaultRandom(),
56
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
57
+ email: varchar('email', { length: 255 }).notNull(),
58
+ /** Whether this email is verified on GitHub */
59
+ verified: boolean('verified').notNull().default(false),
60
+ /** Whether this is the primary email on GitHub */
61
+ primary: boolean('primary').notNull().default(false),
62
+ /** Source of this email: 'github', 'manual', etc. */
63
+ source: varchar('source', { length: 50 }).notNull().default('github'),
64
+ createdAt: timestamp('created_at').defaultNow().notNull(),
65
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
66
+ }, (table) => ({
67
+ userEmailIdx: unique('user_emails_user_email_unique').on(table.userId, table.email),
68
+ emailIdx: index('idx_user_emails_email').on(table.email),
69
+ userIdIdx: index('idx_user_emails_user_id').on(table.userId),
70
+ }));
71
+ export const userEmailsRelations = relations(userEmails, ({ one }) => ({
72
+ user: one(users, {
73
+ fields: [userEmails.userId],
74
+ references: [users.id],
75
+ }),
40
76
  }));
41
77
  // ============================================================================
42
78
  // GitHub App Installations
@@ -1438,10 +1438,14 @@ class DockerProvisioner {
1438
1438
  const publicUrl = `http://localhost:${hostPort}`;
1439
1439
  // Wait for container to be healthy before returning
1440
1440
  // When running in Docker, use the internal container name for health check
1441
+ // Use 120s timeout to allow for:
1442
+ // - GitHub token fetch with retries (up to 36s worst case)
1443
+ // - Repository cloning (can take minutes for large repos)
1444
+ // - Daemon startup
1441
1445
  const healthCheckUrl = runningInDocker
1442
1446
  ? `http://${containerName}:${WORKSPACE_PORT}`
1443
1447
  : publicUrl;
1444
- await this.waitForHealthy(healthCheckUrl);
1448
+ await this.waitForHealthy(healthCheckUrl, 120_000);
1445
1449
  return {
1446
1450
  computeId: containerName,
1447
1451
  publicUrl,
@@ -34,6 +34,7 @@ import { testHelpersRouter } from './api/test-helpers.js';
34
34
  import { webhooksRouter } from './api/webhooks.js';
35
35
  import { githubAppRouter } from './api/github-app.js';
36
36
  import { nangoAuthRouter } from './api/nango-auth.js';
37
+ import { emailAuthRouter } from './api/email-auth.js';
37
38
  import { gitRouter } from './api/git.js';
38
39
  import { sessionsRouter } from './api/sessions.js';
39
40
  import { codexAuthHelperRouter } from './api/codex-auth-helper.js';
@@ -288,6 +289,7 @@ export async function createServer() {
288
289
  //
289
290
  // --- Routes with alternative auth (must be before teamsRouter) ---
290
291
  app.use('/api/auth', authRouter); // Login endpoints (public)
292
+ app.use('/api/auth/email', emailAuthRouter); // Email/password authentication
291
293
  app.use('/api/auth/nango', nangoAuthRouter); // Nango webhook (signature verification)
292
294
  app.use('/api/auth/codex-helper', codexAuthHelperRouter);
293
295
  app.use('/api/git', gitRouter); // Workspace token auth
@@ -1203,23 +1205,56 @@ export async function createServer() {
1203
1205
  maxPayload: 1024 * 1024, // 1MB - presence messages are small
1204
1206
  });
1205
1207
  const onlineUsers = new Map();
1208
+ // Track workspace per WebSocket connection for filtering
1209
+ const connectionWorkspace = new Map();
1206
1210
  // Validation helpers
1207
1211
  const isValidUsername = (username) => {
1208
1212
  if (typeof username !== 'string')
1209
1213
  return false;
1210
- return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
1214
+ // Username can be:
1215
+ // - GitHub-style: alphanumeric with hyphens (e.g., "khaliqgant")
1216
+ // - Display name style: allows spaces for email users (e.g., "Khaliq Gant")
1217
+ // Max 50 chars, must start/end with alphanumeric, no consecutive spaces
1218
+ if (username.length === 0 || username.length > 50)
1219
+ return false;
1220
+ // Must start and end with alphanumeric
1221
+ if (!/^[a-zA-Z0-9]/.test(username) || !/[a-zA-Z0-9]$/.test(username))
1222
+ return false;
1223
+ // Only allow alphanumeric, spaces, hyphens, underscores, and periods
1224
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9 _.-]*[a-zA-Z0-9]$/.test(username) && username.length > 1)
1225
+ return false;
1226
+ // Single character usernames must be alphanumeric
1227
+ if (username.length === 1 && !/^[a-zA-Z0-9]$/.test(username))
1228
+ return false;
1229
+ // No consecutive spaces
1230
+ if (/ /.test(username))
1231
+ return false;
1232
+ return true;
1211
1233
  };
1212
1234
  const isValidAvatarUrl = (url) => {
1213
1235
  if (url === undefined || url === null)
1214
1236
  return true;
1215
1237
  if (typeof url !== 'string')
1216
1238
  return false;
1239
+ // Must be a valid HTTPS URL from known avatar providers
1217
1240
  try {
1218
1241
  const parsed = new URL(url);
1219
- return parsed.protocol === 'https:' &&
1220
- (parsed.hostname === 'avatars.githubusercontent.com' ||
1221
- parsed.hostname === 'github.com' ||
1222
- parsed.hostname.endsWith('.githubusercontent.com'));
1242
+ if (parsed.protocol !== 'https:')
1243
+ return false;
1244
+ // Allow GitHub avatars
1245
+ if (parsed.hostname === 'avatars.githubusercontent.com' ||
1246
+ parsed.hostname === 'github.com' ||
1247
+ parsed.hostname.endsWith('.githubusercontent.com'))
1248
+ return true;
1249
+ // Allow Gravatar for email-based avatars
1250
+ if (parsed.hostname === 'www.gravatar.com' ||
1251
+ parsed.hostname === 'gravatar.com' ||
1252
+ parsed.hostname === 'secure.gravatar.com')
1253
+ return true;
1254
+ // Allow UI Avatars (placeholder service)
1255
+ if (parsed.hostname === 'ui-avatars.com')
1256
+ return true;
1257
+ return false;
1223
1258
  }
1224
1259
  catch {
1225
1260
  return false;
@@ -1474,9 +1509,16 @@ export async function createServer() {
1474
1509
  }
1475
1510
  });
1476
1511
  };
1477
- // Get online users list
1478
- const getOnlineUsersList = () => {
1479
- return Array.from(onlineUsers.values()).map((state) => state.info);
1512
+ // Get online users list, optionally filtered by workspace
1513
+ const getOnlineUsersList = (workspaceId) => {
1514
+ if (!workspaceId) {
1515
+ // No workspace filter - return all (for backwards compat)
1516
+ return Array.from(onlineUsers.values()).map((state) => state.info);
1517
+ }
1518
+ // Filter to only users in this workspace
1519
+ return Array.from(onlineUsers.values())
1520
+ .filter((state) => state.workspaceIds.has(workspaceId))
1521
+ .map((state) => state.info);
1480
1522
  };
1481
1523
  // Heartbeat interval to detect dead connections (30 seconds)
1482
1524
  const PRESENCE_HEARTBEAT_INTERVAL = 30000;
@@ -1676,32 +1718,45 @@ export async function createServer() {
1676
1718
  onlineUsers.set(username, {
1677
1719
  info: { username, avatarUrl, connectedAt: now, lastSeen: now },
1678
1720
  connections: new Set([ws]),
1721
+ workspaceIds: new Set(), // Workspace set when subscribe_channels is called
1679
1722
  });
1680
1723
  // Register with shared presence registry for cross-module access
1681
1724
  registerUserPresence({ username, avatarUrl, connectedAt: now, lastSeen: now });
1682
1725
  console.log(`[cloud] User ${username} came online`);
1683
- broadcastPresence({
1684
- type: 'presence_join',
1685
- user: { username, avatarUrl, connectedAt: now, lastSeen: now },
1686
- }, ws);
1726
+ // Don't broadcast globally - wait until we know which workspace they're in
1687
1727
  }
1728
+ // Send empty presence list initially - real list sent when workspace is known
1688
1729
  ws.send(JSON.stringify({
1689
1730
  type: 'presence_list',
1690
- users: getOnlineUsersList(),
1731
+ users: [],
1691
1732
  }));
1692
1733
  }
1693
1734
  else if (msg.action === 'leave') {
1694
1735
  if (!clientUsername || msg.username !== clientUsername)
1695
1736
  return;
1696
1737
  const userState = onlineUsers.get(clientUsername);
1738
+ const leavingWorkspace = connectionWorkspace.get(ws);
1739
+ connectionWorkspace.delete(ws);
1697
1740
  if (userState) {
1698
1741
  userState.connections.delete(ws);
1742
+ // Remove workspace from user's set if no more connections to it
1743
+ if (leavingWorkspace) {
1744
+ const stillHasWorkspaceConnection = Array.from(userState.connections).some((conn) => connectionWorkspace.get(conn) === leavingWorkspace);
1745
+ if (!stillHasWorkspaceConnection) {
1746
+ userState.workspaceIds.delete(leavingWorkspace);
1747
+ // Broadcast leave to users in that workspace
1748
+ wssPresence.clients.forEach((client) => {
1749
+ if (client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === leavingWorkspace) {
1750
+ client.send(JSON.stringify({ type: 'presence_leave', username: clientUsername }));
1751
+ }
1752
+ });
1753
+ }
1754
+ }
1699
1755
  if (userState.connections.size === 0) {
1700
1756
  onlineUsers.delete(clientUsername);
1701
1757
  // Unregister from shared presence registry
1702
1758
  unregisterUserPresence(clientUsername);
1703
1759
  console.log(`[cloud] User ${clientUsername} went offline`);
1704
- broadcastPresence({ type: 'presence_leave', username: clientUsername });
1705
1760
  }
1706
1761
  }
1707
1762
  }
@@ -1715,12 +1770,20 @@ export async function createServer() {
1715
1770
  // Update last seen in shared presence registry
1716
1771
  updateUserLastSeen(clientUsername);
1717
1772
  }
1718
- broadcastPresence({
1719
- type: 'typing',
1720
- username: clientUsername,
1721
- avatarUrl: userState?.info.avatarUrl,
1722
- isTyping: msg.isTyping,
1723
- }, ws);
1773
+ // Only broadcast typing to users in the same workspace
1774
+ const typingWorkspace = connectionWorkspace.get(ws);
1775
+ if (typingWorkspace) {
1776
+ wssPresence.clients.forEach((client) => {
1777
+ if (client !== ws && client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === typingWorkspace) {
1778
+ client.send(JSON.stringify({
1779
+ type: 'typing',
1780
+ username: clientUsername,
1781
+ avatarUrl: userState?.info.avatarUrl,
1782
+ isTyping: msg.isTyping,
1783
+ }));
1784
+ }
1785
+ });
1786
+ }
1724
1787
  }
1725
1788
  else if (msg.type === 'subscribe_channels') {
1726
1789
  // Subscribe to channel messages for a specific workspace
@@ -1732,8 +1795,38 @@ export async function createServer() {
1732
1795
  console.warn(`[cloud] subscribe_channels missing workspaceId`);
1733
1796
  return;
1734
1797
  }
1735
- console.log(`[cloud] User ${clientUsername} subscribing to channels in workspace ${msg.workspaceId}`);
1736
- setupDaemonChannelProxy(ws, msg.workspaceId, clientUsername).catch((err) => {
1798
+ const workspaceId = msg.workspaceId;
1799
+ console.log(`[cloud] User ${clientUsername} subscribing to channels in workspace ${workspaceId}`);
1800
+ // Track which workspace this connection is in
1801
+ connectionWorkspace.set(ws, workspaceId);
1802
+ // Add workspace to user's workspace set
1803
+ const userState = onlineUsers.get(clientUsername);
1804
+ if (userState) {
1805
+ const isNewToWorkspace = !userState.workspaceIds.has(workspaceId);
1806
+ userState.workspaceIds.add(workspaceId);
1807
+ // If user is new to this workspace, broadcast presence_join to others in workspace
1808
+ if (isNewToWorkspace) {
1809
+ const { info } = userState;
1810
+ // Broadcast to all users in this workspace
1811
+ wssPresence.clients.forEach((client) => {
1812
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
1813
+ const clientWsId = connectionWorkspace.get(client);
1814
+ if (clientWsId === workspaceId) {
1815
+ client.send(JSON.stringify({
1816
+ type: 'presence_join',
1817
+ user: info,
1818
+ }));
1819
+ }
1820
+ }
1821
+ });
1822
+ }
1823
+ // Send updated presence list filtered by this workspace
1824
+ ws.send(JSON.stringify({
1825
+ type: 'presence_list',
1826
+ users: getOnlineUsersList(workspaceId),
1827
+ }));
1828
+ }
1829
+ setupDaemonChannelProxy(ws, workspaceId, clientUsername).catch((err) => {
1737
1830
  console.error(`[cloud] Failed to setup channel subscription:`, err);
1738
1831
  });
1739
1832
  }
@@ -1759,16 +1852,30 @@ export async function createServer() {
1759
1852
  ws.on('close', () => {
1760
1853
  // Clean up daemon proxies
1761
1854
  cleanupDaemonProxies(ws);
1855
+ const closingWorkspace = connectionWorkspace.get(ws);
1856
+ connectionWorkspace.delete(ws);
1762
1857
  if (clientUsername) {
1763
1858
  const userState = onlineUsers.get(clientUsername);
1764
1859
  if (userState) {
1765
1860
  userState.connections.delete(ws);
1861
+ // Remove workspace from user's set if no more connections to it
1862
+ if (closingWorkspace) {
1863
+ const stillHasWorkspaceConnection = Array.from(userState.connections).some((conn) => connectionWorkspace.get(conn) === closingWorkspace);
1864
+ if (!stillHasWorkspaceConnection) {
1865
+ userState.workspaceIds.delete(closingWorkspace);
1866
+ // Broadcast leave to users in that workspace
1867
+ wssPresence.clients.forEach((client) => {
1868
+ if (client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === closingWorkspace) {
1869
+ client.send(JSON.stringify({ type: 'presence_leave', username: clientUsername }));
1870
+ }
1871
+ });
1872
+ }
1873
+ }
1766
1874
  if (userState.connections.size === 0) {
1767
1875
  onlineUsers.delete(clientUsername);
1768
1876
  // Unregister from shared presence registry
1769
1877
  unregisterUserPresence(clientUsername);
1770
1878
  console.log(`[cloud] User ${clientUsername} disconnected`);
1771
- broadcastPresence({ type: 'presence_leave', username: clientUsername });
1772
1879
  }
1773
1880
  }
1774
1881
  }
@@ -1917,7 +2024,10 @@ export async function createServer() {
1917
2024
  if (server) {
1918
2025
  await new Promise((resolve) => server.close(() => resolve()));
1919
2026
  }
1920
- await redisClient.quit();
2027
+ // Only quit Redis if client is still open
2028
+ if (redisClient.isOpen) {
2029
+ await redisClient.quit();
2030
+ }
1921
2031
  },
1922
2032
  };
1923
2033
  }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Nango Integration Configuration
3
+ *
4
+ * REQUIRED SCOPES:
5
+ * - github (GITHUB_USER): Requires 'user:email' scope for email reconciliation.
6
+ * Configure this in Nango Dashboard: Integrations → GitHub → OAuth Scopes
7
+ */
1
8
  export declare const NANGO_INTEGRATIONS: {
2
9
  readonly GITHUB_USER: "github";
3
10
  readonly GITHUB_APP: "github-app-oauth";
@@ -29,6 +36,17 @@ declare class NangoService {
29
36
  * Fetch GitHub user profile via Nango proxy.
30
37
  */
31
38
  getGithubUser(connectionId: string): Promise<GithubUserProfile>;
39
+ /**
40
+ * Fetch all email addresses associated with a GitHub user.
41
+ * Requires 'user:email' scope to be configured in Nango.
42
+ * @see https://docs.github.com/en/rest/users/emails#list-email-addresses-for-the-authenticated-user
43
+ */
44
+ getGithubUserEmails(connectionId: string): Promise<Array<{
45
+ email: string;
46
+ verified: boolean;
47
+ primary: boolean;
48
+ visibility: string | null;
49
+ }>>;
32
50
  /**
33
51
  * Retrieve an installation access token from a GitHub App connection.
34
52
  * Use this ONLY when you need the raw token (e.g., for git clone URLs).
@@ -1,6 +1,13 @@
1
1
  import { Nango } from '@nangohq/node';
2
2
  import crypto from 'node:crypto';
3
3
  import { getConfig } from '../config.js';
4
+ /**
5
+ * Nango Integration Configuration
6
+ *
7
+ * REQUIRED SCOPES:
8
+ * - github (GITHUB_USER): Requires 'user:email' scope for email reconciliation.
9
+ * Configure this in Nango Dashboard: Integrations → GitHub → OAuth Scopes
10
+ */
4
11
  export const NANGO_INTEGRATIONS = {
5
12
  GITHUB_USER: 'github',
6
13
  GITHUB_APP: 'github-app-oauth',
@@ -50,6 +57,31 @@ class NangoService {
50
57
  });
51
58
  return response.data;
52
59
  }
60
+ /**
61
+ * Fetch all email addresses associated with a GitHub user.
62
+ * Requires 'user:email' scope to be configured in Nango.
63
+ * @see https://docs.github.com/en/rest/users/emails#list-email-addresses-for-the-authenticated-user
64
+ */
65
+ async getGithubUserEmails(connectionId) {
66
+ try {
67
+ const response = await this.client.get({
68
+ connectionId,
69
+ providerConfigKey: NANGO_INTEGRATIONS.GITHUB_USER,
70
+ endpoint: '/user/emails',
71
+ });
72
+ return response.data || [];
73
+ }
74
+ catch (err) {
75
+ // If scope is not granted, return empty array
76
+ const error = err;
77
+ if (error.response?.status === 403 || error.response?.status === 404) {
78
+ console.warn('[nango] Cannot fetch user emails - user:email scope may not be granted');
79
+ return [];
80
+ }
81
+ console.error('[nango] Error fetching user emails:', err);
82
+ return [];
83
+ }
84
+ }
53
85
  /**
54
86
  * Retrieve an installation access token from a GitHub App connection.
55
87
  * Use this ONLY when you need the raw token (e.g., for git clone URLs).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/cloud",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Cloud API server and services for Agent Relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "test:watch": "vitest"
39
39
  },
40
40
  "dependencies": {
41
- "@agent-relay/wrapper": "2.0.16",
42
- "@agent-relay/config": "2.0.16",
43
- "@agent-relay/resiliency": "2.0.16",
44
- "@agent-relay/storage": "2.0.16",
45
- "@agent-relay/protocol": "2.0.16"
41
+ "@agent-relay/wrapper": "2.0.18",
42
+ "@agent-relay/config": "2.0.18",
43
+ "@agent-relay/resiliency": "2.0.18",
44
+ "@agent-relay/storage": "2.0.18",
45
+ "@agent-relay/protocol": "2.0.18"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/config",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Shared configuration schemas and loaders for Agent Relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -83,7 +83,7 @@
83
83
  "test:watch": "vitest"
84
84
  },
85
85
  "dependencies": {
86
- "@agent-relay/protocol": "2.0.16",
86
+ "@agent-relay/protocol": "2.0.18",
87
87
  "zod": "^3.23.8",
88
88
  "zod-to-json-schema": "^3.23.1"
89
89
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/continuity",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Session continuity manager for Relay (ledgers, handoffs, resume)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/daemon",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Relay daemon server - agent coordination and message routing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,17 +22,17 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/protocol": "2.0.16",
26
- "@agent-relay/config": "2.0.16",
27
- "@agent-relay/storage": "2.0.16",
28
- "@agent-relay/bridge": "2.0.16",
29
- "@agent-relay/utils": "2.0.16",
30
- "@agent-relay/policy": "2.0.16",
31
- "@agent-relay/memory": "2.0.16",
32
- "@agent-relay/resiliency": "2.0.16",
33
- "@agent-relay/user-directory": "2.0.16",
34
- "@agent-relay/wrapper": "2.0.16",
35
- "@agent-relay/telemetry": "2.0.16",
25
+ "@agent-relay/protocol": "2.0.18",
26
+ "@agent-relay/config": "2.0.18",
27
+ "@agent-relay/storage": "2.0.18",
28
+ "@agent-relay/bridge": "2.0.18",
29
+ "@agent-relay/utils": "2.0.18",
30
+ "@agent-relay/policy": "2.0.18",
31
+ "@agent-relay/memory": "2.0.18",
32
+ "@agent-relay/resiliency": "2.0.18",
33
+ "@agent-relay/user-directory": "2.0.18",
34
+ "@agent-relay/wrapper": "2.0.18",
35
+ "@agent-relay/telemetry": "2.0.18",
36
36
  "ws": "^8.18.3",
37
37
  "better-sqlite3": "^12.6.2",
38
38
  "pg": "^8.16.3",
@@ -551,21 +551,50 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
551
551
  const isValidUsername = (username) => {
552
552
  if (typeof username !== 'string')
553
553
  return false;
554
- // Username should be 1-39 chars, alphanumeric with hyphens (GitHub username rules)
555
- return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
554
+ // Username can be:
555
+ // - GitHub-style: alphanumeric with hyphens (e.g., "khaliqgant")
556
+ // - Display name style: allows spaces for email users (e.g., "Khaliq Gant")
557
+ // Max 50 chars, must start/end with alphanumeric, no consecutive spaces
558
+ if (username.length === 0 || username.length > 50)
559
+ return false;
560
+ // Must start and end with alphanumeric
561
+ if (!/^[a-zA-Z0-9]/.test(username) || !/[a-zA-Z0-9]$/.test(username))
562
+ return false;
563
+ // Only allow alphanumeric, spaces, hyphens, underscores, and periods
564
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9 _.-]*[a-zA-Z0-9]$/.test(username) && username.length > 1)
565
+ return false;
566
+ // Single character usernames must be alphanumeric
567
+ if (username.length === 1 && !/^[a-zA-Z0-9]$/.test(username))
568
+ return false;
569
+ // No consecutive spaces
570
+ if (/ /.test(username))
571
+ return false;
572
+ return true;
556
573
  };
557
574
  const isValidAvatarUrl = (url) => {
558
575
  if (url === undefined || url === null)
559
576
  return true;
560
577
  if (typeof url !== 'string')
561
578
  return false;
562
- // Must be a valid HTTPS URL from GitHub or similar known providers
579
+ // Must be a valid HTTPS URL from known avatar providers
563
580
  try {
564
581
  const parsed = new URL(url);
565
- return parsed.protocol === 'https:' &&
566
- (parsed.hostname === 'avatars.githubusercontent.com' ||
567
- parsed.hostname === 'github.com' ||
568
- parsed.hostname.endsWith('.githubusercontent.com'));
582
+ if (parsed.protocol !== 'https:')
583
+ return false;
584
+ // Allow GitHub avatars
585
+ if (parsed.hostname === 'avatars.githubusercontent.com' ||
586
+ parsed.hostname === 'github.com' ||
587
+ parsed.hostname.endsWith('.githubusercontent.com'))
588
+ return true;
589
+ // Allow Gravatar for email-based avatars
590
+ if (parsed.hostname === 'www.gravatar.com' ||
591
+ parsed.hostname === 'gravatar.com' ||
592
+ parsed.hostname === 'secure.gravatar.com')
593
+ return true;
594
+ // Allow UI Avatars (placeholder service)
595
+ if (parsed.hostname === 'ui-avatars.com')
596
+ return true;
597
+ return false;
569
598
  }
570
599
  catch {
571
600
  return false;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/dashboard",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Web dashboard for Agent Relay - optional package for visual agent coordination",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,18 +25,18 @@
25
25
  "test:watch": "vitest"
26
26
  },
27
27
  "dependencies": {
28
- "@agent-relay/protocol": "2.0.16",
29
- "@agent-relay/config": "2.0.16",
30
- "@agent-relay/storage": "2.0.16",
31
- "@agent-relay/bridge": "2.0.16",
32
- "@agent-relay/utils": "2.0.16",
33
- "@agent-relay/resiliency": "2.0.16",
34
- "@agent-relay/trajectory": "2.0.16",
35
- "@agent-relay/cloud": "2.0.16",
36
- "@agent-relay/daemon": "2.0.16",
37
- "@agent-relay/user-directory": "2.0.16",
38
- "@agent-relay/wrapper": "2.0.16",
39
- "@agent-relay/sdk": "2.0.16",
28
+ "@agent-relay/protocol": "2.0.18",
29
+ "@agent-relay/config": "2.0.18",
30
+ "@agent-relay/storage": "2.0.18",
31
+ "@agent-relay/bridge": "2.0.18",
32
+ "@agent-relay/utils": "2.0.18",
33
+ "@agent-relay/resiliency": "2.0.18",
34
+ "@agent-relay/trajectory": "2.0.18",
35
+ "@agent-relay/cloud": "2.0.18",
36
+ "@agent-relay/daemon": "2.0.18",
37
+ "@agent-relay/user-directory": "2.0.18",
38
+ "@agent-relay/wrapper": "2.0.18",
39
+ "@agent-relay/sdk": "2.0.18",
40
40
  "express": "^5.2.1",
41
41
  "ws": "^8.18.3"
42
42
  },