acrylblends 0.7.7 → 0.8.0
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/dist/index.d.ts +14 -6
- package/dist/index.js +130 -31
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +3 -3
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +3 -3
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +7 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +14 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +7 -2
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +7 -2
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +17 -3
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +6 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +45 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +46 -17
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +19 -6
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +11 -5
- package/templates/agent/test/app.test.ts +30 -9
- package/templates/agent/test/builder.test.ts +35 -24
- package/templates/agent/test/model.test.ts +25 -20
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +29 -9
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +29 -10
- package/templates/blank/tsconfig.json +1 -1
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +29 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +106 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +11 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +26 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +92 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +3 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +29 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +106 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +11 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +32 -12
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +48 -23
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- package/lib/kv-DWDm0Lja.d.ts +0 -39
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host's side of the page channel: the connected pages of the app, and calls that go to the current one (moved from
|
|
3
|
+
* `acryl-agent-control`, spec 041).
|
|
4
|
+
*
|
|
5
|
+
* The Host cannot reach into a page, so each page opens one event stream to the Host and answers calls by POST. With several pages open,
|
|
6
|
+
* the one the user focused last is the one the agent drives. A page that reconnects with the same window id replaces its old stream.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
UiControlError,
|
|
11
|
+
type ChannelToHost,
|
|
12
|
+
type ChannelToPage,
|
|
13
|
+
type UiRequest,
|
|
14
|
+
type UiResult,
|
|
15
|
+
} from '../page/protocol.ts'
|
|
16
|
+
|
|
17
|
+
/** The slice of a page's stream the channel needs, so tests can supply their own. */
|
|
18
|
+
export interface PageStream {
|
|
19
|
+
send(data: string): void
|
|
20
|
+
close(): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Pending {
|
|
24
|
+
readonly connection: Connection
|
|
25
|
+
readonly resolve: (value: UiResult) => void
|
|
26
|
+
readonly reject: (cause: UiControlError) => void
|
|
27
|
+
readonly timer: NodeJS.Timeout
|
|
28
|
+
readonly cleanup: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Connection {
|
|
32
|
+
readonly stream: PageStream
|
|
33
|
+
readonly windowId: string
|
|
34
|
+
focused: boolean
|
|
35
|
+
/** Order of last focus (or connection), so the most recent wins. */
|
|
36
|
+
lastActive: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How long a call may take before it is given up (a wait may take up to 10 seconds itself). */
|
|
40
|
+
const DEFAULT_CALL_TIMEOUT_MS = 30_000
|
|
41
|
+
|
|
42
|
+
export class UiChannel {
|
|
43
|
+
private readonly connections = new Map<string, Connection>()
|
|
44
|
+
private readonly pending = new Map<number, Pending>()
|
|
45
|
+
private nextId = 1
|
|
46
|
+
private clock = 0
|
|
47
|
+
private closed = false
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
|
|
51
|
+
* @returns the function to call when this stream ends.
|
|
52
|
+
*/
|
|
53
|
+
attach(windowId: string, focused: boolean, stream: PageStream): () => void {
|
|
54
|
+
const previous = this.connections.get(windowId)
|
|
55
|
+
if (previous !== undefined) this.drop(previous, true)
|
|
56
|
+
const connection: Connection = { stream, windowId, focused, lastActive: ++this.clock }
|
|
57
|
+
this.connections.set(windowId, connection)
|
|
58
|
+
return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A message a page sent by POST. Only the window that was asked may answer a call, so another page cannot forge a result. */
|
|
62
|
+
receive(windowId: string, message: ChannelToHost): void {
|
|
63
|
+
const connection = this.connections.get(windowId)
|
|
64
|
+
if (connection === undefined) return
|
|
65
|
+
if (message.t === 'focus') {
|
|
66
|
+
connection.focused = message.focused
|
|
67
|
+
if (message.focused) connection.lastActive = ++this.clock
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const entry = this.pending.get(message.id)
|
|
71
|
+
if (entry === undefined || entry.connection !== connection) return
|
|
72
|
+
if (message.t === 'result') this.settle(message.id, message.value)
|
|
73
|
+
else this.settle(message.id, undefined, new UiControlError(message.code, message.message))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when at least one page is connected. */
|
|
77
|
+
get connected(): boolean {
|
|
78
|
+
return this.connections.size > 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The windows currently connected, most recently active first (for diagnostics and tests). */
|
|
82
|
+
windows(): readonly { windowId: string; focused: boolean }[] {
|
|
83
|
+
return this.byRecency().map(({ windowId, focused }) => ({ windowId, focused }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the current page to do something.
|
|
88
|
+
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
89
|
+
*/
|
|
90
|
+
call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<UiResult> {
|
|
91
|
+
if (this.closed) return Promise.reject(new UiControlError('unloaded', 'the door was unloaded'))
|
|
92
|
+
const target = this.byRecency()[0]
|
|
93
|
+
if (target === undefined) return Promise.reject(new UiControlError('no-window', 'no page of the app is open to control'))
|
|
94
|
+
const signal = options.signal
|
|
95
|
+
if (signal?.aborted === true) return Promise.reject(new UiControlError('aborted', 'the call was cancelled'))
|
|
96
|
+
const id = this.nextId
|
|
97
|
+
this.nextId += 1
|
|
98
|
+
return new Promise<UiResult>((resolve, reject) => {
|
|
99
|
+
const timer = setTimeout(() => { this.settle(id, undefined, new UiControlError('timeout', 'the page did not answer in time')) }, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)
|
|
100
|
+
const onAbort = (): void => { this.settle(id, undefined, new UiControlError('aborted', 'the call was cancelled')) }
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
102
|
+
this.pending.set(id, { connection: target, resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } })
|
|
103
|
+
const frame: ChannelToPage = { t: 'call', id, request }
|
|
104
|
+
try {
|
|
105
|
+
target.stream.send(JSON.stringify(frame))
|
|
106
|
+
} catch {
|
|
107
|
+
this.settle(id, undefined, new UiControlError('disconnected', 'the page could not be reached'))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Close every page's stream and settle every pending call as `unloaded`. */
|
|
113
|
+
close(): void {
|
|
114
|
+
this.closed = true
|
|
115
|
+
for (const id of [...this.pending.keys()]) this.settle(id, undefined, new UiControlError('unloaded', 'the door was unloaded'))
|
|
116
|
+
for (const connection of [...this.connections.values()]) connection.stream.close()
|
|
117
|
+
this.connections.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private byRecency(): Connection[] {
|
|
121
|
+
return [...this.connections.values()].sort((a, b) => b.lastActive - a.lastActive)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
|
|
125
|
+
private drop(connection: Connection, closeStream: boolean): void {
|
|
126
|
+
this.connections.delete(connection.windowId)
|
|
127
|
+
for (const [id, entry] of [...this.pending]) {
|
|
128
|
+
if (entry.connection === connection) this.settle(id, undefined, new UiControlError('disconnected', 'the page went away before it answered'))
|
|
129
|
+
}
|
|
130
|
+
if (closeStream) connection.stream.close()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private settle(id: number, value: UiResult | undefined, failure?: UiControlError): void {
|
|
134
|
+
const entry = this.pending.get(id)
|
|
135
|
+
if (entry === undefined) return
|
|
136
|
+
this.pending.delete(id)
|
|
137
|
+
clearTimeout(entry.timer)
|
|
138
|
+
entry.cleanup()
|
|
139
|
+
if (failure !== undefined) entry.reject(failure)
|
|
140
|
+
else if (value !== undefined) entry.resolve(value)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door in the built-in chat: each verb registered in the app's `tools` registry (the one the chat's agent reads), as
|
|
3
|
+
* `door_<verb>`. A tool answers the verb's text, what an agent reads in a terminal; a failed verb throws with its code, as the operator
|
|
4
|
+
* client prints it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { UiControlError } from '../page/protocol.ts'
|
|
8
|
+
import { DoorError, type Door, type ToolsPort } from './door.ts'
|
|
9
|
+
import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
|
|
10
|
+
|
|
11
|
+
/** Register every verb. @returns the function that removes them all; it also aborts the calls still running. */
|
|
12
|
+
export function registerChatTools(tools: ToolsPort, door: Door): () => void {
|
|
13
|
+
const running = new AbortController()
|
|
14
|
+
const removers: Array<() => void> = []
|
|
15
|
+
const removeAll = (): void => {
|
|
16
|
+
running.abort()
|
|
17
|
+
for (const remove of removers.splice(0).reverse()) remove()
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
for (const verb of VERBS) {
|
|
21
|
+
removers.push(tools.register(chatToolName(verb), {
|
|
22
|
+
description: VERB_SPECS[verb].description,
|
|
23
|
+
run: async (input: unknown) => {
|
|
24
|
+
try {
|
|
25
|
+
return (await door.run(verb, input ?? {}, running.signal)).text
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
|
|
28
|
+
throw cause
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
// A name already taken: leave nothing half registered.
|
|
35
|
+
removeAll()
|
|
36
|
+
throw cause
|
|
37
|
+
}
|
|
38
|
+
return removeAll
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's connection file, `<app>/.acryl/door.json`: where the running app listens and this run's secret, for the operator client in
|
|
3
|
+
* the same folder. The secret is never on a command line or in an environment variable another process can read.
|
|
4
|
+
*
|
|
5
|
+
* Protection: on macOS and Linux the folder is `0700` and the file `0600`, so only the OS user who started the app can read it. On
|
|
6
|
+
* Windows file modes mean nothing; the file is protected by where it lives, the app folder in the user's profile, whose ACL gives access
|
|
7
|
+
* to that user, SYSTEM and Administrators only. The door does not widen it. The folder also carries its own `.gitignore` of `*`, so
|
|
8
|
+
* `git add -A` never stages the secret even in an app whose `.gitignore` lacks the `.acryl/` line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** The folder of the door's state below the app folder. */
|
|
16
|
+
export const STATE_DIR = '.acryl'
|
|
17
|
+
export const DOOR_FILE = 'door.json'
|
|
18
|
+
|
|
19
|
+
export interface DoorFile {
|
|
20
|
+
readonly version: 1
|
|
21
|
+
/** The origin the operator reaches, on a loopback address. */
|
|
22
|
+
readonly url: string
|
|
23
|
+
readonly secret: string
|
|
24
|
+
/** The process that wrote it; a file whose process is gone is stale. */
|
|
25
|
+
readonly pid: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function stateDir(appRoot: string): string {
|
|
29
|
+
return join(appRoot, STATE_DIR)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function doorFilePath(appRoot: string): string {
|
|
33
|
+
return join(stateDir(appRoot), DOOR_FILE)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fresh secret for this run; a restart rotates it. */
|
|
37
|
+
export function newSecret(): string {
|
|
38
|
+
return randomBytes(24).toString('hex')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The state folder, private, with its own `.gitignore`. */
|
|
42
|
+
export function ensureStateDir(appRoot: string): string {
|
|
43
|
+
const dir = stateDir(appRoot)
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
45
|
+
if (process.platform !== 'win32') chmodSync(dir, 0o700)
|
|
46
|
+
const ignore = join(dir, '.gitignore')
|
|
47
|
+
if (!existsSync(ignore)) writeFileSync(ignore, '# The door\'s state: this run\'s secret and the audit. Never commit it.\n*\n')
|
|
48
|
+
return dir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The connection file as written, or undefined when there is none or it does not parse. */
|
|
52
|
+
export function readDoorFile(appRoot: string): DoorFile | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const value = JSON.parse(readFileSync(doorFilePath(appRoot), 'utf8')) as Partial<DoorFile>
|
|
55
|
+
if (value.version === 1 && typeof value.url === 'string' && typeof value.secret === 'string' && typeof value.pid === 'number') return value as DoorFile
|
|
56
|
+
} catch {
|
|
57
|
+
// Missing or unreadable: there is no door to reach.
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True while the process is alive (a process of another user still counts: `EPERM`). */
|
|
63
|
+
function alive(pid: number): boolean {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0)
|
|
66
|
+
return true
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
return (cause as NodeJS.ErrnoException).code === 'EPERM'
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write this run's connection file, replacing a stale one.
|
|
74
|
+
* @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
|
|
75
|
+
*/
|
|
76
|
+
export function writeDoorFile(appRoot: string, url: string, secret: string): string {
|
|
77
|
+
const dir = ensureStateDir(appRoot)
|
|
78
|
+
const existing = readDoorFile(appRoot)
|
|
79
|
+
if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
|
|
80
|
+
throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
|
|
81
|
+
}
|
|
82
|
+
const file = doorFilePath(appRoot)
|
|
83
|
+
const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
|
|
84
|
+
const content: DoorFile = { version: 1, url, secret, pid: process.pid }
|
|
85
|
+
writeFileSync(temporary, `${JSON.stringify(content)}\n`, { mode: 0o600 })
|
|
86
|
+
if (process.platform !== 'win32') chmodSync(temporary, 0o600)
|
|
87
|
+
renameSync(temporary, file)
|
|
88
|
+
return file
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Remove the connection file, only when it is this run's: a newer run's file is left alone. */
|
|
92
|
+
export function removeDoorFile(appRoot: string, secret: string): void {
|
|
93
|
+
if (readDoorFile(appRoot)?.secret === secret) rmSync(doorFilePath(appRoot), { force: true })
|
|
94
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's verbs, one implementation for every face: `brief`, `look`, `act`, `plugin list|add|remove|reload` and `tool list|call`
|
|
3
|
+
* (`verbs.ts` lists them; the operator client, MCP and the built-in chat all call these). Each verb takes plain JSON arguments,
|
|
4
|
+
* returns a typed value and a text rendering, and fails with a {@link DoorError} or a {@link UiControlError} carrying a code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
9
|
+
import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
|
|
10
|
+
import { renderSnapshot } from '../page/snapshot-text.ts'
|
|
11
|
+
import type { AuditLog } from './audit.ts'
|
|
12
|
+
import type { UiChannel } from './channel.ts'
|
|
13
|
+
import { VERB_SPECS, VERBS } from './verbs.ts'
|
|
14
|
+
|
|
15
|
+
export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed'
|
|
16
|
+
|
|
17
|
+
export class DoorError extends Error {
|
|
18
|
+
readonly code: DoorErrorCode
|
|
19
|
+
|
|
20
|
+
constructor(code: DoorErrorCode, message: string) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.code = code
|
|
23
|
+
this.name = 'DoorError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The slice of acrylblends' `extensions` service the plugin verbs use, declared here so the door does not depend on that plugin. */
|
|
28
|
+
export interface ExtensionsPort {
|
|
29
|
+
install(source: string): Promise<unknown>
|
|
30
|
+
remove(name: string): Promise<unknown>
|
|
31
|
+
reload(): Promise<unknown>
|
|
32
|
+
list(): readonly unknown[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
|
|
36
|
+
export interface ToolsPort {
|
|
37
|
+
/** @returns the function that removes the tool. @throws when the name is taken. */
|
|
38
|
+
register(name: string, tool: { readonly description: string; run(input: unknown): unknown }): () => void
|
|
39
|
+
list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
|
|
40
|
+
call(name: string, input: unknown): Promise<unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DoorDeps {
|
|
44
|
+
readonly channel: UiChannel
|
|
45
|
+
readonly audit: AuditLog
|
|
46
|
+
/** The app's name and folder. */
|
|
47
|
+
readonly app: { readonly name: string; readonly root: string }
|
|
48
|
+
/** Read at each call: the services may come and go while the door runs. */
|
|
49
|
+
readonly extensions: () => ExtensionsPort | undefined
|
|
50
|
+
readonly tools: () => ToolsPort | undefined
|
|
51
|
+
/** The app tools the door offers; anything else is refused before it reaches the registry. */
|
|
52
|
+
readonly expose: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
|
|
56
|
+
export interface VerbAnswer {
|
|
57
|
+
readonly value: unknown
|
|
58
|
+
readonly text: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The files an agent should read first, when the app has them. */
|
|
62
|
+
const SELF_KNOWLEDGE = ['AGENTS.md', 'CLAUDE.md', '.agents/skills', '.claude/skills', 'docs', 'plugins']
|
|
63
|
+
|
|
64
|
+
export class Door {
|
|
65
|
+
private readonly deps: DoorDeps
|
|
66
|
+
|
|
67
|
+
constructor(deps: DoorDeps) {
|
|
68
|
+
this.deps = deps
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run one verb. @throws DoorError or UiControlError. */
|
|
72
|
+
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
73
|
+
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
74
|
+
switch (verb) {
|
|
75
|
+
case 'brief': return this.brief()
|
|
76
|
+
case 'look': {
|
|
77
|
+
const snapshot = await this.look(input, signal)
|
|
78
|
+
return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
|
|
79
|
+
}
|
|
80
|
+
case 'act': {
|
|
81
|
+
const result = await this.act(input, signal)
|
|
82
|
+
return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
|
|
83
|
+
}
|
|
84
|
+
case 'plugin-list': return this.plugin(extensions => extensions.list())
|
|
85
|
+
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
86
|
+
case 'plugin-remove': {
|
|
87
|
+
const name = input.name
|
|
88
|
+
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
|
|
89
|
+
return this.plugin(extensions => extensions.remove(name))
|
|
90
|
+
}
|
|
91
|
+
case 'plugin-reload': return this.plugin(extensions => extensions.reload())
|
|
92
|
+
case 'tool-list': {
|
|
93
|
+
const offered = this.offeredTools()
|
|
94
|
+
return { value: offered, text: offered.length === 0 ? 'no app tools are offered through the door (tools.expose is empty)' : offered.map(tool => `${tool.name}: ${tool.description}`).join('\n') }
|
|
95
|
+
}
|
|
96
|
+
case 'tool-call': return this.callTool(input)
|
|
97
|
+
default: throw new DoorError('unknown-verb', `unknown verb ${JSON.stringify(verb)}; the verbs are ${VERBS.join(', ')}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
brief(): VerbAnswer {
|
|
102
|
+
const { name, root } = this.deps.app
|
|
103
|
+
const read = SELF_KNOWLEDGE.filter(path => existsSync(resolve(root, path)))
|
|
104
|
+
const value = {
|
|
105
|
+
app: name,
|
|
106
|
+
root,
|
|
107
|
+
read,
|
|
108
|
+
page: this.deps.channel.connected ? 'open' : 'not open',
|
|
109
|
+
plugins: this.deps.extensions() === undefined ? 'unavailable' : 'available',
|
|
110
|
+
verbs: VERBS.map(verb => VERB_SPECS[verb].usage),
|
|
111
|
+
}
|
|
112
|
+
const text = [
|
|
113
|
+
`App ${name} in ${root}.`,
|
|
114
|
+
read.length === 0 ? 'No AGENTS.md or skill in the folder.' : `Read first: ${read.join(', ')}.`,
|
|
115
|
+
`Page: ${value.page}${value.page === 'not open' ? ' (open the app in a browser to look and act)' : ''}.`,
|
|
116
|
+
`Live plugins: ${value.plugins}.`,
|
|
117
|
+
'Verbs:',
|
|
118
|
+
...value.verbs.map(line => ` ${line}`),
|
|
119
|
+
].join('\n')
|
|
120
|
+
return { value, text }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
|
|
124
|
+
const request = this.parse({ ...input, op: 'snapshot' })
|
|
125
|
+
return this.callPage('look', request, signal) as Promise<UiSnapshot>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
|
|
129
|
+
if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
|
|
130
|
+
return this.callPage(`act ${String(input.op)}`, this.parse(input), signal) as Promise<UiActionResult>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private parse(input: Record<string, unknown>): UiRequest {
|
|
134
|
+
try {
|
|
135
|
+
return parseUiRequest(input)
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
throw new DoorError('invalid', cause instanceof Error ? cause.message : 'invalid request')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
142
|
+
private async callPage(verb: string, request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
143
|
+
const approval: AuditEntry['approval'] = 'token'
|
|
144
|
+
try {
|
|
145
|
+
const value = await this.deps.channel.call(request, { signal, ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
|
|
146
|
+
const target = 'target' in value ? value.target : undefined
|
|
147
|
+
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), outcome: 'ok', approval })
|
|
148
|
+
return value
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
const code = cause instanceof UiControlError ? cause.code : 'failed'
|
|
151
|
+
const refused = cause instanceof UiControlError && ['protected', 'sensitive', 'killed', 'invalid', 'stale-ref', 'unknown-ref', 'not-actionable'].includes(cause.code)
|
|
152
|
+
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, outcome: refused ? 'refused' : 'failed', detail: code, approval })
|
|
153
|
+
throw cause
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async plugin(run: (extensions: ExtensionsPort) => unknown): Promise<VerbAnswer> {
|
|
158
|
+
const extensions = this.deps.extensions()
|
|
159
|
+
if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
|
|
160
|
+
const value = await run(extensions)
|
|
161
|
+
return { value, text: JSON.stringify(value, null, 2) }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
165
|
+
private insideApp(folder: unknown): string {
|
|
166
|
+
if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
|
|
167
|
+
const root = this.deps.app.root
|
|
168
|
+
const full = resolve(root, folder)
|
|
169
|
+
const path = relative(root, full)
|
|
170
|
+
if (path === '' || path.startsWith('..') || isAbsolute(path)) throw new DoorError('outside-app', `${folder} is not a folder inside the app (${root})`)
|
|
171
|
+
return full
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private offeredTools(): Array<{ name: string; description: string }> {
|
|
175
|
+
const tools = this.deps.tools()
|
|
176
|
+
if (tools === undefined) return []
|
|
177
|
+
const allowed = new Set(this.deps.expose)
|
|
178
|
+
return tools.list().filter(tool => allowed.has(tool.name)).map(({ name, description }) => ({ name, description }))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async callTool(input: Record<string, unknown>): Promise<VerbAnswer> {
|
|
182
|
+
const name = input.tool
|
|
183
|
+
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'tool call needs --tool <name>')
|
|
184
|
+
if (!this.deps.expose.includes(name)) throw new DoorError('not-exposed', `${name} is not offered through the door`)
|
|
185
|
+
const tools = this.deps.tools()
|
|
186
|
+
if (tools === undefined || !tools.list().some(tool => tool.name === name)) throw new DoorError('unavailable', `${name} is not available right now`)
|
|
187
|
+
let value: unknown
|
|
188
|
+
try {
|
|
189
|
+
value = await tools.call(name, input.args ?? {})
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
throw new DoorError('failed', cause instanceof Error ? cause.message : String(cause))
|
|
192
|
+
}
|
|
193
|
+
return { value, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every door route shares (moved from `acryl-agent-control`, spec 041). Two kinds of caller reach it, each with its own guard: the
|
|
3
|
+
* app's own page (same origin, on a loopback address), and an operator on this machine (`.acryl/bin/acryl`, an agent's shell) presenting
|
|
4
|
+
* this run's secret.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { Acryl, type Context, type MiddlewareHandler } from '../../../acryl-router/src/index.ts'
|
|
9
|
+
import { bodyLimit, loopbackAuthority, loopbackOnly, methodNotAllowed } from '../../../acryl-router/src/middleware/index.ts'
|
|
10
|
+
|
|
11
|
+
/** Every door route lives below this path. */
|
|
12
|
+
export const DOOR_PREFIX = '/api/acryl-door'
|
|
13
|
+
|
|
14
|
+
/** The cap on a JSON request body. */
|
|
15
|
+
export const MAX_BODY_BYTES = 64 * 1024
|
|
16
|
+
|
|
17
|
+
export type ReportError = (operation: string, cause: unknown) => void
|
|
18
|
+
|
|
19
|
+
/** The JSON body of a refusal. */
|
|
20
|
+
export function error(message: string): { readonly error: string } {
|
|
21
|
+
return { error: message }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The app's own page, on a loopback address, and nothing else: another origin, another port and a script on the machine are refused. */
|
|
25
|
+
export const ownPage: MiddlewareHandler = loopbackOnly({ callers: 'own-page' })
|
|
26
|
+
|
|
27
|
+
/** A constant-time comparison of a `Bearer` header with the secret. */
|
|
28
|
+
export function tokenMatches(header: string | undefined, secret: string): boolean {
|
|
29
|
+
const presented = header?.startsWith('Bearer ') === true ? header.slice('Bearer '.length) : ''
|
|
30
|
+
const a = Buffer.from(presented)
|
|
31
|
+
const b = Buffer.from(secret)
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Only an operator on this machine: a loopback peer addressing this server's own port (`loopbackAuthority`), presenting this run's
|
|
37
|
+
* secret. Anything else is 403 `forbidden`, which the client reads as "refused: the app restarted and the secret rotated?".
|
|
38
|
+
*/
|
|
39
|
+
export function operatorOnly(secret: string): MiddlewareHandler {
|
|
40
|
+
return async (c, next) => {
|
|
41
|
+
if (loopbackAuthority(c) === undefined || !tokenMatches(c.req.header('authorization'), secret)) return c.json(error('forbidden'), 403)
|
|
42
|
+
await next()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The headers every private answer carries, set after the handler so a refusal carries them too. */
|
|
47
|
+
const privateHeaders: MiddlewareHandler = async (c, next) => {
|
|
48
|
+
await next()
|
|
49
|
+
c.header('cache-control', 'no-store')
|
|
50
|
+
c.header('x-content-type-options', 'nosniff')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The door's routes as one app: each part's routes, every answer with the private headers, and a known path asked with another method
|
|
55
|
+
* answered 405 with its `Allow` before any guard runs. The middleware is scoped to the prefix, so it never wraps another plugin's routes.
|
|
56
|
+
*/
|
|
57
|
+
export function privateApi(prefix: string, ...routes: readonly Acryl[]): Acryl {
|
|
58
|
+
const app = new Acryl()
|
|
59
|
+
app.use(`${prefix}/*`, privateHeaders, methodNotAllowed({
|
|
60
|
+
app,
|
|
61
|
+
onMethodNotAllowed: (c, allowed) => c.json(error('method not allowed'), 405, { allow: allowed.join(', ') }),
|
|
62
|
+
}))
|
|
63
|
+
for (const part of routes) app.route('/', part)
|
|
64
|
+
return app
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Refuse a body over {@link MAX_BODY_BYTES} with 413 before the handler reads it. */
|
|
68
|
+
export const jsonBody: MiddlewareHandler = bodyLimit({ maxSize: MAX_BODY_BYTES, onError: c => c.json(error('request body is too large'), 413) })
|
|
69
|
+
|
|
70
|
+
/** Returned by {@link readJson} for a body that is not JSON. */
|
|
71
|
+
export const INVALID_JSON = Symbol('invalid JSON')
|
|
72
|
+
|
|
73
|
+
/** The parsed body, or {@link INVALID_JSON}. */
|
|
74
|
+
export async function readJson(c: Context): Promise<unknown> {
|
|
75
|
+
try {
|
|
76
|
+
return await c.req.json<unknown>()
|
|
77
|
+
} catch {
|
|
78
|
+
return INVALID_JSON
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door over MCP: `POST <prefix>/mcp`, streamable HTTP in its request/response form (one JSON-RPC message or a batch in, its JSON
|
|
3
|
+
* answer out, 202 for notifications only; no event stream is offered). Each verb is one tool, named as the verb, with the arguments the
|
|
4
|
+
* operator client sends. The same guard as the operator's routes: loopback, this run's secret.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Acryl } from '../../../acryl-router/src/index.ts'
|
|
8
|
+
import { UiControlError } from '../page/protocol.ts'
|
|
9
|
+
import type { Door } from './door.ts'
|
|
10
|
+
import { DoorError } from './door.ts'
|
|
11
|
+
import { INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
|
|
12
|
+
import { VERB_SPECS, VERBS, type Verb } from './verbs.ts'
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'] as const
|
|
15
|
+
const SERVER_INFO = { name: 'acryl-door', version: '1' } as const
|
|
16
|
+
|
|
17
|
+
interface JsonRpcRequest { readonly jsonrpc: '2.0'; readonly id?: string | number | null; readonly method: string; readonly params?: unknown }
|
|
18
|
+
|
|
19
|
+
function isRequest(value: unknown): value is JsonRpcRequest {
|
|
20
|
+
return typeof value === 'object' && value !== null && (value as { jsonrpc?: unknown }).jsonrpc === '2.0' && typeof (value as { method?: unknown }).method === 'string'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rpcError = (id: JsonRpcRequest['id'], code: number, message: string) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } })
|
|
24
|
+
const result = (id: JsonRpcRequest['id'], value: unknown) => ({ jsonrpc: '2.0', id: id ?? null, result: value })
|
|
25
|
+
|
|
26
|
+
/** The verbs as MCP tools. */
|
|
27
|
+
export function mcpTools(): Array<{ name: Verb; description: string; inputSchema: unknown }> {
|
|
28
|
+
return VERBS.map(verb => ({ name: verb, description: VERB_SPECS[verb].description, inputSchema: VERB_SPECS[verb].arguments }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isVerb = (name: unknown): name is Verb => typeof name === 'string' && (VERBS as readonly string[]).includes(name)
|
|
32
|
+
|
|
33
|
+
/** One JSON-RPC message; `undefined` for a notification (no answer owed). */
|
|
34
|
+
async function answer(message: JsonRpcRequest, door: Door, signal: AbortSignal): Promise<Record<string, unknown> | undefined> {
|
|
35
|
+
const isNotification = message.id === undefined
|
|
36
|
+
switch (message.method) {
|
|
37
|
+
case 'initialize': {
|
|
38
|
+
const asked = (message.params as { protocolVersion?: unknown } | undefined)?.protocolVersion
|
|
39
|
+
const protocolVersion = (SUPPORTED_PROTOCOLS as readonly unknown[]).includes(asked) ? asked : SUPPORTED_PROTOCOLS[0]
|
|
40
|
+
return result(message.id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO })
|
|
41
|
+
}
|
|
42
|
+
case 'ping':
|
|
43
|
+
return isNotification ? undefined : result(message.id, {})
|
|
44
|
+
case 'tools/list':
|
|
45
|
+
return result(message.id, { tools: mcpTools() })
|
|
46
|
+
case 'tools/call': {
|
|
47
|
+
// MCP allows extra fields on params (`_meta`, a progress token); only `name` and `arguments` mean anything here.
|
|
48
|
+
const params = typeof message.params === 'object' && message.params !== null ? message.params as Record<string, unknown> : {}
|
|
49
|
+
if (!isVerb(params.name)) return rpcError(message.id, -32602, `unknown tool ${JSON.stringify(params.name)}; the tools are ${VERBS.join(', ')}`)
|
|
50
|
+
try {
|
|
51
|
+
const verbAnswer = await door.run(params.name, params.arguments ?? {}, signal)
|
|
52
|
+
return result(message.id, { content: [{ type: 'text', text: verbAnswer.text }], structuredContent: { value: verbAnswer.value }, isError: false })
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
// A verb that failed is a tool result the model reads, with the same code the operator client prints.
|
|
55
|
+
if (cause instanceof DoorError || cause instanceof UiControlError) return result(message.id, { content: [{ type: 'text', text: `${cause.code}: ${cause.message}` }], isError: true })
|
|
56
|
+
throw cause
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return isNotification ? undefined : rpcError(message.id, -32601, `method not found: ${message.method}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mcpRoutes(prefix: string, secret: string, door: Door, reportError: ReportError): Acryl {
|
|
65
|
+
return new Acryl().post(`${prefix}/mcp`, operatorOnly(secret), jsonBody, async (c) => {
|
|
66
|
+
const body = await readJson(c)
|
|
67
|
+
if (body === INVALID_JSON) return c.json(rpcError(null, -32700, 'parse error'), 400)
|
|
68
|
+
try {
|
|
69
|
+
const messages = Array.isArray(body) ? body : [body]
|
|
70
|
+
const answers: Record<string, unknown>[] = []
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
if (!isRequest(message)) { answers.push(rpcError(null, -32600, 'invalid request')); continue }
|
|
73
|
+
const response = await answer(message, door, c.req.raw.signal)
|
|
74
|
+
if (response !== undefined) answers.push(response)
|
|
75
|
+
}
|
|
76
|
+
if (answers.length === 0) return c.body(null, 202)
|
|
77
|
+
return c.json(Array.isArray(body) ? answers : (answers[0] as object))
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
reportError('answer an MCP request', cause)
|
|
80
|
+
return c.json(rpcError(null, -32603, 'internal error'), 500)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|