heyquilt 0.3.90 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/RELEASES.md +131 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +141 -5
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +143 -17
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +120 -10
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +136 -27
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
'content-
|
|
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
|
|
package/src/update-check.js
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/vault/kms.js
ADDED
|
@@ -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
|
+
}
|