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
|
@@ -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
|
+
}
|