heyquilt 0.0.1 → 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/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- 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 +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -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 +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- 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/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/hooks.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `quilt hook`: the AI tools' own delivery of Quilt's rules. The rules themselves are
|
|
2
|
+
// the same for every agent (duties.js, Session.prepareEdit): every MCP agent gets
|
|
3
|
+
// them through quilt_before_edit, its quilt answers and quilt_set_work, and the
|
|
4
|
+
// file watcher undoes edits to files someone else holds whatever made them. The
|
|
5
|
+
// hooks make them automatic in every tool that runs hooks: Claude Code and Cursor
|
|
6
|
+
// (which reads the project's Claude Code hooks) from .claude/settings.local.json,
|
|
7
|
+
// Gemini CLI from its user settings (integrations.js). Each speaks its own dialect
|
|
8
|
+
// (DIALECTS below); what happens is the same. Before every edit, the file is
|
|
9
|
+
// claimed for this person (or the edit is refused when someone else holds it, with
|
|
10
|
+
// a nudge to ask them for help) and what was said about it in chat is shown; claims the hooks
|
|
11
|
+
// made are released when Claude finishes, once it has answered who wrote to it.
|
|
12
|
+
// Direct messages, mentions and tasks handed over are shown as Claude works, and when
|
|
13
|
+
// this person lets their AI pick up work by itself, it is handed its next task as it finishes.
|
|
14
|
+
//
|
|
15
|
+
// Reads the hook event as JSON on stdin and answers on stdout, as Claude Code
|
|
16
|
+
// expects. Without a running session it does nothing, so the hooks are harmless
|
|
17
|
+
// in a folder that isn't in a Quilt session.
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { findDaemon } from './control.js'
|
|
22
|
+
import { migrateDir } from './legacy.js'
|
|
23
|
+
import { eventLines, REFERS_HOW } from './inbox.js'
|
|
24
|
+
import { renderQueued, renderChatAbout, heldRefusal } from './duties.js'
|
|
25
|
+
import { renderNextTask } from './tasks.js'
|
|
26
|
+
import { quiltShellCommand } from './integrations.js'
|
|
27
|
+
import { foldPersonas } from './persona.js'
|
|
28
|
+
|
|
29
|
+
// Tools that change files: Claude Code's, Cursor's (it reads Claude Code's hook settings and
|
|
30
|
+
// matches its own tool names against them) and Gemini CLI's.
|
|
31
|
+
const CLAUDE_EDITS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
|
|
32
|
+
const CURSOR_EDITS = ['StrReplace', 'MultiStrReplace', 'ApplyPatch', 'Delete', 'EditNotebook']
|
|
33
|
+
const GEMINI_EDITS = ['write_file', 'replace']
|
|
34
|
+
const EDIT_TOOLS = new Set([...CLAUDE_EDITS, ...CURSOR_EDITS, ...GEMINI_EDITS, 'apply_patch'])
|
|
35
|
+
const TIMEOUT_MS = 5000
|
|
36
|
+
|
|
37
|
+
/** Settings for .claude/settings.local.json: every hook runs this Quilt's `hook`, by absolute path (no PATH needed). */
|
|
38
|
+
export const HOOK_COMMAND = 'quilt hook' // what older versions wrote
|
|
39
|
+
export const hookCommand = (dialect = '') => quiltShellCommand(dialect ? ['hook', dialect] : ['hook'])
|
|
40
|
+
/** Is this hook entry one of Quilt's (any version, any tool)? */
|
|
41
|
+
export const isQuiltHook = (h) => !!h && typeof h.command === 'string' && (h.command.startsWith(HOOK_COMMAND) || /quilt\.js"? hook( \w+)?$/.test(h.command))
|
|
42
|
+
export function hookSettings (command = hookCommand()) {
|
|
43
|
+
const run = { type: 'command', command, timeout: 10 }
|
|
44
|
+
const edits = [...CLAUDE_EDITS, ...CURSOR_EDITS].join('|')
|
|
45
|
+
return {
|
|
46
|
+
SessionStart: [{ hooks: [run] }],
|
|
47
|
+
UserPromptSubmit: [{ hooks: [run] }],
|
|
48
|
+
PreToolUse: [{ matcher: edits, hooks: [run] }],
|
|
49
|
+
PostToolUse: [{ matcher: edits, hooks: [run] }],
|
|
50
|
+
Stop: [{ hooks: [run] }],
|
|
51
|
+
SessionEnd: [{ hooks: [run] }]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Gemini CLI's hooks (its user settings.json): the same events under its names, timeouts in milliseconds. */
|
|
56
|
+
export function geminiHookSettings (command = hookCommand('gemini')) {
|
|
57
|
+
const run = { type: 'command', command, timeout: 10000 }
|
|
58
|
+
return {
|
|
59
|
+
SessionStart: [{ hooks: [run] }],
|
|
60
|
+
BeforeAgent: [{ hooks: [run] }],
|
|
61
|
+
BeforeTool: [{ matcher: GEMINI_EDITS.join('|'), hooks: [run] }],
|
|
62
|
+
AfterTool: [{ matcher: GEMINI_EDITS.join('|'), hooks: [run] }],
|
|
63
|
+
AfterAgent: [{ hooks: [run] }],
|
|
64
|
+
SessionEnd: [{ hooks: [run] }]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ------------------------------------------------------------ dialects --
|
|
69
|
+
// Each tool names its events and wants its answers in its own shape. Events are mapped to one of
|
|
70
|
+
// start | preEdit | postEdit | stop | end, and answers are made by the dialect's say* functions.
|
|
71
|
+
|
|
72
|
+
const claudeLike = (eventName) => ({
|
|
73
|
+
context: (ctx) => ({ hookSpecificOutput: { hookEventName: eventName, additionalContext: ctx } }),
|
|
74
|
+
block: (reason) => ({ decision: 'block', reason })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const DIALECTS = {
|
|
78
|
+
claude: {
|
|
79
|
+
events: { SessionStart: 'start', UserPromptSubmit: 'prompt', PreToolUse: 'preEdit', PostToolUse: 'postEdit', Stop: 'stop', SessionEnd: 'end' },
|
|
80
|
+
context: (name, ctx) => claudeLike(name).context(ctx),
|
|
81
|
+
deny: (name, reason) => ({ hookSpecificOutput: { hookEventName: name, permissionDecision: 'deny', permissionDecisionReason: reason } }),
|
|
82
|
+
block: (name, reason) => claudeLike(name).block(reason),
|
|
83
|
+
none: () => null
|
|
84
|
+
},
|
|
85
|
+
// Cursor runs the project's Claude Code hooks, and its own, with its own event names and answers.
|
|
86
|
+
cursor: {
|
|
87
|
+
events: { sessionStart: 'start', beforeSubmitPrompt: 'prompt', preToolUse: 'preEdit', postToolUse: 'postEdit', stop: 'stop', sessionEnd: 'end' },
|
|
88
|
+
context: (name, ctx) => ({ additional_context: ctx }),
|
|
89
|
+
deny: (name, reason) => ({ permission: 'deny', user_message: reason, agent_message: reason }),
|
|
90
|
+
block: (name, reason) => ({ followup_message: reason }),
|
|
91
|
+
// Cursor takes no answer for an allowed step as an invalid one, so it always gets an object.
|
|
92
|
+
none: () => ({})
|
|
93
|
+
},
|
|
94
|
+
gemini: {
|
|
95
|
+
events: { SessionStart: 'start', BeforeAgent: 'prompt', BeforeTool: 'preEdit', AfterTool: 'postEdit', AfterAgent: 'stop', SessionEnd: 'end' },
|
|
96
|
+
context: (name, ctx) => claudeLike(name).context(ctx),
|
|
97
|
+
deny: (name, reason) => ({ decision: 'deny', reason }),
|
|
98
|
+
block: (name, reason) => ({ decision: 'block', reason }),
|
|
99
|
+
none: () => null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Which tool sent this event: the one named on the command line, else what the event looks like. */
|
|
104
|
+
export function dialectOf (event, named = '') {
|
|
105
|
+
if (DIALECTS[named]) return named
|
|
106
|
+
if (event && (event.cursor_version || DIALECTS.cursor.events[event.hook_event_name])) return 'cursor'
|
|
107
|
+
if (event && ['BeforeTool', 'AfterTool', 'AfterAgent'].includes(event.hook_event_name)) return 'gemini'
|
|
108
|
+
return 'claude'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The project files an edit tool is about to change: its path fields, or the files a patch names. */
|
|
112
|
+
export function editedFiles (input) {
|
|
113
|
+
if (!input) return []
|
|
114
|
+
if (typeof input === 'string') return patchFiles(input)
|
|
115
|
+
const out = []
|
|
116
|
+
for (const k of ['file_path', 'path', 'target_file', 'notebook_path', 'filePath', 'absolute_path']) {
|
|
117
|
+
if (typeof input[k] === 'string' && input[k]) out.push(input[k])
|
|
118
|
+
}
|
|
119
|
+
for (const v of Object.values(input)) if (typeof v === 'string') out.push(...patchFiles(v))
|
|
120
|
+
return [...new Set(out)]
|
|
121
|
+
}
|
|
122
|
+
const patchFiles = (text) => [...String(text).matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$/gm)].map((m) => m[1])
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handles one hook event. Returns { output?, exitCode } where `output` is the
|
|
126
|
+
* JSON object to print. `daemon` and `call` can be injected for tests.
|
|
127
|
+
*/
|
|
128
|
+
export async function handleHook (event, { findDaemon: find = findDaemon, call = callWithTimeout, dialect: named = '', pids = null } = {}) {
|
|
129
|
+
const say = DIALECTS[dialectOf(event, named)]
|
|
130
|
+
const name = event.hook_event_name
|
|
131
|
+
const answer = (r) => (r.output || r.exitCode) ? r : { exitCode: 0, ...(say.none() ? { output: say.none() } : {}) }
|
|
132
|
+
const cwd = event.cwd || (Array.isArray(event.workspace_roots) && event.workspace_roots[0]) || process.cwd()
|
|
133
|
+
const d = find(cwd)
|
|
134
|
+
if (!d) return answer({ exitCode: 0 })
|
|
135
|
+
// Which AI session this is: the one whose tool process (the parent of its `quilt mcp`) is
|
|
136
|
+
// among this hook's parents. Its claims, inbox and duties are its own (persona.js).
|
|
137
|
+
const mine = pids || parentPids()
|
|
138
|
+
const api = (method, route, body) => method === 'GET'
|
|
139
|
+
? call(d, method, `${route}${route.includes('?') ? '&' : '?'}pids=${mine.join(',')}`)
|
|
140
|
+
: call(d, method, route, { ...(body || {}), pids: mine })
|
|
141
|
+
const state = hookState(d.dir, event.session_id || event.conversation_id)
|
|
142
|
+
const ev = { ...event, cwd }
|
|
143
|
+
switch (say.events[name]) {
|
|
144
|
+
case 'start': return answer(await sessionStart(api, state, say, name))
|
|
145
|
+
// The first prompt names an AI session that has no name yet (persona.js); the prompt goes on as asked.
|
|
146
|
+
case 'prompt': await api('POST', '/persona/prompt', { text: String(event.prompt || '').slice(0, 2000) }).catch(() => {}); return answer({ exitCode: 0 })
|
|
147
|
+
case 'preEdit': return answer(await preEdit(ev, d, api, state, say, name))
|
|
148
|
+
case 'postEdit': return answer(await postEdit(api, state, say, name))
|
|
149
|
+
case 'stop': return answer(await stop(ev, api, state, say, name))
|
|
150
|
+
case 'end': return answer(await sessionEnd(api, state))
|
|
151
|
+
default: return answer({ exitCode: 0 })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function sessionStart (api, state, say, name) {
|
|
156
|
+
const st = await api('GET', '/status')
|
|
157
|
+
// Only what arrives from now on is for this Claude; what came before is for quilt_inbox.
|
|
158
|
+
const { seq } = await api('POST', '/inbox', { after: 0 }).catch(() => ({ seq: 0 }))
|
|
159
|
+
state.update((s) => { s.after = seq || 0 })
|
|
160
|
+
// A person's AI sessions are one name to people: "Daniel's AI (3 sessions)".
|
|
161
|
+
const who = st.peers.length ? foldPersonas(st.peers).map((p) => p.sessions ? `${p.name} (${p.sessions.length === 1 ? 'one AI session' : `${p.sessions.length} AI sessions`}${p.mine ? ', you among them' : ''})` : p.name + (p.kind === 'agent' ? ' (AI agent)' : '')).join(', ') : 'nobody else yet'
|
|
162
|
+
const parts = [
|
|
163
|
+
`This folder is in a live Quilt session (room ${st.room}) with ${who}. Files can change underneath you at any time; re-read a file right before editing it.`,
|
|
164
|
+
'Quilt claims each file for you the moment you edit it, and releases those claims when you finish. ' +
|
|
165
|
+
'If a file is claimed by someone else, your edit is refused: do not retry or work around it. ' +
|
|
166
|
+
'Ask for it in its file queue with quilt_request_file (a title like "Working on <what> for <task>" and up to 300 characters on your plan), then carry on with other work: you are told when it is handed to you, with their context. ' +
|
|
167
|
+
'When someone asks for a file you hold, finish your change, then hand it off with quilt_handoff and your context; you cannot finish before you do.',
|
|
168
|
+
'Messages from collaborators, mentions of you and tasks handed to you are shown to you as you work; answer with quilt_message and take a task with quilt_move_task.',
|
|
169
|
+
'You are a member of your own in the session, apart from your person and their other AI sessions, named after your work (your git branch, or the first thing you say you are doing); rename yourself with quilt_name_session. ' +
|
|
170
|
+
'People see all of a person\'s AI sessions as one, "<person>\'s AI": write to another person\'s AI by that name, and what is written to your person\'s AI reaches whichever of their sessions was active last (with none of them live, their cloud AI, if they connected one).'
|
|
171
|
+
]
|
|
172
|
+
const duties = await api('GET', '/duties').catch(() => ({}))
|
|
173
|
+
if (duties.pickup && duties.pickup !== 'off') parts.push(`This person lets you pick up work from the task board by yourself (${duties.pickup === 'any' ? 'tasks assigned to you, then unassigned ones' : 'tasks assigned to you'}): when you finish, you are handed the next one.`)
|
|
174
|
+
const merges = mergesContext(st.merges)
|
|
175
|
+
if (merges) parts.push(merges)
|
|
176
|
+
return { exitCode: 0, output: say.context(name, parts.join('\n')) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One short paragraph naming the files still open for merging, or null when there are none. */
|
|
180
|
+
function mergesContext (merges) {
|
|
181
|
+
const open = (merges || []).filter((m) => m.state !== 'done')
|
|
182
|
+
if (!open.length) return null
|
|
183
|
+
const describe = (m) => {
|
|
184
|
+
const action = m.oursDeleted ? 'deleted it offline' : 'changed it offline'
|
|
185
|
+
const other = (m.others && m.others[0]) || m.claimedBy
|
|
186
|
+
return `\`${m.path}\` (${m.by} ${action}${other ? `, ${other} in the session` : ''})`
|
|
187
|
+
}
|
|
188
|
+
const shown = open.slice(0, 5).map(describe).join(', ')
|
|
189
|
+
const more = open.length > 5 ? `, and ${open.length - 5} more` : ''
|
|
190
|
+
return `${open.length} file(s) wait for whoever holds them: ${shown}${more}. Quilt merges those changes in by itself when they let go; leave those files alone until then.`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function preEdit (event, d, api, state, say, name) {
|
|
194
|
+
if (!EDIT_TOOLS.has(event.tool_name)) return { exitCode: 0 }
|
|
195
|
+
const rels = editedFiles(event.tool_input)
|
|
196
|
+
.map((file) => path.relative(d.dir, path.resolve(event.cwd || d.dir, String(file))).split(path.sep).join('/'))
|
|
197
|
+
.filter((rel) => rel && !rel.startsWith('..') && !path.isAbsolute(rel)) // not in the project
|
|
198
|
+
if (!rels.length) return { exitCode: 0 }
|
|
199
|
+
// The same check quilt_before_edit makes for every other agent.
|
|
200
|
+
const r = await api('POST', '/before-edit', { paths: rels })
|
|
201
|
+
const files = (r.files || []).filter((f) => f && f.shared) // Quilt doesn't sync the rest, so nobody can clash on them
|
|
202
|
+
const refused = files.find((f) => !f.ok)
|
|
203
|
+
if (refused) return { exitCode: 0, output: say.deny(name, heldRefusal(refused.path, refused.claim, refused.error)) }
|
|
204
|
+
if (!files.length) return { exitCode: 0 }
|
|
205
|
+
const seen = new Set(state.read().seen)
|
|
206
|
+
// What people said about these files in chat, shown once per message per AI session.
|
|
207
|
+
const said = (r.chat || []).filter((q) => q.id && !seen.has(`ask:${q.id}`))
|
|
208
|
+
state.update((s) => {
|
|
209
|
+
for (const f of files) if (f.claimed && !s.claims.includes(f.path)) s.claims.push(f.path)
|
|
210
|
+
for (const q of said) s.seen.push(`ask:${q.id}`)
|
|
211
|
+
})
|
|
212
|
+
if (!said.length) return { exitCode: 0 }
|
|
213
|
+
return { exitCode: 0, output: say.context(name, renderChatAbout(said)) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function postEdit (api, state, say, name) {
|
|
217
|
+
const { events, me } = await unseenEvents(api, state)
|
|
218
|
+
if (!events.length) return { exitCode: 0 }
|
|
219
|
+
state.update((s) => { for (const e of events) s.seen.push(e.id) })
|
|
220
|
+
return { exitCode: 0, output: say.context(name, renderAsks(events, me)) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function stop (event, api, state, say, name) {
|
|
224
|
+
// Claude Code and Gemini say when this stop follows one we held back; a hold is once per item anyway.
|
|
225
|
+
if (!event.stop_hook_active) {
|
|
226
|
+
// What Claude hasn't been shown, and anyone it still owes an answer (the rule quilt_set_work applies to every agent).
|
|
227
|
+
const { events, me } = await unseenEvents(api, state)
|
|
228
|
+
// Each unanswered one holds Claude back once, so a message meant for the person doesn't stop every turn.
|
|
229
|
+
const asked = new Set(state.read().seen)
|
|
230
|
+
const owed = (await owedAnswers(api).catch(() => [])).filter((e) => !asked.has(`owed:${e.id}`))
|
|
231
|
+
for (const e of owed) if (!events.some((x) => x.id === e.id)) events.push(e)
|
|
232
|
+
if (events.length) {
|
|
233
|
+
state.update((s) => { for (const e of events) s.seen.push(e.id); for (const e of owed) s.seen.push(`owed:${e.id}`) })
|
|
234
|
+
return { exitCode: 0, output: say.block(name, `${renderAsks(events, me)}\nBefore you finish, reply with quilt_message to what asks something of you (settle what needs nothing back with quilt_inbox no_reply), and take or decline a task you were handed (and release files you no longer need with quilt_release), then finish.`) }
|
|
235
|
+
}
|
|
236
|
+
// Files someone waits for in the file queue: the AI hands them off with its context before it stops (once per request).
|
|
237
|
+
const duties = await api('GET', '/duties').catch(() => ({}))
|
|
238
|
+
const queued = duties.queued || []
|
|
239
|
+
const fresh = queued.flatMap((q) => q.queue).filter((r) => !asked.has(`queue:${r.id}`))
|
|
240
|
+
if (fresh.length) {
|
|
241
|
+
state.update((s) => { for (const r of fresh) s.seen.push(`queue:${r.id}`) })
|
|
242
|
+
return { exitCode: 0, output: say.block(name, renderQueued(queued, 'finish')) }
|
|
243
|
+
}
|
|
244
|
+
// This person lets their AI pick up work by itself: its next task, once per task.
|
|
245
|
+
if (duties.next && !asked.has(`next:${duties.next.id}`)) {
|
|
246
|
+
await releaseAll(api, state) // the files of the work just finished are free again
|
|
247
|
+
state.update((s) => { s.seen.push(`next:${duties.next.id}`) })
|
|
248
|
+
return { exitCode: 0, output: say.block(name, renderNextTask(duties.next)) }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
await releaseAll(api, state)
|
|
252
|
+
await api('POST', '/finish', {}).catch(() => {})
|
|
253
|
+
return { exitCode: 0 }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Who is still waiting for an answer from this person or their AI (the rule every MCP agent is held to). */
|
|
257
|
+
async function owedAnswers (api) {
|
|
258
|
+
return (await api('GET', '/duties')).waiting || []
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function sessionEnd (api, state) {
|
|
262
|
+
await releaseAll(api, state)
|
|
263
|
+
state.remove()
|
|
264
|
+
return { exitCode: 0 }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Inbox events (direct messages, mentions, tasks handed over) since this Claude session
|
|
269
|
+
* started that it hasn't been shown yet, each with the conversation before it (conversation.js),
|
|
270
|
+
* and `me`, the names this session goes by. The person's own unread state is untouched.
|
|
271
|
+
*/
|
|
272
|
+
async function unseenEvents (api, state) {
|
|
273
|
+
const s = state.read()
|
|
274
|
+
const { events, me } = await api('POST', '/inbox', { after: s.after || 0 })
|
|
275
|
+
const seen = new Set(s.seen)
|
|
276
|
+
return { events: (events || []).filter((e) => e && e.id && !seen.has(e.id)), me: me || [] }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What the hooks show: each event with the conversation before it, like quilt_inbox, so "that" means something. */
|
|
280
|
+
export function renderAsks (events, me = []) {
|
|
281
|
+
return `Quilt: collaborators wrote to you, or handed you work, while you were working:\n${eventLines(events, { me }).join('\n')}\n` +
|
|
282
|
+
'Someone asking for a file you hold in its file queue gets it from you with quilt_handoff and your context once your change is done; answer other messages that ask something of you with quilt_message (to: their name); one that needs nothing back (thanks, a greeting, an FYI) gets no reply, settle it with quilt_inbox (no_reply: [its id]). ' +
|
|
283
|
+
'Take a task you were handed with quilt_move_task when you are free, or say in chat why not. ' + REFERS_HOW
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Releases every claim the hooks made for this Claude session. */
|
|
287
|
+
async function releaseAll (api, state) {
|
|
288
|
+
const s = state.read()
|
|
289
|
+
for (const pattern of s.claims) await api('POST', '/release', { pattern }).catch(() => {})
|
|
290
|
+
state.update((x) => { x.claims = [] })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** This process's parents, nearest first (a few levels), for finding its AI session. */
|
|
294
|
+
export function parentPids (start = process.ppid, depth = 6) {
|
|
295
|
+
const out = []
|
|
296
|
+
let pid = start
|
|
297
|
+
for (let i = 0; i < depth && pid > 1; i++) {
|
|
298
|
+
out.push(pid)
|
|
299
|
+
if (process.platform === 'win32') break
|
|
300
|
+
try { pid = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 }).toString().trim()) } catch { break }
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Per-Claude-session state in .quilt/hooks/<session>.json: the claims it made and the messages it has seen. */
|
|
306
|
+
export function hookState (projectDir, sessionId) {
|
|
307
|
+
const dir = path.join(migrateDir(projectDir), 'hooks')
|
|
308
|
+
const file = path.join(dir, `${String(sessionId || 'default').replace(/[^\w.-]/g, '_')}.json`)
|
|
309
|
+
const read = () => {
|
|
310
|
+
try { const s = JSON.parse(fs.readFileSync(file, 'utf8')); return { claims: s.claims || [], seen: s.seen || [], after: s.after || 0 } } catch { return { claims: [], seen: [], after: 0 } }
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
file,
|
|
314
|
+
read,
|
|
315
|
+
update (fn) {
|
|
316
|
+
const s = read()
|
|
317
|
+
fn(s)
|
|
318
|
+
if (s.seen.length > 500) s.seen = s.seen.slice(-500)
|
|
319
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
320
|
+
fs.writeFileSync(file, JSON.stringify(s))
|
|
321
|
+
},
|
|
322
|
+
remove () { try { fs.rmSync(file) } catch {} }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Releases claims left behind by hooks of Claude sessions that ended without
|
|
328
|
+
* telling us (a crash, a closed laptop). Run when a session starts for the folder.
|
|
329
|
+
*/
|
|
330
|
+
export async function releaseLeftoverHookClaims (session) {
|
|
331
|
+
const dir = path.join(session.stateDir, 'hooks')
|
|
332
|
+
let names = []
|
|
333
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')) } catch { return 0 }
|
|
334
|
+
let released = 0
|
|
335
|
+
for (const n of names) {
|
|
336
|
+
const st = hookState(session.root, n.slice(0, -5))
|
|
337
|
+
for (const pattern of st.read().claims) {
|
|
338
|
+
try { released += await session.release(pattern) } catch {}
|
|
339
|
+
}
|
|
340
|
+
st.remove()
|
|
341
|
+
}
|
|
342
|
+
return released
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function callWithTimeout (daemon, method, route, body) {
|
|
346
|
+
const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
|
|
347
|
+
method,
|
|
348
|
+
headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
|
|
349
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
350
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
351
|
+
})
|
|
352
|
+
const json = await res.json()
|
|
353
|
+
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
|
|
354
|
+
return json
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The CLI entry: stdin JSON in, JSON (if any) out. Never fails loudly: a broken hook must not block someone's editor. */
|
|
358
|
+
export async function runHook ({ stdin = process.stdin, stdout = process.stdout, dialect = process.argv[3] || '' } = {}) {
|
|
359
|
+
let raw = ''
|
|
360
|
+
for await (const chunk of stdin) raw += chunk
|
|
361
|
+
let event
|
|
362
|
+
try { event = JSON.parse(raw) } catch { return 0 }
|
|
363
|
+
try {
|
|
364
|
+
const r = await handleHook(event, { dialect })
|
|
365
|
+
if (r.output) stdout.write(JSON.stringify(r.output) + '\n')
|
|
366
|
+
return r.exitCode
|
|
367
|
+
} catch (err) {
|
|
368
|
+
process.stderr.write(`quilt hook: ${err.message}\n`)
|
|
369
|
+
return 0
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A hosted agent's entry on the relay (hosted-agents.json): its webhook, and one record per
|
|
2
|
+
// session it is in ({ since, seenAt, pending, invitedAs, name, kind, denied, visit, inbox,
|
|
3
|
+
// webhookState }). Older relays kept one session per agent ({ room, ... }); such an entry is
|
|
4
|
+
// converted as it is read, so nothing is lost across an update.
|
|
5
|
+
|
|
6
|
+
/** The entry in today's shape, whatever shape it was saved in; null for nothing. */
|
|
7
|
+
export function normalizeHosted (h) {
|
|
8
|
+
if (!h || typeof h !== 'object') return null
|
|
9
|
+
if (h.rooms && typeof h.rooms === 'object') return h
|
|
10
|
+
const { room, webhook, seenAt, ...rest } = h
|
|
11
|
+
const out = { seenAt: seenAt || 0, rooms: {} }
|
|
12
|
+
if (webhook) out.webhook = webhook
|
|
13
|
+
if (room) out.rooms[room] = { seenAt: seenAt || 0, ...rest }
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The sessions (room names) the agent is in, joined first first. */
|
|
18
|
+
export const hostedRooms = (h) => (h && h.rooms ? Object.keys(h.rooms) : [])
|
|
19
|
+
|
|
20
|
+
/** The agent's record for one session, or null when it is not in it. */
|
|
21
|
+
export const hostedIn = (h, room) => (h && h.rooms && room && Object.hasOwn(h.rooms, room) ? h.rooms[room] : null)
|
|
22
|
+
|
|
23
|
+
/** A fresh entry for an agent the relay has nothing on. */
|
|
24
|
+
export const newHosted = () => ({ seenAt: Date.now(), rooms: {} })
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The hosted MCP, reached with a QUILT_AGENT_KEY (agent-env-key.js): plain JSON-RPC over HTTPS to
|
|
2
|
+
// the accounts API's /mcp, which signs the agent in with the key's app key on every request. No
|
|
3
|
+
// state is kept anywhere: each call names the key's session, and an agent the relay doesn't have
|
|
4
|
+
// in that session (its first call, or the relay forgot) joins it with the key's invite, once.
|
|
5
|
+
// Used by `quilt sync`, every other quilt command and `quilt mcp` when a sandbox has the key.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const VERSION = (() => {
|
|
11
|
+
try { return JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version } catch { return '' }
|
|
12
|
+
})()
|
|
13
|
+
|
|
14
|
+
// What the relay says (relay-mcp.js) when the agent isn't in the session, or is still waiting.
|
|
15
|
+
const NOT_IN = /^(You are not in a session|You are not in session |You are no longer in that session|Reconnecting you to the session)/
|
|
16
|
+
const WAITING = /^(The session owner has not let you in yet|Asked to join room)/
|
|
17
|
+
const DENIED = /^The session owner did not let you in/
|
|
18
|
+
// Tools that are not about one session: no `session` for them.
|
|
19
|
+
const NO_SESSION = new Set(['quilt_join_session', 'quilt_session_info', 'quilt_check_update'])
|
|
20
|
+
const TIMEOUT_MS = 120 * 1000
|
|
21
|
+
|
|
22
|
+
export class HostedError extends Error {
|
|
23
|
+
constructor (message, { status = 0, tool = '' } = {}) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.status = status
|
|
26
|
+
this.tool = tool
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Why the API turned the key away, in words for the person who set it. */
|
|
31
|
+
function refusedKey (status, error) {
|
|
32
|
+
if (status === 401) return `Quilt turned this ${'QUILT_AGENT_KEY'} away (${error || 'not valid'}). It may have been revoked: make a new one on heyquilt.com (Agents, Sandbox key) and put it in your sandbox's secrets.`
|
|
33
|
+
if (status === 429) return `Quilt says too many requests (${error || 'slow down'}); try again in a minute.`
|
|
34
|
+
return `Quilt answered ${status}${error ? `: ${error}` : ''}.`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The JSON-RPC answer in a response: JSON, or the first data line of an event stream. */
|
|
38
|
+
async function rpcBody (res) {
|
|
39
|
+
const type = res.headers.get('content-type') || ''
|
|
40
|
+
const raw = await res.text()
|
|
41
|
+
if (type.includes('text/event-stream')) {
|
|
42
|
+
for (const line of raw.split('\n')) if (line.startsWith('data:')) { try { return JSON.parse(line.slice(5)) } catch {} }
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
try { return JSON.parse(raw) } catch { return null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A client for the key's agent. `call(name, args)` resolves to the tool's text, or throws a
|
|
50
|
+
* HostedError (the tool's refusal, a turned-away key, the owner not having let the agent in).
|
|
51
|
+
*/
|
|
52
|
+
export function hostedClient ({ key, fetch: fetchImpl = globalThis.fetch, image = VERSION, timeoutMs = TIMEOUT_MS }) {
|
|
53
|
+
let id = 0
|
|
54
|
+
const url = `${key.api}/mcp`
|
|
55
|
+
async function rpc (method, params) {
|
|
56
|
+
let res
|
|
57
|
+
try {
|
|
58
|
+
res = await fetchImpl(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { authorization: `Bearer ${key.key}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(image ? { 'x-quilt-image': image } : {}) },
|
|
61
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, ...(params ? { params } : {}) }),
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
63
|
+
})
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new HostedError(`Couldn't reach Quilt at ${key.api} (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
66
|
+
}
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const body = await res.json().catch(() => null)
|
|
69
|
+
throw new HostedError(refusedKey(res.status, body && body.error), { status: res.status })
|
|
70
|
+
}
|
|
71
|
+
const body = await rpcBody(res)
|
|
72
|
+
if (!body) throw new HostedError('Quilt sent back something unexpected.')
|
|
73
|
+
if (body.error) throw new HostedError(String(body.error.message || 'Quilt refused the request.'))
|
|
74
|
+
return body.result
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const textOf = (result) => (result && Array.isArray(result.content) ? result.content.filter((c) => c && c.type === 'text').map((c) => c.text).join('\n') : '')
|
|
78
|
+
|
|
79
|
+
/** Joins the key's session with its invite. Resolves when in; a HostedError while waiting or turned away. */
|
|
80
|
+
async function join () {
|
|
81
|
+
const r = await rpc('tools/call', { name: 'quilt_join_session', arguments: { invite: key.session } })
|
|
82
|
+
const said = textOf(r)
|
|
83
|
+
if (r && r.isError) throw new HostedError(said, { tool: 'quilt_join_session' })
|
|
84
|
+
if (WAITING.test(said)) throw new HostedError(`Waiting for the session owner to let ${key.name || 'this agent'} in. They see it on their list in Quilt; run this again once they have.`, { tool: 'quilt_join_session' })
|
|
85
|
+
return said
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The raw result of a tool call (content and isError), joining the session first when needed. */
|
|
89
|
+
async function callRaw (name, args = {}) {
|
|
90
|
+
const params = { name, arguments: NO_SESSION.has(name) || name.startsWith('quilt_workspace') ? args : { session: key.room, ...args } }
|
|
91
|
+
let r = await rpc('tools/call', params)
|
|
92
|
+
for (let tries = 0; r && r.isError && tries < 2; tries++) {
|
|
93
|
+
const said = textOf(r)
|
|
94
|
+
if (DENIED.test(said)) break
|
|
95
|
+
if (WAITING.test(said)) throw new HostedError(`Waiting for the session owner to let ${key.name || 'this agent'} in. They see it on their list in Quilt; run this again once they have.`, { tool: name })
|
|
96
|
+
if (!NOT_IN.test(said)) break
|
|
97
|
+
if (!said.startsWith('Reconnecting')) await join()
|
|
98
|
+
r = await rpc('tools/call', params)
|
|
99
|
+
}
|
|
100
|
+
return r
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A tool's text answer; a refusal is a HostedError carrying it. */
|
|
104
|
+
async function call (name, args = {}) {
|
|
105
|
+
const r = await callRaw(name, args)
|
|
106
|
+
const said = textOf(r)
|
|
107
|
+
if (r && r.isError) throw new HostedError(said || `${name} was refused.`, { tool: name })
|
|
108
|
+
return said
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { call, callRaw, rpc, join, textOf, key }
|
|
112
|
+
}
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Per-person identity: an Ed25519 key pair kept in ~/.quilt/identity.json.
|
|
2
|
+
// The relay ties each name in a room to the first key that used it, and
|
|
3
|
+
// checks a signature on every connect, so nobody can act under someone
|
|
4
|
+
// else's name (for example to fake or release their claims).
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import crypto from 'node:crypto'
|
|
9
|
+
import { quiltHome } from './legacy.js'
|
|
10
|
+
|
|
11
|
+
const AUTH_CONTEXT = 'cowove-auth-v1'
|
|
12
|
+
// Linking a computer to an account signs the device code in a context of its own,
|
|
13
|
+
// so a signature made to link a computer can never be replayed to join a session,
|
|
14
|
+
// and a relay can never collect one by posing as a session.
|
|
15
|
+
const DEVICE_LINK_CONTEXT = 'quilt-device-link-v1'
|
|
16
|
+
// Signing a linked computer back in (a new token, no browser) has its own context too.
|
|
17
|
+
const DEVICE_RESUME_CONTEXT = 'quilt-device-resume-v1'
|
|
18
|
+
// An agent on a computer proves it holds the key it joined with, to get new keys (agent-join.js).
|
|
19
|
+
const AGENT_RESUME_CONTEXT = 'quilt-agent-resume-v1'
|
|
20
|
+
// The room name device links used to be signed for. The relay client refuses it.
|
|
21
|
+
export const RESERVED_ROOM = 'device-link'
|
|
22
|
+
|
|
23
|
+
export const identityFile = () => path.join(quiltHome(), 'identity.json')
|
|
24
|
+
|
|
25
|
+
export function generateIdentity () {
|
|
26
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
27
|
+
return {
|
|
28
|
+
publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
|
|
29
|
+
privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url')
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Loads this machine's identity, creating it on first use. */
|
|
34
|
+
export function loadIdentity (file = identityFile()) {
|
|
35
|
+
try {
|
|
36
|
+
const id = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
37
|
+
if (id.publicKey && id.privateKey) return id
|
|
38
|
+
} catch {}
|
|
39
|
+
const id = generateIdentity()
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
41
|
+
fs.writeFileSync(file, JSON.stringify(id), { mode: 0o600 })
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const privateKeyOf = (identity) => crypto.createPrivateKey({ key: Buffer.from(identity.privateKey, 'base64url'), format: 'der', type: 'pkcs8' })
|
|
46
|
+
const payload = (room, nonce) => Buffer.concat([Buffer.from(`${AUTH_CONTEXT}\0${room}\0`), Buffer.from(nonce)])
|
|
47
|
+
|
|
48
|
+
export function signChallenge (identity, room, nonce) {
|
|
49
|
+
if (room === RESERVED_ROOM) throw new Error(`"${RESERVED_ROOM}" is not a session name`)
|
|
50
|
+
return new Uint8Array(crypto.sign(null, payload(room, nonce), privateKeyOf(identity)))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parses a public key sent by a client; null unless it's a valid Ed25519 key. */
|
|
54
|
+
export function parsePublicKey (b64) {
|
|
55
|
+
try {
|
|
56
|
+
const key = crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64url'), format: 'der', type: 'spki' })
|
|
57
|
+
return key.asymmetricKeyType === 'ed25519' ? key : null
|
|
58
|
+
} catch { return null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function verifyChallenge (key, room, nonce, signature) {
|
|
62
|
+
try { return crypto.verify(null, payload(room, nonce), key, Buffer.from(signature)) } catch { return false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const linkPayload = (deviceCode) => Buffer.from(`${DEVICE_LINK_CONTEXT}\0${deviceCode}`)
|
|
66
|
+
|
|
67
|
+
/** Proves this computer holds its key when it collects its account token. Returns base64url. */
|
|
68
|
+
export function signDeviceLink (identity, deviceCode) {
|
|
69
|
+
return crypto.sign(null, linkPayload(String(deviceCode)), privateKeyOf(identity)).toString('base64url')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function verifyDeviceLink (key, deviceCode, signature) {
|
|
73
|
+
if (!key || typeof signature !== 'string') return false
|
|
74
|
+
try { return crypto.verify(null, linkPayload(String(deviceCode)), key, Buffer.from(signature, 'base64url')) } catch { return false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resumePayload = (at) => Buffer.from(`${DEVICE_RESUME_CONTEXT}\0${Number(at)}`)
|
|
78
|
+
|
|
79
|
+
/** Proves this computer holds its key when it asks for a new token at time `at` (ms). Returns base64url. */
|
|
80
|
+
export function signDeviceResume (identity, at) {
|
|
81
|
+
return crypto.sign(null, resumePayload(at), privateKeyOf(identity)).toString('base64url')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function verifyDeviceResume (key, at, signature) {
|
|
85
|
+
if (!key || typeof signature !== 'string') return false
|
|
86
|
+
try { return crypto.verify(null, resumePayload(at), key, Buffer.from(signature, 'base64url')) } catch { return false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const agentResumePayload = (agentId, at) => Buffer.from(`${AGENT_RESUME_CONTEXT}\0${agentId}\0${Number(at)}`)
|
|
90
|
+
|
|
91
|
+
/** Proves an agent holds the key it joined with when it asks for new keys at time `at` (ms). Returns base64url. */
|
|
92
|
+
export function signAgentResume (identity, agentId, at) {
|
|
93
|
+
return crypto.sign(null, agentResumePayload(String(agentId), at), privateKeyOf(identity)).toString('base64url')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function verifyAgentResume (key, agentId, at, signature) {
|
|
97
|
+
if (!key || typeof signature !== 'string') return false
|
|
98
|
+
try { return crypto.verify(null, agentResumePayload(String(agentId), at), key, Buffer.from(signature, 'base64url')) } catch { return false }
|
|
99
|
+
}
|