heyquilt 0.3.90 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -0,0 +1,283 @@
1
+ // The Secrets card in the app (Session settings → Secrets, and a workspace's page), and the bar
2
+ // that asks a person to allow a people-only secret for one run. The HTML is secrets-view.js's;
3
+ // this file loads, binds and saves. A typed value lives in its input and in `typed` here until it
4
+ // goes to the app's server, which sends it straight to the vault; it is never put in markup.
5
+ import { api, toast } from './common.js'
6
+ import { secretsCardHtml, levelTabs, parseCustomFormat, asksBarHtml } from './secrets-view.js'
7
+ import { cleanSecretName, FORMAT_IDS, formatId } from './secret-rules.js'
8
+
9
+ const views = new Map() // `${type}:${id}` -> the last view, so a redraw shows it at once
10
+ const urlOf = (type, id, rest = '') => `/api/secrets/${type}/${encodeURIComponent(id)}${rest}`
11
+
12
+ /**
13
+ * Draws the card into `root` for one scope ({ type, id }: a session's room, a workspace's id, a
14
+ * team's slug). `importFrom` says where .env files may be read from ({ sessionId } or
15
+ * { workspaceId }); without it there is no Import .env. Answers { refresh, destroy }.
16
+ */
17
+ export function mountSecrets (root, { type, id, importFrom = null }) {
18
+ const ui = { tabs: [], cur: { type, id }, form: { format: 'bearer' }, mode: null, choose: false, chooseSel: 'all', import: null, msg: null, busy: false, confirmDelete: null, canImport: !!importFrom, error: '', flash: '' }
19
+ let view = views.get(`${type}:${id}`) || null
20
+ if (view) ui.tabs = levelTabs(view)
21
+ let typed = ''
22
+ let alive = true
23
+ let fresh = false
24
+
25
+ /** What the add row holds now (the value only in memory). */
26
+ function capture () {
27
+ const f = root.querySelector('[data-add]')
28
+ if (!f) return
29
+ ui.form = { ...ui.form, name: f.elements.name.value, upstream: f.elements.upstream.value, format: f.elements.format.value, custom: f.elements.custom ? f.elements.custom.value : ui.form.custom }
30
+ typed = f.elements.value.value
31
+ }
32
+
33
+ function draw ({ focus } = {}) {
34
+ if (!alive) return
35
+ const had = root.contains(document.activeElement) ? document.activeElement.getAttribute('name') : null
36
+ // Keep what was typed, unless the form was just cleared on purpose.
37
+ if (fresh) fresh = false
38
+ else capture()
39
+ root.innerHTML = secretsCardHtml(view, { ...ui, now: Date.now() })
40
+ const f = root.querySelector('[data-add]')
41
+ if (f) f.elements.value.value = typed
42
+ const want = focus || had
43
+ if (want && f && f.elements[want]) f.elements[want].focus()
44
+ }
45
+
46
+ async function load () {
47
+ try {
48
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
49
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
50
+ if (!ui.tabs.length) ui.tabs = levelTabs(view)
51
+ ui.error = ''
52
+ } catch (err) {
53
+ ui.error = err.status === 503 ? 'Secrets are not switched on for your account\'s server yet.' : err.message
54
+ view = null
55
+ }
56
+ draw()
57
+ }
58
+
59
+ const say = (text, kind = '') => { ui.msg = text ? { text, kind } : null }
60
+ const resetForm = () => { ui.form = { format: 'bearer' }; ui.mode = null; typed = ''; fresh = true }
61
+ const scopeUrl = (rest) => urlOf(ui.cur.type, ui.cur.id, rest)
62
+
63
+ async function act (fn) {
64
+ if (ui.busy) return
65
+ ui.busy = true
66
+ try { await fn() } catch (err) { say(err.message, 'bad') } finally { ui.busy = false; draw() }
67
+ }
68
+
69
+ const setSettings = (patch) => act(async () => { view = await api('POST', scopeUrl('/settings'), patch); views.set(`${ui.cur.type}:${ui.cur.id}`, view); say('') })
70
+
71
+ function formatOf (f) {
72
+ if (f.format === 'custom') {
73
+ const c = parseCustomFormat(f.custom)
74
+ if (!c) throw new Error('Write your header like X-Token: Token {value}')
75
+ return c
76
+ }
77
+ return FORMAT_IDS.includes(f.format) ? f.format : 'bearer'
78
+ }
79
+
80
+ async function save () {
81
+ capture()
82
+ const f = ui.form
83
+ let name
84
+ try { name = cleanSecretName(f.name) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'name' }) }
85
+ if (!typed) { say(`Type ${name}'s value.`, 'bad'); return draw({ focus: 'value' }) }
86
+ let format
87
+ try { format = formatOf(f) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'custom' }) }
88
+ const had = view.secrets.find((s) => s.name === name)
89
+ await act(async () => {
90
+ const r = await api('POST', scopeUrl('/save'), { entries: [{ name, upstream: f.upstream, format, who: had ? had.who : 'agents' }], values: { [name]: typed } })
91
+ typed = ''
92
+ resetForm()
93
+ const d = r.saved && r.saved[0]
94
+ say(`${had ? 'Replaced' : 'Saved'} ${name}${d ? ` (${d.digest})` : ''}. Nobody can see its value again, you included.`, 'ok')
95
+ ui.flash = name
96
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
97
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
98
+ })
99
+ }
100
+
101
+ // --------------------------------------------------------------- Import .env --
102
+ async function openImport () {
103
+ ui.import = { loading: true, files: [], rows: [], fileIdx: 0 }
104
+ draw()
105
+ try {
106
+ const r = await api('POST', '/api/secrets/env-scan', importFrom)
107
+ ui.import = { files: r.files, fileIdx: 0, rows: [], empty: r.empty || '' }
108
+ pickFile(0)
109
+ } catch (err) { ui.import = null; say(err.message, 'bad') }
110
+ draw()
111
+ }
112
+ function pickFile (i) {
113
+ const f = ui.import.files[i]
114
+ ui.import.fileIdx = i
115
+ ui.import.rows = f ? f.entries.map((e) => ({ name: e.name, upstream: e.upstream || '', format: e.format || 'bearer', who: 'agents', skip: e.skip || '', pick: !e.skip && !!e.upstream })) : []
116
+ }
117
+ function captureImport () {
118
+ if (!ui.import) return
119
+ root.querySelectorAll('[data-import-host]').forEach((el) => { ui.import.rows[+el.dataset.importHost].upstream = el.value })
120
+ root.querySelectorAll('[data-import-format]').forEach((el) => { ui.import.rows[+el.dataset.importFormat].format = el.value })
121
+ }
122
+ async function importSave () {
123
+ captureImport()
124
+ const im = ui.import
125
+ const rows = im.rows.filter((r) => r.pick && !r.skip)
126
+ if (rows.some((r) => r.format === 'custom')) { say('An imported secret uses one of the listed formats; add a header of your own one at a time.', 'bad'); return draw() }
127
+ const file = im.files[im.fileIdx]
128
+ await act(async () => {
129
+ const r = await api('POST', '/api/secrets/env-import', { ...importFrom, scope: ui.cur.type, id: ui.cur.id, file: file.path, entries: rows.map((x) => ({ name: x.name, upstream: x.upstream, format: x.format, who: x.who })) })
130
+ ui.import = { ...im, done: { rel: file.rel, path: file.path, saved: r.saved, deleted: false } }
131
+ say('')
132
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
133
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
134
+ })
135
+ }
136
+
137
+ // --------------------------------------------------------------- events --
138
+ async function onClick (e) {
139
+ const t = (sel) => e.target.closest(sel)
140
+ let b
141
+ if ((b = t('[data-level]'))) {
142
+ if (b.classList.contains('on')) return
143
+ capture(); resetForm(); ui.choose = false; ui.import = null; ui.confirmDelete = null; say('')
144
+ ui.cur = { type: b.dataset.level, id: b.dataset.levelId }
145
+ view = views.get(`${ui.cur.type}:${ui.cur.id}`) || null
146
+ draw()
147
+ return load()
148
+ }
149
+ if (!view) return
150
+ if ((b = t('[data-inherit]')) && !b.disabled) return setSettings({ inheritOff: !view.settings.inheritOff })
151
+ if (t('[data-inherit-on]')) return setSettings({ inheritOff: false })
152
+ if ((b = t('[data-agents-on]')) && !b.disabled) { ui.choose = false; return setSettings({ agentsOn: !view.settings.agentsOn }) }
153
+ if (t('[data-choose]')) { ui.choose = !ui.choose; ui.chooseSel = view.settings.agents === 'all' ? 'all' : [...view.settings.agents]; return draw() }
154
+ if (t('[data-choose-cancel]')) { ui.choose = false; return draw() }
155
+ if (t('[data-choose-save]')) { ui.choose = false; return setSettings({ agents: ui.chooseSel }) }
156
+ if ((b = t('[data-who]')) && !b.disabled) {
157
+ const r = view.secrets.find((s) => s.name === b.dataset.who)
158
+ return act(async () => { view = await api('POST', scopeUrl('/who'), { name: r.name, who: r.who === 'people' ? 'agents' : 'people' }); say('') })
159
+ }
160
+ if ((b = t('[data-replace]')) || (b = t('[data-override]'))) {
161
+ const replace = !!b.dataset.replace
162
+ const name = replace ? b.dataset.replace : b.dataset.override
163
+ const r = (replace ? view.secrets : view.inherited).find((s) => s.name === name)
164
+ ui.mode = { kind: replace ? 'replace' : 'override', name }
165
+ const fid = formatId(r.format)
166
+ ui.form = { name, upstream: r.upstream, format: fid || 'custom', custom: fid ? '' : `${r.format.name}: ${r.format.template}` }
167
+ typed = ''
168
+ fresh = true
169
+ say(replace ? `A new value for ${name}. Where it goes and how can change with it.` : `${name} for this ${ui.cur.type === 'org' ? 'team' : ui.cur.type} only: it overrides the ${r.level === 'org' ? 'team' : r.level}'s.`)
170
+ return draw({ focus: 'value' })
171
+ }
172
+ if (t('[data-add-cancel]')) { resetForm(); say(''); return draw() }
173
+ if ((b = t('[data-delete]'))) { ui.confirmDelete = b.dataset.delete; return draw() }
174
+ if (t('[data-delete-no]')) { ui.confirmDelete = null; return draw() }
175
+ if ((b = t('[data-delete-yes]'))) {
176
+ const name = b.dataset.deleteYes
177
+ ui.confirmDelete = null
178
+ return act(async () => { view = await api('POST', scopeUrl('/delete'), { name }); say(`Deleted ${name}.`, 'ok') })
179
+ }
180
+ if (t('[data-import]')) return openImport()
181
+ if (t('[data-import-close]')) { ui.import = null; return draw() }
182
+ if ((b = t('[data-import-who]'))) { captureImport(); const r = ui.import.rows[+b.dataset.importWho]; r.who = r.who === 'people' ? 'agents' : 'people'; return draw() }
183
+ if (t('[data-import-save]')) return importSave()
184
+ if (t('[data-import-delete]')) {
185
+ const d = ui.import.done
186
+ return act(async () => { await api('POST', '/api/secrets/env-delete', { ...importFrom, file: d.path }); d.deleted = true; toast(`Deleted ${d.rel}`) })
187
+ }
188
+ }
189
+
190
+ function onChange (e) {
191
+ const el = e.target
192
+ if (el.matches('[data-choose-all]')) { ui.chooseSel = 'all'; return draw() }
193
+ if (el.matches('[data-choose-some]')) { ui.chooseSel = view.agentChoices.map((a) => a.id); return draw() }
194
+ if (el.matches('[data-choose-agent]')) {
195
+ const set = new Set(ui.chooseSel === 'all' ? [] : ui.chooseSel)
196
+ if (el.checked) set.add(el.dataset.chooseAgent); else set.delete(el.dataset.chooseAgent)
197
+ ui.chooseSel = [...set]
198
+ return
199
+ }
200
+ if (el.matches('[data-add] [name=format]')) { capture(); return draw({ focus: el.value === 'custom' ? 'custom' : 'format' }) }
201
+ if (el.matches('[data-import-file]')) { pickFile(+el.value); return draw() }
202
+ if (el.matches('[data-import-pick]')) { captureImport(); ui.import.rows[+el.dataset.importPick].pick = el.checked; return draw() }
203
+ }
204
+
205
+ function onInput (e) {
206
+ // Names are capitals: typed as such.
207
+ if (e.target.matches('[data-add] [name=name]')) {
208
+ const s = e.target.selectionStart
209
+ e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
210
+ e.target.setSelectionRange(s, s)
211
+ }
212
+ }
213
+
214
+ function onSubmit (e) {
215
+ if (!e.target.matches('[data-add]')) return
216
+ e.preventDefault()
217
+ save()
218
+ }
219
+
220
+ root.addEventListener('click', onClick)
221
+ root.addEventListener('change', onChange)
222
+ root.addEventListener('input', onInput)
223
+ root.addEventListener('submit', onSubmit)
224
+ draw()
225
+ load()
226
+ return {
227
+ refresh: load,
228
+ destroy () { alive = false; typed = ''; root.removeEventListener('click', onClick); root.removeEventListener('change', onChange); root.removeEventListener('input', onInput); root.removeEventListener('submit', onSubmit) }
229
+ }
230
+ }
231
+
232
+ // ------------------------------------------------- people-only asks, app-wide --
233
+ let asking = false
234
+ let asks = []
235
+
236
+ function drawAsks () {
237
+ let bar = document.getElementById('ask-bar')
238
+ const show = asks.length > 0
239
+ document.body.classList.toggle('has-asks', show)
240
+ if (!show) { bar?.remove(); return }
241
+ if (!bar) {
242
+ bar = document.createElement('div')
243
+ bar.id = 'ask-bar'
244
+ bar.addEventListener('click', async (e) => {
245
+ const b = e.target.closest('[data-ask-allow],[data-ask-deny]')
246
+ if (!b) return
247
+ const allow = !!b.dataset.askAllow
248
+ const runId = allow ? b.dataset.askAllow : b.dataset.askDeny
249
+ b.disabled = true
250
+ try {
251
+ await api('POST', `/api/secret-asks/${encodeURIComponent(runId)}`, { allow })
252
+ asks = asks.filter((a) => a.runId !== runId)
253
+ toast(allow ? 'Allowed for that run' : 'Denied')
254
+ } catch (err) { toast(err.message) }
255
+ drawAsks()
256
+ })
257
+ document.body.prepend(bar)
258
+ }
259
+ bar.innerHTML = asksBarHtml(asks)
260
+ }
261
+
262
+ /**
263
+ * Watches for runs of yours waiting for you to allow a people-only secret (a held request that
264
+ * the API answers when one comes, or after 25 seconds), and shows them in the bar. Once per page.
265
+ */
266
+ export function watchSecretAsks () {
267
+ if (asking) return
268
+ asking = true
269
+ const loop = async () => {
270
+ let wait = 2000
271
+ try {
272
+ const r = await api('GET', '/api/secret-asks?wait=1')
273
+ asks = r.asks || []
274
+ drawAsks()
275
+ wait = asks.length ? 3000 : 250
276
+ } catch (err) {
277
+ // Secrets off on the server, or signed out: look again much later.
278
+ wait = err.status === 503 || err.status === 404 ? 10 * 60 * 1000 : 15000
279
+ }
280
+ setTimeout(loop, wait)
281
+ }
282
+ loop()
283
+ }