heyquilt 0.3.90 → 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.
- package/README.md +14 -11
- package/RELEASES.md +131 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +141 -5
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +143 -17
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +120 -10
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +136 -27
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- 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
|
-
}
|