acrylblends 0.7.8 → 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 +13 -5
- package/dist/index.js +72 -13
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +2 -1
- package/docs/framework/upstream.json +3 -3
- package/lib/node-server.js +1 -0
- package/package.json +1 -1
- package/plugins/acryl-db/plugin.json +6 -1
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +1 -1
- 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 +6 -1
- 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/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/upstream.json +2 -2
- 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/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/resolve.ts +16 -2
- package/plugins/tsconfig.json +6 -0
- package/shared/DEFAULTS.md +11 -6
- package/shared/SKILL.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +18 -2
- package/templates/agent/src/routes.ts +14 -1
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +10 -4
- package/templates/agent/test/app.test.ts +29 -8
- package/templates/agent/test/builder.test.ts +34 -23
- package/templates/agent/test/model.test.ts +24 -19
- package/templates/blank/src/app.ts +25 -5
- package/templates/blank/test/app.test.ts +28 -9
- 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/src/app.ts +27 -7
- package/templates/todo/test/todo.test.ts +47 -22
- package/templates/todo/upstream.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The door's operator client: what `<app>/.acryl/bin/acryl` runs. It finds the app from the folder it is started in (the nearest
|
|
4
|
+
* `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
|
|
5
|
+
* folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
|
|
6
|
+
*
|
|
7
|
+
* acryl brief | look | act <op> ... | plugin list|add|remove|reload | tool list|call | verbs (--json for the raw value)
|
|
8
|
+
* acryl control <...> the same, the ACRYL Host form (`control snapshot`, `control click`, ... are look and act)
|
|
9
|
+
*
|
|
10
|
+
* Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
14
|
+
import { dirname, join, resolve } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
|
|
17
|
+
|
|
18
|
+
const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait'])
|
|
19
|
+
const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args'])
|
|
20
|
+
const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--help'])
|
|
21
|
+
|
|
22
|
+
export class UsageError extends Error {}
|
|
23
|
+
|
|
24
|
+
export interface Invocation {
|
|
25
|
+
readonly verb: string
|
|
26
|
+
readonly args: Record<string, unknown>
|
|
27
|
+
readonly json: boolean
|
|
28
|
+
readonly dir: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
32
|
+
brief
|
|
33
|
+
look [--cursor <n>] [--max-nodes <n>]
|
|
34
|
+
act click --ref <r>
|
|
35
|
+
act type --ref <r> --text <t> [--submit] [--no-clear]
|
|
36
|
+
act select --ref <r> --option <o>
|
|
37
|
+
act press --key <key> [--ref <r>]
|
|
38
|
+
act scroll --direction up|down|left|right [--ref <r>] [--amount <px>]
|
|
39
|
+
act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
|
|
40
|
+
plugin list | plugin add <folder> | plugin remove <name> | plugin reload
|
|
41
|
+
tool list | tool call --tool <name> [--args '<json>']
|
|
42
|
+
verbs`
|
|
43
|
+
|
|
44
|
+
function integer(values: Map<string, string>, flag: string): number | undefined {
|
|
45
|
+
const raw = values.get(flag)
|
|
46
|
+
if (raw === undefined) return undefined
|
|
47
|
+
const value = Number(raw)
|
|
48
|
+
if (!Number.isInteger(value) || value < 0) throw new UsageError(`${flag} must be a non-negative integer`)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @throws UsageError. */
|
|
53
|
+
export function parseInvocation(argv: readonly string[]): Invocation {
|
|
54
|
+
const words: string[] = []
|
|
55
|
+
const values = new Map<string, string>()
|
|
56
|
+
const set = new Set<string>()
|
|
57
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
58
|
+
const arg = argv[at] as string
|
|
59
|
+
if (VALUED.has(arg)) {
|
|
60
|
+
const value = argv[at + 1]
|
|
61
|
+
if (value === undefined) throw new UsageError(`${arg} needs a value`)
|
|
62
|
+
values.set(arg, value)
|
|
63
|
+
at += 1
|
|
64
|
+
} else if (FLAGS.has(arg)) set.add(arg)
|
|
65
|
+
else if (arg.startsWith('--')) throw new UsageError(`unknown option ${arg}`)
|
|
66
|
+
else words.push(arg)
|
|
67
|
+
}
|
|
68
|
+
if (set.has('--help')) throw new UsageError(USAGE)
|
|
69
|
+
if (words[0] === 'control') words.shift()
|
|
70
|
+
// The ACRYL Host form: `control snapshot` is look, `control click` is act click.
|
|
71
|
+
if (words[0] === 'snapshot') words[0] = 'look'
|
|
72
|
+
else if (words[0] !== undefined && ACT_OPS.has(words[0])) words.unshift('act')
|
|
73
|
+
const [first, second, third] = words
|
|
74
|
+
const json = set.has('--json')
|
|
75
|
+
const dir = values.get('--dir')
|
|
76
|
+
const make = (verb: string, args: Record<string, unknown> = {}): Invocation => ({ verb, args, json, dir })
|
|
77
|
+
switch (first) {
|
|
78
|
+
case 'brief': return make('brief')
|
|
79
|
+
case 'verbs': return make('verbs')
|
|
80
|
+
case 'look': {
|
|
81
|
+
const cursor = integer(values, '--cursor')
|
|
82
|
+
const maxNodes = integer(values, '--max-nodes')
|
|
83
|
+
return make('look', { ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) })
|
|
84
|
+
}
|
|
85
|
+
case 'act': {
|
|
86
|
+
if (second === undefined || !ACT_OPS.has(second)) throw new UsageError(`act needs one of ${[...ACT_OPS].join(', ')}\n${USAGE}`)
|
|
87
|
+
const amount = integer(values, '--amount')
|
|
88
|
+
const timeoutMs = integer(values, '--timeout-ms')
|
|
89
|
+
const pick = (flag: string, key: string): Record<string, string> => (values.has(flag) ? { [key]: values.get(flag) as string } : {})
|
|
90
|
+
return make('act', {
|
|
91
|
+
op: second,
|
|
92
|
+
...pick('--ref', 'ref'), ...pick('--text', 'text'), ...pick('--option', 'option'), ...pick('--key', 'key'),
|
|
93
|
+
...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'),
|
|
94
|
+
...(amount === undefined ? {} : { amount }),
|
|
95
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
96
|
+
...(set.has('--submit') ? { submit: true } : {}),
|
|
97
|
+
...(set.has('--no-clear') ? { clear: false } : {}),
|
|
98
|
+
...(set.has('--gone') ? { gone: true } : {}),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
case 'plugin': {
|
|
102
|
+
if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
|
|
103
|
+
if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
|
|
104
|
+
if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
|
|
105
|
+
throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
|
|
106
|
+
}
|
|
107
|
+
case 'tool': {
|
|
108
|
+
if (second === 'list') return make('tool-list')
|
|
109
|
+
if (second === 'call') {
|
|
110
|
+
const tool = values.get('--tool')
|
|
111
|
+
if (tool === undefined) throw new UsageError('tool call needs --tool <name>')
|
|
112
|
+
const raw = values.get('--args')
|
|
113
|
+
let args: unknown = {}
|
|
114
|
+
if (raw !== undefined) {
|
|
115
|
+
try { args = JSON.parse(raw) } catch { throw new UsageError('--args must be JSON') }
|
|
116
|
+
}
|
|
117
|
+
return make('tool-call', { tool, args })
|
|
118
|
+
}
|
|
119
|
+
throw new UsageError(`tool needs list or call\n${USAGE}`)
|
|
120
|
+
}
|
|
121
|
+
default: throw new UsageError(USAGE)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json` (or at least `.acryl/`). */
|
|
126
|
+
export function findApp(cwd: string, dir: string | undefined): string | undefined {
|
|
127
|
+
if (dir !== undefined) return resolve(cwd, dir)
|
|
128
|
+
let fallback: string | undefined
|
|
129
|
+
for (let at = resolve(cwd); ; at = dirname(at)) {
|
|
130
|
+
if (existsSync(join(at, STATE_DIR, DOOR_FILE))) return at
|
|
131
|
+
if (fallback === undefined && existsSync(join(at, STATE_DIR))) fallback = at
|
|
132
|
+
if (dirname(at) === at) return fallback
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Io {
|
|
137
|
+
readonly cwd: string
|
|
138
|
+
readonly out: (text: string) => void
|
|
139
|
+
readonly err: (text: string) => void
|
|
140
|
+
readonly fetch?: typeof fetch
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Run one invocation. @returns the exit code. */
|
|
144
|
+
export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
145
|
+
let invocation: Invocation
|
|
146
|
+
try {
|
|
147
|
+
invocation = parseInvocation(argv)
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
if (cause instanceof UsageError) { io.err(`${cause.message}\n`); return 2 }
|
|
150
|
+
throw cause
|
|
151
|
+
}
|
|
152
|
+
const app = findApp(io.cwd, invocation.dir)
|
|
153
|
+
const file = app === undefined ? undefined : readDoorFile(app)
|
|
154
|
+
if (app === undefined || file === undefined) {
|
|
155
|
+
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then try again\n`)
|
|
156
|
+
return 3
|
|
157
|
+
}
|
|
158
|
+
const request = io.fetch ?? fetch
|
|
159
|
+
const headers = { authorization: `Bearer ${file.secret}`, 'content-type': 'application/json' }
|
|
160
|
+
let response: Response
|
|
161
|
+
try {
|
|
162
|
+
response = invocation.verb === 'verbs'
|
|
163
|
+
? await request(`${file.url}/api/acryl-door/verbs`, { headers })
|
|
164
|
+
: await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
|
|
165
|
+
} catch {
|
|
166
|
+
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
|
|
167
|
+
return 3
|
|
168
|
+
}
|
|
169
|
+
if (response.status === 403) {
|
|
170
|
+
io.err(`refused by the app in ${app}: the secret did not match (the app restarted? run the command again)\n`)
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
const body = await response.json().catch(() => undefined) as { ok?: boolean; value?: unknown; text?: string; code?: string; message?: string; verbs?: unknown } | undefined
|
|
174
|
+
if (body === undefined) { io.err(`the app answered ${String(response.status)} with no readable body\n`); return 1 }
|
|
175
|
+
if (invocation.verb === 'verbs') {
|
|
176
|
+
io.out(invocation.json ? `${JSON.stringify(body.verbs)}\n` : `${(body.verbs as string[]).join('\n')}\n`)
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
if (body.ok !== true) {
|
|
180
|
+
io.err(invocation.json ? `${JSON.stringify(body)}\n` : `${body.code ?? 'error'}: ${body.message ?? JSON.stringify(body)}\n`)
|
|
181
|
+
return 1
|
|
182
|
+
}
|
|
183
|
+
io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
|
|
184
|
+
return 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** True when this file is the program Node was started with (not imported by a test). */
|
|
188
|
+
function isProgram(): boolean {
|
|
189
|
+
const entry = process.argv[1]
|
|
190
|
+
if (entry === undefined) return false
|
|
191
|
+
try {
|
|
192
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isProgram()) {
|
|
199
|
+
main(process.argv.slice(2), {
|
|
200
|
+
cwd: process.cwd(),
|
|
201
|
+
out: (text) => { process.stdout.write(text) },
|
|
202
|
+
err: (text) => { process.stderr.write(text) },
|
|
203
|
+
}).then((code) => { process.exitCode = code }, (cause: unknown) => {
|
|
204
|
+
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
|
|
205
|
+
process.exitCode = 1
|
|
206
|
+
})
|
|
207
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's host core for another Host to build its own door on (`acryl-door/host`): the page channel and its routes, and the audit log.
|
|
3
|
+
* ACRYL's DSH Host uses it in `acryl-agent-control`, with its own tools, approval and secret.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { UiChannel, type PageStream } from './channel.ts'
|
|
7
|
+
export { AuditLog, auditPath, type AuditEntry } from './audit.ts'
|
|
8
|
+
export { HEARTBEAT_MS, pageChannelRoutes, type PageRoutesOptions } from './page-routes.ts'
|
|
9
|
+
export { renderSnapshot } from '../page/snapshot-text.ts'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit log: one JSON line per door call, append-only, in the app's own `.acryl/audit/` (moved from `acryl-agent-control`, spec 041).
|
|
3
|
+
*
|
|
4
|
+
* It is the record of what the agent did to the app. It never holds text the agent typed or values it read,
|
|
5
|
+
* only which control was touched and how the call ended.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import type { AuditEntry } from '../page/protocol.ts'
|
|
11
|
+
|
|
12
|
+
export type { AuditEntry } from '../page/protocol.ts'
|
|
13
|
+
|
|
14
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
/** The audit log below a folder the caller owns (the app's `.acryl/` here; ACRYL's app home for the DSH face). */
|
|
17
|
+
export function auditPath(folder: string): string {
|
|
18
|
+
return join(folder, 'audit', 'ui-control.jsonl')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class AuditLog {
|
|
22
|
+
private readonly path: string
|
|
23
|
+
|
|
24
|
+
constructor(path: string) {
|
|
25
|
+
this.path = path
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Append one line; a failing disk never breaks the tool call. */
|
|
29
|
+
record(entry: AuditEntry): void {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dirname(this.path), { recursive: true })
|
|
32
|
+
this.rotateIfLarge()
|
|
33
|
+
const fd = openSync(this.path, 'a', 0o600)
|
|
34
|
+
try {
|
|
35
|
+
writeSync(fd, `${JSON.stringify(entry)}\n`)
|
|
36
|
+
} finally {
|
|
37
|
+
closeSync(fd)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort: the record is important, but the app must keep working.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The most recent entries, oldest first. */
|
|
45
|
+
recent(limit = 100): AuditEntry[] {
|
|
46
|
+
try {
|
|
47
|
+
const lines = readFileSync(this.path, 'utf8').split('\n').filter(line => line !== '').slice(-limit)
|
|
48
|
+
return lines.flatMap((line) => {
|
|
49
|
+
try { return [JSON.parse(line) as AuditEntry] } catch { return [] }
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private rotateIfLarge(): void {
|
|
57
|
+
try {
|
|
58
|
+
if (statSync(this.path).size >= MAX_LOG_BYTES) renameSync(this.path, `${this.path}.1`)
|
|
59
|
+
} catch {
|
|
60
|
+
// Nothing to rotate yet.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
@@ -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
|
+
}
|