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/relay-upstream.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// and upstream branch the branch follows, and the last commit a folder on it was at while
|
|
8
8
|
// in step with the session; then, every so often, it asks GitHub whether that upstream
|
|
9
9
|
// moved on, and if it is strictly ahead, merges its files into the branch document the
|
|
10
|
-
// same way a folder would (upstream.js planCatchUp)
|
|
11
|
-
//
|
|
12
|
-
// merge
|
|
10
|
+
// same way a folder would (upstream.js planCatchUp): where both changed the same lines, Quilt
|
|
11
|
+
// combines them itself, the newer edit keeping them, and tells whoever's lines lost
|
|
12
|
+
// (relay-clash.js). Nobody is asked to merge. Never a history merge, never anything written to GitHub.
|
|
13
13
|
//
|
|
14
14
|
// The relay has no git: the commit it records is "the session's files are this commit plus
|
|
15
15
|
// the session's work". A folder that comes back behind its upstream follows it like a
|
|
@@ -21,13 +21,10 @@ import { planCatchUp, publishedOf } from './upstream.js'
|
|
|
21
21
|
import { applyTextDiff } from './textdiff.js'
|
|
22
22
|
import { looksBinary, sha1 } from './fsutil.js'
|
|
23
23
|
import { LARGE_FILE_BYTES, MAX_TEXT_BYTES, makeIgnore, scopeIgnore, isIgnored, isSafeRelPath } from './pathrules.js'
|
|
24
|
-
import {
|
|
25
|
-
import { readTasks,
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { HistoryLog, lineDiff } from './history.js'
|
|
29
|
-
import { clashTaskId, clashTitle, clashFiles, relayClashBrief } from './clash.js'
|
|
30
|
-
import { RELAY_BY } from './branches.js'
|
|
24
|
+
import { unionMatcher } from './merge3.js'
|
|
25
|
+
import { readTasks, updateTask } from './tasks.js'
|
|
26
|
+
import { HistoryLog } from './history.js'
|
|
27
|
+
import { RELAY_BY, isRelay } from './branches.js'
|
|
31
28
|
|
|
32
29
|
/** How often the relay asks GitHub about a branch nobody's folder is on (per branch). */
|
|
33
30
|
export const UPSTREAM_CHECK_MS = 10 * 60 * 1000
|
|
@@ -38,7 +35,7 @@ export const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000
|
|
|
38
35
|
export const FIRST_BACKOFF_MS = 15 * 60 * 1000
|
|
39
36
|
/** GitHub's compare lists at most this many files: more and the relay leaves it to a folder. */
|
|
40
37
|
const COMPARE_FILES = 300
|
|
41
|
-
/**
|
|
38
|
+
/** Kept for server.js's settings (an older clash task's assignee was handed on after this long); unused since Quilt merges on its own. */
|
|
42
39
|
export const CLASH_IDLE_MS = 15 * 60 * 1000
|
|
43
40
|
/** quilt_sync_branch asks GitHub at most this often per branch (Room.syncUpstreamNow). */
|
|
44
41
|
export const SYNC_EVERY_MS = 60 * 1000
|
|
@@ -490,16 +487,13 @@ async function quiltignoresAt (gh, sha, list) {
|
|
|
490
487
|
return out
|
|
491
488
|
}
|
|
492
489
|
|
|
493
|
-
const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
|
|
494
|
-
const regionKey = (lines) => sha1(Buffer.from(lines.join('\n'), 'utf8'))
|
|
495
|
-
/** The session's side of each region of a file that clashes with upstream, by fingerprint (a rewrite of those lines is the merge). */
|
|
496
|
-
export const clashRegions = (b, o, t) => isText(b) && isText(o) && isText(t) ? mergeAdditive(b, o, t).conflicts.map((c) => regionKey(c.ours)) : null
|
|
497
|
-
|
|
498
490
|
/**
|
|
499
491
|
* One look at branch `key` (the caller checked relayLooks). Asks GitHub for the upstream's
|
|
500
492
|
* head; when it is strictly ahead of the recorded commit, reads every changed file at both
|
|
501
|
-
* ends and merges them into the branch document,
|
|
502
|
-
*
|
|
493
|
+
* ends and merges them into the branch document, combining what both sides changed (the
|
|
494
|
+
* newer edit keeps the lines both changed: the newest compared commit's time against the
|
|
495
|
+
* session's last edit of the file). Returns what happened: { state, ... } for tests and the
|
|
496
|
+
* log. Never throws.
|
|
503
497
|
*/
|
|
504
498
|
export async function checkBranch (room, key, { fetch, now = () => Date.now(), log = () => {}, fallbackToken = null } = {}) {
|
|
505
499
|
const recs = upstreamsOf(room)
|
|
@@ -629,53 +623,27 @@ export async function checkBranch (room, key, { fetch, now = () => Date.now(), l
|
|
|
629
623
|
const baseMap = new Map([...files].map(([rel, f]) => [rel, f.base]))
|
|
630
624
|
const theirs = new Map([...files].map(([rel, f]) => [rel, f.head]))
|
|
631
625
|
const disk = (rel) => docKey(e, rel)
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const settled = !!(rec.clash.settled && rec.clash.sha === head)
|
|
643
|
-
resolved = plan.conflicts.map((c) => c.path).filter((rel) => {
|
|
644
|
-
const d = disk(rel)
|
|
645
|
-
if (!(rel in was) || d === undefined) return false
|
|
646
|
-
if (settled) return !hasMarkers(d) // the assignee says it's merged: never with conflict markers left
|
|
647
|
-
if (was[rel] === fingerprint(d) || hasMarkers(d)) return false
|
|
648
|
-
const b = baseMap.get(rel)
|
|
649
|
-
const t = theirs.get(rel)
|
|
650
|
-
if (!isText(b) || !isText(d) || !isText(t)) return true // a deletion or a binary: changed since is all there is to go on
|
|
651
|
-
const old = regions[rel]
|
|
652
|
-
return !old || mergeAdditive(b, d, t).conflicts.every((c) => !old.includes(regionKey(c.ours)))
|
|
653
|
-
})
|
|
654
|
-
if (resolved.length) {
|
|
655
|
-
plan = planCatchUp({ changes: new Map([...changes].filter(([rel]) => !resolved.includes(rel))), base: baseMap, theirs, disk, published: publishedOf(room.doc) })
|
|
656
|
-
if (!plan.conflicts.length) {
|
|
657
|
-
for (const rel of resolved) {
|
|
658
|
-
const b = baseMap.get(rel)
|
|
659
|
-
const d = disk(rel)
|
|
660
|
-
const t = theirs.get(rel)
|
|
661
|
-
if (!isText(b) || !isText(d) || !isText(t)) continue
|
|
662
|
-
const merged = mergeAdditive(b, d, t).text
|
|
663
|
-
if (merged !== d) plan.writes.set(rel, merged)
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
626
|
+
// Which edit is newer, for the lines both sides changed: the session's last edit of the file against GitHub's newest commit here.
|
|
627
|
+
const commits = Array.isArray(cmp.commits) ? cmp.commits : []
|
|
628
|
+
const newest = commits.at(-1) || null
|
|
629
|
+
const ghAt = newest && newest.commit ? Date.parse((newest.commit.committer && newest.commit.committer.date) || (newest.commit.author && newest.commit.author.date) || '') || 0 : 0
|
|
630
|
+
const ghSide = newest ? { name: (newest.commit && newest.commit.author && newest.commit.author.name) || (newest.author && newest.author.login) || rec.name, sha: newest.sha || head, ts: ghAt } : null
|
|
631
|
+
const parties = new Map()
|
|
632
|
+
const partyOf = (rel) => { if (!parties.has(rel)) parties.set(rel, lastEditor(e, rel)); return parties.get(rel) }
|
|
633
|
+
const oursNewer = (rel) => ((partyOf(rel) || {}).ts || 0) > ghAt + 999 // a commit's whole second is its own
|
|
634
|
+
const union = unionOf(e)
|
|
635
|
+
const plan = planCatchUp({ changes, base: baseMap, theirs, disk, published: publishedOf(room.doc), oursNewer, union })
|
|
668
636
|
if (plan.conflicts.length) {
|
|
669
|
-
|
|
670
|
-
const
|
|
671
|
-
said(
|
|
672
|
-
return done({ state: '
|
|
637
|
+
// Files the relay can't read (too large for it): nothing comes in until a member's folder brings them in (all or nothing: one commit is recorded).
|
|
638
|
+
const list = plan.conflicts.map((c) => c.path)
|
|
639
|
+
said(`${rec.name} changes ${list.slice(0, 4).join(', ')}${list.length > 4 ? ' and more' : ''}, too large for the relay to read: a member's folder on ${key} brings ${list.length === 1 ? 'it' : 'them'} in`)
|
|
640
|
+
return done({ state: 'too-large', head, files: list })
|
|
673
641
|
}
|
|
674
642
|
// A file someone holds is never written under them (as a folder's bring-in waits, Session.bringIn):
|
|
675
643
|
// only the clash's files, held for its merge. The bring-in waits until they let go of it.
|
|
676
644
|
for (const rel of plan.writes.keys()) {
|
|
677
645
|
const c = room.claimFor ? room.claimFor(rel, key) : null
|
|
678
|
-
if (c
|
|
646
|
+
if (c) {
|
|
679
647
|
said(`${c.by} holds ${rel}: the relay brings ${rec.name} in once they let go of it (quilt_release, or quilt_handoff)`)
|
|
680
648
|
rec.waitingOn = { path: rel, by: c.by, pattern: c.pattern } // looked at again as soon as that claim goes (Room.noteClaimsChanged)
|
|
681
649
|
return done({ state: 'waiting', head, held: rel, by: c.by })
|
|
@@ -717,9 +685,12 @@ export async function checkBranch (room, key, { fetch, now = () => Date.now(), l
|
|
|
717
685
|
rec.sha = head
|
|
718
686
|
rec.relayHead = head
|
|
719
687
|
rec.brought = { count: behind, files: written, at: now(), head }
|
|
688
|
+
// Whoever's lines lost is told: a direct message for the session's, the activity log for GitHub's (relay-clash.js).
|
|
689
|
+
const { tellAutoMerge } = await import('./relay-clash.js')
|
|
690
|
+
for (const m of plan.merged) tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted || null, session: partyOf(m.path), github: ghSide, upstream: rec.name, head, now: now() })
|
|
720
691
|
said(null)
|
|
721
|
-
closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: ${RELAY_BY} merged ${plural(behind, 'commit')} from ${rec.name} into ${key}
|
|
722
|
-
return done({ state: 'brought', head, count: behind, files: written,
|
|
692
|
+
closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: ${RELAY_BY} merged ${plural(behind, 'commit')} from ${rec.name} into ${key}.`)
|
|
693
|
+
return done({ state: 'brought', head, count: behind, files: written, merged: plan.merged.map((m) => ({ path: m.path, lost: m.lost.length ? m.lost[0].side : null, ...(m.deleted ? { deleted: m.deleted } : {}) })) })
|
|
723
694
|
}
|
|
724
695
|
|
|
725
696
|
/** The relay's clash task on `key` is done: the session is at or past its commit. */
|
|
@@ -732,119 +703,18 @@ export function closeLanded (room, key, rec, verified) {
|
|
|
732
703
|
delete rec.clash
|
|
733
704
|
}
|
|
734
705
|
|
|
735
|
-
/**
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
if (!task) task = tasks.find((t) => t.id === clashTaskId(key, head)) || null
|
|
748
|
-
// Hosted agents first (not a chat link: it can only add new files), then CLI agents connected on the branch.
|
|
749
|
-
const cli = [...room.conns.keys()]
|
|
750
|
-
.filter((ws) => ws.branch === key && ws.pass && ws.pass.kind === 'agent' && room.names && room.names.get(ws))
|
|
751
|
-
.map((ws) => ({ ...(room.access.get(ws) || {}), name: room.names.get(ws), who: room.claimant(ws) }))
|
|
752
|
-
.filter((a) => a.role)
|
|
753
|
-
const hosted = hostedOn(room, key).filter((a) => !a.chat).map((a) => ({ ...a, who: { name: a.name, id: a.id, talk: a.talk !== false, branch: key } }))
|
|
754
|
-
const agents = [...hosted, ...cli].filter(mayTake)
|
|
755
|
-
.filter((a, i, all) => all.findIndex((x) => x.name === a.name) === i)
|
|
756
|
-
const id = task ? task.id : clashTaskId(key, head)
|
|
757
|
-
// Taken off the board for this commit: not again (a newer commit may be handed out).
|
|
758
|
-
if (!task && rec.clash && rec.clash.sha === head) return { problem: `${n} clash with the session's work on ${key} (task ${rec.clash.task} was taken off the board): a member's folder on ${key} has to bring ${upstream} in` }
|
|
759
|
-
if (!task && !agents.length) return { problem: `${behind} commit${behind === 1 ? '' : 's'} from ${upstream} clash with the session's work on ${key} in ${paths.slice(0, 4).join(', ')}${paths.length > 4 ? ' and more' : ''}, and no agent that may change them is on ${key} to merge them` }
|
|
760
|
-
const same = rec.clash && rec.clash.task === id
|
|
761
|
-
const files = { ...(same ? rec.clash.files : {}) }
|
|
762
|
-
const regions = { ...(same ? rec.clash.regions || {} : {}) }
|
|
763
|
-
for (const rel of paths) {
|
|
764
|
-
if (rel in files) continue
|
|
765
|
-
files[rel] = fingerprint(disk(rel))
|
|
766
|
-
const r = clashRegions(baseMap.get(rel), disk(rel), theirs.get(rel))
|
|
767
|
-
if (r) regions[rel] = r
|
|
768
|
-
}
|
|
769
|
-
const brief = () => relayClashBrief({ branch: key, upstream, sha: head, base, behind, conflicts, compare: `https://${rec.repo}/compare/${base.slice(0, 12)}...${head.slice(0, 12)}` })
|
|
770
|
-
const post = (text) => { try { addComment(room.doc, room.taskComments, readTasks(room.tasks), { taskId: id, by: RELAY_BY, text }, ORIGIN) } catch {} }
|
|
771
|
-
const diffs = () => {
|
|
772
|
-
for (const c of conflicts.slice(0, 8)) {
|
|
773
|
-
const b = baseMap.get(c.path)
|
|
774
|
-
const t = theirs.get(c.path)
|
|
775
|
-
const head7 = head.slice(0, 7)
|
|
776
|
-
const intro = `What ${upstream} changed in ${c.path} (${base.slice(0, 7)} → ${head7}):`
|
|
777
|
-
if (typeof b !== 'string' && b !== null) { post(`${intro} not readable by the relay; see https://${rec.repo}/blob/${head7}/${c.path}`); continue }
|
|
778
|
-
if (typeof t !== 'string' && t !== null) { post(`${intro} not readable by the relay; see https://${rec.repo}/blob/${head7}/${c.path}`); continue }
|
|
779
|
-
if ((b && b.startsWith('bin:')) || (t && t.startsWith('bin:'))) { post(`${intro} a binary file; see https://${rec.repo}/blob/${head7}/${c.path}`); continue }
|
|
780
|
-
const room4 = MAX_COMMENT - intro.length - 20
|
|
781
|
-
const d = t === null ? `(deleted in ${upstream})` : lineDiff(b || '', t, { max: room4 })
|
|
782
|
-
post(`${intro}\n\`\`\`diff\n${d.slice(0, room4)}\n\`\`\``)
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
// Its assignee (or the member whose AI session it is) connected on the branch: its own Quilt
|
|
786
|
-
// carries the merge on; the relay never hands it on nor takes its files.
|
|
787
|
-
const present = !!task && connectedAs(room, key, task.assignee)
|
|
788
|
-
// No progress (the clashing files unchanged, the task not moved) for a while: handed on to the next agent.
|
|
789
|
-
const progressKey = JSON.stringify([task && task.assignee, task && task.column, paths.map((rel) => fingerprint(disk(rel)))])
|
|
790
|
-
const idleMs = (room.cfg && Number.isFinite(room.cfg.clashIdleMs)) ? room.cfg.clashIdleMs : CLASH_IDLE_MS
|
|
791
|
-
const was = rec.clash && rec.clash.task === id && rec.clash.progress
|
|
792
|
-
const progress = was && was.key === progressKey ? was : { key: progressKey, since: now }
|
|
793
|
-
const stalled = !!task && !present && now - progress.since > idleMs && agents.some((a) => a.name !== task.assignee)
|
|
794
|
-
if (!task) {
|
|
795
|
-
const to = agents[0]
|
|
796
|
-
try {
|
|
797
|
-
task = addTask(room.doc, room.tasks, { id, title: clashTitle({ upstream, behind, conflicts }), by: RELAY_BY, assignee: to.name, forAi: false, tool: '', files: clashFiles(conflicts) }, ORIGIN)
|
|
798
|
-
} catch (err) { return { problem: `${n} clash with the session's work on ${key}, and the relay couldn't put the merge on the board (${err.message})` } }
|
|
799
|
-
room.log(`[${room.name}] ${key}: the clash with ${upstream} ${head.slice(0, 7)} went to ${to.name} (task ${id})`)
|
|
800
|
-
post(brief())
|
|
801
|
-
diffs()
|
|
802
|
-
} else {
|
|
803
|
-
const fresh = !rec.clash || rec.clash.sha !== head
|
|
804
|
-
const patch = { id: task.id }
|
|
805
|
-
if (fresh) { patch.title = clashTitle({ upstream, behind, conflicts }); patch.files = clashFiles(conflicts) }
|
|
806
|
-
// Its assignee left the branch (no longer an agent on it), or made no progress for a while, and
|
|
807
|
-
// another agent is there: theirs now. Never while its assignee's member is connected on the branch.
|
|
808
|
-
const assigneeHere = present || agents.some((a) => a.name === task.assignee)
|
|
809
|
-
const next = agents.find((a) => a.name !== task.assignee)
|
|
810
|
-
if (!present && next && (!assigneeHere || stalled)) { patch.assignee = next.name; patch.forAi = false; patch.tool = '' }
|
|
811
|
-
const why = patch.assignee && assigneeHere ? `${task.assignee} made no progress on it for ${Math.round(idleMs / 60000)} minutes` : `the last assignee is no longer on \`${key}\``
|
|
812
|
-
// Moved to Done while it still clashes (by someone else, or for an older commit): open again.
|
|
813
|
-
if (task.column === 'done') patch.column = 'doing'
|
|
814
|
-
if (Object.keys(patch).length > 1) {
|
|
815
|
-
try { task = updateTask(room.doc, room.tasks, patch, ORIGIN) } catch {}
|
|
816
|
-
}
|
|
817
|
-
if (present) {
|
|
818
|
-
// Its member's own Quilt has it: nothing to add.
|
|
819
|
-
} else if (!rec.clash || rec.clash.task !== task.id) {
|
|
820
|
-
// A task a member's Quilt wrote for this commit, its folder now offline: the relay carries it on.
|
|
821
|
-
post(`${patch.assignee ? `Handed to ${patch.assignee}: no folder on \`${key}\` is online, so the relay carries this merge on in the session.\n\n` : ''}${brief()}`)
|
|
822
|
-
diffs()
|
|
823
|
-
} else if (fresh) {
|
|
824
|
-
post(`${upstream} moved on to ${head.slice(0, 7)}.\n\n${brief()}`)
|
|
825
|
-
diffs()
|
|
826
|
-
} else if (patch.assignee) {
|
|
827
|
-
post(`Handed to ${patch.assignee}: ${why}.\n\n${brief()}`)
|
|
828
|
-
} else if (patch.column) {
|
|
829
|
-
post(`Open again: ${head.slice(0, 7)} from ${upstream} still clashes with the session's work on \`${key}\`. Quilt closes this task by itself once the merge lands: no need to move it.`)
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
const keep = rec.clash && rec.clash.task === task.id
|
|
833
|
-
// Its assignee holds the clashing files while it merges (asked for in their queues when someone
|
|
834
|
-
// else holds one), so nobody else edits them meanwhile; let go of when the task is done (Room.dropClashClaims).
|
|
835
|
-
const holder = present ? null : agents.find((a) => a.name === task.assignee)
|
|
836
|
-
const had = keep && rec.clash.claims && rec.clash.claims.by === task.assignee ? rec.clash.claims : { by: task.assignee, paths: [] }
|
|
837
|
-
const toClaim = paths.filter((rel) => !had.paths.includes(rel))
|
|
838
|
-
if (holder && toClaim.length && room.claimClashFiles) {
|
|
839
|
-
const r = room.claimClashFiles(holder.who, task.id, toClaim, { upstream })
|
|
840
|
-
had.paths = [...had.paths, ...r.claimed, ...r.asked.map((x) => x.path)]
|
|
841
|
-
if (r.asked.length) post(`${r.asked.map((x) => `${x.holder} holds ${x.path}`).join('; ')}: asked for ${r.asked.length === 1 ? 'it' : 'them'} in the file queue for ${task.assignee}, who gets ${r.asked.length === 1 ? 'it' : 'them'} with their context when they hand ${r.asked.length === 1 ? 'it' : 'them'} over.`)
|
|
842
|
-
}
|
|
843
|
-
const moved = progress.key !== JSON.stringify([task.assignee, task.column, paths.map((rel) => fingerprint(disk(rel)))])
|
|
844
|
-
rec.clash = { task: task.id, sha: head, base, files, regions, claims: had, progress: moved ? { key: JSON.stringify([task.assignee, task.column, paths.map((rel) => fingerprint(disk(rel)))]), since: now } : progress, at: keep ? rec.clash.at : now, ...(keep && rec.clash.settled && rec.clash.sha === head ? { settled: rec.clash.settled } : {}) }
|
|
845
|
-
// The room's clash record, as a member's Quilt keeps it: a folder that comes back finds the same task.
|
|
846
|
-
try { room.doc.transact(() => room.doc.getMap('clashes').set(key, { task: task.id, sha: head, upstream, ts: now }), ORIGIN) } catch {}
|
|
847
|
-
return { task: task.id }
|
|
706
|
+
/** The member who last changed `rel` on a branch document (not the relay, not a pull): { name, ts }, or null. */
|
|
707
|
+
function lastEditor (e, rel) {
|
|
708
|
+
let best = null
|
|
709
|
+
for (const h of e.doc.getArray('history').toArray()) if (h && h.path === rel && !h.pulled && !h.seed && h.by && !isRelay(h.by) && (!best || (h.ts || 0) >= best.ts)) best = { name: h.by, ts: h.ts || 0 }
|
|
710
|
+
return best
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The merge=union matcher from the session's .gitattributes files. */
|
|
714
|
+
function unionOf (e) {
|
|
715
|
+
const texts = []
|
|
716
|
+
for (const [rel, t] of e.files) if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) texts.push([rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '', t.toString()])
|
|
717
|
+
return unionMatcher(texts)
|
|
848
718
|
}
|
|
849
719
|
|
|
850
720
|
/** What a hosted agent (or a member's AI) is told when the relay can't read a private repository. */
|
|
@@ -863,12 +733,12 @@ const ago = (ts, now = Date.now()) => {
|
|
|
863
733
|
export function describeRelayCheck (r, rec, key, room = null) {
|
|
864
734
|
const up = (rec && rec.name) || 'its upstream'
|
|
865
735
|
if (!r) return `The relay didn't look at ${up} for \`${key}\`.`
|
|
866
|
-
if (r.state === 'brought')
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
const t = room && r.task ? readTasks(room.tasks).find((x) => x.id === r.task) : null
|
|
870
|
-
return `Nothing brought in: ${up} (${r.head.slice(0, 7)}) clashes with the session's uncommitted work on \`${key}\` in:\n${(r.conflicts || []).map((c) => `- ${c.path}: ${c.why}`).join('\n')}\n${r.task ? `Task ${r.task} on the board${t && t.assignee ? ` (${t.assignee})` : ''}: write each merged file (quilt_write_file, or in your folder), then move the task to QA. The merge lands as soon as the relay sees it.` : (rec && rec.problem) || ''}`
|
|
736
|
+
if (r.state === 'brought') {
|
|
737
|
+
const lost = (r.merged || []).filter((m) => m.lost)
|
|
738
|
+
return `Brought ${plural(r.count, 'commit')} from ${up} into \`${key}\` (${plural(r.files, 'file')}), merged with the session's uncommitted work${lost.length ? `; where both changed the same lines of ${lost.map((m) => m.path).slice(0, 4).join(', ')}, the newer edit was kept (the other lines are in the file's history)` : ''}. \`${key}\` is up to date with ${up} (${r.head.slice(0, 7)}).`
|
|
871
739
|
}
|
|
740
|
+
if (r.state === 'up-to-date') return `\`${key}\`: up to date with ${up} (${r.head.slice(0, 7)}).`
|
|
741
|
+
if (r.state === 'too-large') return `Nothing brought in yet: ${up} (${r.head.slice(0, 7)}) changes ${(r.files || []).join(', ')}, too large for the relay to read; a member's folder on \`${key}\` brings ${(r.files || []).length === 1 ? 'it' : 'them'} in. Nothing to merge by hand.`
|
|
872
742
|
if (r.state === 'moved-on') return `A folder on \`${key}\` that brings commits in itself came online, so the relay left it to that folder.`
|
|
873
743
|
if (r.state === 'recovered') return `The commit the session's files on \`${key}\` were measured from wasn't on GitHub (a folder's local commit, since dropped); the relay follows ${up} at ${r.head.slice(0, 7)} now. Nothing to bring in from a commit it can't read; the session's files stay as they are.`
|
|
874
744
|
if (r.state === 'gone') return `Nothing brought in: ${up} isn't on GitHub any more (deleted after its merge?). \`${key}\` stays in the session as it is.`
|
package/src/releases.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// Release notes and the update check. RELEASES.md (shipped with the app) says what changed
|
|
2
|
-
// in each version;
|
|
2
|
+
// in each version; the latest release says whether a newer build exists. The releases are on a
|
|
3
|
+
// private repository, so the app asks the accounts API for them (src/api/releases.js) and
|
|
4
|
+
// downloads from heyquilt.com, which sends it on to the file.
|
|
3
5
|
import fs from 'node:fs'
|
|
4
6
|
import path from 'node:path'
|
|
5
7
|
import { fileURLToPath } from 'node:url'
|
|
6
8
|
import { getSettings, saveSettings } from './settings.js'
|
|
9
|
+
import { apiUrl } from './account.js'
|
|
7
10
|
|
|
8
11
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
12
|
export const REPO = 'DanielCarmichaelGit/heyquilt'
|
|
10
|
-
const
|
|
11
|
-
|
|
13
|
+
export const SITE = 'https://heyquilt.com'
|
|
14
|
+
/** Where every build is downloaded: heyquilt.com/download/<file> (the latest release's). */
|
|
15
|
+
export const DOWNLOADS = `${SITE}/download`
|
|
16
|
+
/** The one line that installs the command line on a Linux server or cloud machine. */
|
|
17
|
+
export const LINUX_INSTALL = `curl -fsSL ${SITE}/install.sh | sh`
|
|
18
|
+
const latestApi = () => `${apiUrl()}/v1/releases/latest`
|
|
12
19
|
const CACHE_MS = 10 * 60 * 1000
|
|
13
20
|
|
|
14
21
|
/** The version this app is, from package.json. */
|
|
@@ -63,7 +70,7 @@ export function downloadUrl (platform = process.platform, arch = process.arch) {
|
|
|
63
70
|
: platform === 'win32' ? 'quilt-windows-x64.exe'
|
|
64
71
|
: platform === 'linux' ? `quilt-linux-${arch === 'arm64' ? 'arm64' : 'x86_64'}.AppImage`
|
|
65
72
|
: null
|
|
66
|
-
return file ? `${
|
|
73
|
+
return file ? `${DOWNLOADS}/${file}` : SITE
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/** The notes inside a GitHub release body written by releaseNotesBody (or by hand in that shape). */
|
|
@@ -75,19 +82,19 @@ function parseGitHubBody (body) {
|
|
|
75
82
|
|
|
76
83
|
const cache = new Map() // url -> { at, release }
|
|
77
84
|
/**
|
|
78
|
-
* The newest release
|
|
85
|
+
* The newest release (asked of the accounts API): { version, url, date, summary, items }, or null when it can't be
|
|
79
86
|
* reached and nothing is cached. Asks at most every ten minutes; a failed refresh keeps the last answer.
|
|
80
87
|
*/
|
|
81
|
-
export async function latestRelease ({ url = process.env.QUILT_RELEASES_URL ||
|
|
88
|
+
export async function latestRelease ({ url = process.env.QUILT_RELEASES_URL || latestApi(), now = Date.now, timeoutMs = 6000 } = {}) {
|
|
82
89
|
const hit = cache.get(url)
|
|
83
90
|
if (hit && now() - hit.at < CACHE_MS) return hit.release
|
|
84
91
|
try {
|
|
85
92
|
const res = await fetch(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': `quilt/${currentVersion()}` }, signal: AbortSignal.timeout(timeoutMs) })
|
|
86
|
-
if (!res.ok) throw new Error(`
|
|
93
|
+
if (!res.ok) throw new Error(`the release check answered ${res.status}`)
|
|
87
94
|
const r = await res.json()
|
|
88
95
|
const version = String(r.tag_name || '').replace(/^v/, '')
|
|
89
96
|
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('no version in the release')
|
|
90
|
-
const release = { version, url: r.html_url ||
|
|
97
|
+
const release = { version, url: r.html_url || SITE, date: String(r.published_at || '').slice(0, 10), ...parseGitHubBody(r.body) }
|
|
91
98
|
cache.set(url, { at: now(), release })
|
|
92
99
|
return release
|
|
93
100
|
} catch {
|
|
@@ -120,7 +127,7 @@ export function releaseNotesBody (release) {
|
|
|
120
127
|
'- Mac (Intel): quilt-mac-x64.dmg',
|
|
121
128
|
'- Windows: quilt-windows-x64.exe',
|
|
122
129
|
'- Linux desktop: quilt-linux-x86_64.AppImage (Intel/AMD), quilt-linux-arm64.AppImage (ARM)',
|
|
123
|
-
`- Linux servers and cloud machines (no desktop):
|
|
130
|
+
`- Linux servers and cloud machines (no desktop): \`${LINUX_INSTALL}\``,
|
|
124
131
|
'',
|
|
125
132
|
'**Note on signing:** these builds are unsigned (no Apple Developer ID / notarization, no Windows code-signing certificate configured). Windows SmartScreen or macOS Gatekeeper may warn on first launch.',
|
|
126
133
|
'',
|
package/src/runner.js
CHANGED
|
@@ -101,7 +101,7 @@ export async function describeOtherSync (dir) {
|
|
|
101
101
|
* optionally overrides the relay address given out in invites (e.g. a public
|
|
102
102
|
* tunnel URL when the relay runs on this machine).
|
|
103
103
|
*/
|
|
104
|
-
export async function runSession ({ dir, conn, name, tool, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', workspace = '' }) {
|
|
104
|
+
export async function runSession ({ dir, conn, name, tool, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', workspace = '', apiAuth = null }) {
|
|
105
105
|
dir = path.resolve(dir)
|
|
106
106
|
if (!/^wss?:\/\//.test(conn.server)) throw new Error('The relay address must start with ws:// or wss://')
|
|
107
107
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
@@ -162,7 +162,8 @@ export async function runSession ({ dir, conn, name, tool, color = null, shareBy
|
|
|
162
162
|
// The pass may have named us differently from the name we started with.
|
|
163
163
|
if (session.name !== name) saveName(session.name)
|
|
164
164
|
session.on('identity', ({ name }) => saveName(name))
|
|
165
|
-
|
|
165
|
+
// apiAuth: { api, token } for the accounts API as this session's person or agent (secrets, `quilt run`).
|
|
166
|
+
const control = await startControl(session, { invite, viewInvite, joined, apiAuth })
|
|
166
167
|
// Work for this person's AI can run on its own here, when they turned that on (autowork.js).
|
|
167
168
|
autowork().attach(session)
|
|
168
169
|
remember({ dir, room: conn.room, server: conn.server, name: session.name, tool, kind, workspace: savedWorkspace })
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Import .env (the app's Secrets card): finds a session folder's .env files and says, by name
|
|
2
|
+
// only, what each holds and where it would go. The values are read again only to send them
|
|
3
|
+
// straight to the vault (ui-server.js), and the file can be deleted after.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { parseDotenv, guessUpstream, cleanSecretName } from './ui/secret-rules.js'
|
|
7
|
+
|
|
8
|
+
const ENV_FILE = /^\.env(\.[A-Za-z0-9_.-]+)?$/
|
|
9
|
+
// Templates people commit on purpose: never secrets.
|
|
10
|
+
const TEMPLATE = /\.(example|sample|template|dist|defaults)$/i
|
|
11
|
+
const MAX_FILE = 256 * 1024
|
|
12
|
+
|
|
13
|
+
/** The .env files at the top of each folder: [{ path, rel, entries: [{ name, upstream?, format?, skip? }] }]. Never a value. */
|
|
14
|
+
export function envFilesIn (dirs) {
|
|
15
|
+
const out = []
|
|
16
|
+
const many = new Set(dirs).size > 1
|
|
17
|
+
for (const dir of [...new Set(dirs.filter(Boolean))]) {
|
|
18
|
+
let names = []
|
|
19
|
+
try { names = fs.readdirSync(dir) } catch { continue }
|
|
20
|
+
for (const n of names.filter((x) => ENV_FILE.test(x) && !TEMPLATE.test(x)).sort()) {
|
|
21
|
+
const file = path.join(dir, n)
|
|
22
|
+
let text
|
|
23
|
+
try {
|
|
24
|
+
const st = fs.statSync(file)
|
|
25
|
+
if (!st.isFile() || st.size > MAX_FILE) continue
|
|
26
|
+
text = fs.readFileSync(file, 'utf8')
|
|
27
|
+
} catch { continue }
|
|
28
|
+
const entries = parseDotenv(text).map(({ name, value }) => {
|
|
29
|
+
try { cleanSecretName(name) } catch { return { name, skip: 'not a secret name (capitals, digits and underscores)' } }
|
|
30
|
+
if (!value) return { name, skip: 'empty' }
|
|
31
|
+
const g = guessUpstream(name, value)
|
|
32
|
+
return g.skip ? { name, skip: g.skip } : { name, ...(g.upstream ? { upstream: g.upstream, format: g.format } : {}) }
|
|
33
|
+
})
|
|
34
|
+
out.push({ path: file, rel: many ? `${path.basename(dir)}/${n}` : n, entries })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One of `files` (from envFilesIn) by its path, or an error: nothing else is ever read or deleted. */
|
|
41
|
+
export function knownEnvFile (files, file) {
|
|
42
|
+
const f = files.find((x) => x.path === String(file || ''))
|
|
43
|
+
if (!f) throw Object.assign(new Error('That .env file is not in this session\'s folder any more.'), { status: 400 })
|
|
44
|
+
return f
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The values of `names` in an .env file, for the vault alone. */
|
|
48
|
+
export function envValues (file, names) {
|
|
49
|
+
const want = new Set(names)
|
|
50
|
+
const values = {}
|
|
51
|
+
for (const { name, value } of parseDotenv(fs.readFileSync(file, 'utf8'))) if (want.has(name)) values[name] = value
|
|
52
|
+
const missing = names.filter((n) => !(n in values) || !values[n])
|
|
53
|
+
if (missing.length) throw Object.assign(new Error(`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not in the file any more.`), { status: 400 })
|
|
54
|
+
return values
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// quilt_secret_names (src/mcp.js, src/relay-mcp.js): a session's secrets as an agent reads them.
|
|
2
|
+
// Names, where each goes, who can use it, and the variables `quilt run` sets: never a value.
|
|
3
|
+
import { LEVEL_WORDS } from './api/secrets-access.js'
|
|
4
|
+
|
|
5
|
+
export const SECRET_NAMES_DESCRIPTION = "The session's secrets (API keys and the like), by name: each one's level (session, workspace or team), the one host it is sent to, who can use it, whether you can, and the environment variables `quilt run` sets for it. Never a value: nobody can read one back, you included. To use them, run the command that needs them through `quilt run -- <command>` (for example `quilt run -- npm run dev`): the command gets NAME=quilt and NAME's base URL, and its requests go through Quilt's vault, which adds the real key."
|
|
6
|
+
|
|
7
|
+
export const RUN_GUIDE = 'Run commands that need the session\'s secrets through `quilt run -- <command>`, never with keys pasted into files, chat or .env: the command gets `NAME=quilt` and `<NAME>_BASE_URL` (STRIPE_KEY gives STRIPE_BASE_URL; known SDK variables like OPENAI_BASE_URL and ANTHROPIC_BASE_URL are set too), so point the code\'s base URL at that variable; Quilt\'s vault adds the real key on the way to its one service. `quilt_secret_names` lists the names. Nobody can read a value back, and you never need one.'
|
|
8
|
+
|
|
9
|
+
const WHO = { agents: 'agents and people', people: 'people only' }
|
|
10
|
+
|
|
11
|
+
/** The tool's answer for `names` (GET /v1/sessions/:room/secret-names). */
|
|
12
|
+
export function describeSecretNames ({ names = [], kind = 'agent', hosted = false } = {}) {
|
|
13
|
+
if (hosted) {
|
|
14
|
+
const head = 'You are a hosted agent: you run no commands, so you can\'t use secrets. A person, or an agent with a computer or a sandbox, runs commands with them through `quilt run`.'
|
|
15
|
+
if (!names.length) return `${head} This session has no secrets.`
|
|
16
|
+
return `${head}\n\nThe session's secrets (names only):\n${names.map((n) => `- ${n.name} (${LEVEL_WORDS[n.level] || n.level}) → ${n.upstream}, ${WHO[n.who] || n.who}`).join('\n')}`
|
|
17
|
+
}
|
|
18
|
+
if (!names.length) return 'This session has no secrets. People add them in the Quilt app (Session settings → Secrets) or on heyquilt.com for a team; nobody pastes keys into files or chat.'
|
|
19
|
+
const line = (n) => {
|
|
20
|
+
const can = n.state === 'ready' ? 'You can use it.'
|
|
21
|
+
: n.state === 'ask' ? 'Each run asks your person to allow it in the Quilt app (quilt run waits for them).'
|
|
22
|
+
: `You can't: ${n.why || 'not for you'}.`
|
|
23
|
+
return `- ${n.name} (${LEVEL_WORDS[n.level] || n.level}) → ${n.upstream}, ${WHO[n.who] || n.who}. ${can} quilt run sets ${n.env.map((v, i) => (i === 0 ? `${v}=quilt` : v)).join(', ')}.`
|
|
24
|
+
}
|
|
25
|
+
return `The session's secrets (names only: nobody can read a value back, you included):\n${names.map(line).join('\n')}\n\n${RUN_GUIDE}`
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `quilt run -- <command>` (docs/superpowers/specs/2026-10-18-secrets.md): starts a command with
|
|
2
|
+
// a stand-in and a gateway address for each secret it may use, never a value. Where it asks:
|
|
3
|
+
// a sandbox's QUILT_AGENT_KEY (the agent), else the session running in this folder (its person,
|
|
4
|
+
// or the agent it runs as), through its control API.
|
|
5
|
+
import { spawn as spawnChild } from 'node:child_process'
|
|
6
|
+
import { runEnv } from './ui/secret-rules.js'
|
|
7
|
+
import { ENV_KEY_NAME } from './ui/agent-env-key.js'
|
|
8
|
+
import { createRun, waitRun, endRun } from './secrets-client.js'
|
|
9
|
+
|
|
10
|
+
// `quilt git-token` printed a GitHub token, which put it in the terminal and the AI's context.
|
|
11
|
+
export const GIT_TOKEN_GONE = 'quilt git-token no longer prints a token: Quilt never hands a value to a terminal or an AI. git signs in to GitHub through Quilt by itself (its credential helper), so fetch, commit and push as usual; Quilt opens the pull request for your task\'s branch. Keys your project needs go in the session\'s secrets and run through `quilt run -- <command>`.'
|
|
12
|
+
|
|
13
|
+
export const RUN_USAGE = `usage: quilt run -- <command> [args...]
|
|
14
|
+
|
|
15
|
+
Runs a command with the session's secrets, without ever giving it a value. For each secret it
|
|
16
|
+
may use, the command gets NAME=quilt and NAME's base URL (STRIPE_KEY gives STRIPE_BASE_URL, and
|
|
17
|
+
known SDK variables like OPENAI_BASE_URL): its requests go to Quilt's vault, which adds the real
|
|
18
|
+
credential and sends them on to that secret's one service. People-only secrets wait for the
|
|
19
|
+
person to allow them in the Quilt app, each run; agents never get them.`
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where run tokens come from: { start(label), wait(id), end(id) } for the session in `dir`
|
|
23
|
+
* (control API) or the sandbox's key. null when there is neither.
|
|
24
|
+
*/
|
|
25
|
+
export function runSource ({ key, daemon, call }) {
|
|
26
|
+
if (key) {
|
|
27
|
+
const c = { api: key.api, token: key.key }
|
|
28
|
+
return { who: key.name || 'this agent', start: (label) => createRun(c, { room: key.room, label }), wait: (id) => waitRun(c, id), end: (id) => endRun(c, id) }
|
|
29
|
+
}
|
|
30
|
+
if (daemon) {
|
|
31
|
+
return { who: 'you', start: (label) => call(daemon, 'POST', '/secrets/run', { label }), wait: (id) => call(daemon, 'POST', '/secrets/run/wait', { id }), end: (id) => call(daemon, 'POST', '/secrets/run/end', { id }) }
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs `command` (an argv array) with its secrets' stand-ins: resolves to its exit code.
|
|
38
|
+
* Says on stderr which names it got and why any were left out; never a value.
|
|
39
|
+
*/
|
|
40
|
+
export async function runWithSecrets ({ command, source, env = process.env, say = (m) => process.stderr.write(`${m}\n`), spawn = spawnChild, waitMs = Number(env.QUILT_RUN_WAIT_MS) || 2 * 60 * 1000, now = Date.now }) {
|
|
41
|
+
if (!command.length) throw new Error(RUN_USAGE)
|
|
42
|
+
const label = command.join(' ').slice(0, 200)
|
|
43
|
+
let started = await source.start(label)
|
|
44
|
+
let secrets = started.secrets || []
|
|
45
|
+
const asked = secrets.filter((s) => s.state === 'asked')
|
|
46
|
+
if (asked.length) {
|
|
47
|
+
say(`quilt run: waiting for ${source.who === 'you' ? 'you' : 'its person'} to allow ${asked.map((s) => s.name).join(', ')} (people only) in the Quilt app…`)
|
|
48
|
+
const end = now() + waitMs
|
|
49
|
+
while (secrets.some((s) => s.state === 'asked') && now() < end) {
|
|
50
|
+
try { secrets = (await source.wait(started.run.id)).secrets || secrets } catch (err) { say(`quilt run: ${err.message}`); break }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ready = secrets.filter((s) => s.state === 'ready')
|
|
54
|
+
for (const s of secrets) {
|
|
55
|
+
if (s.state === 'ready') continue
|
|
56
|
+
say(`quilt run: ${s.name} left out: ${s.state === 'asked' ? 'nobody allowed it in time' : s.why || 'not for this run'}`)
|
|
57
|
+
}
|
|
58
|
+
const childEnv = { ...env }
|
|
59
|
+
// The command is the project's own code: it gets no Quilt key of its own.
|
|
60
|
+
delete childEnv[ENV_KEY_NAME]
|
|
61
|
+
if (ready.length) {
|
|
62
|
+
Object.assign(childEnv, runEnv(ready, { vault: started.vault, token: started.token }))
|
|
63
|
+
say(`quilt run: ${ready.map((s) => `${s.name} → ${s.upstream}`).join(', ')} (through Quilt's vault; the command never sees a value)`)
|
|
64
|
+
} else if (!secrets.length) say('quilt run: this session has no secrets yet; running the command as it is')
|
|
65
|
+
const runId = started.run && started.run.id
|
|
66
|
+
const code = await new Promise((resolve) => {
|
|
67
|
+
let child
|
|
68
|
+
try {
|
|
69
|
+
child = spawn(command[0], command.slice(1), { stdio: 'inherit', env: childEnv, shell: process.platform === 'win32' })
|
|
70
|
+
} catch (err) { say(`quilt run: could not start ${command[0]}: ${err.message}`); return resolve(127) }
|
|
71
|
+
const pass = (sig) => () => { try { child.kill(sig) } catch {} }
|
|
72
|
+
const onInt = pass('SIGINT'); const onTerm = pass('SIGTERM')
|
|
73
|
+
process.on('SIGINT', onInt); process.on('SIGTERM', onTerm)
|
|
74
|
+
child.on('error', (err) => { say(`quilt run: could not start ${command[0]}: ${err.code === 'ENOENT' ? 'no such command' : err.message}`); resolve(127) })
|
|
75
|
+
child.on('exit', (c, sig) => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); resolve(c ?? (sig ? 128 + (sig === 'SIGINT' ? 2 : 15) : 1)) })
|
|
76
|
+
})
|
|
77
|
+
// The run token stops working now, not in an hour.
|
|
78
|
+
if (runId) await source.end(runId).catch(() => {})
|
|
79
|
+
return code
|
|
80
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Secrets on the accounts API (src/api/routes/secrets.js), for the app, the session's control
|
|
2
|
+
// API and `quilt run`. Names, settings and run tokens only: no call here sends or gets a value,
|
|
3
|
+
// except saveSecrets, which sends values to the vault (never to the API) with a write ticket.
|
|
4
|
+
|
|
5
|
+
async function call ({ api, token, fetch: fetchImpl = globalThis.fetch }, method, route, body, timeoutMs = 30_000) {
|
|
6
|
+
let res
|
|
7
|
+
try {
|
|
8
|
+
res = await fetchImpl(String(api).replace(/\/+$/, '') + route, {
|
|
9
|
+
method,
|
|
10
|
+
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
11
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
12
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
13
|
+
})
|
|
14
|
+
} catch (err) {
|
|
15
|
+
throw new Error(`Couldn't reach Quilt (${err.name === 'TimeoutError' ? 'ETIMEDOUT' : err.cause?.code || err.message}).`)
|
|
16
|
+
}
|
|
17
|
+
const data = await res.json().catch(() => null)
|
|
18
|
+
if (!res.ok) throw Object.assign(new Error(data?.error || `Quilt answered ${res.status}.`), { status: res.status })
|
|
19
|
+
return data || {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scopePath = (scope, id) => `/v1/secrets/${encodeURIComponent(scope)}/${encodeURIComponent(id)}`
|
|
23
|
+
|
|
24
|
+
/** A run token for `quilt run`: { run, token, vault, secrets }. */
|
|
25
|
+
export const createRun = (c, { room, label = '' }) => call(c, 'POST', '/v1/secret-runs', { room, label })
|
|
26
|
+
/** Holds until no secret of the run waits for its person (about 25 s): { run, secrets }. */
|
|
27
|
+
export const waitRun = (c, id) => call(c, 'POST', `/v1/secret-runs/${encodeURIComponent(id)}/wait`, {}, 40_000)
|
|
28
|
+
export const endRun = (c, id) => call(c, 'DELETE', `/v1/secret-runs/${encodeURIComponent(id)}`, null, 5_000)
|
|
29
|
+
/** What the session's secrets come to for this caller: { names: [{ name, level, upstream, who, state, why?, env }] }. */
|
|
30
|
+
export const secretNames = (c, room) => call(c, 'GET', `/v1/sessions/${encodeURIComponent(room)}/secret-names`)
|
|
31
|
+
|
|
32
|
+
// The app and the website's card.
|
|
33
|
+
export const getSecrets = (c, scope, id) => call(c, 'GET', scopePath(scope, id))
|
|
34
|
+
export const putSecretSettings = (c, scope, id, patch) => call(c, 'PUT', `${scopePath(scope, id)}/settings`, patch)
|
|
35
|
+
export const setSecretWho = (c, scope, id, name, who) => call(c, 'PATCH', `${scopePath(scope, id)}/${encodeURIComponent(name)}`, { who })
|
|
36
|
+
export const deleteSecret = (c, scope, id, name) => call(c, 'DELETE', `${scopePath(scope, id)}/${encodeURIComponent(name)}`)
|
|
37
|
+
export const secretUses = (c, scope, id) => call(c, 'GET', `${scopePath(scope, id)}/uses`)
|
|
38
|
+
export const secretAsks = (c, { wait = false } = {}) => call(c, 'GET', `/v1/me/secret-asks${wait ? '?wait=1' : ''}`, null, 40_000)
|
|
39
|
+
export const answerAsk = (c, runId, allow) => call(c, 'POST', `/v1/me/secret-asks/${encodeURIComponent(runId)}`, { allow: allow === true })
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Saves secrets: a write ticket from the API for `entries` ({ name, upstream, format, who }),
|
|
43
|
+
* then the values straight to the vault with it. Answers { saved: [{ name, digest }] }.
|
|
44
|
+
*/
|
|
45
|
+
export async function saveSecrets (c, scope, id, entries, values) {
|
|
46
|
+
const t = await call(c, 'POST', `${scopePath(scope, id)}/tickets`, { entries })
|
|
47
|
+
let res
|
|
48
|
+
try {
|
|
49
|
+
res = await (c.fetch || globalThis.fetch)(`${t.vault}/v1/write`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ticket: t.ticket, values }), signal: AbortSignal.timeout(30_000) })
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`Couldn't reach Quilt's vault (${err.cause?.code || err.message}).`)
|
|
52
|
+
}
|
|
53
|
+
const data = await res.json().catch(() => null)
|
|
54
|
+
if (!res.ok) throw Object.assign(new Error(data?.error || `The vault answered ${res.status}.`), { status: res.status })
|
|
55
|
+
return data
|
|
56
|
+
}
|