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.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -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 +141 -5
  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 +143 -17
  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 +120 -10
  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 +136 -27
  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
package/src/ui-server.js CHANGED
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
  import { createRequire } from 'node:module'
11
11
  import { Readable } from 'node:stream'
12
12
  import { pipeline } from 'node:stream/promises'
13
- import { runSession, decodeInvite, newConn, readConfig, recentSessions, forgetRecent, rememberWorkspace, forgetWorkspace } from './runner.js'
13
+ import { runSession, decodeInvite, encodeInvite, newConn, readConfig, recentSessions, forgetRecent, rememberWorkspace, forgetWorkspace } from './runner.js'
14
14
  import { MAX_SHARED_FILE_BYTES } from './protocol.js'
15
15
  import { getSettings, saveSettings, unsupportedRelay, relayUrl } from './settings.js'
16
16
  import { PICKUP_MODES, pickupMode } from './tasks.js'
@@ -21,18 +21,20 @@ import { installedEditors, openIn, copyToClipboard } from './editors.js'
21
21
  import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
22
22
  import { migrateDir } from './legacy.js'
23
23
  import { writePrivateJson } from './private-file.js'
24
- import { readAccount, saveAccount, workspaceGithub, setWorkspaceGithub, removeWorkspaceGithub, setOrgGithub, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, githubStatus, githubConnectUrl, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect } from './account.js'
24
+ import { readAccount, saveAccount, workspaceGithub, setWorkspaceGithub, removeWorkspaceGithub, setOrgGithub, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, githubStatus, githubConnectUrl, listSandboxKeys, createSandboxKey, rotateSandboxKey, revokeSandboxKey, removeSandboxKey, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect, apiUrl } from './account.js'
25
25
  import { personaName } from './persona.js'
26
26
  import { CLOUD_LABEL } from './cloud-ai.js'
27
27
  import { effectiveAccess, builtinType } from './session-access.js'
28
28
  import { cleanSessionName, BAD_SESSION_NAME, SESSION_NAME_MAX } from './session-name.js'
29
- import { personPasses } from './pass-source.js'
29
+ import { personPasses, personApiAuth } from './pass-source.js'
30
30
  import { INVALID_INVITE } from './ui/invite.js'
31
31
  import { loadIdentity } from './identity.js'
32
32
  import { currentVersion, localReleases, latestRelease, compareVersions, downloadUrl, seenVersion, markSeen, hiddenUpdate, hideUpdate } from './releases.js'
33
33
  import { createReporter } from './report.js'
34
34
  import { queryHistory } from './history.js'
35
35
  import { contentDisposition } from './api/file-paths.js'
36
+ import { getSecrets, putSecretSettings, setSecretWho, deleteSecret, secretUses, saveSecrets, secretAsks, answerAsk } from './secrets-client.js'
37
+ import { envFilesIn, knownEnvFile, envValues } from './secret-import.js'
36
38
 
37
39
  const TOOL_NAMES = ['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'GitHub Copilot', 'Zed', 'Aider', 'Other']
38
40
  const MAX_WS_FILE_BYTES = 500 * 1024 * 1024
@@ -150,7 +152,6 @@ export const STATIC = {
150
152
  '/tool-logo.js': ['tool-logo.js', 'text/javascript; charset=utf-8'],
151
153
  '/tree.js': ['tree.js', 'text/javascript; charset=utf-8'],
152
154
  '/fileview.js': ['fileview.js', 'text/javascript; charset=utf-8'],
153
- '/merges.js': ['merges.js', 'text/javascript; charset=utf-8'],
154
155
  '/catchup.js': ['catchup.js', 'text/javascript; charset=utf-8'],
155
156
  '/home.js': ['home.js', 'text/javascript; charset=utf-8'],
156
157
  '/signin.js': ['signin.js', 'text/javascript; charset=utf-8'],
@@ -176,7 +177,12 @@ export const STATIC = {
176
177
  '/sounds.js': ['sounds.js', 'text/javascript; charset=utf-8'],
177
178
  '/git-rule.js': ['git-rule.js', 'text/javascript; charset=utf-8'],
178
179
  '/cloud-ai.js': ['cloud-ai.js', 'text/javascript; charset=utf-8'],
179
- '/cloud-ai-model.js': ['cloud-ai-model.js', 'text/javascript; charset=utf-8']
180
+ '/cloud-ai-model.js': ['cloud-ai-model.js', 'text/javascript; charset=utf-8'],
181
+ '/sandbox-keys.js': ['sandbox-keys.js', 'text/javascript; charset=utf-8'],
182
+ '/sandbox-keys-model.js': ['sandbox-keys-model.js', 'text/javascript; charset=utf-8'],
183
+ '/secrets.js': ['secrets.js', 'text/javascript; charset=utf-8'],
184
+ '/secrets-view.js': ['secrets-view.js', 'text/javascript; charset=utf-8'],
185
+ '/secret-rules.js': ['secret-rules.js', 'text/javascript; charset=utf-8']
180
186
  }
181
187
 
182
188
  // The page's Content-Security-Policy: scripts only from our own files (no inline script or
@@ -421,6 +427,8 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
421
427
  prefer: prefer === 'local' ? 'local' : 'remote',
422
428
  inviteServer,
423
429
  passes: sessionPasses,
430
+ // Secrets (`quilt run` in this folder): the API as this computer's account.
431
+ apiAuth: personApiAuth,
424
432
  // A new session is named after its folder (the owner can rename it later).
425
433
  startName: mode === 'create' ? cleanSessionName([...path.basename(dir)].slice(0, SESSION_NAME_MAX).join('')) || '' : '',
426
434
  // Your default for what agents may commit, set on the new room once the relay makes you its owner.
@@ -525,11 +533,27 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
525
533
  if (has) log('☁️ your cloud AI is joining this session too')
526
534
  }, (err) => log(`could not tell your cloud AI about this session: ${err.message}`))
527
535
  }
536
+ // Sessions handed to the cloud AI since it was connected (rooms), so each is handed over once.
537
+ const handed = new Set()
538
+ /**
539
+ * The sessions open now that the cloud AI isn't in, handed to it: one connected while sessions
540
+ * were already open joins them too, not only the ones started after.
541
+ */
542
+ const followOpen = (vm) => {
543
+ const inIt = new Set((vm.sessions || []).map((x) => x.room))
544
+ for (const entry of runs.values()) {
545
+ const room = entry.run && entry.run.session && entry.run.session.room
546
+ if (!room || !entry.run.invite || inIt.has(room) || handed.has(room)) continue
547
+ handed.add(room)
548
+ followWithCloudAi(entry.run.invite)
549
+ }
550
+ }
528
551
  // Settings → Your AI: the person's cloud AI, as this computer's account. `vm` is null without one.
529
552
  const CLOUD_TOOLS = ['Claude Code', 'Codex', 'Cursor']
530
553
  const cloudView = (vm) => {
531
554
  hasCloudAi = !!vm
532
- if (!vm) return { vm: null }
555
+ if (!vm) { handed.clear(); return { vm: null } }
556
+ followOpen(vm)
533
557
  const person = readAccount()?.account?.name || profile().name || 'You'
534
558
  return { vm: { ...vm, name: personaName(person, CLOUD_LABEL) } }
535
559
  }
@@ -601,6 +625,24 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
601
625
  const needInviteId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which invite?'); return String(id) }
602
626
  const needWorkspaceId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which workspace?'); return id }
603
627
  const needAgentId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which agent?'); return id }
628
+ const needKeyId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which key?'); return String(id) }
629
+ /**
630
+ * Sessions on this computer a sandbox key can be made for: open ones, then recent ones, each with
631
+ * its invite link (read from its folder; the page never gets it). { value, room, name, link }.
632
+ */
633
+ const localSessions = () => {
634
+ const seen = new Set()
635
+ const out = []
636
+ const add = (dir, link, name) => {
637
+ const c = readConfig(dir)
638
+ if (!c || !c.room || seen.has(c.room) || !(link || c.secret)) return
639
+ seen.add(c.room)
640
+ out.push({ value: `local:${idFor(dir)}`, room: c.room, name: name || path.basename(dir), link: link || encodeInvite({ server: c.inviteServer || c.server, room: c.room, secret: c.secret }) })
641
+ }
642
+ for (const r of runs.values()) add(r.run.dir, r.run.invite, r.run.session.status().sessionName)
643
+ for (const r of recentList()) if (!r.unsupported) add(r.dir, '', '')
644
+ return out
645
+ }
604
646
  const needOrgSlug = (slug) => { if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(String(slug || ''))) throw httpError(400, 'Which org?'); return slug }
605
647
  const ofWorkspace = (id) => ({
606
648
  running: [...runs.keys()].filter((k) => (readConfig(runs.get(k).run.dir) || {}).workspace === id),
@@ -720,8 +762,60 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
720
762
  })
721
763
  }
722
764
 
765
+ // Secrets (src/ui/secrets.js): names and settings through the accounts API; a typed or imported
766
+ // value goes from here straight to the vault with a write ticket, and is never kept or logged.
767
+ const SECRET_SCOPE_ID = { org: /^[a-z0-9][a-z0-9-]{0,63}$/i, workspace: /^[0-9a-f-]{36}$/i, session: /^[A-Za-z0-9_-]{1,64}$/ }
768
+ const secretsApi = (token) => ({ api: apiUrl(), token })
769
+ async function secretsRoute (method, scope, id, rest, body) {
770
+ if (!SECRET_SCOPE_ID[scope] || !SECRET_SCOPE_ID[scope].test(id)) throw httpError(400, 'Which secrets?')
771
+ return asAccount(async (token) => {
772
+ const c = secretsApi(token)
773
+ if (method === 'GET' && rest === '') return getSecrets(c, scope, id)
774
+ if (method === 'GET' && rest === '/uses') return secretUses(c, scope, id)
775
+ if (method === 'POST' && rest === '/settings') return putSecretSettings(c, scope, id, Object.fromEntries(['inheritOff', 'agentsOn', 'agents'].filter((k) => k in body).map((k) => [k, body[k]])))
776
+ if (method === 'POST' && rest === '/who') return setSecretWho(c, scope, id, String(body.name || ''), String(body.who || ''))
777
+ if (method === 'POST' && rest === '/delete') return deleteSecret(c, scope, id, String(body.name || ''))
778
+ if (method === 'POST' && rest === '/save') {
779
+ const values = body.values && typeof body.values === 'object' ? Object.fromEntries(Object.entries(body.values).map(([k, v]) => [k, String(v ?? '')])) : {}
780
+ return saveSecrets(c, scope, id, Array.isArray(body.entries) ? body.entries : [], values)
781
+ }
782
+ throw httpError(404, 'not found')
783
+ })
784
+ }
785
+ /** The folders whose .env files a card may import: one session's, or a workspace's sessions' on this computer. */
786
+ const envDirs = (b) => {
787
+ if (b && b.sessionId) { const r = runs.get(String(b.sessionId)); return r ? [r.run.dir] : [] }
788
+ if (b && b.workspaceId) { const w = ofWorkspace(needWorkspaceId(b.workspaceId)); return [...w.running.map((k) => runs.get(k)?.run.dir), ...w.recent.map((r) => r.dir)] }
789
+ return []
790
+ }
791
+
723
792
  const api = {
724
793
  'GET /api/account': () => accountState(),
794
+ // Which optional parts the accounts API has on (secrets): public there; off when it can't be reached.
795
+ 'GET /api/features': async () => {
796
+ try {
797
+ const res = await fetch(`${apiUrl()}/v1/features`, { signal: AbortSignal.timeout(5000) })
798
+ const f = res.ok ? await res.json() : {}
799
+ return { secrets: !!f.secrets, workspaces: !!f.workspaces }
800
+ } catch { return { secrets: false, workspaces: false } }
801
+ },
802
+ 'POST /api/secrets/env-scan': (b) => {
803
+ const dirs = envDirs(b)
804
+ if (!dirs.length) return { files: [], empty: 'Open this session (or one of the workspace\'s) on this computer to import its .env files.' }
805
+ return { files: envFilesIn(dirs) }
806
+ },
807
+ 'POST /api/secrets/env-import': (b) => {
808
+ const f = knownEnvFile(envFilesIn(envDirs(b)), b.file)
809
+ const entries = (Array.isArray(b.entries) ? b.entries : []).filter((e) => f.entries.some((x) => x.name === e.name && !x.skip))
810
+ if (!entries.length) throw httpError(400, 'Pick the secrets to save.')
811
+ if (!SECRET_SCOPE_ID[b.scope] || !SECRET_SCOPE_ID[b.scope].test(String(b.id || ''))) throw httpError(400, 'Which secrets?')
812
+ return asAccount((token) => saveSecrets(secretsApi(token), b.scope, String(b.id), entries, envValues(f.path, entries.map((e) => e.name))))
813
+ },
814
+ 'POST /api/secrets/env-delete': (b) => {
815
+ const f = knownEnvFile(envFilesIn(envDirs(b)), b.file)
816
+ fs.rmSync(f.path, { force: true })
817
+ return { ok: true }
818
+ },
725
819
  'GET /api/access-types': () => asAccount(async (token) => ({ types: await listAccessTypes({ token }) })),
726
820
  // Quilt's GitHub App: whether this account connected GitHub, and the link that connects it (opened in the browser).
727
821
  ...cloudRoutes,
@@ -754,6 +848,20 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
754
848
  'POST /api/workspaces/:id/members': (b, id) => asAccount(async (token) => ({ member: await putWorkspaceMember({ token, id: needWorkspaceId(id), account: String(b.account || ''), access: String(b.access || ''), sessions: b.sessions === undefined ? undefined : String(b.sessions) }) })),
755
849
  // Where agents work: an agent's placement (yours), an org's agents, a workspace's say over
756
850
  // an agent placed there, and a link for a new agent that joins the workspace.
851
+ // An agent's sandbox keys (sandbox-keys.js): the API's list, plus this computer's sessions for New key.
852
+ 'GET /api/agents/:id/sandbox-keys': (b, id) => asAccount(async (token) => ({ ...(await listSandboxKeys({ token, id: needAgentId(id) })), local: localSessions().map(({ link, ...s }) => s) })),
853
+ // `session`: local:<id> (a session on this computer: its link is read here) or room:<room> (one a key already has); or `link`, pasted.
854
+ 'POST /api/agents/:id/sandbox-keys': (b, id) => asAccount(async (token) => {
855
+ const pick = String(b.session || '')
856
+ const local = pick.startsWith('local:') ? localSessions().find((s) => s.value === pick) : null
857
+ if (pick.startsWith('local:') && !local) throw httpError(404, 'That session is not on this computer any more.')
858
+ const where = local ? { session: local.link, sessionName: local.name } : pick.startsWith('room:') ? { room: pick.slice(5) } : { session: String(b.link || '').trim() }
859
+ const r = await createSandboxKey({ token, id: needAgentId(id), name: String(b.name || '').trim(), ...where })
860
+ return { envKey: r.envKey, key: r.key }
861
+ }),
862
+ 'POST /api/agents/:id/sandbox-keys/rotate': (b, id) => asAccount(async (token) => { const r = await rotateSandboxKey({ token, id: needAgentId(id), keyId: needKeyId(b.keyId) }); return { envKey: r.envKey, key: r.key } }),
863
+ 'POST /api/agents/:id/sandbox-keys/revoke': (b, id) => asAccount(async (token) => { await revokeSandboxKey({ token, id: needAgentId(id), keyId: needKeyId(b.keyId) }); return { ok: true } }),
864
+ 'POST /api/agents/:id/sandbox-keys/remove': (b, id) => asAccount(async (token) => { await removeSandboxKey({ token, id: needAgentId(id), keyId: needKeyId(b.keyId) }); return { ok: true } }),
757
865
  'GET /api/agents/:id/placement': (b, id) => asAccount(async (token) => ({ placement: await getAgentPlacement({ token, id: needAgentId(id) }) })),
758
866
  'POST /api/agents/:id/placement': (b, id) => asAccount(async (token) => ({ placement: await putAgentPlacement({ token, id: needAgentId(id), placement: { reach: b.reach, workspaceIds: b.workspaceIds, sessions: b.sessions, access: b.access, scopes: b.scopes } }) })),
759
867
  'GET /api/orgs/:slug/agents': (b, slug) => asAccount(async (token) => ({ agents: await listOrgAgents({ token, slug: needOrgSlug(slug) }) })),
@@ -817,21 +925,6 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
817
925
  // Gathering the project's AI rules into AGENTS.md (project-rules.js): look first, then apply what was shown.
818
926
  'GET /api/sessions/:id/rules/gather': (b, id) => get(id).rulesGatherPlan(),
819
927
  'POST /api/sessions/:id/rules/gather': (b, id) => get(id).applyRulesGather(String(b.key || '')),
820
- 'POST /api/sessions/:id/merges/resolve': async (b, id) => { const r = await get(id).settleMerge(String(b.id || ''), { how: b.how }); pushStatus(id); return r },
821
- 'POST /api/sessions/:id/merges/send': async (b, id) => {
822
- const s = get(id)
823
- const mergeId = String(b.id || '')
824
- const { prompt } = await s.sendMerge(mergeId)
825
- pushStatus(id)
826
- const app = String(b.app || '')
827
- // Your AI, working on its own (autowork.js): the run says how it went in chat.
828
- if (app === 'auto') {
829
- const rec = s.mergeList().find((m) => m.id === mergeId)
830
- return { ...autowork().sendMerge(s, { id: mergeId, path: rec ? rec.path : '', prompt }), app }
831
- }
832
- const { copied, filled } = await openIn(app, s.root, { prompt })
833
- return { copied, filled: !!filled, app }
834
- },
835
928
  'POST /api/sessions/:id/stop': (b, id) => stop(id).then(() => ({ ok: true })),
836
929
  'POST /api/sessions/:id/say': (b, id) => get(id).say(b.text, { to: b.to || null }),
837
930
  'POST /api/sessions/:id/focus': (b, id) => { get(id).setFocus(b.text); return { ok: true } },
@@ -1034,12 +1127,25 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1034
1127
  let m = url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)\/send$/)
1035
1128
  if (req.method === 'POST' && m) return json(200, await receiveUpload(req, get(m[1])))
1036
1129
  m = url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)\/files\/([a-f0-9]+)$/)
1037
- if (req.method === 'GET' && m) return await serveFile(res, get(m[1]), m[2])
1130
+ if (req.method === 'GET' && m) return await serveFile(res, get(m[1]), m[2], url.searchParams.get('inline') === '1')
1038
1131
  const wu = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/upload$/)
1039
1132
  if (req.method === 'PUT' && wu) return json(200, await receiveWorkspaceUpload(req, wu[1]))
1040
1133
  const wd = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/files\/([^/]+)\/data$/)
1041
1134
  if (req.method === 'GET' && wd) return await streamWorkspaceFile(req, res, wd[1], wd[2])
1042
1135
 
1136
+ // Secrets: /api/secrets/<scope>/<id>[/<what>], and the asks for people-only secrets.
1137
+ const sec = url.pathname.match(/^\/api\/secrets\/(org|workspace|session)\/([^/]+)(\/(?:uses|settings|who|delete|save))?$/)
1138
+ const askOne = url.pathname.match(/^\/api\/secret-asks\/([0-9a-f-]{36})$/i)
1139
+ if (sec || askOne || url.pathname === '/api/secret-asks') {
1140
+ let raw = ''
1141
+ for await (const chunk of req) raw += chunk
1142
+ const body = raw ? JSON.parse(raw) : {}
1143
+ if (sec) return json(200, await secretsRoute(req.method, sec[1], decodeURIComponent(sec[2]), sec[3] || '', body))
1144
+ if (askOne && req.method === 'POST') return json(200, await asAccount((token) => answerAsk(secretsApi(token), askOne[1], body.allow === true)))
1145
+ if (!askOne && req.method === 'GET') return json(200, await asAccount((token) => secretAsks(secretsApi(token), { wait: url.searchParams.get('wait') === '1' })))
1146
+ return json(404, { error: 'not found' })
1147
+ }
1148
+
1043
1149
  let pathKey = url.pathname
1044
1150
  .replace(/^\/api\/sessions\/[a-f0-9]+/, '/api/sessions/:id')
1045
1151
  .replace(/^\/api\/workspaces\/[^/]+/, '/api/workspaces/:id')
@@ -1134,14 +1240,17 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1134
1240
  }
1135
1241
  }
1136
1242
 
1137
- async function serveFile (res, session, msgId) {
1243
+ // A chat file the page shows as a picture (?inline=1); anything else is a download.
1244
+ const CHAT_IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }
1245
+
1246
+ async function serveFile (res, session, msgId, inline = false) {
1138
1247
  const msg = session.messages({ limit: 500, markRead: false }).find((m) => m.id === msgId)
1139
1248
  if (!msg || !msg.file) throw httpError(404, 'No such file.')
1140
1249
  const local = msg.file.localPath ? path.join(session.root, msg.file.localPath) : await session.fetchFile(msgId)
1141
- res.writeHead(200, {
1142
- 'content-type': 'application/octet-stream',
1143
- 'content-disposition': `attachment; filename="${msg.file.name.replace(/[^\w.\- ]/g, '_')}"`
1144
- })
1250
+ const image = inline && CHAT_IMAGES[path.extname(msg.file.name).toLowerCase()]
1251
+ res.writeHead(200, image
1252
+ ? { 'content-type': image, 'x-content-type-options': 'nosniff', 'content-security-policy': 'sandbox', 'content-disposition': 'inline' }
1253
+ : { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${msg.file.name.replace(/[^\w.\- ]/g, '_')}"` })
1145
1254
  fs.createReadStream(local).pipe(res)
1146
1255
  }
1147
1256
 
@@ -3,7 +3,7 @@
3
3
  // quilt_check_update answers for whatever image the agent names.
4
4
  import { currentVersion, latestRelease, compareVersions } from './releases.js'
5
5
 
6
- export const UPDATE_HOW = 'Update Quilt (the desktop app: Settings → About → Update Quilt; the CLI: npm i -g github:DanielCarmichaelGit/heyquilt, or on a Linux server run its install script again: curl -fsSL https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/install.sh | sh), then rejoin the session.'
6
+ export const UPDATE_HOW = 'Update Quilt (the desktop app: Settings → About → Update Quilt; the CLI: npm i -g heyquilt, or on a Linux server run its install script again: curl -fsSL https://heyquilt.com/install.sh | sh), then rejoin the session.'
7
7
 
8
8
  /** True when `image` looks like a Quilt version. */
9
9
  export const validImage = (image) => /^v?\d+\.\d+\.\d+$/.test(String(image || '').trim())
package/src/upstream.js CHANGED
@@ -3,13 +3,15 @@
3
3
  // running git pull. Quilt never merges git history: the branch only ever moves
4
4
  // forward to a commit it is behind. What it merges is the session's
5
5
  // uncommitted work into those commits' files, file by file, the way
6
- // `git pull --autostash` would, except nothing is written unless every file
7
- // merges cleanly.
6
+ // `git pull --autostash` would, except that where both changed the same lines
7
+ // Quilt combines them itself (combine, merge3.js mergeAuto): nobody is asked to
8
+ // merge (docs/superpowers/specs/2026-10-18-automatic-merges.md). Only a file git
9
+ // can't read holds a catch-up up.
8
10
  //
9
11
  // planCatchUp is pure: session.js reads git and the disk, asks for a plan,
10
12
  // and applies it (bringIn there).
11
13
  import crypto from 'node:crypto'
12
- import { mergeAdditive as merge3 } from './merge3.js'
14
+ import { mergeAuto } from './merge3.js'
13
15
 
14
16
  const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
15
17
 
@@ -29,22 +31,6 @@ export const wasPublished = (published, rel, key) => {
29
31
  return !!ids && ids.includes(contentId(key))
30
32
  }
31
33
 
32
- /**
33
- * How the folder at commit `from` (its files on disk: the commit plus the
34
- * session's uncommitted work) becomes the folder at `to` with that work kept.
35
- *
36
- * - changes: Map path -> git status letter (A, M, D, T...) between the commits.
37
- * - base / theirs: Map path -> the file at `from` / at `to` (text, "bin:<sha1>",
38
- * null when absent, undefined when git couldn't read it).
39
- * - disk(rel): the file in the folder now (same keys).
40
- * - moved: candidates for a file the session moved: Map path -> its text, for
41
- * files on disk that `from` didn't have (new or moved here).
42
- * - emptied: folders the session emptied (had files at `from`, none on disk now).
43
- *
44
- * Returns { writes: Map path -> key (null: delete), moves: [{ from, to }],
45
- * conflicts: [{ path, why }], strays: [path] }. With conflicts, writes is empty:
46
- * a catch-up is all or nothing.
47
- */
48
34
  /** What the session published (relay commits), read from the room document: Map path -> [content ids]. */
49
35
  export function publishedOf (doc) {
50
36
  const out = new Map()
@@ -72,11 +58,63 @@ export function notePublishedIds (doc, entries, origin) {
72
58
  }, origin)
73
59
  }
74
60
 
75
- export function planCatchUp ({ changes, base, theirs, disk, moved = new Map(), emptied = new Set(), published = null }) {
61
+ /**
62
+ * Two versions of a file that both moved on from `base`, combined the way Quilt does it on its own
63
+ * (keys as planCatchUp's: text, "bin:<sha1>", null when absent): lines only one side changed are
64
+ * kept, lines both changed go to the newer side (`oursNewer`), an edit beats a deletion, and a binary
65
+ * file (or one both created, differently) is the newer version. Returns { key, lost, how }: `lost` as mergeAuto's ({ side, lines, kept,
66
+ * at }; a binary that lost has `binary: true` and no lines); `how` 'lines', 'kept-edit' or 'newer'.
67
+ */
68
+ export function combine (base, ours, theirs, { oursNewer = true, union = false } = {}) {
69
+ if (ours === theirs || theirs === base) return { key: ours, lost: [], how: 'lines' }
70
+ if (ours === base) return { key: theirs, lost: [], how: 'lines' }
71
+ // Deleted on one side, edited on the other: the edit stays (a deletion is cheap to redo; lost work is not).
72
+ if (ours === null) return { key: theirs, lost: [], how: 'kept-edit', deleted: 'ours' }
73
+ if (theirs === null) return { key: ours, lost: [], how: 'kept-edit', deleted: 'theirs' }
74
+ if (base === null && isText(ours) && isText(theirs) && !union) {
75
+ // Created on both sides, differently: nothing to measure either from, so the newer file, and the other's lines it lacks are what lost.
76
+ const [won, lose] = oursNewer ? [ours, theirs] : [theirs, ours]
77
+ const left = new Map()
78
+ for (const l of won.split('\n')) left.set(l, (left.get(l) || 0) + 1)
79
+ const gone = lose.split('\n').filter((l) => { const n = left.get(l); if (n) { left.set(l, n - 1); return false } return l !== '' })
80
+ return { key: won, lost: gone.length ? [{ side: oursNewer ? 'theirs' : 'ours', lines: gone, kept: [], at: 0 }] : [], how: 'newer' }
81
+ }
82
+ if ((base === null || isText(base)) && isText(ours) && isText(theirs)) {
83
+ const r = mergeAuto(base ?? '', ours, theirs, { oursNewer, union })
84
+ return { key: r.text, lost: r.lost, how: 'lines' }
85
+ }
86
+ // A binary file (or one that became one): the newer version.
87
+ return { key: oursNewer ? ours : theirs, lost: [{ side: oursNewer ? 'theirs' : 'ours', lines: [], kept: [], at: 0, binary: true }], how: 'newer' }
88
+ }
89
+
90
+ /**
91
+ * How the folder at commit `from` (its files on disk: the commit plus the
92
+ * session's uncommitted work) becomes the folder at `to` with that work kept.
93
+ *
94
+ * - changes: Map path -> git status letter (A, M, D, T...) between the commits.
95
+ * - base / theirs: Map path -> the file at `from` / at `to` (text, "bin:<sha1>",
96
+ * null when absent, undefined when git couldn't read it).
97
+ * - disk(rel): the file in the folder now (same keys).
98
+ * - moved: candidates for a file the session moved: Map path -> its text, for
99
+ * files on disk that `from` didn't have (new or moved here).
100
+ * - emptied: folders the session emptied (had files at `from`, none on disk now).
101
+ *
102
+ * - oursNewer(rel): whether the session's (the folder's) edit of rel is newer than
103
+ * the commits' (it keeps the lines both changed); union(rel): a merge=union file.
104
+ *
105
+ * Every file both sides changed is combined (combine): nothing clashes. Returns
106
+ * { writes: Map path -> key (null: delete), moves: [{ from, to }], conflicts:
107
+ * [{ path, why }] (only files git couldn't read), strays: [path], merged: [{ path,
108
+ * how, lost, oursNewer, deleted? }] (the files both changed, for the history and
109
+ * the notice to whoever's lines lost) }. With conflicts, writes is empty: a
110
+ * catch-up is all or nothing.
111
+ */
112
+ export function planCatchUp ({ changes, base, theirs, disk, moved = new Map(), emptied = new Set(), published = null, oursNewer = () => true, union = () => false }) {
76
113
  const writes = new Map()
77
114
  const conflicts = []
78
115
  const moves = []
79
116
  const strays = []
117
+ const merged = []
80
118
  const taken = new Set()
81
119
  for (const rel of changes.keys()) {
82
120
  const b = base.get(rel)
@@ -91,33 +129,32 @@ export function planCatchUp ({ changes, base, theirs, disk, moved = new Map(), e
91
129
  if (b === null && t !== null && emptied.has(dirOf(rel))) strays.push(rel)
92
130
  continue
93
131
  }
94
- if (o === t) continue // the session already has it
132
+ if (o === t || t === b) continue // the session already has it, or the commits didn't change it
95
133
  // The commits bring a version of this file the session itself published (an agent committed it
96
134
  // to a branch, and the branch was merged, squashed or rebased in): the session's copy is that
97
135
  // version or newer, so it stays as it is. Matched by content, so every merge strategy counts.
98
136
  if (o !== null && wasPublished(published, rel, t)) continue
137
+ const newer = !!oursNewer(rel)
99
138
  if (o === null && isText(b) && isText(t)) {
100
139
  // Gone here, changed there: the session may have moved it. Followed by content, as git
101
140
  // follows a rename, into the moved copy, which takes the commits' change.
102
141
  const to = findMove(rel, b, moved, taken)
103
142
  if (to) {
104
- const r = merge3(b, moved.get(to), t)
105
- if (!r.conflicts.length) { taken.add(to); moves.push({ from: rel, to }); if (r.text !== moved.get(to)) writes.set(to, r.text); continue }
106
- conflicts.push({ path: rel, why: `moved to ${to} here, and changed in the same lines there` })
143
+ const r = mergeAuto(b, moved.get(to), t, { oursNewer: newer, union: !!union(to) })
144
+ taken.add(to)
145
+ moves.push({ from: rel, to })
146
+ if (r.text !== moved.get(to)) writes.set(to, r.text)
147
+ if (r.lost.length) merged.push({ path: to, how: 'lines', lost: r.lost, oursNewer: newer })
107
148
  continue
108
149
  }
109
- conflicts.push({ path: rel, why: 'deleted here, changed in the new commits' })
110
- continue
111
150
  }
112
- if (t === null) { conflicts.push({ path: rel, why: 'changed here, deleted in the new commits' }); continue }
113
- if (b === null) { conflicts.push({ path: rel, why: 'added both here and in the new commits, differently' }); continue }
114
- if (!isText(b) || !isText(o) || !isText(t)) { conflicts.push({ path: rel, why: 'a binary file changed both here and in the new commits' }); continue }
115
- const r = merge3(b, o, t)
116
- if (r.conflicts.length) { conflicts.push({ path: rel, why: 'changed in the same lines here and in the new commits' }); continue }
117
- if (r.text !== o) writes.set(rel, r.text)
151
+ // Both changed it: Quilt combines them (an edit beats a deletion; the newer edit keeps the lines both changed).
152
+ const r = combine(b, o, t, { oursNewer: newer, union: !!union(rel) })
153
+ if (r.key !== o) writes.set(rel, r.key)
154
+ merged.push({ path: rel, how: r.how, lost: r.lost, oursNewer: newer, ...(r.deleted ? { deleted: r.deleted } : {}) })
118
155
  }
119
- if (conflicts.length) return { writes: new Map(), moves: [], conflicts, strays: [] }
120
- return { writes, moves, conflicts, strays }
156
+ if (conflicts.length) return { writes: new Map(), moves: [], conflicts, strays: [], merged: [] }
157
+ return { writes, moves, conflicts, strays, merged }
121
158
  }
122
159
 
123
160
  const dirOf = (rel) => rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
@@ -146,10 +183,10 @@ export function similarity (a, b) {
146
183
  return same / Math.max(la.length, lb.length, 1)
147
184
  }
148
185
 
149
- /** One line for the AI and the log about why a catch-up waits. */
150
- export function catchUpAdvice ({ branch, upstream, behind, conflicts, diverged, ahead }) {
186
+ /** One line for the AI and the log about why a catch-up waits. Nobody is told to pull or merge: Quilt combines what it can read. */
187
+ export function catchUpAdvice ({ branch, upstream, behind, conflicts = [], diverged, ahead }) {
151
188
  const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
152
- if (diverged) return `${branch} and ${upstream} have both moved on (${n(ahead, 'commit')} here, ${n(behind, 'commit')} there). Quilt never merges git history: pull or rebase it yourself in this folder, and the session takes in whatever that changes.`
189
+ if (diverged) return `${branch} and ${upstream} have both moved on (${n(ahead, 'commit')} here, ${n(behind, 'commit')} there). Quilt never rewrites git history in this folder: the session's files take in ${upstream}'s changes, combined with yours, and your commits go to GitHub through the session.`
153
190
  const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
154
- return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and the session's uncommitted work clashes with them in ${list}. Quilt brought nothing in. Run git pull in this folder and resolve those; the session takes in the result.`
191
+ return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to combine ${conflicts.length === 1 ? 'it' : 'them'} with the session's work, so it brought nothing in yet. A folder on ${branch} that can read ${conflicts.length === 1 ? 'it' : 'them'} brings the commits in; nothing to merge by hand.`
155
192
  }
@@ -0,0 +1,96 @@
1
+ // The vault's sealing (docs/superpowers/specs/2026-10-18-secrets.md). Each scope (a team, a
2
+ // workspace, a session) has its own random data key; values are sealed with AES-256-GCM under
3
+ // it, bound to their scope and name. Data keys are sealed by a root key behind a provider:
4
+ // `env` (VAULT_ROOT_KEY, here) or `aws-kms` (kms.js). Only the vault runs this file.
5
+ import crypto from 'node:crypto'
6
+
7
+ const b64u = (buf) => Buffer.from(buf).toString('base64url')
8
+ const unb64u = (s) => Buffer.from(String(s), 'base64url')
9
+ const ALG = 'aes-256-gcm'
10
+
11
+ /** A new data key: 32 random bytes. */
12
+ export const newDataKey = () => crypto.randomBytes(32)
13
+
14
+ const scopeOf = ({ scopeType, scopeId }) => `${scopeType}:${scopeId}`
15
+ const valueAad = (ctx, name) => `quilt-secret:v1:${scopeOf(ctx)}:${name}`
16
+ const keyAad = (ctx, version) => `quilt-data-key:v1:${scopeOf(ctx)}:${version}`
17
+
18
+ function seal (key, aad, plain) {
19
+ const iv = crypto.randomBytes(12)
20
+ const c = crypto.createCipheriv(ALG, key, iv)
21
+ c.setAAD(Buffer.from(aad))
22
+ const ct = Buffer.concat([c.update(plain), c.final()])
23
+ return [b64u(iv), b64u(ct), b64u(c.getAuthTag())].join('.')
24
+ }
25
+
26
+ function open (key, aad, sealed) {
27
+ const [iv, ct, tag] = String(sealed).split('.')
28
+ if (!iv || ct === undefined || !tag) throw new Error('that sealed value is malformed')
29
+ const d = crypto.createDecipheriv(ALG, key, unb64u(iv))
30
+ d.setAAD(Buffer.from(aad))
31
+ d.setAuthTag(unb64u(tag))
32
+ return Buffer.concat([d.update(unb64u(ct)), d.final()])
33
+ }
34
+
35
+ /** A value sealed under its scope's data key: `v1.<iv>.<ciphertext>.<tag>`. */
36
+ export function sealValue (dataKey, ctx, name, value) {
37
+ return `v1.${seal(dataKey, valueAad(ctx, name), Buffer.from(String(value), 'utf8'))}`
38
+ }
39
+
40
+ /** The value again; throws when the key, scope or name doesn't match. */
41
+ export function openValue (dataKey, ctx, name, sealed) {
42
+ const s = String(sealed)
43
+ if (!s.startsWith('v1.')) throw new Error('unknown sealed value version')
44
+ return open(dataKey, valueAad(ctx, name), s.slice(3)).toString('utf8')
45
+ }
46
+
47
+ /** The digest the app shows: HMAC-SHA256 of the value under the vault's own digest key. */
48
+ export function digestOf (digestKey, value) {
49
+ return crypto.createHmac('sha256', digestKey).update(String(value), 'utf8').digest('hex')
50
+ }
51
+
52
+ /** A 32-byte key from base64 (or base64url), or an error naming the variable. */
53
+ export function keyFrom (value, name) {
54
+ const buf = Buffer.from(String(value || '').trim(), 'base64')
55
+ if (buf.length !== 32) throw new Error(`${name} must be 32 random bytes in base64 (openssl rand -base64 32)`)
56
+ return buf
57
+ }
58
+
59
+ /**
60
+ * The `env` provider: data keys sealed with AES-256-GCM under VAULT_ROOT_KEY, bound to their
61
+ * scope and version. Sealed keys look like `env1.<iv>.<ciphertext>.<tag>`.
62
+ */
63
+ export function envKeyProvider ({ rootKey }) {
64
+ const key = keyFrom(rootKey, 'VAULT_ROOT_KEY')
65
+ return {
66
+ id: 'env',
67
+ prefix: 'env1.',
68
+ async wrap (dataKey, ctx) { return `env1.${seal(key, keyAad(ctx, ctx.version), dataKey)}` },
69
+ async unwrap (sealed, ctx) { return open(key, keyAad(ctx, ctx.version), String(sealed).slice(5)) }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Providers together: new keys are sealed by the first; a sealed key opens with whichever
75
+ * made it (its prefix), so switching to KMS leaves keys sealed under the root key readable.
76
+ * Opened keys are kept in memory for `cacheMs`.
77
+ */
78
+ export function keyRing (providers, { cacheMs = 5 * 60 * 1000, now = Date.now } = {}) {
79
+ const list = providers.filter(Boolean)
80
+ if (!list.length) throw new Error('the vault has no root key: set VAULT_ROOT_KEY (or VAULT_KEY_PROVIDER=aws-kms)')
81
+ const cache = new Map() // sealed -> { key, at }
82
+ return {
83
+ primary: list[0].id,
84
+ async wrap (dataKey, ctx) { return list[0].wrap(dataKey, ctx) },
85
+ async unwrap (sealed, ctx) {
86
+ const hit = cache.get(sealed)
87
+ if (hit && now() - hit.at < cacheMs) return hit.key
88
+ const p = list.find((x) => String(sealed).startsWith(x.prefix))
89
+ if (!p) throw new Error('this data key was sealed by a key provider the vault does not have')
90
+ const key = await p.unwrap(sealed, ctx)
91
+ cache.set(sealed, { key, at: now() })
92
+ if (cache.size > 10_000) cache.delete(cache.keys().next().value)
93
+ return key
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,61 @@
1
+ // The `aws-kms` key provider: the vault's data keys sealed by an AWS KMS key, so the root key
2
+ // never leaves AWS and every use of it is in AWS's own log. Switched on by the vault's
3
+ // environment (VAULT_KEY_PROVIDER=aws-kms, VAULT_KMS_KEY_ID, AWS_REGION and the access keys).
4
+ // Plain HTTPS with SigV4 (sigv4.js): no AWS SDK in the vault.
5
+ import { signAws } from './sigv4.js'
6
+
7
+ /** EncryptionContext for a scope's data key: KMS refuses to open it for any other. */
8
+ const contextOf = (ctx) => ({ quilt_scope: `${ctx.scopeType}:${ctx.scopeId}`, quilt_version: String(ctx.version) })
9
+
10
+ export function awsKmsProvider ({ keyId, region, credentials, endpoint = '', fetch: fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = 10_000 }) {
11
+ if (!keyId) throw new Error('VAULT_KMS_KEY_ID is not set')
12
+ if (!region) throw new Error('AWS_REGION is not set')
13
+ if (!credentials?.accessKeyId || !credentials?.secretAccessKey) throw new Error('AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are not set')
14
+ const url = endpoint || `https://kms.${region}.amazonaws.com/`
15
+
16
+ async function call (target, payload) {
17
+ const body = JSON.stringify(payload)
18
+ const headers = { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': `TrentService.${target}` }
19
+ const signed = signAws({ method: 'POST', url, headers, body, region, service: 'kms', credentials, now })
20
+ let res
21
+ try {
22
+ res = await fetchImpl(url, { method: 'POST', headers: { ...headers, ...signed }, body, signal: AbortSignal.timeout(timeoutMs) })
23
+ } catch (err) { throw new Error(`AWS KMS did not answer: ${err.message}`) }
24
+ const out = await res.json().catch(() => ({}))
25
+ // KMS's errors name a type and a message, never key material.
26
+ if (!res.ok) throw new Error(`AWS KMS ${target} failed: ${out.__type || res.status} ${out.message || out.Message || ''}`.trim())
27
+ return out
28
+ }
29
+
30
+ return {
31
+ id: 'aws-kms',
32
+ prefix: 'kms1.',
33
+ async wrap (dataKey, ctx) {
34
+ const out = await call('Encrypt', { KeyId: keyId, Plaintext: Buffer.from(dataKey).toString('base64'), EncryptionContext: contextOf(ctx) })
35
+ return `kms1.${out.CiphertextBlob}`
36
+ },
37
+ async unwrap (sealed, ctx) {
38
+ const out = await call('Decrypt', { KeyId: keyId, CiphertextBlob: String(sealed).slice(5), EncryptionContext: contextOf(ctx) })
39
+ const key = Buffer.from(String(out.Plaintext || ''), 'base64')
40
+ if (key.length !== 32) throw new Error('AWS KMS gave back a key of the wrong size')
41
+ return key
42
+ }
43
+ }
44
+ }
45
+
46
+ /** The vault's key providers from its environment: KMS first when it is switched on, then the root key. */
47
+ export async function providersFromEnv (env = process.env, { fetch: fetchImpl } = {}) {
48
+ const { envKeyProvider } = await import('./crypto.js')
49
+ const out = []
50
+ if (env.VAULT_KEY_PROVIDER === 'aws-kms') {
51
+ out.push(awsKmsProvider({
52
+ keyId: env.VAULT_KMS_KEY_ID,
53
+ region: env.AWS_REGION,
54
+ endpoint: env.VAULT_KMS_ENDPOINT || '',
55
+ credentials: { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, sessionToken: env.AWS_SESSION_TOKEN || '' },
56
+ ...(fetchImpl ? { fetch: fetchImpl } : {})
57
+ }))
58
+ } else if (env.VAULT_KEY_PROVIDER && env.VAULT_KEY_PROVIDER !== 'env') throw new Error(`VAULT_KEY_PROVIDER must be env or aws-kms, not ${env.VAULT_KEY_PROVIDER}`)
59
+ if (env.VAULT_ROOT_KEY) out.push(envKeyProvider({ rootKey: env.VAULT_ROOT_KEY }))
60
+ return out
61
+ }