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,279 @@
1
+ // quilt-vault (docs/superpowers/specs/2026-10-18-secrets.md): the only place a secret's value is
2
+ // ever opened. Two jobs, nothing else:
3
+ //
4
+ // - POST /v1/write: a person's app or browser sends values here with a write ticket from
5
+ // quilt-api. The vault seals each under its scope's data key and stores the sealed blob
6
+ // through quilt-api's internal routes. It answers each name's digest, never a value.
7
+ // - /g/<run token>/<NAME>/<path>: the gateway `quilt run` points a process at. quilt-api says
8
+ // whether the run may use NAME now; the vault opens the value, attaches it to the request in
9
+ // the secret's format, sends the request to the secret's one host, and streams the answer
10
+ // back with every copy of the value masked.
11
+ //
12
+ // No route answers a value. Values are never logged.
13
+ import http from 'node:http'
14
+ import https from 'node:https'
15
+ import dns from 'node:dns'
16
+ import net from 'node:net'
17
+ import { Transform } from 'node:stream'
18
+ import zlib from 'node:zlib'
19
+ import { newDataKey, sealValue, openValue, digestOf } from './crypto.js'
20
+ import { applyCredential, maskForms, shortDigest, cleanUpstream, MAX_VALUE_BYTES, STAND_IN } from '../ui/secret-rules.js'
21
+
22
+ export const MASK = '[hidden by Quilt]'
23
+ const MAX_WRITE_BODY = 2 * 1024 * 1024
24
+ const UPSTREAM_TIMEOUT_MS = 120 * 1000
25
+ // Headers that belong to one connection, never passed on (RFC 9110 §7.6.1), and ones the vault sets itself.
26
+ const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length', 'accept-encoding', 'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'via'])
27
+ const NAME = /^[A-Z_][A-Z0-9_]{0,63}$/
28
+ const TOKEN = /^qrun_[A-Za-z0-9_-]{20,100}$/
29
+
30
+ /** Whether an address is inside a private, local or reserved network (the vault never sends there). */
31
+ export function privateAddress (address) {
32
+ const a = String(address).toLowerCase()
33
+ if (net.isIPv4(a)) {
34
+ const [x, y] = a.split('.').map(Number)
35
+ return x === 0 || x === 10 || x === 127 || (x === 100 && y >= 64 && y <= 127) || (x === 169 && y === 254) || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 192 && y === 0) || (x === 198 && (y === 18 || y === 19)) || x >= 224
36
+ }
37
+ if (net.isIPv6(a)) {
38
+ if (a === '::' || a === '::1') return true
39
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(a)
40
+ if (mapped) return privateAddress(mapped[1])
41
+ return /^(fc|fd|fe8|fe9|fea|feb|ff)/.test(a) || a.startsWith('64:ff9b:') || a.startsWith('2001:db8')
42
+ }
43
+ return true
44
+ }
45
+
46
+ /** A DNS lookup that refuses names pointing inside a network, checked as the connection is made. */
47
+ export function publicLookup (hostname, options, callback) {
48
+ dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
49
+ if (err) return callback(err)
50
+ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: options.family || 4 }]
51
+ if (!list.length || list.some((x) => privateAddress(x.address))) return callback(Object.assign(new Error(`${hostname} points inside a private network`), { code: 'EPRIVATE' }))
52
+ if (options.all) return callback(null, list)
53
+ callback(null, list[0].address, list[0].family)
54
+ })
55
+ }
56
+
57
+ /** Replaces every form of a value in a stream, including one split across chunks. */
58
+ export function masker (forms, replacement = MASK) {
59
+ const needles = forms.map((f) => Buffer.from(f)).filter((b) => b.length).sort((a, b) => b.length - a.length)
60
+ const keep = needles.length ? Math.max(...needles.map((n) => n.length)) - 1 : 0
61
+ const out = Buffer.from(replacement)
62
+ let carry = Buffer.alloc(0)
63
+ const run = (data, end, push) => {
64
+ const boundary = end ? data.length : Math.max(0, data.length - keep)
65
+ let pos = 0
66
+ while (needles.length) {
67
+ let best = -1; let len = 0
68
+ for (const n of needles) { const i = data.indexOf(n, pos); if (i !== -1 && (best === -1 || i < best)) { best = i; len = n.length } }
69
+ if (best === -1 || best >= boundary) break
70
+ if (best > pos) push(data.subarray(pos, best))
71
+ push(out)
72
+ pos = best + len
73
+ }
74
+ const cut = Math.max(pos, boundary)
75
+ if (cut > pos) push(data.subarray(pos, cut))
76
+ return data.subarray(cut)
77
+ }
78
+ return new Transform({
79
+ transform (chunk, enc, cb) { carry = run(Buffer.concat([carry, chunk]), false, (b) => this.push(b)); cb() },
80
+ flush (cb) { run(carry, true, (b) => this.push(b)); carry = Buffer.alloc(0); cb() }
81
+ })
82
+ }
83
+
84
+ const maskText = (s, forms) => forms.reduce((acc, f) => acc.split(f).join(MASK), String(s))
85
+
86
+ /**
87
+ * Starts the vault. `apiUrl` and `vaultSecret` reach quilt-api's internal routes; `keys` is a
88
+ * key ring (crypto.js keyRing); `digestKey` (32 bytes) makes digests. `allowedOrigins` may write
89
+ * from a browser (the website). `upstreamProtocol` and `allowLocalUpstreams` are for tests only.
90
+ */
91
+ export function startVault ({ port = 0, host = '127.0.0.1', apiUrl, vaultSecret, keys, digestKey, fetch: fetchImpl = globalThis.fetch, now = Date.now, log = () => {}, allowedOrigins = [], upstreamProtocol = 'https', allowLocalUpstreams = false, runLimit = 1200 }) {
92
+ if (!apiUrl || !vaultSecret || !keys || !digestKey) throw new Error('the vault needs QUILT_API_URL, QUILT_VAULT_SECRET, a root key and VAULT_DIGEST_KEY')
93
+ const api = String(apiUrl).replace(/\/+$/, '')
94
+ const origins = new Set(allowedOrigins.map((o) => String(o).replace(/\/+$/, '')))
95
+ const client = upstreamProtocol === 'http' ? http : https
96
+
97
+ /** quilt-api's internal routes: { status, body }. */
98
+ async function internal (path, body) {
99
+ let res
100
+ try {
101
+ res = await fetchImpl(`${api}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-quilt-vault-secret': vaultSecret }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) })
102
+ } catch (err) { return { status: 502, body: { error: `Quilt did not answer (${err.message})` } } }
103
+ return { status: res.status, body: await res.json().catch(() => ({})) }
104
+ }
105
+
106
+ const hits = new Map()
107
+ const limited = (key) => {
108
+ const t = now()
109
+ const recent = (hits.get(key) || []).filter((x) => t - x < 60_000)
110
+ recent.push(t)
111
+ hits.set(key, recent)
112
+ if (hits.size > 50_000) hits.delete(hits.keys().next().value)
113
+ return recent.length > runLimit
114
+ }
115
+
116
+ const cors = (req) => {
117
+ const o = String(req.headers.origin || '')
118
+ return origins.has(o) ? { 'access-control-allow-origin': o, vary: 'origin' } : {}
119
+ }
120
+ const json = (req, res, status, data) => {
121
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...cors(req) })
122
+ res.end(JSON.stringify(data))
123
+ }
124
+
125
+ /** The scope's data key, opened: made (and stored) the first time the scope saves a secret. */
126
+ async function dataKeyFor (scope, given) {
127
+ let dk = given
128
+ if (!dk) {
129
+ const ctx = { scopeType: scope.type, scopeId: scope.id, version: 1 }
130
+ const fresh = newDataKey()
131
+ const sealedKey = await keys.wrap(fresh, ctx)
132
+ const r = await internal('/v1/vault/data-keys', { scopeType: scope.type, scopeId: scope.id, version: 1, sealedKey, provider: keys.primary })
133
+ if (r.status !== 200) throw Object.assign(new Error(r.body.error || 'could not store the data key'), { status: 502 })
134
+ dk = r.body.dataKey
135
+ // Another save made it first: theirs is the one stored, so it is the one used.
136
+ if (dk.sealedKey === sealedKey) return { version: 1, key: fresh }
137
+ }
138
+ return { version: dk.version, key: await keys.unwrap(dk.sealedKey, { scopeType: scope.type, scopeId: scope.id, version: dk.version }) }
139
+ }
140
+
141
+ async function write (req, res) {
142
+ let body
143
+ try { body = JSON.parse((await readBody(req, MAX_WRITE_BODY)).toString('utf8')) } catch (err) { return json(req, res, err.status || 400, { error: err.status ? err.message : 'bad json' }) }
144
+ const values = body && typeof body.values === 'object' && !Array.isArray(body.values) ? body.values : null
145
+ if (!values || !Object.keys(values).length) return json(req, res, 400, { error: 'send the values to save' })
146
+ for (const [name, v] of Object.entries(values)) {
147
+ if (typeof v !== 'string' || !v.length) return json(req, res, 400, { error: `${String(name).slice(0, 64)} has no value` })
148
+ if (Buffer.byteLength(v) > MAX_VALUE_BYTES) return json(req, res, 413, { error: `${String(name).slice(0, 64)} is longer than ${MAX_VALUE_BYTES / 1024} KB` })
149
+ }
150
+ const t = await internal('/v1/vault/tickets/redeem', { ticket: String(body.ticket || '') })
151
+ if (t.status !== 200) return json(req, res, t.status, { error: t.body.error || 'could not save' })
152
+ const wanted = new Set(t.body.entries.map((e) => e.name))
153
+ const names = Object.keys(values)
154
+ if (names.some((n) => !wanted.has(n)) || names.length !== wanted.size) return json(req, res, 400, { error: 'those are not the names this save was for' })
155
+ let dk
156
+ try { dk = await dataKeyFor(t.body.scope, t.body.dataKey) } catch (err) { log(`write: data key: ${err.message}`); return json(req, res, err.status || 500, { error: 'could not open this scope\'s key; try again' }) }
157
+ const scope = { scopeType: t.body.scope.type, scopeId: t.body.scope.id }
158
+ const items = names.map((name) => ({ name, sealedValue: sealValue(dk.key, scope, name, values[name]), keyVersion: dk.version, digest: digestOf(digestKey, values[name]) }))
159
+ const stored = await internal('/v1/vault/secrets', { ticketId: t.body.ticketId, items })
160
+ if (stored.status !== 200) return json(req, res, stored.status, { error: stored.body.error || 'could not save' })
161
+ log(`write: ${items.length} secret(s) saved in ${scope.scopeType} ${scope.scopeId}`)
162
+ return json(req, res, 200, { saved: items.map((i) => ({ name: i.name, digest: shortDigest(i.digest) })) })
163
+ }
164
+
165
+ async function gateway (req, res, token, name, rest) {
166
+ if (!TOKEN.test(token) || !NAME.test(name)) return json(req, res, 404, { error: 'not a Quilt gateway address' })
167
+ if (limited(token)) return json(req, res, 429, { error: 'too many requests for this run; slow down' })
168
+ const r = await internal('/v1/vault/resolve', { token, name })
169
+ if (r.status !== 200) {
170
+ req.resume()
171
+ return json(req, res, r.status, { error: r.body.error || 'not allowed' })
172
+ }
173
+ const { secret, dataKey } = r.body
174
+ let value
175
+ try {
176
+ const key = await keys.unwrap(dataKey.sealedKey, { scopeType: secret.scopeType, scopeId: secret.scopeId, version: dataKey.version })
177
+ value = openValue(key, { scopeType: secret.scopeType, scopeId: secret.scopeId }, secret.name, secret.sealedValue)
178
+ } catch (err) {
179
+ log(`gateway: could not open ${secret.name} (${err.message})`)
180
+ req.resume()
181
+ return json(req, res, 500, { error: 'the vault could not open this secret; replace it' })
182
+ }
183
+ let upstream
184
+ try { upstream = cleanUpstream(secret.upstream, { allowLocal: allowLocalUpstreams }) } catch { req.resume(); return json(req, res, 502, { error: 'this secret has no host the vault may reach' }) }
185
+ const cred = applyCredential(secret.format, value)
186
+ const forms = maskForms(secret.format, value)
187
+ const incoming = new URL(rest || '/', 'http://x')
188
+ let path = incoming.pathname
189
+ if (cred.path !== undefined) path = `/${cred.path.replace(/\/+$/, '')}${path === '/' ? '' : path}`
190
+ const query = new URLSearchParams(incoming.search)
191
+ if (cred.query) { query.delete(cred.query[0]); query.append(cred.query[0], cred.query[1]) }
192
+ const qs = query.toString()
193
+ const headers = {}
194
+ for (const [k, v] of Object.entries(req.headers)) {
195
+ const lower = k.toLowerCase()
196
+ if (HOP.has(lower) || lower.startsWith('fly-') || lower.startsWith('x-quilt-')) continue
197
+ // The process's stand-in credential (an SDK sends "Bearer quilt"): never passed on.
198
+ if (cred.header && lower === cred.header[0]) continue
199
+ if ((lower === 'authorization' || lower === 'x-api-key') && String(v).replace(/^(Bearer|Basic|Token)\s+/i, '') === STAND_IN) continue
200
+ headers[lower] = v
201
+ }
202
+ if (cred.header) headers[cred.header[0]] = cred.header[1]
203
+ headers['accept-encoding'] = 'identity' // the mask has to see the answer as text
204
+ const [hostname, port] = upstream.split(':')
205
+ const out = client.request({
206
+ protocol: `${upstreamProtocol}:`,
207
+ hostname,
208
+ port: port ? Number(port) : undefined,
209
+ method: req.method,
210
+ path: `${path}${qs ? `?${qs}` : ''}`,
211
+ headers: { ...headers, host: upstream },
212
+ servername: hostname,
213
+ lookup: allowLocalUpstreams ? undefined : publicLookup,
214
+ timeout: UPSTREAM_TIMEOUT_MS
215
+ })
216
+ let answered = false
217
+ out.on('timeout', () => out.destroy(Object.assign(new Error('the service took too long to answer'), { code: 'ETIMEDOUT' })))
218
+ out.on('error', (err) => {
219
+ if (answered) return res.destroy()
220
+ answered = true
221
+ log(`gateway: ${secret.name} to ${upstream}: ${err.code || err.message}`)
222
+ json(req, res, 502, { error: err.code === 'EPRIVATE' ? `${upstream} points inside a private network; the vault won't send secrets there` : `${upstream} did not answer (${err.code || 'error'})` })
223
+ })
224
+ out.on('response', (up) => {
225
+ answered = true
226
+ const h = {}
227
+ for (const [k, v] of Object.entries(up.headers)) {
228
+ if (HOP.has(k) || k === 'content-encoding') continue
229
+ h[k] = Array.isArray(v) ? v.map((x) => maskText(x, forms)) : maskText(v, forms)
230
+ }
231
+ res.writeHead(up.statusCode, up.statusMessage, h)
232
+ // A service that compresses anyway (it was asked not to): opened here, so the mask sees the text.
233
+ const enc = String(up.headers['content-encoding'] || '').trim().toLowerCase()
234
+ const open = enc === 'gzip' || enc === 'x-gzip' ? zlib.createGunzip() : enc === 'deflate' ? zlib.createInflate() : enc === 'br' ? zlib.createBrotliDecompress() : null
235
+ const body = open ? up.pipe(open) : up
236
+ if (open) open.on('error', () => res.destroy())
237
+ body.pipe(masker(forms)).pipe(res)
238
+ up.on('error', () => res.destroy())
239
+ })
240
+ req.pipe(out)
241
+ req.on('error', () => out.destroy())
242
+ }
243
+
244
+ const server = http.createServer(async (req, res) => {
245
+ try {
246
+ const url = new URL(req.url, 'http://x')
247
+ if (req.method === 'GET' && url.pathname === '/healthz') return json(req, res, 200, { ok: true })
248
+ if (url.pathname === '/v1/write') {
249
+ if (req.method === 'OPTIONS') {
250
+ res.writeHead(204, { ...cors(req), 'access-control-allow-methods': 'POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '600' })
251
+ return res.end()
252
+ }
253
+ if (req.method === 'POST') return await write(req, res)
254
+ }
255
+ // /g/<token>/<NAME>[/<path>]: the raw path, so the service gets it exactly as sent.
256
+ const m = /^\/g\/([^/?#]+)\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?$/.exec(req.url)
257
+ if (m) return await gateway(req, res, m[1], m[2], `${m[3] || '/'}${m[4] || ''}`)
258
+ json(req, res, 404, { error: 'not found' })
259
+ } catch (err) {
260
+ log(`vault error: ${err.message}`)
261
+ if (!res.headersSent) json(req, res, 500, { error: 'internal error' })
262
+ else res.destroy()
263
+ }
264
+ })
265
+
266
+ return new Promise((resolve) => server.listen(port, host, () => {
267
+ const p = server.address().port
268
+ resolve({ port: p, url: `http://${host}:${p}`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r) }) })
269
+ }))
270
+ }
271
+
272
+ function readBody (req, max) {
273
+ return new Promise((resolve, reject) => {
274
+ let size = 0; const chunks = []
275
+ req.on('data', (c) => { size += c.length; if (size > max) { req.destroy(); reject(Object.assign(new Error('too large'), { status: 413 })) } else chunks.push(c) })
276
+ req.on('end', () => resolve(Buffer.concat(chunks)))
277
+ req.on('error', reject)
278
+ })
279
+ }
@@ -0,0 +1,38 @@
1
+ // AWS Signature Version 4, for the vault's calls to AWS KMS (kms.js). Small and dependency
2
+ // free: the vault keeps as little code as it can.
3
+ import crypto from 'node:crypto'
4
+
5
+ const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex')
6
+ const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest()
7
+ // RFC 3986 encoding, as AWS wants it (encodeURIComponent leaves !'()* alone).
8
+ const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
9
+
10
+ /** 20150830T123600Z and 20150830 for a time. */
11
+ export function amzDates (at) {
12
+ const iso = new Date(at).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
13
+ return { amzDate: iso, dateStamp: iso.slice(0, 8) }
14
+ }
15
+
16
+ /**
17
+ * The headers that sign a request: { authorization, 'x-amz-date', 'x-amz-security-token'? }.
18
+ * `headers` are the ones to sign (host is taken from the URL when absent).
19
+ */
20
+ export function signAws ({ method, url, headers = {}, body = '', region, service, credentials, now = Date.now }) {
21
+ const u = new URL(url)
22
+ const { amzDate, dateStamp } = amzDates(now())
23
+ const all = { host: u.host, ...Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)])), 'x-amz-date': amzDate }
24
+ if (credentials.sessionToken) all['x-amz-security-token'] = credentials.sessionToken
25
+ const names = Object.keys(all).sort()
26
+ const canonicalHeaders = names.map((n) => `${n}:${all[n].trim().replace(/\s+/g, ' ')}\n`).join('')
27
+ const signedHeaders = names.join(';')
28
+ const path = u.pathname.split('/').map((seg) => enc(decodeURIComponent(seg))).join('/') || '/'
29
+ const query = [...u.searchParams].map(([k, v]) => [enc(k), enc(v)]).sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0)).map(([k, v]) => `${k}=${v}`).join('&')
30
+ const canonical = [method.toUpperCase(), path, query, canonicalHeaders, signedHeaders, sha256(body)].join('\n')
31
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`
32
+ const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n')
33
+ const key = hmac(hmac(hmac(hmac(`AWS4${credentials.secretAccessKey}`, dateStamp), region), service), 'aws4_request')
34
+ const signature = crypto.createHmac('sha256', key).update(toSign).digest('hex')
35
+ const out = { 'x-amz-date': amzDate, authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}` }
36
+ if (credentials.sessionToken) out['x-amz-security-token'] = credentials.sessionToken
37
+ return out
38
+ }
@@ -0,0 +1,235 @@
1
+ // Webhooks: an agent asks to be told, by an HTTP POST to a URL of its own, when
2
+ // it is mentioned in chat (@name), sent a direct message, or handed a task. The
3
+ // agent sets the subscription itself (quilt_webhook_subscribe), so a cloud agent
4
+ // with a routine behind a webhook trigger wakes up instead of polling quilt_inbox.
5
+ //
6
+ // One POST per event, JSON, signed with the subscription's secret so the agent can
7
+ // check it came from Quilt (`x-quilt-signature: sha256=<hmac>` over `<ts>.<body>`).
8
+ // A failed delivery is tried again a few times; after that the event is still in
9
+ // quilt_inbox, which never depends on the webhook.
10
+ //
11
+ // The pure parts (shapes, signing, validation) are shared by the relay, which
12
+ // delivers for hosted agents, and the local session, which delivers for agents
13
+ // joined from a computer.
14
+ import crypto from 'node:crypto'
15
+ import dns from 'node:dns'
16
+ import net from 'node:net'
17
+
18
+ export const WEBHOOK_EVENTS = ['chat.mention', 'chat.dm', 'task.assigned']
19
+ export const EVENT_OF_KIND = { mention: 'chat.mention', dm: 'chat.dm', task: 'task.assigned' }
20
+ export const MAX_URL = 2000
21
+ export const MAX_SECRET = 200
22
+ export const MIN_SECRET = 16
23
+ export const MAX_BEARER = 500
24
+ export const DELIVERY_TIMEOUT_MS = 10_000
25
+ // Waits before the second, third and fourth tries.
26
+ export const RETRY_DELAYS_MS = [1000, 5000, 25_000]
27
+
28
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
29
+ const isPrivateIp = (host) => {
30
+ const h = host.replace(/^\[|\]$/g, '')
31
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
32
+ const [a, b] = h.split('.').map(Number)
33
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
34
+ }
35
+ if (h.includes(':')) return /^(::1|::|fc|fd|fe80)/i.test(h)
36
+ return false
37
+ }
38
+
39
+ // Addresses that are not on the public internet: unspecified, loopback, private, shared
40
+ // (CGNAT), link-local, benchmarking, multicast and reserved; for IPv6 also unique-local,
41
+ // link-local, site-local, multicast, IPv4-compatible and NAT64. Two lists, because a
42
+ // BlockList matches IPv4 rules against IPv4-mapped IPv6 addresses (wanted) and IPv6 rules
43
+ // covering ::ffff:0:0/96 against every IPv4 address (not wanted).
44
+ const PRIVATE_V4 = new net.BlockList()
45
+ for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE_V4.addSubnet(a, p, 'ipv4')
46
+ const PRIVATE_V6 = new net.BlockList()
47
+ for (const [a, p] of [['::', 96], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) PRIVATE_V6.addSubnet(a, p, 'ipv6')
48
+
49
+ /** Whether `address` (an IP, as DNS answers it) is not on the public internet. Anything that isn't an address counts as private. */
50
+ export function isPrivateAddress (address) {
51
+ let a = String(address || '').replace(/^\[|\]$/g, '').split('%')[0]
52
+ const mapped = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
53
+ if (mapped) a = mapped[1]
54
+ const kind = net.isIP(a)
55
+ if (kind === 4) return PRIVATE_V4.check(a, 'ipv4')
56
+ if (kind === 6) return PRIVATE_V6.check(a, 'ipv6') || PRIVATE_V4.check(a, 'ipv6')
57
+ return true
58
+ }
59
+
60
+ /**
61
+ * Whether a webhook URL's host is on the public internet as it resolves now: { ok: true }, or
62
+ * { ok: false, reason }. A trailing dot is the same host; a local name or address never reaches
63
+ * DNS; every address the name resolves to must be public. Never throws. `lookup` is for tests.
64
+ * (parseWebhookUrl only reads the URL as written; this is for sends from Quilt's own servers.)
65
+ */
66
+ export async function publicWebhookHost (url, { lookup = dns.promises.lookup } = {}) {
67
+ let host
68
+ try { host = new URL(url).hostname.toLowerCase() } catch { return { ok: false, reason: 'not a valid URL' } }
69
+ const bare = host.replace(/^\[|\]$/g, '').replace(/\.+$/, '')
70
+ if (!bare || LOCAL_HOSTS.has(bare) || bare.endsWith('.localhost')) return { ok: false, reason: `${host} is a local address` }
71
+ if (net.isIP(bare)) return isPrivateAddress(bare) ? { ok: false, reason: `${host} is a local or private address` } : { ok: true }
72
+ let addrs
73
+ try { addrs = await lookup(bare, { all: true, verbatim: true }) } catch (err) { return { ok: false, reason: `${host} did not resolve (${err?.code || err?.message || err})` } }
74
+ if (!Array.isArray(addrs) || !addrs.length) return { ok: false, reason: `${host} did not resolve` }
75
+ const bad = addrs.find((a) => isPrivateAddress(a?.address))
76
+ if (bad) return { ok: false, reason: `${host} resolves to a local or private address (${bad.address})` }
77
+ return { ok: true }
78
+ }
79
+
80
+ /**
81
+ * The URL a subscription may POST to: https, no credentials, and not the relay's own
82
+ * network. `allowLocal` (the local session) also takes http to this computer, for an
83
+ * agent whose receiver runs here.
84
+ */
85
+ export function parseWebhookUrl (raw, { allowLocal = false } = {}) {
86
+ const s = String(raw || '').trim()
87
+ if (!s) throw new Error('Give the URL Quilt should POST to.')
88
+ if (s.length > MAX_URL) throw new Error('That URL is too long.')
89
+ let u
90
+ try { u = new URL(s) } catch { throw new Error('That is not a valid URL.') }
91
+ if (u.username || u.password) throw new Error('The URL may not carry a user name or password.')
92
+ const host = u.hostname.toLowerCase()
93
+ const local = LOCAL_HOSTS.has(host) || host.endsWith('.localhost') || isPrivateIp(host)
94
+ if (u.protocol === 'http:') {
95
+ if (!allowLocal || !local) throw new Error('The URL must use https (http is only for a receiver on this computer).')
96
+ } else if (u.protocol !== 'https:') {
97
+ throw new Error('The URL must start with https://.')
98
+ } else if (local && !allowLocal) {
99
+ throw new Error('The URL must be reachable from the internet, not a local or private address.')
100
+ }
101
+ return u.toString()
102
+ }
103
+
104
+ /** The event names in `list` (default: all), or an error naming a bad one. */
105
+ export function parseWebhookEvents (list) {
106
+ if (list == null) return WEBHOOK_EVENTS.slice()
107
+ if (!Array.isArray(list)) throw new Error(`events: give a list of ${WEBHOOK_EVENTS.join(', ')}.`)
108
+ const out = []
109
+ for (const e of list) {
110
+ const n = String(e || '').trim()
111
+ if (!WEBHOOK_EVENTS.includes(n)) throw new Error(`Unknown event "${n}". Events: ${WEBHOOK_EVENTS.join(', ')}.`)
112
+ if (!out.includes(n)) out.push(n)
113
+ }
114
+ if (!out.length) throw new Error(`events: give at least one of ${WEBHOOK_EVENTS.join(', ')}.`)
115
+ return out
116
+ }
117
+
118
+ export const newSecret = () => crypto.randomBytes(24).toString('hex')
119
+
120
+ /**
121
+ * A subscription from what an agent gave: { url, secret, events, since }. A secret the
122
+ * agent chose is kept (16 to 200 characters); without one, a new one is made and the
123
+ * agent is shown it once, in the tool's answer.
124
+ */
125
+ export function makeSubscription ({ url, secret, events, bearer } = {}, { allowLocal = false, now = Date.now } = {}) {
126
+ const u = parseWebhookUrl(url, { allowLocal })
127
+ const ev = parseWebhookEvents(events)
128
+ let s = secret == null ? '' : String(secret)
129
+ if (s && (s.length < MIN_SECRET || s.length > MAX_SECRET)) throw new Error(`The secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and Quilt makes one).`)
130
+ const made = !s
131
+ if (made) s = newSecret()
132
+ // A key the receiver wants on every POST (a Grok Bot routine's sender key, for example), sent as Authorization: Bearer <key>.
133
+ const b = bearer == null ? '' : String(bearer).trim()
134
+ if (b.length > MAX_BEARER || /[\r\n]/.test(b)) throw new Error(`The bearer key must be one line of at most ${MAX_BEARER} characters.`)
135
+ return { url: u, secret: s, events: ev, since: now(), made, ...(b ? { bearer: b } : {}) }
136
+ }
137
+
138
+ /** The subscription as an agent sees it (its secret only when it was just made). */
139
+ export function describeSubscription (sub, { showSecret = false } = {}) {
140
+ if (!sub) return 'No webhook: Quilt only answers quilt_inbox when you ask.'
141
+ const lines = [`Webhook: Quilt POSTs to ${sub.url} on ${sub.events.join(', ')}${sub.bearer ? ', with your bearer key in the Authorization header' : ''}.`]
142
+ if (showSecret) lines.push(`Secret (shown once; check x-quilt-signature with it): ${sub.secret}`)
143
+ lines.push('Each POST is JSON ({ event, id, session, workspace, room, to, by, text, ts, task?, context? }; session: the session it is from, which you name as `session` in the tools you answer with, so an answer never lands in another session; workspace: { id } or null; context: the messages before this one between you and its sender, oldest first) with x-quilt-event, x-quilt-delivery, x-quilt-timestamp and ' +
144
+ 'x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"). Answer 2xx quickly; a failed POST is retried a few times. ' +
145
+ 'What you are told is still in quilt_inbox. Stop with quilt_webhook_unsubscribe.')
146
+ return lines.join('\n')
147
+ }
148
+
149
+ /**
150
+ * The body for one inbox event (see inbox.js) to `to`, in `room`: `session` is the room (the id
151
+ * to answer in, with every tool's `session`), `workspace` the workspace it is in, when known.
152
+ */
153
+ export function webhookPayload (e, { room = '', to = '', workspace = '' } = {}) {
154
+ const p = { event: EVENT_OF_KIND[e.kind] || e.kind, id: String(e.id || ''), room, session: room, workspace: workspace ? { id: String(workspace) } : null, to, by: String(e.by || ''), text: String(e.text || ''), ts: Number(e.ts) || Date.now() }
155
+ if (e.kind === 'task' && e.task) p.task = { id: e.task.id, title: e.task.title, column: e.task.column, assignee: e.task.assignee ?? null, forAi: !!e.task.forAi, tool: e.task.tool || '', files: e.task.files || [] }
156
+ if (e.commit) p.commit = true // work this member asked to commit was committed: nothing to answer
157
+ if (e.nudge) p.nudge = true // a reminder from Quilt (uncommitted work): nothing to answer
158
+ // The conversation before this with whoever it is from (conversation.js), so an agent woken
159
+ // fresh knows what "that" refers to; `earlier` older messages are a quilt_conversation away.
160
+ if (Array.isArray(e.context) && e.context.length) {
161
+ p.context = e.context.map((m) => ({ id: m.id, by: m.by, ...(m.to ? { to: m.to } : {}), text: m.text, ts: m.ts }))
162
+ if (e.earlier) p.earlier = e.earlier
163
+ }
164
+ return p
165
+ }
166
+
167
+ /** `sha256=<hex>`: HMAC-SHA256 with `secret` over `<ts>.<body>`. */
168
+ export function signWebhook (secret, ts, body) {
169
+ return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(`${ts}.${body}`).digest('hex')
170
+ }
171
+
172
+ /** For receivers: whether `signature` is right for this body and timestamp. */
173
+ export function verifyWebhook (secret, ts, body, signature) {
174
+ const want = Buffer.from(signWebhook(secret, ts, body))
175
+ const got = Buffer.from(String(signature || ''))
176
+ return want.length === got.length && crypto.timingSafeEqual(want, got)
177
+ }
178
+
179
+ /** Whether a response means "try again later". */
180
+ const retryable = (status) => status === 408 || status === 429 || status >= 500
181
+
182
+ /**
183
+ * POSTs `payload` to `sub.url`, signed, trying again after each delay in `delays` when
184
+ * the receiver is down, slow or answers 5xx/429. Resolves { ok, status, attempts, error? };
185
+ * never throws. `fetch`, `delays` and `sleep` are for tests.
186
+ */
187
+ export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {} } = {}) {
188
+ const body = JSON.stringify(payload)
189
+ const delivery = crypto.randomUUID()
190
+ let status = 0
191
+ let error = ''
192
+ let attempt = 0
193
+ while (attempt < delays.length + 1) {
194
+ attempt++
195
+ const ts = String(now())
196
+ try {
197
+ const res = await fetch(sub.url, {
198
+ method: 'POST',
199
+ headers: {
200
+ 'content-type': 'application/json',
201
+ 'user-agent': 'quilt-webhook',
202
+ 'x-quilt-event': payload.event,
203
+ 'x-quilt-delivery': delivery,
204
+ 'x-quilt-timestamp': ts,
205
+ 'x-quilt-signature': signWebhook(sub.secret, ts, body),
206
+ ...(sub.bearer ? { authorization: `Bearer ${sub.bearer}` } : {})
207
+ },
208
+ body,
209
+ redirect: 'manual',
210
+ signal: AbortSignal.timeout(timeoutMs)
211
+ })
212
+ status = res.status
213
+ error = ''
214
+ if (res.ok) return { ok: true, status, attempts: attempt, delivery }
215
+ if (!retryable(status)) break
216
+ } catch (err) {
217
+ status = 0
218
+ error = err && err.message ? err.message : String(err)
219
+ }
220
+ if (attempt <= delays.length) await sleep(delays[attempt - 1])
221
+ }
222
+ log(`webhook to ${sub.url} failed (${payload.event}): ${error || `HTTP ${status}`}`)
223
+ return { ok: false, status, attempts: attempt, error: error || `HTTP ${status}`, delivery }
224
+ }
225
+
226
+ /** Delivers each of `events` the subscription asks for, one after another (order kept). */
227
+ export async function deliverEvents (sub, events, { room = '', to = '', workspace = '' } = {}, opts = {}) {
228
+ const out = []
229
+ for (const e of events || []) {
230
+ const p = webhookPayload(e, { room, to, workspace })
231
+ if (!sub.events.includes(p.event)) continue
232
+ out.push(await deliverWebhook(sub, p, opts))
233
+ }
234
+ return out
235
+ }
@@ -0,0 +1,62 @@
1
+ // `quilt workspace ...`: the workspace library from a shell, for agents (and people) that work
2
+ // with commands rather than MCP. Each command runs the same tool the MCP offers
3
+ // (workspace-tools.js), as whoever the MCP would act as here (mcp.js workspaceActor): this
4
+ // folder's agent, or the person signed in on this computer.
5
+ import { registerWorkspaceTools } from './workspace-tools.js'
6
+
7
+ export const WORKSPACE_USAGE = `Usage:
8
+ quilt workspace list Workspaces you can reach
9
+ quilt workspace files <workspace> [folder] [--glob g] What is in its library
10
+ quilt workspace get <workspace> <path> [--version n] Read a file (text is printed; anything else is saved and its path printed)
11
+ quilt workspace put <workspace> <path> <local file> [--note "what it is"]
12
+ Upload a file from this project (a new version if the path exists)
13
+ quilt workspace write <workspace> <path> <text> [--note n]
14
+ Save text as a file
15
+ quilt workspace mkdir <workspace> <folder> Make a folder (and any above it)
16
+ quilt workspace mv <workspace> <path> <new path> Rename or move a file or folder
17
+ quilt workspace rm <workspace> <path> Delete a file or folder
18
+ quilt workspace webhook <https url> | webhook-off Workspace events to your URL (agents)
19
+ <workspace> is its name or id.`
20
+
21
+ /** Pulls `--name value` options out of `args`: { opts, rest }. */
22
+ function options (args, names) {
23
+ const opts = {}
24
+ const rest = []
25
+ for (let i = 0; i < args.length; i++) {
26
+ const m = String(args[i]).match(/^--([a-z]+)(?:=(.*))?$/)
27
+ if (m && names.includes(m[1])) {
28
+ opts[m[1]] = m[2] !== undefined ? m[2] : args[++i]
29
+ if (opts[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`)
30
+ } else rest.push(args[i])
31
+ }
32
+ return { opts, rest }
33
+ }
34
+
35
+ /** Which tool a command runs, with what arguments; throws the usage when it doesn't fit. */
36
+ export function toolCall (sub, args) {
37
+ const { opts, rest } = options(args, ['glob', 'version', 'note'])
38
+ const [ws, a, b] = rest
39
+ const need = (n) => { if (rest.length < n) throw new Error(WORKSPACE_USAGE) }
40
+ switch (sub) {
41
+ case 'list': case 'ls': return ['quilt_workspaces', {}]
42
+ case 'files': need(1); return ['quilt_workspace_files', { workspace: ws, ...(a !== undefined ? { folder: a } : {}), ...(opts.glob ? { glob: opts.glob } : {}) }]
43
+ case 'get': case 'read': need(2); return ['quilt_workspace_read_file', { workspace: ws, path: a, ...(opts.version ? { version: Number(opts.version) } : {}) }]
44
+ case 'put': case 'upload': need(3); return ['quilt_workspace_write_file', { workspace: ws, path: a, fromPath: b, ...(opts.note ? { note: opts.note } : {}) }]
45
+ case 'write': need(3); return ['quilt_workspace_write_file', { workspace: ws, path: a, text: rest.slice(2).join(' '), ...(opts.note ? { note: opts.note } : {}) }]
46
+ case 'mkdir': need(2); return ['quilt_workspace_make_folder', { workspace: ws, path: a }]
47
+ case 'mv': case 'move': need(3); return ['quilt_workspace_move_file', { workspace: ws, path: a, to: b }]
48
+ case 'rm': case 'delete': need(2); return ['quilt_workspace_delete_file', { workspace: ws, path: a }]
49
+ case 'webhook': need(1); return ['quilt_workspace_webhook', { url: ws }]
50
+ case 'webhook-off': return ['quilt_workspace_webhook_off', {}]
51
+ default: throw new Error(WORKSPACE_USAGE)
52
+ }
53
+ }
54
+
55
+ /** Runs one command with `access` (localWorkspaceAccess): { ok, text }. */
56
+ export async function runWorkspaceCommand (sub, args, access) {
57
+ const [name, input] = toolCall(sub, args)
58
+ const tools = new Map()
59
+ registerWorkspaceTools({ registerTool: (n, def, fn) => tools.set(n, fn) }, access)
60
+ const r = await tools.get(name)(input)
61
+ return { ok: !r.isError, text: r.content.map((c) => c.text).join('\n') }
62
+ }