heyquilt 0.3.90 → 0.3.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/RELEASES.md +135 -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 +142 -6
- 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 +146 -19
- 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 +121 -11
- 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 +140 -28
- 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,87 @@
|
|
|
1
|
+
// Quilt's own downloads and update check, now that the repository with the releases is private.
|
|
2
|
+
// The API reads the latest GitHub release with Quilt's GitHub App (read-only, that repository
|
|
3
|
+
// only) and answers on its own address, so nobody's download depends on the repository being
|
|
4
|
+
// public:
|
|
5
|
+
//
|
|
6
|
+
// GET /v1/releases/latest the release, shaped like GitHub's (tag_name, published_at, body…),
|
|
7
|
+
// with each file's link on this API; the app's update check reads it
|
|
8
|
+
// GET /v1/download/<file> a redirect to GitHub's own short-lived link for that file of the
|
|
9
|
+
// latest release: the bytes never pass through here
|
|
10
|
+
// GET /v1/download/v1.2.3/<file> the same, from that version's release
|
|
11
|
+
import { githubApp } from './github-app.js'
|
|
12
|
+
|
|
13
|
+
export const RELEASES_REPO = { owner: 'DanielCarmichaelGit', name: 'heyquilt' }
|
|
14
|
+
const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
15
|
+
const TAG = /^v\d+\.\d+\.\d+$/
|
|
16
|
+
const RELEASE_CACHE_MS = 60 * 1000
|
|
17
|
+
const API = 'https://api.github.com'
|
|
18
|
+
|
|
19
|
+
export class ReleaseError extends Error {
|
|
20
|
+
constructor (status, message) { super(message); this.status = status }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reads the latest release of `repo` as the GitHub App. `fetch` and `now` are for tests. */
|
|
24
|
+
export function releaseSource ({ github, fetch = globalThis.fetch, now = Date.now, repo = RELEASES_REPO, apiUrl = 'https://api.heyquilt.com' }) {
|
|
25
|
+
if (!github) return null
|
|
26
|
+
const app = githubApp(github, { fetch, now })
|
|
27
|
+
let token = null // { token, expiresAt }
|
|
28
|
+
let release = null // { at, value }
|
|
29
|
+
const tagged = new Map() // tag -> release (a published release's files don't change)
|
|
30
|
+
const auth = async () => {
|
|
31
|
+
if (token && token.expiresAt - now() > 5 * 60 * 1000) return token.token
|
|
32
|
+
const installation = await app.installation(repo.owner, repo.name)
|
|
33
|
+
if (!installation) throw new ReleaseError(503, `Quilt's GitHub App is not installed on ${repo.owner}/${repo.name}`)
|
|
34
|
+
token = await app.repoToken(installation, repo.name, { access: 'read' })
|
|
35
|
+
return token.token
|
|
36
|
+
}
|
|
37
|
+
const get = async (path, { accept = 'application/vnd.github+json', redirect = 'follow' } = {}) => fetch(`${API}${path}`, {
|
|
38
|
+
redirect,
|
|
39
|
+
headers: { 'user-agent': 'quilt-api', accept, 'x-github-api-version': '2022-11-28', authorization: `Bearer ${await auth()}` }
|
|
40
|
+
})
|
|
41
|
+
return {
|
|
42
|
+
/** The latest release, as GitHub has it ({ tag_name, assets, … }); asked at most once a minute. */
|
|
43
|
+
async latest () {
|
|
44
|
+
if (release && now() - release.at < RELEASE_CACHE_MS) return release.value
|
|
45
|
+
const res = await get(`/repos/${repo.owner}/${repo.name}/releases/latest`)
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
if (release) return release.value // GitHub is having a moment: the last answer stands
|
|
48
|
+
throw new ReleaseError(res.status === 404 ? 404 : 502, res.status === 404 ? 'there is no release yet' : `GitHub answered ${res.status}`)
|
|
49
|
+
}
|
|
50
|
+
release = { at: now(), value: await res.json() }
|
|
51
|
+
return release.value
|
|
52
|
+
},
|
|
53
|
+
/** The latest release as the app's update check reads it, every file linked on this API. */
|
|
54
|
+
async latestPublic () {
|
|
55
|
+
const r = await this.latest()
|
|
56
|
+
return {
|
|
57
|
+
tag_name: r.tag_name,
|
|
58
|
+
name: r.name,
|
|
59
|
+
html_url: 'https://heyquilt.com/download',
|
|
60
|
+
published_at: r.published_at,
|
|
61
|
+
body: r.body || '',
|
|
62
|
+
assets: (r.assets || []).map((a) => ({ name: a.name, size: a.size, browser_download_url: `${apiUrl}/v1/download/${encodeURIComponent(a.name)}` }))
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
/** The release tagged `tag` (v1.2.3). */
|
|
66
|
+
async byTag (tag) {
|
|
67
|
+
if (!TAG.test(String(tag))) throw new ReleaseError(404, 'no such version')
|
|
68
|
+
if (tagged.has(tag)) return tagged.get(tag)
|
|
69
|
+
const res = await get(`/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`)
|
|
70
|
+
if (!res.ok) throw new ReleaseError(res.status === 404 ? 404 : 502, res.status === 404 ? `there is no release ${tag}` : `GitHub answered ${res.status}`)
|
|
71
|
+
const value = await res.json()
|
|
72
|
+
if (tagged.size > 200) tagged.clear()
|
|
73
|
+
tagged.set(tag, value)
|
|
74
|
+
return value
|
|
75
|
+
},
|
|
76
|
+
/** GitHub's short-lived link for `file` in the latest release, or in release `tag`. */
|
|
77
|
+
async downloadUrl (file, tag = '') {
|
|
78
|
+
if (!FILE.test(String(file))) throw new ReleaseError(404, 'no such file')
|
|
79
|
+
const asset = ((tag ? await this.byTag(tag) : await this.latest()).assets || []).find((a) => a.name === file)
|
|
80
|
+
if (!asset) throw new ReleaseError(404, `${tag ? `release ${tag}` : 'the latest release'} has no ${file}`)
|
|
81
|
+
const res = await get(`/repos/${repo.owner}/${repo.name}/releases/assets/${asset.id}`, { accept: 'application/octet-stream', redirect: 'manual' })
|
|
82
|
+
const to = res.headers.get('location')
|
|
83
|
+
if (res.status >= 300 && res.status < 400 && to) return to
|
|
84
|
+
throw new ReleaseError(502, `GitHub answered ${res.status} for ${file}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/api/routes/agents.js
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
import { HttpError, needId, cleanName } from '../http.js'
|
|
4
4
|
import { parsePublicKey, verifyAgentResume } from '../../identity.js'
|
|
5
5
|
import { keyStatus } from '../agent-auth.js'
|
|
6
|
-
import { encodeAgentEnvKey } from '../../ui/agent-env-key.js'
|
|
6
|
+
import { encodeAgentEnvKey, decodeAgentEnvKey } from '../../ui/agent-env-key.js'
|
|
7
|
+
import { usesByDay, lastDays, keyState, linkSealer } from '../sandbox-keys.js'
|
|
7
8
|
|
|
8
9
|
// Live app keys an agent may hold at once.
|
|
9
10
|
export const MAX_APP_KEYS = 10
|
|
10
11
|
const keyName = (v) => cleanName(typeof v === 'string' ? v : '', 40, 'name the key (up to 40 characters), e.g. Pipedream')
|
|
11
|
-
const keyView = (k) => ({ id: k.id, name: k.name, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt })
|
|
12
|
+
const keyView = (k) => ({ id: k.id, name: k.name, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt, ...(k.kind === 'sandbox' ? { sandbox: true, hint: k.hint } : {}) })
|
|
13
|
+
const sessionName = (v) => (typeof v === 'string' ? v.trim().slice(0, 80) : '')
|
|
12
14
|
|
|
13
15
|
// Never the key itself, just whether it has one. With a key it joins sessions from a computer
|
|
14
16
|
// running Quilt; without one it is hosted: it joins through the API's /mcp. Either way it can join.
|
|
15
17
|
const profileOf = (a) => ({ id: a.id, name: a.name, provider: a.provider, type: a.type, description: a.description, canJoinSessions: true, hosted: !a.publicKey })
|
|
16
18
|
|
|
17
|
-
export function agentRoutes ({ store, user, person, now, limit, limitTokens, limitStarts, spendResume, resumeWindowMs, agentAuth, apiUrl }) {
|
|
19
|
+
export function agentRoutes ({ store, user, person, now, limit, limitTokens, limitStarts, spendResume, resumeWindowMs, agentAuth, apiUrl, passKey = '' }) {
|
|
20
|
+
// Sandbox keys keep their session's invite link sealed (sandbox-keys.js), for Rotate.
|
|
21
|
+
const links = linkSealer(passKey)
|
|
18
22
|
// A personal agent of this person's that isn't revoked; anyone else's gets the same 404 as a missing one.
|
|
19
23
|
async function mine (req, id, who = user) {
|
|
20
24
|
const u = await who(req)
|
|
@@ -22,19 +26,56 @@ export function agentRoutes ({ store, user, person, now, limit, limitTokens, lim
|
|
|
22
26
|
if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
|
|
23
27
|
return agent
|
|
24
28
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
// `rotating`: a key that replaces one, so the agent holds no more keys than before.
|
|
30
|
+
async function newAppKey (agent, name, sandbox = null, rotating = false) {
|
|
31
|
+
if (!rotating && (await store.listAgentAppKeys(agent.id)).length >= MAX_APP_KEYS) throw new HttpError(409, `an agent can have ${MAX_APP_KEYS} app keys; revoke one first`)
|
|
32
|
+
return agentAuth.mintAppKey(agent.id, name, sandbox)
|
|
28
33
|
}
|
|
29
34
|
const connect = (agent, key) => ({ agent: profileOf(agent), key, mcp: `${apiUrl}/mcp` })
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A sandbox's QUILT_AGENT_KEY (ui/agent-env-key.js): a new app key with the API and the session it
|
|
37
|
+
* works in, kept as a sandbox key (its session, sync slot and hint). `slot` carries a rotated key's on.
|
|
38
|
+
*/
|
|
39
|
+
async function envKeyFor (agent, { session, name, label = '', slot, rotating = false }) {
|
|
40
|
+
let made
|
|
41
|
+
const check = { api: apiUrl, key: 'qk_check', session, slot, name: agent.name }
|
|
42
|
+
try { made = decodeAgentEnvKey(encodeAgentEnvKey(check)) } catch (err) { throw new HttpError(400, err.message) }
|
|
43
|
+
const room = made.room
|
|
44
|
+
const known = await store.sessionByRoom(room).catch(() => null)
|
|
45
|
+
// A session keeps the name its other keys show (the app's label is only its folder's name).
|
|
46
|
+
const prior = agent.ownerUserId ? (await personSandboxKeys(agent.ownerUserId)).find((k) => k.room === room && k.sessionName)?.sessionName : null
|
|
47
|
+
const key = await newAppKey(agent, keyName(name || 'Sandbox'), { room, sessionLink: links.seal(made.session), sessionName: known?.name || prior || sessionName(label) || null, slot: made.slot }, rotating)
|
|
48
|
+
const envKey = encodeAgentEnvKey({ ...check, session: made.session, slot: made.slot, key: key.key })
|
|
49
|
+
return { envKey, key: (await sandboxViews([await store.agentAppKeyById(key.id)]))[0] }
|
|
50
|
+
}
|
|
51
|
+
/** Sandbox keys as the Keys table shows them: never the key, the link or the slot. */
|
|
52
|
+
async function sandboxViews (rows) {
|
|
53
|
+
const at = now()
|
|
54
|
+
const uses = await store.appKeyUses(rows.map((k) => k.id), lastDays(at)[0])
|
|
55
|
+
const names = new Map()
|
|
56
|
+
for (const room of new Set(rows.map((k) => k.room))) names.set(room, (await store.sessionByRoom(room).catch(() => null))?.name || '')
|
|
57
|
+
return rows.map((k) => ({
|
|
58
|
+
id: k.id,
|
|
59
|
+
name: k.name,
|
|
60
|
+
hint: k.hint,
|
|
61
|
+
room: k.room,
|
|
62
|
+
session: names.get(k.room) || k.sessionName || k.room,
|
|
63
|
+
createdAt: k.createdAt,
|
|
64
|
+
lastUsedAt: k.lastUsedAt,
|
|
65
|
+
revokedAt: k.revokedAt,
|
|
66
|
+
uses: usesByDay(uses.filter((u) => u.keyId === k.id), at),
|
|
67
|
+
state: keyState(k, at)
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
/** The person's sandbox keys across their agents: the sessions they can make one for by room. */
|
|
71
|
+
async function personSandboxKeys (userId) {
|
|
72
|
+
return store.listSandboxKeys((await store.listPersonalAgents(userId)).map((a) => a.id))
|
|
73
|
+
}
|
|
74
|
+
/** A sandbox key of this agent's, or the same 404 as a missing one. */
|
|
75
|
+
async function sandboxKey (agent, keyId) {
|
|
76
|
+
const k = await store.agentAppKeyById(needId(keyId, 'key'))
|
|
77
|
+
if (!k || k.agentId !== agent.id || k.kind !== 'sandbox') throw new HttpError(404, 'no such key')
|
|
78
|
+
return k
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
return [
|
|
@@ -116,16 +157,70 @@ export function agentRoutes ({ store, user, person, now, limit, limitTokens, lim
|
|
|
116
157
|
if (!keyRow) throw new HttpError(403, 'sign in with the agent\'s access key (an app key can\'t make keys)')
|
|
117
158
|
if (!agent.ownerUserId) throw new HttpError(403, 'only a personal agent makes its own keys; an org agent\'s are made by its org')
|
|
118
159
|
limit(req)
|
|
119
|
-
if (body.session != null && body.session !== '') return envKeyFor(agent, String(body.session), body.name)
|
|
160
|
+
if (body.session != null && body.session !== '') return envKeyFor(agent, { session: String(body.session), name: body.name })
|
|
120
161
|
return newAppKey(agent, keyName(body.name || 'Sandbox'))
|
|
121
162
|
}],
|
|
122
163
|
|
|
123
164
|
// A person making a sandbox's QUILT_AGENT_KEY for their agent (the website, the app): a new app key
|
|
124
165
|
// with the session it works in. Shown once; revoked like any of the agent's app keys.
|
|
166
|
+
// `room` instead of `session`: a session one of this person's sandbox keys already works in
|
|
167
|
+
// (the website's session picker, which never has the link). `sessionName` names it in the table.
|
|
125
168
|
['POST', /^\/v1\/agents\/([^/]+)\/env-key$/, async (req, body, [id]) => {
|
|
126
169
|
const agent = await mine(req, id, person)
|
|
127
170
|
limit(req)
|
|
128
|
-
|
|
171
|
+
let session = String(body.session || '')
|
|
172
|
+
if (!session && body.room) {
|
|
173
|
+
const u = await person(req)
|
|
174
|
+
const known = (await personSandboxKeys(u.userId)).filter((k) => k.room === String(body.room))
|
|
175
|
+
session = known.map((k) => links.open(k.sessionLink)).find(Boolean) || ''
|
|
176
|
+
if (!session) throw new HttpError(400, 'paste the session\'s invite link')
|
|
177
|
+
}
|
|
178
|
+
return envKeyFor(agent, { session, name: body.name, label: body.sessionName })
|
|
179
|
+
}],
|
|
180
|
+
|
|
181
|
+
// The Keys table on an agent: its sandbox keys (revoked ones until removed), each with its
|
|
182
|
+
// session, last use, requests per day over the last seven days and state; and the sessions
|
|
183
|
+
// a new key can be made for without pasting a link.
|
|
184
|
+
['GET', /^\/v1\/agents\/([^/]+)\/sandbox-keys$/, async (req, body, [id]) => {
|
|
185
|
+
const agent = await mine(req, id, person)
|
|
186
|
+
const u = await person(req)
|
|
187
|
+
const all = await personSandboxKeys(u.userId)
|
|
188
|
+
const sessions = new Map()
|
|
189
|
+
for (const k of all) if (!sessions.has(k.room) && links.open(k.sessionLink)) sessions.set(k.room, k)
|
|
190
|
+
const named = await sandboxViews([...sessions.values()])
|
|
191
|
+
return {
|
|
192
|
+
keys: await sandboxViews(all.filter((k) => k.agentId === agent.id)),
|
|
193
|
+
sessions: named.map((k) => ({ room: k.room, name: k.session }))
|
|
194
|
+
}
|
|
195
|
+
}],
|
|
196
|
+
|
|
197
|
+
// Rotate: a new key for the same agent, session, name and sync slot (shown once), and the old one revoked.
|
|
198
|
+
['POST', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)\/rotate$/, async (req, body, [id, keyId]) => {
|
|
199
|
+
const agent = await mine(req, id, person)
|
|
200
|
+
const old = await sandboxKey(agent, keyId)
|
|
201
|
+
if (old.revokedAt) throw new HttpError(404, 'no such key')
|
|
202
|
+
limit(req)
|
|
203
|
+
const session = links.open(old.sessionLink)
|
|
204
|
+
if (!session) throw new HttpError(409, 'make a new key for this session instead (paste its invite link)')
|
|
205
|
+
const made = await envKeyFor(agent, { session, name: old.name, label: old.sessionName || '', slot: old.slot, rotating: true })
|
|
206
|
+
await store.revokeAgentAppKey(agent.id, old.id)
|
|
207
|
+
return { ...made, replaced: old.id }
|
|
208
|
+
}],
|
|
209
|
+
|
|
210
|
+
['POST', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)\/revoke$/, async (req, body, [id, keyId]) => {
|
|
211
|
+
const agent = await mine(req, id, person)
|
|
212
|
+
const k = await sandboxKey(agent, keyId)
|
|
213
|
+
if (!await store.revokeAgentAppKey(agent.id, k.id)) throw new HttpError(404, 'no such key')
|
|
214
|
+
return { ok: true }
|
|
215
|
+
}],
|
|
216
|
+
|
|
217
|
+
// Remove: a revoked key's row leaves the table (its use counts go with it).
|
|
218
|
+
['DELETE', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)$/, async (req, body, [id, keyId]) => {
|
|
219
|
+
const agent = await mine(req, id, person)
|
|
220
|
+
const k = await sandboxKey(agent, keyId)
|
|
221
|
+
if (!k.revokedAt) throw new HttpError(409, 'revoke the key first')
|
|
222
|
+
await store.removeAgentAppKey(agent.id, k.id)
|
|
223
|
+
return { ok: true }
|
|
129
224
|
}],
|
|
130
225
|
|
|
131
226
|
['GET', /^\/v1\/agents\/([^/]+)\/keys$/, async (req, body, [id]) => {
|
package/src/api/routes/ai-vm.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// or the app collects them: none is ever stored. Quilt never sees a Claude, OpenAI or Cursor token.
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
10
|
import { HttpError, UUID } from '../http.js'
|
|
11
|
-
import { newToken, hashToken } from '../tokens.js'
|
|
11
|
+
import { newToken, hashToken, sameHash } from '../tokens.js'
|
|
12
12
|
import { AGENT_INVITE_TTL_MS } from './agent-invites.js'
|
|
13
13
|
import { parseInvite, buildInvite } from '../../ui/invite.js'
|
|
14
14
|
import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
|
|
@@ -27,6 +27,11 @@ const CONTROL = /[\u0000-\u001f\u007f]/g // eslint-disable-line no-control-regex
|
|
|
27
27
|
const STEPS = ['starting', 'link', 'code', 'waiting', 'done', 'failed']
|
|
28
28
|
const CODE = /^[A-Za-z0-9-]{1,40}$/
|
|
29
29
|
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
30
|
+
// How often the spare machines are checked (made again, or replaced for a new image).
|
|
31
|
+
export const SPARES_EVERY_MS = 10 * 60 * 1000
|
|
32
|
+
// A spare not ready by then is made again; a claimed spare's join link is kept this long.
|
|
33
|
+
const SPARE_STUCK_MS = 15 * 60 * 1000
|
|
34
|
+
const SPARE_CLAIM_MS = 15 * 60 * 1000
|
|
30
35
|
const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
|
|
31
36
|
|
|
32
37
|
/** What the person's app shows of a sign-in step: a link, a device code, whether to paste a code back. */
|
|
@@ -63,15 +68,15 @@ export async function cloudAiOf (store, agent) {
|
|
|
63
68
|
return store.updateAiVm(theirs.userId, { agentId: agent.id })
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS }) {
|
|
67
|
-
// user id -> { version, signIn,
|
|
71
|
+
export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS, sparesEveryMs = SPARES_EVERY_MS, spareSuspendMs = 1000 }) {
|
|
72
|
+
// user id -> { version, signIn, waiters, forVm: { follow, leave, signIn, code, config, waiters } }
|
|
68
73
|
const live = new Map()
|
|
69
74
|
const ownRelay = String(relayUrl).replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
|
|
70
75
|
const relayWokeAt = new Map()
|
|
71
76
|
|
|
72
77
|
const liveOf = (userId) => {
|
|
73
78
|
let l = live.get(userId)
|
|
74
|
-
if (!l) { l = { version: 1, signIn: null,
|
|
79
|
+
if (!l) { l = { version: 1, signIn: null, waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, config: false, waiters: new Set() } }; live.set(userId, l) }
|
|
75
80
|
return l
|
|
76
81
|
}
|
|
77
82
|
const wakeWaiters = (set) => { for (const w of [...set]) w(); set.clear() }
|
|
@@ -85,6 +90,105 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
85
90
|
set.add(done)
|
|
86
91
|
})
|
|
87
92
|
|
|
93
|
+
// Spare machines (fly-machines.js), by id: { id, volumeId, region, hash, state, ready }. A
|
|
94
|
+
// spare reads the AI tools into memory, says it is ready and is suspended; connecting resumes it.
|
|
95
|
+
const spares = new Map()
|
|
96
|
+
// Spares handed to someone, by id: { link, hash, at }. The machine asks for its join link with
|
|
97
|
+
// its spare key; the link is kept a while, so a machine that restarts first can ask again.
|
|
98
|
+
const claimed = new Map()
|
|
99
|
+
let filling = null
|
|
100
|
+
|
|
101
|
+
/** Makes spares up to `fly.spares`, and replaces those of an older image or that never got ready. One run at a time. */
|
|
102
|
+
function fillSpares () {
|
|
103
|
+
if (!fly || !fly.spares || !fly.listSpares) return Promise.resolve()
|
|
104
|
+
if (filling) return filling
|
|
105
|
+
filling = (async () => {
|
|
106
|
+
for (const [id, c] of claimed) if (now() - c.at > SPARE_CLAIM_MS) claimed.delete(id)
|
|
107
|
+
const there = await fly.listSpares()
|
|
108
|
+
const seen = new Set()
|
|
109
|
+
for (const m of there) {
|
|
110
|
+
if (claimed.has(m.id)) continue
|
|
111
|
+
const had = spares.get(m.id)
|
|
112
|
+
const ready = m.state === 'suspended' || !!(had && had.ready)
|
|
113
|
+
const stuck = !ready && m.createdAt && Date.now() - m.createdAt > SPARE_STUCK_MS
|
|
114
|
+
if (m.image !== fly.image || !m.volumeId || !m.hash || stuck) {
|
|
115
|
+
spares.delete(m.id)
|
|
116
|
+
await fly.destroyMachine(m.id).catch((err) => log(`cloud AI spare ${m.id}: could not delete it: ${err.message}`))
|
|
117
|
+
if (m.volumeId) await fly.destroyVolume(m.volumeId).catch(() => {})
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
seen.add(m.id)
|
|
121
|
+
spares.set(m.id, { id: m.id, volumeId: m.volumeId, region: m.region, hash: m.hash, state: m.state, ready })
|
|
122
|
+
}
|
|
123
|
+
for (const id of [...spares.keys()]) if (!seen.has(id)) spares.delete(id)
|
|
124
|
+
while (spares.size < fly.spares) {
|
|
125
|
+
const short = crypto.randomBytes(10).toString('hex')
|
|
126
|
+
const key = newToken('qs_')
|
|
127
|
+
const volume = await fly.createVolume({ name: `ai_s_${short}` })
|
|
128
|
+
try {
|
|
129
|
+
const m = await fly.createMachine({ name: `ai-s-${short}`, volumeId: volume.id, env: { QUILT_API: apiUrl, QUILT_AI_VM_SPARE: key, HOME: '/data/home' }, spareHash: hashToken(key) })
|
|
130
|
+
spares.set(m.id, { id: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region, hash: hashToken(key), state: 'started', ready: false })
|
|
131
|
+
log(`cloud AI spare ${m.id}: made`)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await fly.destroyVolume(volume.id).catch(() => {})
|
|
134
|
+
throw err
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})().catch((err) => log(`cloud AI spares: ${err.message}`)).finally(() => { filling = null })
|
|
138
|
+
return filling
|
|
139
|
+
}
|
|
140
|
+
let sparesTimer = null
|
|
141
|
+
if (fly && fly.spares) {
|
|
142
|
+
sparesTimer = setInterval(() => { fillSpares() }, sparesEveryMs)
|
|
143
|
+
sparesTimer.unref?.()
|
|
144
|
+
setImmediate(() => { fillSpares() })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The spare calling, by its key: { id, spare } before it is claimed, { id, claim } after. */
|
|
148
|
+
function spareFrom (req) {
|
|
149
|
+
const key = bearer(req)
|
|
150
|
+
if (!key || !key.startsWith('qs_')) throw new HttpError(401, 'a spare key is needed')
|
|
151
|
+
const hash = hashToken(key)
|
|
152
|
+
for (const [id, c] of claimed) if (sameHash(c.hash, hash)) return { id, claim: c }
|
|
153
|
+
for (const sp of spares.values()) if (sameHash(sp.hash, hash)) return { id: sp.id, spare: sp }
|
|
154
|
+
throw new HttpError(401, 'no such spare')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The person's machine: a spare when there is one (a ready one first: it is running, tools in
|
|
159
|
+
* memory, in a second or two), else a new volume and machine. A spare Fly refuses is deleted.
|
|
160
|
+
*/
|
|
161
|
+
async function machineFor (userId, env) {
|
|
162
|
+
while (spares.size) {
|
|
163
|
+
const spare = [...spares.values()].sort((a, b) => Number(b.ready) - Number(a.ready))[0]
|
|
164
|
+
spares.delete(spare.id)
|
|
165
|
+
claimed.set(spare.id, { link: env.QUILT_AI_VM_JOIN, hash: spare.hash, at: now() })
|
|
166
|
+
try {
|
|
167
|
+
// One being suspended right now is woken when that is done (the ready route).
|
|
168
|
+
await fly.claimSpare(spare.id, { state: spare.suspending ? 'started' : spare.state })
|
|
169
|
+
fillSpares()
|
|
170
|
+
return { machineId: spare.id, volumeId: spare.volumeId, region: spare.region }
|
|
171
|
+
} catch (err) {
|
|
172
|
+
log(`cloud AI spare ${spare.id}: could not use it: ${err.message}`)
|
|
173
|
+
claimed.delete(spare.id)
|
|
174
|
+
// Fly busy or briefly down: the spare is fine, and stays for the next person. Else it goes.
|
|
175
|
+
if (err.status === 429 || err.status >= 500) { spares.set(spare.id, spare); break }
|
|
176
|
+
await fly.destroyMachine(spare.id).catch(() => {})
|
|
177
|
+
await fly.destroyVolume(spare.volumeId).catch(() => {})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
fillSpares()
|
|
181
|
+
const short = crypto.createHash('sha256').update(userId).digest('hex').slice(0, 20)
|
|
182
|
+
const volume = await fly.createVolume({ name: `ai_${short}` })
|
|
183
|
+
try {
|
|
184
|
+
const m = await fly.createMachine({ name: `ai-${short}`, volumeId: volume.id, env })
|
|
185
|
+
return { machineId: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region }
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await fly.destroyVolume(volume.id).catch(() => {})
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
88
192
|
const needFly = () => { if (!fly) throw new HttpError(503, "Cloud AI isn't set up on this server.") }
|
|
89
193
|
async function mine (req) {
|
|
90
194
|
const u = await person(req)
|
|
@@ -106,7 +210,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
106
210
|
error: vm.error,
|
|
107
211
|
signedIn: vm.signedIn,
|
|
108
212
|
signIn: l.signIn,
|
|
109
|
-
|
|
213
|
+
// Kept with the record: shown while the machine sleeps, and after the API restarts.
|
|
214
|
+
sessions: Array.isArray(vm.sessions) ? vm.sessions : [],
|
|
110
215
|
usageToday: vm.usageToday || {},
|
|
111
216
|
region: vm.region,
|
|
112
217
|
lastReportAt: vm.lastReportAt,
|
|
@@ -156,7 +261,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
156
261
|
return { agent, vm }
|
|
157
262
|
}
|
|
158
263
|
|
|
159
|
-
|
|
264
|
+
// `config`: the settings changed since the machine last asked (a new tool signs in at once).
|
|
265
|
+
const pendingFor = (f) => f.follow.length || f.leave.length || f.signIn || f.code !== null || f.config
|
|
160
266
|
|
|
161
267
|
function cleanSettings (b, vm) {
|
|
162
268
|
const out = {}
|
|
@@ -205,19 +311,11 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
205
311
|
const invite = await store.createAgentInvite({ tokenHash: hashToken(token), ownerUserId: u.userId, createdBy: u.userId, expiresAt: now() + AGENT_INVITE_TTL_MS })
|
|
206
312
|
let vm = await store.createAiVm({ userId: u.userId, tool: body.tool, region: fly.region })
|
|
207
313
|
vm = await store.updateAiVm(u.userId, { inviteId: invite.id })
|
|
208
|
-
const short = crypto.createHash('sha256').update(u.userId).digest('hex').slice(0, 20)
|
|
209
|
-
let volume = null
|
|
210
314
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
name: `ai-${short}`,
|
|
214
|
-
volumeId: volume.id,
|
|
215
|
-
env: { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' }
|
|
216
|
-
})
|
|
217
|
-
vm = await store.updateAiVm(u.userId, { machineId: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region, state: 'starting' })
|
|
315
|
+
const m = await machineFor(u.userId, { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' })
|
|
316
|
+
vm = await store.updateAiVm(u.userId, { ...m, state: 'starting' })
|
|
218
317
|
} catch (err) {
|
|
219
318
|
log(`cloud AI ${u.userId}: could not create it: ${err.message}`)
|
|
220
|
-
if (volume) await fly.destroyVolume(volume.id).catch(() => {})
|
|
221
319
|
await store.cancelAgentInvite(invite.id).catch(() => {})
|
|
222
320
|
await store.deleteAiVm(u.userId)
|
|
223
321
|
throw new HttpError(502, `Couldn't start your cloud AI. ${err.message}`)
|
|
@@ -232,6 +330,9 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
232
330
|
const patch = cleanSettings(body, vm)
|
|
233
331
|
if (!Object.keys(patch).length) return view(vm)
|
|
234
332
|
let up = await store.updateAiVm(u.userId, patch)
|
|
333
|
+
liveOf(u.userId).forVm.config = true
|
|
334
|
+
// A new tool's sign-in starts over: the old tool's link and code are not shown for it.
|
|
335
|
+
if (patch.tool) liveOf(u.userId).signIn = { step: 'starting', url: '', code: '', needsCode: false, error: '' }
|
|
235
336
|
bump(u.userId)
|
|
236
337
|
poke(u.userId)
|
|
237
338
|
up = await wake(up, 'settings')
|
|
@@ -310,7 +411,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
310
411
|
signIn: f.signIn,
|
|
311
412
|
code: f.code
|
|
312
413
|
}
|
|
313
|
-
Object.assign(f, { follow: [], leave: [], signIn: false, code: null })
|
|
414
|
+
Object.assign(f, { follow: [], leave: [], signIn: false, code: null, config: false })
|
|
314
415
|
return out
|
|
315
416
|
}],
|
|
316
417
|
|
|
@@ -321,7 +422,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
321
422
|
if (typeof body.signedIn === 'boolean') patch.signedIn = body.signedIn
|
|
322
423
|
if (body.usageToday !== undefined) patch.usageToday = cleanUsage(body.usageToday)
|
|
323
424
|
if (body.signIn !== undefined) l.signIn = cleanSignIn(body.signIn)
|
|
324
|
-
if (body.sessions !== undefined)
|
|
425
|
+
if (body.sessions !== undefined) patch.sessions = cleanSessions(body.sessions)
|
|
325
426
|
await store.updateAiVm(vm.userId, patch)
|
|
326
427
|
bump(vm.userId)
|
|
327
428
|
return { ok: true }
|
|
@@ -342,6 +443,30 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
342
443
|
return { ok: true }
|
|
343
444
|
}],
|
|
344
445
|
|
|
446
|
+
// A spare machine has read the tools into memory: it is suspended (after it has had its
|
|
447
|
+
// answer). It asks again while awake; once someone has taken it, the answer is its join link.
|
|
448
|
+
['POST', /^\/v1\/ai-vm\/spare\/ready$/, async (req) => {
|
|
449
|
+
const s = spareFrom(req)
|
|
450
|
+
if (s.claim) return { join: s.claim.link }
|
|
451
|
+
const sp = s.spare
|
|
452
|
+
if (!sp.ready) log(`cloud AI spare ${sp.id}: ready`)
|
|
453
|
+
sp.ready = true
|
|
454
|
+
if (fly && !sp.suspending) {
|
|
455
|
+
sp.suspending = new Promise((resolve) => setTimeout(resolve, spareSuspendMs)).then(async () => {
|
|
456
|
+
if (!claimed.has(sp.id)) { await fly.suspend(sp.id); sp.state = 'suspended' }
|
|
457
|
+
// Taken while it was being suspended: wake it for its person.
|
|
458
|
+
if (claimed.has(sp.id)) await fly.start(sp.id)
|
|
459
|
+
}).catch((err) => log(`cloud AI spare ${sp.id}: could not suspend it: ${err.message}`)).finally(() => { sp.suspending = null })
|
|
460
|
+
}
|
|
461
|
+
return { join: null }
|
|
462
|
+
}],
|
|
463
|
+
|
|
464
|
+
// A spare still reading its tools, asking whether someone has taken it: its join link if so.
|
|
465
|
+
['GET', /^\/v1\/ai-vm\/spare\/claim$/, async (req) => {
|
|
466
|
+
const s = spareFrom(req)
|
|
467
|
+
return { join: s.claim ? s.claim.link : null }
|
|
468
|
+
}],
|
|
469
|
+
|
|
345
470
|
// The relay: a mention, a message or a task waits for a cloud AI that isn't connected.
|
|
346
471
|
['POST', /^\/v1\/relay\/ai-vm\/wake$/, async (req, body) => {
|
|
347
472
|
if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
|
|
@@ -365,5 +490,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
|
|
|
365
490
|
if (vm) await destroy(vm)
|
|
366
491
|
}
|
|
367
492
|
|
|
368
|
-
|
|
493
|
+
const close = () => { if (sparesTimer) clearInterval(sparesTimer) }
|
|
494
|
+
|
|
495
|
+
return { routes, destroyFor, fillSpares, close }
|
|
369
496
|
}
|
package/src/api/routes/orgs.js
CHANGED
|
@@ -14,7 +14,7 @@ const sortRoles = (roles) => [...roles].sort((a, b) => (ROLE_ORDER[a.builtin] ??
|
|
|
14
14
|
const orgView = (o) => ({ id: o.id, name: o.name, slug: o.slug, domain: o.domain, domainRequests: o.domainRequests, createdAt: o.createdAt })
|
|
15
15
|
const roleView = (r) => ({ id: r.id, name: r.name, builtin: r.builtin, grants: normalizeGrants(r.grants), createdAt: r.createdAt })
|
|
16
16
|
|
|
17
|
-
export function orgRoutes ({ store, user, person }) {
|
|
17
|
+
export function orgRoutes ({ store, user, person, log = () => {} }) {
|
|
18
18
|
const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
|
|
19
19
|
|
|
20
20
|
// Two orgs made at once can pick the same slug; the unique index decides and we try the next.
|
|
@@ -101,6 +101,8 @@ export function orgRoutes ({ store, user, person }) {
|
|
|
101
101
|
['DELETE', /^\/v1\/orgs\/([^/]+)$/, async (req, body, [slug]) => {
|
|
102
102
|
const a = await orgFor(req, slug)
|
|
103
103
|
a.needOwner()
|
|
104
|
+
// Its team secrets go with it; never in the way of the delete.
|
|
105
|
+
await store.deleteScopeSecrets('org', a.org.id).catch((err) => log(`team secrets: ${err.message}`))
|
|
104
106
|
await store.deleteOrg(a.org.id)
|
|
105
107
|
return { ok: true }
|
|
106
108
|
}],
|