heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,92 @@
1
+ // Your sessions on the dashboard: where you've been, your time there, and who you
2
+ // worked with (activity.js does the sums). Only sessions you were in, and only people
3
+ // who were there at the same time as you.
4
+ import { HttpError, Raw } from '../http.js'
5
+ import { auditTrail, auditCsv, MAX_ACTIONS } from '../audit.js'
6
+ import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS } from '../activity.js'
7
+ import { cleanSessionName, BAD_SESSION_NAME } from '../../session-name.js'
8
+
9
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
10
+ const NO_SESSION = 'no such session'
11
+
12
+ /** An account's dashboard: its sessions, with the people in each, and the totals. */
13
+ export async function overviewOf (store, account, tz, t) {
14
+ // The latest sessions, plus every one active this week or month, for the totals.
15
+ const since = Math.min(weekStart(t, tz), monthStart(t, tz))
16
+ const sessions = await store.accountSessions(account, { since, limit: MAX_SESSIONS })
17
+ const visits = await store.visitsInRooms(sessions.map((s) => s.room))
18
+ return summarize({ me: account, sessions, visits, now: t, tz })
19
+ }
20
+
21
+ /** People and agents this account has worked with: [{ account, name, kind, lastTogetherAt }], most recent first. */
22
+ export async function collaboratorsOf (store, account, t) {
23
+ return collaborators((await overviewOf(store, account, 'UTC', t)).sessions)
24
+ }
25
+
26
+ export function sessionRoutes ({ store, now, person }) {
27
+ const me = async (req) => `person:${(await person(req)).userId}`
28
+ const zoneOf = (req) => {
29
+ const tz = new URL(req.url, 'http://x').searchParams.get('tz') || 'UTC'
30
+ if (!isTimeZone(tz)) throw new HttpError(400, 'tz must be an IANA time zone, like Europe/London')
31
+ return tz
32
+ }
33
+ const overview = (account, tz) => overviewOf(store, account, tz, now())
34
+
35
+ /** A session I was in, with every visit to it; 404 otherwise, whether or not it exists. */
36
+ async function mine (account, room) {
37
+ if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
38
+ const session = await store.sessionByRoom(room)
39
+ const visits = session ? await store.visitsInRooms([room]) : []
40
+ if (!visits.some((v) => v.account === account)) throw new HttpError(404, NO_SESSION)
41
+ return { session, visits }
42
+ }
43
+
44
+ return [
45
+ ['GET', /^\/v1\/me\/sessions$/, async (req) => overview(await me(req), zoneOf(req))],
46
+
47
+ ['GET', /^\/v1\/me\/sessions\/([^/]+)$/, async (req, body, [room]) => {
48
+ const account = await me(req)
49
+ const { session, visits } = await mine(account, room)
50
+ const [s] = summarize({ me: account, sessions: [session], visits, now: now() }).sessions
51
+ return { session: { ...s, visits: myVisits({ me: account, visits }) } }
52
+ }],
53
+
54
+ // The owner's rename shows for everyone, and the relay's name no longer replaces it.
55
+ ['PUT', /^\/v1\/me\/sessions\/([^/]+)$/, async (req, body, [room]) => {
56
+ const account = await me(req)
57
+ const name = cleanSessionName(body.name)
58
+ if (!name) throw new HttpError(400, BAD_SESSION_NAME)
59
+ const { session } = await mine(account, room)
60
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can rename it.')
61
+ const renamed = await store.renameSession(room, name, now())
62
+ return { session: { room, name: renamed.name } }
63
+ }],
64
+
65
+ // The audit trail, for the owner only: every visit, how it came in, why it ended, and
66
+ // what it did. ?format=csv for a spreadsheet; ?from= and ?to= (epoch ms) narrow it.
67
+ ['GET', /^\/v1\/me\/sessions\/([^/]+)\/audit$/, async (req, body, [room]) => {
68
+ const account = await me(req)
69
+ const { session, visits } = await mine(account, room)
70
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can see its audit trail.')
71
+ const q = new URL(req.url, 'http://x').searchParams
72
+ const t = now()
73
+ const num = (k, d) => { const n = Number(q.get(k)); return q.has(k) && Number.isFinite(n) ? n : d }
74
+ const from = num('from', 0)
75
+ const to = num('to', t + 1)
76
+ const inWindow = visits.filter((v) => v.startedAt < to && (v.endedAt == null || v.endedAt >= from))
77
+ const actions = await store.actionsInRoom(room, { from, to, limit: MAX_ACTIONS + 1 })
78
+ const truncated = actions.length > MAX_ACTIONS
79
+ const revoked = new Map()
80
+ for (const acc of new Set(inWindow.filter((v) => v.kind === 'agent').map((v) => v.account))) {
81
+ const agent = await store.agentById(acc.slice('agent:'.length)).catch(() => null)
82
+ if (agent && agent.revokedAt != null) revoked.set(acc, agent.revokedAt)
83
+ }
84
+ const trail = auditTrail({ visits: inWindow, actions: actions.slice(0, MAX_ACTIONS), revoked })
85
+ if (q.get('format') === 'csv') return new Raw(200, auditCsv(trail), 'text/csv; charset=utf-8')
86
+ return { session: { room, name: session.name }, from, to, truncated, visits: trail }
87
+ }],
88
+
89
+ // People and agents you've worked with, for invites (most recent first, at most 30).
90
+ ['GET', /^\/v1\/me\/collaborators$/, async (req) => ({ collaborators: await collaboratorsOf(store, await me(req), now()) })]
91
+ ]
92
+ }
@@ -0,0 +1,132 @@
1
+ // Flat teams inside an org, and who's in each: people and agents with editor or
2
+ // viewer access, and for agents, optional folders.
3
+ import { HttpError, needId, cleanName } from '../http.js'
4
+ import { orgAccess } from '../org-access.js'
5
+ import { accessOf, cleanScopes } from '../team-access.js'
6
+
7
+ const kindOf = (m) => (m.agentId ? 'agent' : 'person')
8
+ const memberView = (tm) => ({ memberId: tm.memberId, access: tm.access, scopes: tm.scopes || [] })
9
+
10
+ export function teamRoutes ({ store, user, policyChanged = null }) {
11
+ const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
12
+
13
+ async function teamIn (a, id) {
14
+ const team = await store.teamById(a.org.id, needId(id, 'team'))
15
+ if (!team) throw new HttpError(404, 'no such team')
16
+ return team
17
+ }
18
+
19
+ // Folders only limit agents; a person in a team always reaches all of it.
20
+ // undefined means "leave the folders as they are".
21
+ function foldersFor (m, raw) {
22
+ if (raw === undefined) return undefined
23
+ const scopes = cleanScopes(raw)
24
+ if (scopes.length && !m.agentId) throw new HttpError(400, 'Folders are only for agents.')
25
+ return scopes
26
+ }
27
+
28
+ return [
29
+ ['GET', /^\/v1\/orgs\/([^/]+)\/teams$/, async (req, body, [slug]) => {
30
+ const a = await orgFor(req, slug)
31
+ const mine = new Map((await store.teamsOfMember(a.me.id)).map((x) => [x.teamId, x.access]))
32
+ // Teams: Read shows every team; everyone sees the teams they're in.
33
+ const teams = (await store.listTeams(a.org.id)).filter((t) => a.can('teams', 'r') || mine.has(t.id))
34
+ return {
35
+ teams: await Promise.all(teams.map(async (t) => ({
36
+ id: t.id,
37
+ name: t.name,
38
+ createdAt: t.createdAt,
39
+ access: mine.get(t.id) || null,
40
+ // The team's git rule (git for everyone: '' sets nothing, off, branches or any).
41
+ git: (await store.teamGithub(t.id)).agentCommits,
42
+ members: a.can('team_members', 'r') || mine.has(t.id)
43
+ ? (await store.listTeamMembers(t.id)).map((m) => ({ memberId: m.memberId, name: m.name, access: m.access, kind: m.kind, scopes: m.scopes || [] }))
44
+ : null
45
+ }))),
46
+ // The people and agents you could add, for those who add to teams.
47
+ people: a.can('team_members', 'c')
48
+ ? (await store.listMembers(a.org.id)).map((m) => ({ memberId: m.id, name: m.name, kind: kindOf(m) }))
49
+ : null
50
+ }
51
+ }],
52
+
53
+ ['POST', /^\/v1\/orgs\/([^/]+)\/teams$/, async (req, body, [slug]) => {
54
+ const a = await orgFor(req, slug)
55
+ a.need('teams', 'c')
56
+ return { team: await store.createTeam({ orgId: a.org.id, name: cleanName(body.name, 60, 'give the team a name') }) }
57
+ }],
58
+
59
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)$/, async (req, body, [slug, id]) => {
60
+ const a = await orgFor(req, slug)
61
+ a.need('teams', 'u')
62
+ const team = await teamIn(a, id)
63
+ return { team: await store.renameTeam(team.id, cleanName(body.name, 60, 'give the team a name')) }
64
+ }],
65
+
66
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)$/, async (req, body, [slug, id]) => {
67
+ const a = await orgFor(req, slug)
68
+ a.need('teams', 'd')
69
+ await store.deleteTeam((await teamIn(a, id)).id)
70
+ if (policyChanged) policyChanged() // its git rule goes with it
71
+ return { ok: true }
72
+ }],
73
+
74
+ ['POST', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members$/, async (req, body, [slug, id]) => {
75
+ const a = await orgFor(req, slug)
76
+ a.need('team_members', 'c')
77
+ const team = await teamIn(a, id)
78
+ const access = accessOf(body.access ?? 'viewer')
79
+ const m = await store.memberById(a.org.id, needId(body.memberId, 'member'))
80
+ if (!m) throw new HttpError(404, 'no such member')
81
+ const scopes = foldersFor(m, body.scopes) ?? []
82
+ // addTeamMember upserts; without this, Create alone could restyle someone's
83
+ // access by re-adding them, which is Team membership: Update's job.
84
+ if ((await store.listTeamMembers(team.id)).some((x) => x.memberId === m.id)) throw new HttpError(409, 'That person is already in this team.')
85
+ const added = memberView(await store.addTeamMember({ teamId: team.id, memberId: m.id, access, scopes }))
86
+ if (policyChanged) policyChanged() // the team's git rule now covers their sessions
87
+ return { member: added }
88
+ }],
89
+
90
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
91
+ const a = await orgFor(req, slug)
92
+ a.need('team_members', 'u')
93
+ const team = await teamIn(a, id)
94
+ const m = await store.memberById(a.org.id, needId(memberId, 'member'))
95
+ if (!m) throw new HttpError(404, "that person isn't in this team")
96
+ const tm = await store.setTeamAccess(team.id, m.id, accessOf(body.access), foldersFor(m, body.scopes))
97
+ if (!tm) throw new HttpError(404, "that person isn't in this team")
98
+ return { member: memberView(tm) }
99
+ }],
100
+
101
+ // The team's git rule (docs/superpowers/specs/2026-10-17-git-for-everyone.md): whether git is on for
102
+ // everyone in the sessions its people own in the org's workspaces, and what agents may push there.
103
+ // '' sets nothing; off, branches or any is a ceiling for the workspaces' and sessions' own choices.
104
+ ['GET', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
105
+ const a = await orgFor(req, slug)
106
+ const team = await teamIn(a, id)
107
+ const mine = (await store.teamsOfMember(a.me.id)).some((x) => x.teamId === team.id)
108
+ if (!a.can('teams', 'r') && !mine) throw new HttpError(404, 'no such team')
109
+ return store.teamGithub(team.id)
110
+ }],
111
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
112
+ const a = await orgFor(req, slug)
113
+ a.need('teams', 'u')
114
+ const team = await teamIn(a, id)
115
+ if (!('agentCommits' in body)) throw new HttpError(400, 'Give agentCommits: "" (the team sets nothing), off, branches or any.')
116
+ const v = body.agentCommits === null ? '' : body.agentCommits
117
+ if (!['', 'off', 'branches', 'any'].includes(v)) throw new HttpError(400, 'agentCommits is "" (the team sets nothing), off, branches or any.')
118
+ await store.setTeamGithub(team.id, { agentCommits: v })
119
+ if (policyChanged) policyChanged()
120
+ return store.teamGithub(team.id)
121
+ }],
122
+
123
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
124
+ const a = await orgFor(req, slug)
125
+ a.need('team_members', 'd')
126
+ const team = await teamIn(a, id)
127
+ if (!await store.removeTeamMember(team.id, needId(memberId, 'member'))) throw new HttpError(404, "that person isn't in this team")
128
+ if (policyChanged) policyChanged()
129
+ return { ok: true }
130
+ }]
131
+ ]
132
+ }
@@ -0,0 +1,332 @@
1
+ // Agents in workspaces: where an agent works (its placement, set by its owner), a
2
+ // workspace's say over an agent placed there (its override), workspace agent invites,
3
+ // a session owner keeping an agent out of one session, and an agent's own webhook for
4
+ // workspace events. Behind the QUILT_WORKSPACES flag: off, every route answers 404.
5
+ import { HttpError, needId } from '../http.js'
6
+ import { newToken, hashToken } from '../tokens.js'
7
+ import { orgAccess } from '../org-access.js'
8
+ import { cleanAccess, stillInOrg } from '../workspace-access.js'
9
+ import { workspaceReach } from '../workspace-reach.js'
10
+ import { cleanPlacement, placementIn, agentsJoiningSession, sessionAgents, sameOwnerAgent, SESSIONS } from '../agent-placement.js'
11
+ import { AGENT_INVITE_TTL_MS, inviteStatus } from './agent-invites.js'
12
+ import crypto from 'node:crypto'
13
+ import { parseWebhookUrl, newSecret, publicWebhookHost, deliverWebhook, MIN_SECRET, MAX_SECRET } from '../../webhooks.js'
14
+ import { parseInvite, buildInvite } from '../../ui/invite.js'
15
+ import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
16
+
17
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
18
+ export const AGENT_WEBHOOK_EVENTS = ['session.started']
19
+ /** Why someone else's agent in a workspace is never invited to every session there. */
20
+ export const FOREIGN_JOINS = 'Only its owner can have an agent invited to every session.'
21
+
22
+ /** A placement as the routes answer it; an agent with none is 'manual' (it works only where it is added). */
23
+ export function placementView (agentId, p) {
24
+ if (!p) return { agentId, reach: 'manual', workspaceIds: [], sessions: 'invited', access: 'edit', scopes: [], updatedAt: null }
25
+ return { agentId, reach: p.reach, workspaceIds: [...(p.workspaceIds || [])], sessions: p.sessions, access: p.access, scopes: [...(p.scopes || [])], updatedAt: p.updatedAt ?? null }
26
+ }
27
+
28
+ /** A member agent's `sessions`: 'all' or 'invited', and only for an agent. Undefined keeps what the member has. */
29
+ export function cleanMemberSessions (value, kind) {
30
+ if (value === undefined) return undefined
31
+ if (kind !== 'agent') throw new HttpError(400, 'Only an agent joins sessions by itself.')
32
+ if (!SESSIONS.includes(value)) throw new HttpError(400, 'Sessions is all or invited.')
33
+ return value
34
+ }
35
+
36
+ /**
37
+ * The agents in `ws` for its page: member agents (via 'member', managed by the workspace),
38
+ * then agents of the workspace's owner placed there (via 'placed' or 'global', managed by
39
+ * the owner or the org), with the workspace's override applied. Agents the workspace kept
40
+ * out are listed (excluded: true) only for an admin, so they can be let back in. `foreign`
41
+ * marks someone else's agent added here: it joins only when invited, whatever its row says.
42
+ */
43
+ export async function workspaceAgents (store, ws, { admin = false } = {}) {
44
+ const out = []
45
+ const seen = new Set()
46
+ for (const m of await store.listWorkspaceMembers(ws.id)) {
47
+ if (!m.account.startsWith('agent:')) continue
48
+ const agentId = m.account.slice(6)
49
+ const agent = await store.agentById(agentId)
50
+ if (!agent || agent.revokedAt || !(await stillInOrg(store, ws, m.account, null))) continue
51
+ seen.add(agentId)
52
+ const foreign = !(await sameOwnerAgent(store, ws, agentId))
53
+ out.push({ account: m.account, agentId, name: agent.name, provider: agent.provider, via: 'member', access: m.access, sessions: foreign ? 'invited' : (m.sessions || 'invited'), managedBy: 'workspace', excluded: false, foreign })
54
+ }
55
+ const own = ws.orgId ? await store.listOrgAgents(ws.orgId) : ws.ownerUserId ? await store.listPersonalAgents(ws.ownerUserId) : []
56
+ const placed = []
57
+ for (const agent of own) {
58
+ if (seen.has(agent.id)) continue
59
+ const p = await placementIn(store, ws, agent.id)
60
+ if (!p) continue
61
+ const override = await store.workspaceAgentOverride(ws.id, agent.id)
62
+ const excluded = !!override?.excluded
63
+ if (excluded && !admin) continue
64
+ placed.push({ account: `agent:${agent.id}`, agentId: agent.id, name: agent.name, provider: agent.provider, via: p.reach === 'all' ? 'global' : 'placed', access: p.access, sessions: override?.sessions ?? p.sessions, managedBy: ws.orgId ? 'org' : 'owner', excluded, foreign: false })
65
+ }
66
+ placed.sort((a, b) => a.name.localeCompare(b.name))
67
+ return [...out, ...placed]
68
+ }
69
+
70
+ /** Every workspace an agent can find by its placement: its owner's personal ones, or its org's. */
71
+ export async function placementCandidates (store, agent) {
72
+ if (!agent) return []
73
+ if (agent.orgId) return store.listWorkspacesOfOrg(agent.orgId)
74
+ return agent.ownerUserId ? store.listWorkspacesOwnedBy(agent.ownerUserId) : []
75
+ }
76
+
77
+ /** A relay address as join links carry it (ws:// or wss://, no trailing slash). */
78
+ const relayForm = (url) => String(url || '').replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
79
+
80
+ export function workspaceAgentRoutes (ctx) {
81
+ const { store, person, now, apiUrl, limitSend, limitAnnounce = () => {}, log = () => {}, workspaces = false } = ctx
82
+ // The session-started hand-off: how webhooks are sent (tests record them), how host names
83
+ // resolve, and whether a receiver on this computer is allowed (tests only).
84
+ const { relayUrl = HOSTED_RELAY, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, trackDelivery = (p) => p } = ctx
85
+ const ownRelay = relayForm(relayUrl)
86
+ const { caller, reach } = workspaceReach(ctx)
87
+ const gated = (fn) => (...args) => {
88
+ if (!workspaces) throw new HttpError(404, 'not found')
89
+ return fn(...args)
90
+ }
91
+
92
+ /** A placement body, checked, with every listed workspace one of `owns` (the owner's own). */
93
+ async function placementFrom (body, owns) {
94
+ const p = cleanPlacement(body)
95
+ for (const id of p.workspaceIds) {
96
+ const ws = await store.workspaceById(id)
97
+ if (!ws || !owns(ws)) throw new HttpError(400, 'An agent can only be placed in its owner\'s own workspaces.')
98
+ }
99
+ return p
100
+ }
101
+
102
+ async function myAgent (req, id) {
103
+ const u = await person(req)
104
+ const agent = await store.agentById(needId(id, 'agent'))
105
+ // Someone else's agent, an org's, or a revoked one: the same answer as a missing one.
106
+ if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
107
+ return { u, agent }
108
+ }
109
+
110
+ async function orgFor (req, slug) {
111
+ const u = await person(req)
112
+ return { u, ...(await orgAccess(store, u.userId, slug)) }
113
+ }
114
+
115
+ async function orgAgent (a, id) {
116
+ const agent = await store.agentById(needId(id, 'agent'))
117
+ if (!agent || agent.orgId !== a.org.id || agent.revokedAt) throw new HttpError(404, 'no such agent')
118
+ return agent
119
+ }
120
+
121
+ /** The room's session when the caller owns it (as the relay reports it). */
122
+ async function ownRoom (req, room) {
123
+ const me = await caller(req)
124
+ if (!ROOM.test(room)) throw new HttpError(404, 'no such session')
125
+ const session = await store.sessionByRoom(room)
126
+ if (!session) throw new HttpError(404, 'no such session')
127
+ if (!session.ownerAccount || session.ownerAccount !== me.account) throw new HttpError(403, 'Only the session owner can do that.')
128
+ return { me, session }
129
+ }
130
+
131
+ /** The room's session when the caller owns it, and the agent to keep out. */
132
+ async function ownSession (req, room, agentId) {
133
+ const { me, session } = await ownRoom(req, room)
134
+ const agent = await store.agentById(needId(agentId, 'agent'))
135
+ if (!agent) throw new HttpError(404, 'no such agent')
136
+ return { me, session, agent }
137
+ }
138
+
139
+ async function meAsAgent (req) {
140
+ const me = await caller(req)
141
+ if (!me.account.startsWith('agent:')) throw new HttpError(403, 'Only an agent has a webhook here.')
142
+ return me.agent || await store.agentById(me.account.slice(6))
143
+ }
144
+
145
+ /**
146
+ * Sends one agent its session.started event, after checking its webhook still points at the
147
+ * public internet as it resolves now (the URL was only checked as written when it was set).
148
+ * Never throws: a skipped or failed send is logged, without the link.
149
+ */
150
+ async function sendSessionStarted (agentId, hook, payload) {
151
+ try {
152
+ let url
153
+ try { url = parseWebhookUrl(hook.url, { allowLocal: allowLocalWebhooks }) } catch (e) {
154
+ log(`session.started to agent ${agentId} skipped: ${hook.url}: ${e.message}`)
155
+ return
156
+ }
157
+ if (!allowLocalWebhooks) {
158
+ const host = await publicWebhookHost(url, webhookLookup ? { lookup: webhookLookup } : {})
159
+ if (!host.ok) { log(`session.started to agent ${agentId} skipped: ${host.reason}`); return }
160
+ }
161
+ await deliverWebhook({ url, secret: hook.secret }, payload, { fetch: webhookFetch, now, log })
162
+ } catch (err) {
163
+ log(`session.started to agent ${agentId} failed: ${err?.message || err}`)
164
+ }
165
+ }
166
+
167
+ const routes = [
168
+ ['GET', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
169
+ const { agent } = await myAgent(req, id)
170
+ return { placement: placementView(agent.id, await store.agentPlacement(agent.id)) }
171
+ }],
172
+
173
+ ['PUT', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
174
+ const { u, agent } = await myAgent(req, id)
175
+ // A personal agent's workspaces are its owner's personal ones, never an org's.
176
+ const p = await placementFrom(body, (ws) => !ws.orgId && ws.ownerUserId === u.userId)
177
+ return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${u.userId}` })) }
178
+ }],
179
+
180
+ ['GET', /^\/v1\/orgs\/([^/]+)\/agents$/, async (req, body, [slug]) => {
181
+ const a = await orgFor(req, slug)
182
+ a.need('agents', 'r')
183
+ const agents = await store.listOrgAgents(a.org.id)
184
+ const placements = new Map((await store.listAgentPlacements(agents.map((x) => x.id))).map((p) => [p.agentId, p]))
185
+ return { agents: agents.map((x) => ({ id: x.id, name: x.name, provider: x.provider, type: x.type, hosted: !x.publicKey, placement: placementView(x.id, placements.get(x.id)) })) }
186
+ }],
187
+
188
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/agents\/([^/]+)\/placement$/, async (req, body, [slug, id]) => {
189
+ const a = await orgFor(req, slug)
190
+ a.need('agents', 'u')
191
+ // Placing an agent puts it in the org's workspaces, so it takes Workspaces: Update too.
192
+ a.need('workspaces', 'u')
193
+ const agent = await orgAgent(a, id)
194
+ const p = await placementFrom(body, (ws) => ws.orgId === a.org.id)
195
+ return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${a.u.userId}` })) }
196
+ }],
197
+
198
+ // Only for an agent the workspace's owner placed there: an agent added by hand is
199
+ // managed through its member row instead.
200
+ ['PUT', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
201
+ const r = await reach(req, id)
202
+ r.needAdmin()
203
+ if (!(await placementIn(store, r.ws, agentId))) throw new HttpError(404, 'that agent is not placed in this workspace')
204
+ const b = body && typeof body === 'object' ? body : {}
205
+ if (b.sessions !== undefined && b.sessions !== null && !SESSIONS.includes(b.sessions)) throw new HttpError(400, 'Sessions is all, invited or null.')
206
+ if (b.excluded !== undefined && typeof b.excluded !== 'boolean') throw new HttpError(400, 'excluded is true or false.')
207
+ const old = await store.workspaceAgentOverride(r.ws.id, agentId)
208
+ const override = await store.putWorkspaceAgentOverride({
209
+ workspaceId: r.ws.id,
210
+ agentId,
211
+ sessions: b.sessions !== undefined ? b.sessions : (old?.sessions ?? null),
212
+ excluded: b.excluded !== undefined ? b.excluded : !!old?.excluded
213
+ })
214
+ return { override }
215
+ }],
216
+
217
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
218
+ const r = await reach(req, id)
219
+ r.needAdmin()
220
+ await store.deleteWorkspaceAgentOverride(r.ws.id, needId(agentId, 'agent'))
221
+ return { ok: true }
222
+ }],
223
+
224
+ // A one-time link (good for an hour) for a new agent of the workspace's owner, which
225
+ // joins as a member of this workspace. An org's needs Agents: Create too.
226
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/agent-invites$/, async (req, body, [id]) => {
227
+ const r = await reach(req, id)
228
+ r.needAdmin()
229
+ if (!r.me.userId) throw new HttpError(403, 'agents do not invite agents')
230
+ const workspaceAccess = cleanAccess(body.access ?? 'edit')
231
+ const workspaceSessions = body.sessions ?? 'invited'
232
+ if (!SESSIONS.includes(workspaceSessions)) throw new HttpError(400, 'Sessions is all or invited.')
233
+ let home
234
+ if (r.ws.orgId) {
235
+ const a = await orgAccess(store, r.me.userId, (await store.orgById(r.ws.orgId)).slug)
236
+ a.need('agents', 'c')
237
+ home = { orgId: a.org.id }
238
+ } else home = { ownerUserId: r.ws.ownerUserId }
239
+ limitSend(r.me.userId)
240
+ const token = newToken('qj_')
241
+ const invite = await store.createAgentInvite({ tokenHash: hashToken(token), createdBy: r.me.userId, expiresAt: now() + AGENT_INVITE_TTL_MS, ...home, workspaceId: r.ws.id, workspaceAccess, workspaceSessions })
242
+ return {
243
+ invite: { id: invite.id, kind: invite.orgId ? 'org' : 'personal', status: inviteStatus(invite, now()), workspaceId: invite.workspaceId, workspaceAccess: invite.workspaceAccess, workspaceSessions: invite.workspaceSessions, createdAt: invite.createdAt, expiresAt: invite.expiresAt },
244
+ link: `${apiUrl}/v1/join/${token}`
245
+ }
246
+ }],
247
+
248
+ // The session's agents for its People: the ones its workspace invites (and why), and the
249
+ // ones its owner keeps out (Don't invite), so the owner can change either.
250
+ ['GET', /^\/v1\/sessions\/([^/]+)\/agents$/, async (req, body, [room]) => {
251
+ await ownRoom(req, room)
252
+ return { agents: await sessionAgents(store, room) }
253
+ }],
254
+
255
+ // The agents its owner keeps out of this session, so the app can offer to let them back in.
256
+ ['GET', /^\/v1\/sessions\/([^/]+)\/agents\/excluded$/, async (req, body, [room]) => {
257
+ await ownRoom(req, room)
258
+ const agents = []
259
+ for (const e of await store.listSessionAgentExclusions(room)) agents.push({ agentId: e.agentId, name: (await store.agentById(e.agentId))?.name || '' })
260
+ return { agents }
261
+ }],
262
+
263
+ ['PUT', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
264
+ const { me, agent } = await ownSession(req, room, agentId)
265
+ await store.addSessionAgentExclusion({ room, agentId: agent.id, excludedBy: me.account })
266
+ return { ok: true }
267
+ }],
268
+
269
+ ['DELETE', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
270
+ const { agent } = await ownSession(req, room, agentId)
271
+ await store.removeSessionAgentExclusion(room, agent.id)
272
+ return { ok: true }
273
+ }],
274
+
275
+ // The session's owner, as soon as it has started a session in this workspace, hands over
276
+ // its join link; every agent that joins the session by itself and has a webhook is sent it
277
+ // at once. The link is used for these sends and never kept. 409 until the relay has said
278
+ // who owns the session (the app tries again shortly).
279
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/sessions\/([^/]+)\/started$/, async (req, body, [id, room]) => {
280
+ const r = await reach(req, id)
281
+ if (!ROOM.test(room)) throw new HttpError(404, 'that session is not in this workspace')
282
+ const session = await store.sessionByRoom(room)
283
+ if (!session || session.workspaceId !== r.ws.id) throw new HttpError(404, 'that session is not in this workspace')
284
+ if (session.workspaceLinkedBy !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
285
+ if (!session.ownerAccount) throw new HttpError(409, 'Quilt has not heard who owns this session yet; try again in a moment.')
286
+ if (session.ownerAccount !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
287
+ let invite
288
+ try { invite = parseInvite(String(body.link || ''), { allowRelay: (s) => isHostedRelay(s) || relayForm(s) === ownRelay }) } catch { throw new HttpError(400, 'link must be this session\'s Quilt invite link') }
289
+ if (invite.room !== room || !invite.secret) throw new HttpError(400, 'link must be this session\'s Quilt invite link')
290
+ // `agents` (optional): only these of the agents it invites, e.g. one invited again.
291
+ const only = body.agents === undefined ? null : body.agents
292
+ if (only !== null && (!Array.isArray(only) || only.length > 100 || only.some((x) => typeof x !== 'string'))) throw new HttpError(400, 'agents must be a list of agent ids.')
293
+ limitAnnounce(room)
294
+ // Sent as Quilt writes it, whatever surrounded it in the request.
295
+ const link = buildInvite({ server: invite.relay || HOSTED_RELAY, room, secret: invite.secret }, isHostedRelay)
296
+ const by = r.me.userId ? ((await store.profile(r.me.userId))?.name || '') : (r.me.agent?.name || '')
297
+ const notified = []
298
+ const withoutWebhook = []
299
+ // An agent that started the session is in it already: it isn't sent its own link.
300
+ const starter = session.ownerAccount.startsWith('agent:') ? session.ownerAccount.slice(6) : null
301
+ for (const { agentId, via } of await agentsJoiningSession(store, room)) {
302
+ if (agentId === starter || (only && !only.includes(agentId))) continue
303
+ const hook = await store.agentWebhook(agentId)
304
+ if (!hook) { withoutWebhook.push(agentId); continue }
305
+ notified.push(agentId)
306
+ const payload = { event: 'session.started', id: crypto.randomUUID(), ts: now(), workspace: { id: r.ws.id, name: r.ws.name }, room, session: room, name: session.name || '', link, by, via }
307
+ trackDelivery(sendSessionStarted(agentId, hook, payload))
308
+ }
309
+ return { notified, withoutWebhook }
310
+ }],
311
+
312
+ // The agent's own webhook for workspace events. The secret signs deliveries: the agent's
313
+ // own (so one webhook, set from a session's relay, signs the same everywhere), or a new one
314
+ // on every PUT without, answered once.
315
+ ['PUT', /^\/v1\/agents\/me\/webhook$/, async (req, body) => {
316
+ const agent = await meAsAgent(req)
317
+ let url
318
+ try { url = parseWebhookUrl(body.url, { allowLocal: allowLocalWebhooks }) } catch (e) { throw new HttpError(400, e.message) }
319
+ const given = body.secret == null ? '' : String(body.secret)
320
+ if (given && (given.length < MIN_SECRET || given.length > MAX_SECRET)) throw new HttpError(400, `secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and Quilt makes one).`)
321
+ const hook = await store.putAgentWebhook({ agentId: agent.id, url, secret: given || newSecret() })
322
+ return { url: hook.url, secret: hook.secret, events: [...AGENT_WEBHOOK_EVENTS] }
323
+ }],
324
+
325
+ ['DELETE', /^\/v1\/agents\/me\/webhook$/, async (req) => {
326
+ const agent = await meAsAgent(req)
327
+ await store.deleteAgentWebhook(agent.id)
328
+ return { ok: true }
329
+ }]
330
+ ]
331
+ return routes.map(([method, pattern, fn, ...options]) => [method, pattern, gated(fn), ...options])
332
+ }