heyquilt 0.3.90 → 0.3.107

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // quilt-vault's entry point: everything from its own environment (fly.vault.toml).
3
+ // QUILT_API_URL quilt-api, whose internal /v1/vault/* routes hold the sealed blobs
4
+ // QUILT_VAULT_SECRET the shared secret those routes check (the same value on quilt-api)
5
+ // VAULT_ROOT_KEY 32 random bytes, base64: seals the per-scope data keys
6
+ // VAULT_DIGEST_KEY 32 random bytes, base64: the key digests are made with
7
+ // VAULT_KEY_PROVIDER env (default) or aws-kms, with VAULT_KMS_KEY_ID, AWS_REGION,
8
+ // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (AWS_SESSION_TOKEN)
9
+ // VAULT_ALLOWED_ORIGINS sites that may save from a browser (default https://heyquilt.com)
10
+ import { startVault } from './server.js'
11
+ import { keyRing, keyFrom } from './crypto.js'
12
+ import { providersFromEnv } from './kms.js'
13
+
14
+ const env = process.env
15
+ try {
16
+ const vault = await startVault({
17
+ port: Number(env.PORT || 8080),
18
+ host: env.HOST || '0.0.0.0',
19
+ apiUrl: env.QUILT_API_URL || 'https://api.heyquilt.com',
20
+ vaultSecret: env.QUILT_VAULT_SECRET || '',
21
+ keys: keyRing(await providersFromEnv(env)),
22
+ digestKey: keyFrom(env.VAULT_DIGEST_KEY, 'VAULT_DIGEST_KEY'),
23
+ allowedOrigins: String(env.VAULT_ALLOWED_ORIGINS || 'https://heyquilt.com').split(',').map((s) => s.trim()).filter(Boolean),
24
+ log: (m) => console.log(m)
25
+ })
26
+ console.log(`quilt vault listening on ${vault.url}`)
27
+ const stop = async () => { await vault.close(); process.exit(0) }
28
+ process.on('SIGINT', stop)
29
+ process.on('SIGTERM', stop)
30
+ } catch (err) {
31
+ console.error(`quilt vault could not start: ${err.message}`)
32
+ process.exit(1)
33
+ }
@@ -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
+ }
package/src/ui/merges.js DELETED
@@ -1,158 +0,0 @@
1
- // Merges: the buttons for a file changed in two places (the file tree marks them) and
2
- // the side-by-side view of one.
3
- import { esc, I, api, toast, loadingNote } from './common.js'
4
- import { changedLines } from './fileview.js'
5
-
6
- const you = (name, me, fallback = 'someone') => !name ? fallback : name === me ? 'you' : esc(name)
7
- const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
8
-
9
- /** How the opener's side came about (merges.js VIAS). */
10
- export function done (m, deleted = false) {
11
- if (m.via === 'pull') return deleted ? 'pulled commits that delete this' : 'pulled commits that change this'
12
- if (m.via === 'hold') return deleted ? 'deleted this during a git command' : 'changed this during a git command'
13
- return deleted ? 'deleted this offline' : 'changed this offline'
14
- }
15
-
16
- /** One plain sentence: who changed what, where; then who holds the file now. */
17
- function describe (m, me) {
18
- const held = m.heldBy && m.heldBy !== me && m.kind !== 'claimed' ? ` ${cap(esc(m.heldBy))} has it claimed now.` : ''
19
- return what(m, me) + held
20
- }
21
-
22
- function what (m, me) {
23
- const who = cap(you(m.by, me))
24
- const other = you(m.others[0], me)
25
- const did = done(m)
26
- if (m.kind === 'ai') return `${who} ${did} and ${other} changed it in the session. An AI combined the two: have a look.`
27
- if (m.kind === 'claimed' && !m.heldBy) return `${who} ${did} while ${you(m.claimedBy, me)} had it claimed. The session's version is in the file.`
28
- if (m.kind === 'claimed') return `${who} ${did}, but ${you(m.heldBy, me)} ${m.heldBy === me ? 'have' : 'has'} it claimed. The session's version is in the file.`
29
- if (m.oursDeleted) return `${who} ${done(m, true)} and ${other} changed it in the session.`
30
- if (m.theirsHash === null) return `${who} ${did}, but it was deleted in the session.`
31
- return `${who} ${did} and ${other} changed it in the session${m.reason ? ` (${esc(m.reason)})` : ''}.`
32
- }
33
-
34
- const mineLabel = (m, me) => m.oursDeleted ? 'Delete it' : m.by === me ? 'Keep mine' : `Keep ${esc(m.by)}'s`
35
- const theirsLabel = (m, me) => m.theirsHash === null ? 'Keep it deleted' : !m.others[0] ? "Keep the session's" : m.others[0] === me ? 'Keep mine' : `Keep ${esc(m.others[0])}'s`
36
-
37
- /**
38
- * The buttons for one record. `full` (the compare view) also offers both
39
- * versions on rows an AI combined. Viewers see the record but can't act.
40
- */
41
- export function mergeActionsHtml (m, me, editors, { full = false, viewer = false } = {}) {
42
- const id = esc(m.id)
43
- const b = (how, label, cls = 'ghost') => `<button class="btn sm ${cls}" data-merge="${id}" data-how="${how}">${label}</button>`
44
- if (m.state === 'done') return `<span class="tag">Settled by ${you(m.resolvedBy, me)}</span>`
45
- if (viewer) return '<span class="tag">View only</span>'
46
- // Markers are in the file: only "done" and "keep mine" still make sense.
47
- if (m.state === 'editing') return `<span class="hint">Markers are in the file</span>${b('mine', mineLabel(m, me))}${b('agent', 'Resolved', 'primary')}`
48
- if (m.kind === 'ai') return (full ? b('mine', mineLabel(m, me)) : '') + b('review', 'Looks fine', 'primary')
49
- // Settling writes the file, and so would an AI sent to merge it: ask whoever holds it, or keep theirs.
50
- if (m.heldBy && m.heldBy !== me) {
51
- const ask = m.asked
52
- ? `<span class="tag">Asked ${esc(m.heldBy)} for it</span>`
53
- : `<button class="btn sm" data-merge="${id}" data-ask="${esc(m.path)}" title="Join the file queue: ${esc(m.heldBy)} hands it to you when done">Ask ${esc(m.heldBy)} for it</button>`
54
- return b('theirs', theirsLabel(m, me)) + ask
55
- }
56
- const hand = m.binary || m.oursDeleted || m.theirsHash === null ? '' : b('hand', 'Edit by hand')
57
- const [first, ...rest] = editors
58
- const send = first
59
- ? `<span class="merge-send"><button class="btn sm" data-merge="${id}" data-send="${esc(first.id)}" ${first.auto ? 'title="Your AI merges it on its own, on this computer, and says how it went in chat"' : ''}>${first.auto ? 'Have your AI merge it' : `Send to ${esc(first.name)}`}</button>${rest.length
60
- ? `<select class="input" data-merge-apps="${id}" aria-label="Send to another app"><option value="">Other app…</option>${rest.map((e) => `<option value="${esc(e.id)}">${e.auto ? 'Your AI, on its own' : esc(e.name)}</option>`).join('')}</select>`
61
- : ''}</span>`
62
- : ''
63
- return b('mine', mineLabel(m, me)) + b('theirs', theirsLabel(m, me)) + hand + send
64
- }
65
-
66
- function sentToast (r, name) {
67
- if (r.app === 'auto') { toast(`Your AI is on it in ${r.tool || 'your AI tool'}. It says how it went in chat.`); return }
68
- toast(r.filled ? `Opened ${name} with the merge prompt filled in: send it.` : r.copied ? `Opened ${name}. The merge prompt is on your clipboard: paste it in.` : `Opened ${name}.`)
69
- }
70
-
71
- const settledToast = { hand: 'Markers are in the file. Remove them and save to finish.', mine: 'Done', theirs: 'Done', review: 'Thanks for checking', agent: 'Done' }
72
-
73
- /**
74
- * Wires the merge buttons inside `el` (the bar, or the compare view). Call once
75
- * per mount: the listeners are delegated, so redraws need no rebinding.
76
- * `onCompare(id)` opens the compare view; `editors()` names the apps.
77
- */
78
- export function bindMerges (el, { sessionId, onCompare, editors }) {
79
- const nameOf = (app) => (editors().find((e) => e.id === app) || {}).name || 'the app'
80
- const send = async (id, app) => sentToast(await api('POST', `/api/sessions/${sessionId()}/merges/send`, { id, app }), nameOf(app))
81
- el.addEventListener('click', async (e) => {
82
- const cmp = e.target.closest('[data-compare]')
83
- if (cmp) { onCompare(cmp.dataset.compare); return }
84
- const btn = e.target.closest('button[data-merge]')
85
- if (!btn) return
86
- btn.disabled = true
87
- try {
88
- if (btn.dataset.send) await send(btn.dataset.merge, btn.dataset.send)
89
- else if (btn.dataset.ask) {
90
- const file = btn.dataset.ask
91
- await api('POST', `/api/sessions/${sessionId()}/request-file`, { path: file, title: `Settle the merge of ${file}`, description: 'I changed this file outside the session while you had it. When you hand it over, I will merge my changes with yours.' })
92
- toast(`Asked for ${file}. It comes to you, with their notes, when they hand it over.`)
93
- }
94
- else {
95
- await api('POST', `/api/sessions/${sessionId()}/merges/resolve`, { id: btn.dataset.merge, how: btn.dataset.how })
96
- toast(settledToast[btn.dataset.how] || 'Done')
97
- }
98
- } catch (err) { toast(err.message) }
99
- btn.disabled = false // a settled row goes away on the next status
100
- })
101
- el.addEventListener('change', async (e) => {
102
- const sel = e.target.closest('[data-merge-apps]')
103
- if (!sel || !sel.value) return
104
- const app = sel.value
105
- sel.value = ''
106
- sel.blur() // lets the bar redraw again
107
- try { await send(sel.dataset.mergeApps, app) } catch (err) { toast(err.message) }
108
- })
109
- }
110
-
111
- /** One side: its lines, with the ones that differ from the base highlighted. */
112
- function column (title, text, base, note) {
113
- let body
114
- if (text == null) body = `<div class="fv-note">${note}</div>`
115
- else {
116
- const lines = text.split('\n')
117
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
118
- const changed = changedLines(base == null ? [] : base.split('\n'), lines)
119
- body = `<table class="fv-code"><tbody>${lines.map((l, i) =>
120
- `<tr${changed.has(i) ? ' class="mchg"' : ''}><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>`
121
- }
122
- return `<div class="merge-col"><div class="merge-col-head">${title}</div><div class="fv-scroll">${body}</div></div>`
123
- }
124
-
125
- /** A short fingerprint of a text (FNV-1a and length), so a redraw check needn't keep or compare whole files. */
126
- function textSig (t) {
127
- if (t == null) return t === undefined ? 'u' : 'n'
128
- let h = 0x811c9dc5
129
- for (let i = 0; i < t.length; i++) h = Math.imul(h ^ t.charCodeAt(i), 0x01000193)
130
- return `${t.length}:${(h >>> 0).toString(36)}`
131
- }
132
-
133
- /**
134
- * The compare view. `m`: the record without its texts, as status has it.
135
- * `texts`: { ours, base } from the full record, `undefined` while it loads.
136
- * `theirs`: the session's text now, `null` when it isn't there, `undefined` while it loads.
137
- */
138
- export function renderMergeView (el, { merge: m, texts, theirs, me, editors, viewer = false }) {
139
- const showing = el.querySelector(':scope > .merge-view')
140
- // Don't redraw under someone picking an app.
141
- if (showing && el.contains(document.activeElement) && document.activeElement.tagName === 'SELECT') return
142
- const sig = JSON.stringify([m, textSig(texts?.ours), textSig(texts?.base), texts === undefined, textSig(theirs), me, editors.map((e) => e.id), viewer])
143
- if (showing && el._mergeSig === sig) return
144
- el._mergeSig = sig
145
- if (!m) { el.innerHTML = '<div class="main-empty merge-view"><p class="hint">That merge is settled or gone.</p></div>'; return }
146
- const ours = texts ? texts.ours : null
147
- const base = texts ? texts.base : null
148
- const mine = m.by === me ? 'Yours, from offline' : `${esc(m.by)}'s, from offline`
149
- const sess = m.kind === 'ai' ? 'In the session now, combined by AI' : `In the session${m.others[0] ? `, from ${you(m.others[0], me)}` : ''}`
150
- const oursNote = m.oursDeleted ? 'Deleted offline' : texts === undefined ? loadingNote() : m.local ? `Too big to show here. It's in the merge folder on ${m.by === me ? 'this computer' : `${esc(m.by)}'s computer`}.` : 'Not available'
151
- const theirsNote = theirs === undefined ? loadingNote() : m.theirsHash === null ? 'Deleted in the session' : 'Not in the session any more'
152
- el.innerHTML = `<div class="fv merge-view">
153
- <div class="fv-banner"><span class="fv-path mono">${esc(m.path)}</span><span class="fv-edited">${describe(m, me)}</span><span class="spacer"></span>${mergeActionsHtml(m, me, editors, { full: true, viewer })}</div>
154
- ${m.binary
155
- ? `<div class="fv-note">${I.file} Binary file: pick a version above.</div>`
156
- : `<div class="merge-cols">${column(mine, ours, base, oursNote)}${column(sess, theirs, base, theirsNote)}</div>`}
157
- </div>`
158
- }