heyquilt 0.3.90 → 0.3.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
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,13 +533,32 @@ 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
- return { vm: { ...vm, name: personaName(person, CLOUD_LABEL) } }
558
+ // A session nobody named is shown as the app shows it: by this computer's folder for it.
559
+ const local = new Map([...runs.values()].filter((e) => e.run && e.run.session).map((e) => [e.run.session.room, e.run.session.sessionName || path.basename(e.run.dir || '')]))
560
+ const sessions = (vm.sessions || []).map((x) => (x.name ? x : { ...x, name: local.get(x.room) || '' }))
561
+ return { vm: { ...vm, sessions, name: personaName(person, CLOUD_LABEL) } }
535
562
  }
536
563
  const cloudNone = (err) => { if (err.status === 404) return cloudView(null); throw err }
537
564
  /** What Settings may change: the tool, model, instructions and daily cap (blank is no cap). */
@@ -601,6 +628,24 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
601
628
  const needInviteId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which invite?'); return String(id) }
602
629
  const needWorkspaceId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which workspace?'); return id }
603
630
  const needAgentId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which agent?'); return id }
631
+ const needKeyId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which key?'); return String(id) }
632
+ /**
633
+ * Sessions on this computer a sandbox key can be made for: open ones, then recent ones, each with
634
+ * its invite link (read from its folder; the page never gets it). { value, room, name, link }.
635
+ */
636
+ const localSessions = () => {
637
+ const seen = new Set()
638
+ const out = []
639
+ const add = (dir, link, name) => {
640
+ const c = readConfig(dir)
641
+ if (!c || !c.room || seen.has(c.room) || !(link || c.secret)) return
642
+ seen.add(c.room)
643
+ 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 }) })
644
+ }
645
+ for (const r of runs.values()) add(r.run.dir, r.run.invite, r.run.session.status().sessionName)
646
+ for (const r of recentList()) if (!r.unsupported) add(r.dir, '', '')
647
+ return out
648
+ }
604
649
  const needOrgSlug = (slug) => { if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(String(slug || ''))) throw httpError(400, 'Which org?'); return slug }
605
650
  const ofWorkspace = (id) => ({
606
651
  running: [...runs.keys()].filter((k) => (readConfig(runs.get(k).run.dir) || {}).workspace === id),
@@ -720,8 +765,60 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
720
765
  })
721
766
  }
722
767
 
768
+ // Secrets (src/ui/secrets.js): names and settings through the accounts API; a typed or imported
769
+ // value goes from here straight to the vault with a write ticket, and is never kept or logged.
770
+ 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}$/ }
771
+ const secretsApi = (token) => ({ api: apiUrl(), token })
772
+ async function secretsRoute (method, scope, id, rest, body) {
773
+ if (!SECRET_SCOPE_ID[scope] || !SECRET_SCOPE_ID[scope].test(id)) throw httpError(400, 'Which secrets?')
774
+ return asAccount(async (token) => {
775
+ const c = secretsApi(token)
776
+ if (method === 'GET' && rest === '') return getSecrets(c, scope, id)
777
+ if (method === 'GET' && rest === '/uses') return secretUses(c, scope, id)
778
+ 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]])))
779
+ if (method === 'POST' && rest === '/who') return setSecretWho(c, scope, id, String(body.name || ''), String(body.who || ''))
780
+ if (method === 'POST' && rest === '/delete') return deleteSecret(c, scope, id, String(body.name || ''))
781
+ if (method === 'POST' && rest === '/save') {
782
+ const values = body.values && typeof body.values === 'object' ? Object.fromEntries(Object.entries(body.values).map(([k, v]) => [k, String(v ?? '')])) : {}
783
+ return saveSecrets(c, scope, id, Array.isArray(body.entries) ? body.entries : [], values)
784
+ }
785
+ throw httpError(404, 'not found')
786
+ })
787
+ }
788
+ /** The folders whose .env files a card may import: one session's, or a workspace's sessions' on this computer. */
789
+ const envDirs = (b) => {
790
+ if (b && b.sessionId) { const r = runs.get(String(b.sessionId)); return r ? [r.run.dir] : [] }
791
+ 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)] }
792
+ return []
793
+ }
794
+
723
795
  const api = {
724
796
  'GET /api/account': () => accountState(),
797
+ // Which optional parts the accounts API has on (secrets): public there; off when it can't be reached.
798
+ 'GET /api/features': async () => {
799
+ try {
800
+ const res = await fetch(`${apiUrl()}/v1/features`, { signal: AbortSignal.timeout(5000) })
801
+ const f = res.ok ? await res.json() : {}
802
+ return { secrets: !!f.secrets, workspaces: !!f.workspaces }
803
+ } catch { return { secrets: false, workspaces: false } }
804
+ },
805
+ 'POST /api/secrets/env-scan': (b) => {
806
+ const dirs = envDirs(b)
807
+ if (!dirs.length) return { files: [], empty: 'Open this session (or one of the workspace\'s) on this computer to import its .env files.' }
808
+ return { files: envFilesIn(dirs) }
809
+ },
810
+ 'POST /api/secrets/env-import': (b) => {
811
+ const f = knownEnvFile(envFilesIn(envDirs(b)), b.file)
812
+ const entries = (Array.isArray(b.entries) ? b.entries : []).filter((e) => f.entries.some((x) => x.name === e.name && !x.skip))
813
+ if (!entries.length) throw httpError(400, 'Pick the secrets to save.')
814
+ if (!SECRET_SCOPE_ID[b.scope] || !SECRET_SCOPE_ID[b.scope].test(String(b.id || ''))) throw httpError(400, 'Which secrets?')
815
+ return asAccount((token) => saveSecrets(secretsApi(token), b.scope, String(b.id), entries, envValues(f.path, entries.map((e) => e.name))))
816
+ },
817
+ 'POST /api/secrets/env-delete': (b) => {
818
+ const f = knownEnvFile(envFilesIn(envDirs(b)), b.file)
819
+ fs.rmSync(f.path, { force: true })
820
+ return { ok: true }
821
+ },
725
822
  'GET /api/access-types': () => asAccount(async (token) => ({ types: await listAccessTypes({ token }) })),
726
823
  // Quilt's GitHub App: whether this account connected GitHub, and the link that connects it (opened in the browser).
727
824
  ...cloudRoutes,
@@ -754,6 +851,20 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
754
851
  '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
852
  // Where agents work: an agent's placement (yours), an org's agents, a workspace's say over
756
853
  // an agent placed there, and a link for a new agent that joins the workspace.
854
+ // An agent's sandbox keys (sandbox-keys.js): the API's list, plus this computer's sessions for New key.
855
+ 'GET /api/agents/:id/sandbox-keys': (b, id) => asAccount(async (token) => ({ ...(await listSandboxKeys({ token, id: needAgentId(id) })), local: localSessions().map(({ link, ...s }) => s) })),
856
+ // `session`: local:<id> (a session on this computer: its link is read here) or room:<room> (one a key already has); or `link`, pasted.
857
+ 'POST /api/agents/:id/sandbox-keys': (b, id) => asAccount(async (token) => {
858
+ const pick = String(b.session || '')
859
+ const local = pick.startsWith('local:') ? localSessions().find((s) => s.value === pick) : null
860
+ if (pick.startsWith('local:') && !local) throw httpError(404, 'That session is not on this computer any more.')
861
+ const where = local ? { session: local.link, sessionName: local.name } : pick.startsWith('room:') ? { room: pick.slice(5) } : { session: String(b.link || '').trim() }
862
+ const r = await createSandboxKey({ token, id: needAgentId(id), name: String(b.name || '').trim(), ...where })
863
+ return { envKey: r.envKey, key: r.key }
864
+ }),
865
+ '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 } }),
866
+ '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 } }),
867
+ '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
868
  'GET /api/agents/:id/placement': (b, id) => asAccount(async (token) => ({ placement: await getAgentPlacement({ token, id: needAgentId(id) }) })),
758
869
  '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
870
  'GET /api/orgs/:slug/agents': (b, slug) => asAccount(async (token) => ({ agents: await listOrgAgents({ token, slug: needOrgSlug(slug) }) })),
@@ -817,21 +928,6 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
817
928
  // Gathering the project's AI rules into AGENTS.md (project-rules.js): look first, then apply what was shown.
818
929
  'GET /api/sessions/:id/rules/gather': (b, id) => get(id).rulesGatherPlan(),
819
930
  '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
931
  'POST /api/sessions/:id/stop': (b, id) => stop(id).then(() => ({ ok: true })),
836
932
  'POST /api/sessions/:id/say': (b, id) => get(id).say(b.text, { to: b.to || null }),
837
933
  'POST /api/sessions/:id/focus': (b, id) => { get(id).setFocus(b.text); return { ok: true } },
@@ -1034,12 +1130,25 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1034
1130
  let m = url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)\/send$/)
1035
1131
  if (req.method === 'POST' && m) return json(200, await receiveUpload(req, get(m[1])))
1036
1132
  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])
1133
+ if (req.method === 'GET' && m) return await serveFile(res, get(m[1]), m[2], url.searchParams.get('inline') === '1')
1038
1134
  const wu = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/upload$/)
1039
1135
  if (req.method === 'PUT' && wu) return json(200, await receiveWorkspaceUpload(req, wu[1]))
1040
1136
  const wd = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/files\/([^/]+)\/data$/)
1041
1137
  if (req.method === 'GET' && wd) return await streamWorkspaceFile(req, res, wd[1], wd[2])
1042
1138
 
1139
+ // Secrets: /api/secrets/<scope>/<id>[/<what>], and the asks for people-only secrets.
1140
+ const sec = url.pathname.match(/^\/api\/secrets\/(org|workspace|session)\/([^/]+)(\/(?:uses|settings|who|delete|save))?$/)
1141
+ const askOne = url.pathname.match(/^\/api\/secret-asks\/([0-9a-f-]{36})$/i)
1142
+ if (sec || askOne || url.pathname === '/api/secret-asks') {
1143
+ let raw = ''
1144
+ for await (const chunk of req) raw += chunk
1145
+ const body = raw ? JSON.parse(raw) : {}
1146
+ if (sec) return json(200, await secretsRoute(req.method, sec[1], decodeURIComponent(sec[2]), sec[3] || '', body))
1147
+ if (askOne && req.method === 'POST') return json(200, await asAccount((token) => answerAsk(secretsApi(token), askOne[1], body.allow === true)))
1148
+ if (!askOne && req.method === 'GET') return json(200, await asAccount((token) => secretAsks(secretsApi(token), { wait: url.searchParams.get('wait') === '1' })))
1149
+ return json(404, { error: 'not found' })
1150
+ }
1151
+
1043
1152
  let pathKey = url.pathname
1044
1153
  .replace(/^\/api\/sessions\/[a-f0-9]+/, '/api/sessions/:id')
1045
1154
  .replace(/^\/api\/workspaces\/[^/]+/, '/api/workspaces/:id')
@@ -1134,14 +1243,17 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1134
1243
  }
1135
1244
  }
1136
1245
 
1137
- async function serveFile (res, session, msgId) {
1246
+ // A chat file the page shows as a picture (?inline=1); anything else is a download.
1247
+ const CHAT_IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }
1248
+
1249
+ async function serveFile (res, session, msgId, inline = false) {
1138
1250
  const msg = session.messages({ limit: 500, markRead: false }).find((m) => m.id === msgId)
1139
1251
  if (!msg || !msg.file) throw httpError(404, 'No such file.')
1140
1252
  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
- })
1253
+ const image = inline && CHAT_IMAGES[path.extname(msg.file.name).toLowerCase()]
1254
+ res.writeHead(200, image
1255
+ ? { 'content-type': image, 'x-content-type-options': 'nosniff', 'content-security-policy': 'sandbox', 'content-disposition': 'inline' }
1256
+ : { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${msg.file.name.replace(/[^\w.\- ]/g, '_')}"` })
1145
1257
  fs.createReadStream(local).pipe(res)
1146
1258
  }
1147
1259
 
@@ -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
+ }