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.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- 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 +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -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 +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- 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/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|
package/src/webhooks.js
ADDED
|
@@ -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
|
+
}
|