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,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page driver: takes one {@link UiRequest} and does it, under the rules.
|
|
3
|
+
*
|
|
4
|
+
* It owns the ref table, the kill switch, the yield-to-the-user rule and the "driving" activity the indicator
|
|
5
|
+
* shows. It touches only the document it is given, never another window and never the OS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { UiControlError, MUTATING_OPS, type UiRequest, type UiResult } from '../protocol.ts'
|
|
9
|
+
import { click, pressKey, scroll, selectOption, typeText, wait, type ActionEnvironment } from './actions.ts'
|
|
10
|
+
import { RefTable } from './ref-table.ts'
|
|
11
|
+
import { SnapshotBuilder } from './snapshot.ts'
|
|
12
|
+
|
|
13
|
+
/** After the user touches the page, agent actions wait this long, so the user always wins. */
|
|
14
|
+
const YIELD_MS = 400
|
|
15
|
+
const MAX_YIELD_MS = 2000
|
|
16
|
+
|
|
17
|
+
export interface DriverActivity {
|
|
18
|
+
/** True from the moment a call starts until it ends. */
|
|
19
|
+
readonly busy: boolean
|
|
20
|
+
/** What the agent is doing, for the indicator ("click", "type", ...). */
|
|
21
|
+
readonly doing: string
|
|
22
|
+
/** True after the user pressed the kill switch. */
|
|
23
|
+
readonly killed: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UiDriverOptions {
|
|
27
|
+
readonly document: () => Document
|
|
28
|
+
/** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
|
|
29
|
+
readonly msSinceUserInput?: () => number
|
|
30
|
+
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function realSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (signal.aborted) { reject(signal.reason); return }
|
|
36
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms)
|
|
37
|
+
const onAbort = (): void => { clearTimeout(timer); reject(signal.reason) }
|
|
38
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class UiDriver {
|
|
43
|
+
private readonly refs = new RefTable()
|
|
44
|
+
private readonly snapshots: SnapshotBuilder
|
|
45
|
+
private readonly listeners = new Set<(activity: DriverActivity) => void>()
|
|
46
|
+
private readonly env: ActionEnvironment
|
|
47
|
+
private readonly stopAll = new Set<AbortController>()
|
|
48
|
+
private killedFlag = false
|
|
49
|
+
private disposed = false
|
|
50
|
+
private busyCount = 0
|
|
51
|
+
private doing = ''
|
|
52
|
+
private current: DriverActivity = { busy: false, doing: '', killed: false }
|
|
53
|
+
|
|
54
|
+
private readonly options: UiDriverOptions
|
|
55
|
+
|
|
56
|
+
constructor(options: UiDriverOptions) {
|
|
57
|
+
this.options = options
|
|
58
|
+
this.snapshots = new SnapshotBuilder(this.refs, () => options.document().body)
|
|
59
|
+
this.env = { refs: this.refs, document: options.document, sleep: options.sleep ?? realSleep }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The current activity; the same object until something changes, as a store snapshot must be. */
|
|
63
|
+
get activity(): DriverActivity {
|
|
64
|
+
return this.current
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
subscribe(listener: (activity: DriverActivity) => void): () => void {
|
|
68
|
+
this.listeners.add(listener)
|
|
69
|
+
return () => { this.listeners.delete(listener) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The user pressed the kill switch: pending calls settle as `killed` and new ones are refused. */
|
|
73
|
+
kill(): void {
|
|
74
|
+
this.killedFlag = true
|
|
75
|
+
for (const controller of [...this.stopAll]) controller.abort(new UiControlError('killed', 'the user stopped the agent'))
|
|
76
|
+
this.refs.clear()
|
|
77
|
+
this.snapshots.clear()
|
|
78
|
+
this.emit()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The user allowed the agent to drive again. */
|
|
82
|
+
resume(): void {
|
|
83
|
+
this.killedFlag = false
|
|
84
|
+
this.emit()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The driver goes away: pending calls settle as `unloaded`; refs are gone. */
|
|
88
|
+
dispose(): void {
|
|
89
|
+
this.disposed = true
|
|
90
|
+
for (const controller of [...this.stopAll]) controller.abort(new UiControlError('unloaded', 'the driver was unloaded'))
|
|
91
|
+
this.refs.clear()
|
|
92
|
+
this.snapshots.clear()
|
|
93
|
+
this.listeners.clear()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param signal - the Host cancelled the call. @throws UiControlError. */
|
|
97
|
+
async handle(request: UiRequest, signal?: AbortSignal): Promise<UiResult> {
|
|
98
|
+
if (this.disposed) throw new UiControlError('unloaded', 'the driver was unloaded')
|
|
99
|
+
if (this.killedFlag) throw new UiControlError('killed', 'the user stopped the agent; it cannot drive until they allow it again')
|
|
100
|
+
const controller = new AbortController()
|
|
101
|
+
this.stopAll.add(controller)
|
|
102
|
+
const onCancel = (): void => { controller.abort(new UiControlError('aborted', 'the call was cancelled')) }
|
|
103
|
+
signal?.addEventListener('abort', onCancel, { once: true })
|
|
104
|
+
this.busyCount += 1
|
|
105
|
+
this.doing = request.op
|
|
106
|
+
this.emit()
|
|
107
|
+
try {
|
|
108
|
+
if (MUTATING_OPS.includes(request.op)) await this.yieldToUser(controller.signal)
|
|
109
|
+
if (controller.signal.aborted) throw controller.signal.reason
|
|
110
|
+
return await this.run(request, controller.signal)
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
if (cause instanceof UiControlError) throw cause
|
|
113
|
+
if (controller.signal.aborted && controller.signal.reason instanceof UiControlError) throw controller.signal.reason
|
|
114
|
+
throw new UiControlError('not-actionable', cause instanceof Error ? cause.message : 'the action failed')
|
|
115
|
+
} finally {
|
|
116
|
+
signal?.removeEventListener('abort', onCancel)
|
|
117
|
+
this.stopAll.delete(controller)
|
|
118
|
+
this.busyCount -= 1
|
|
119
|
+
if (this.busyCount === 0) this.doing = ''
|
|
120
|
+
this.emit()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async run(request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
125
|
+
switch (request.op) {
|
|
126
|
+
case 'snapshot': return this.snapshots.snapshot({ ...(request.cursor === undefined ? {} : { cursor: request.cursor }), ...(request.maxNodes === undefined ? {} : { maxNodes: request.maxNodes }) })
|
|
127
|
+
case 'click': return click(this.env, request)
|
|
128
|
+
case 'type': return typeText(this.env, request)
|
|
129
|
+
case 'select': return selectOption(this.env, request)
|
|
130
|
+
case 'press': return pressKey(this.env, request)
|
|
131
|
+
case 'scroll': return scroll(this.env, request)
|
|
132
|
+
case 'wait': return wait(this.env, request, signal)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async yieldToUser(signal: AbortSignal): Promise<void> {
|
|
137
|
+
const since = this.options.msSinceUserInput
|
|
138
|
+
if (since === undefined) return
|
|
139
|
+
let waited = 0
|
|
140
|
+
while (since() < YIELD_MS && waited < MAX_YIELD_MS) {
|
|
141
|
+
await this.env.sleep(100, signal)
|
|
142
|
+
waited += 100
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private emit(): void {
|
|
147
|
+
const next: DriverActivity = { busy: this.busyCount > 0, doing: this.doing, killed: this.killedFlag }
|
|
148
|
+
if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
149
|
+
this.current = next
|
|
150
|
+
const activity = next
|
|
151
|
+
for (const listener of [...this.listeners]) listener(activity)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refs: short-lived handles an agent uses to name an element.
|
|
3
|
+
*
|
|
4
|
+
* A ref is `<generation>.<n>`. Each snapshot starts a new generation and every older ref becomes stale, so a
|
|
5
|
+
* ref can never quietly point at a different element after the page changed. Even within a generation, a
|
|
6
|
+
* ref is honoured only while its element is still in the document and still has the role and name it had when
|
|
7
|
+
* the ref was issued; a re-rendered or replaced control is a typed `stale-ref`, never a click somewhere else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { UiControlError } from '../protocol.ts'
|
|
11
|
+
import { nameOf, roleOf, TEXT_ROLE, textRunsOf } from './accessibility.ts'
|
|
12
|
+
|
|
13
|
+
interface Entry {
|
|
14
|
+
readonly element: WeakRef<Element>
|
|
15
|
+
readonly role: string
|
|
16
|
+
readonly name: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolvedRef {
|
|
20
|
+
readonly element: Element
|
|
21
|
+
readonly role: string
|
|
22
|
+
readonly name: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class RefTable {
|
|
26
|
+
private generationNumber = 0
|
|
27
|
+
private entries = new Map<number, Entry>()
|
|
28
|
+
private nextIndex = 1
|
|
29
|
+
|
|
30
|
+
get generation(): number {
|
|
31
|
+
return this.generationNumber
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Start a new generation: every earlier ref is now stale. */
|
|
35
|
+
begin(): number {
|
|
36
|
+
this.generationNumber += 1
|
|
37
|
+
this.entries = new Map()
|
|
38
|
+
this.nextIndex = 1
|
|
39
|
+
return this.generationNumber
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Forget everything (the driver is going away or was stopped). */
|
|
43
|
+
clear(): void {
|
|
44
|
+
this.begin()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
issue(element: Element, role: string, name: string): string {
|
|
48
|
+
const index = this.nextIndex
|
|
49
|
+
this.nextIndex += 1
|
|
50
|
+
this.entries.set(index, { element: new WeakRef(element), role, name })
|
|
51
|
+
return `${String(this.generationNumber)}.${String(index)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @throws UiControlError `unknown-ref` or `stale-ref`. */
|
|
55
|
+
resolve(ref: string): ResolvedRef {
|
|
56
|
+
const [generationText, indexText] = ref.split('.')
|
|
57
|
+
const generation = Number(generationText)
|
|
58
|
+
const index = Number(indexText)
|
|
59
|
+
if (!Number.isInteger(generation) || !Number.isInteger(index)) throw new UiControlError('unknown-ref', `${ref} is not a ref`)
|
|
60
|
+
if (generation !== this.generationNumber) {
|
|
61
|
+
throw new UiControlError('stale-ref', `${ref} is from an earlier snapshot (the latest is ${String(this.generationNumber)}); take a new snapshot`)
|
|
62
|
+
}
|
|
63
|
+
const entry = this.entries.get(index)
|
|
64
|
+
if (entry === undefined) throw new UiControlError('unknown-ref', `${ref} was not in the snapshot`)
|
|
65
|
+
const element = entry.element.deref()
|
|
66
|
+
if (element === undefined || !element.isConnected) throw new UiControlError('stale-ref', `${ref} is no longer on the page; take a new snapshot`)
|
|
67
|
+
// The same node can be reused for something else after a re-render: check it is still what was listed.
|
|
68
|
+
if (entry.role === TEXT_ROLE) {
|
|
69
|
+
if (!textRunsOf(element).includes(entry.name)) throw new UiControlError('stale-ref', `${ref} changed since the snapshot ("${entry.name}" is no longer there); take a new snapshot`)
|
|
70
|
+
return { element, role: entry.role, name: entry.name }
|
|
71
|
+
}
|
|
72
|
+
const role = roleOf(element)
|
|
73
|
+
if (role !== entry.role || nameOf(element, role) !== entry.name) {
|
|
74
|
+
throw new UiControlError('stale-ref', `${ref} changed since the snapshot ("${entry.name}" is now something else); take a new snapshot`)
|
|
75
|
+
}
|
|
76
|
+
return { element, role: entry.role, name: entry.name }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agent may never see or type into, decided by rule and not by trusting the agent:
|
|
3
|
+
* secrets and payment fields (sensitive), and controls that govern the agent itself (protected).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Field names and labels that mean a secret or a payment detail. */
|
|
7
|
+
const SENSITIVE_WORDS = /pass(?:word|code|phrase)|secret|token|api[-_ ]?key|credential|private[-_ ]?key|\bpin\b|card|cvv|cvc|iban|routing|account[-_ ]?number|ssn|social[-_ ]?security|one[-_ ]?time|otp/i
|
|
8
|
+
/** `autocomplete` values that browsers treat as credentials or payment data. */
|
|
9
|
+
const SENSITIVE_AUTOCOMPLETE = /^(?:cc-|current-password|new-password|one-time-code)/
|
|
10
|
+
|
|
11
|
+
const label = (el: Element, attr: string): string => el.getAttribute(attr) ?? ''
|
|
12
|
+
|
|
13
|
+
/** True for a form field (or anything marked) that holds a secret or payment detail. */
|
|
14
|
+
export function isSensitive(el: Element): boolean {
|
|
15
|
+
if (el.closest('[data-acryl-sensitive]') !== null) return true
|
|
16
|
+
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return false
|
|
17
|
+
if (el instanceof HTMLInputElement && el.type === 'password') return true
|
|
18
|
+
const autocomplete = label(el, 'autocomplete').trim().toLowerCase()
|
|
19
|
+
if (autocomplete.split(/\s+/).some(token => SENSITIVE_AUTOCOMPLETE.test(token))) return true
|
|
20
|
+
return SENSITIVE_WORDS.test([el.name, el.id, label(el, 'placeholder'), label(el, 'aria-label')].join(' '))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Areas whose controls the agent may look at but never operate. */
|
|
24
|
+
const PROTECTED_REGION = /approval|permission|policy|agent control|ui control|acryl-agent-control|acryl-door|kill switch/i
|
|
25
|
+
|
|
26
|
+
/** The text an ancestor uses to name itself, for region rules. */
|
|
27
|
+
function ancestorName(el: Element): string {
|
|
28
|
+
const own = label(el, 'aria-label') || label(el, 'data-region') || ''
|
|
29
|
+
if (own !== '') return own
|
|
30
|
+
const labelled = label(el, 'aria-labelledby')
|
|
31
|
+
if (labelled !== '') {
|
|
32
|
+
return labelled.split(/\s+/).map(id => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ')
|
|
33
|
+
}
|
|
34
|
+
return ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True for a control the agent must not operate: inside `[data-acryl-no-agent]`, inside a region whose
|
|
39
|
+
* name says approval, permission, policy or Agent Control, or itself named after switching Agent Control off.
|
|
40
|
+
*/
|
|
41
|
+
export function isProtected(el: Element): boolean {
|
|
42
|
+
if (el.closest('[data-acryl-no-agent]') !== null) return true
|
|
43
|
+
let node: Element | null = el
|
|
44
|
+
for (let depth = 0; node !== null && depth < 12; depth += 1) {
|
|
45
|
+
if (PROTECTED_REGION.test(ancestorName(node))) return true
|
|
46
|
+
node = node.parentElement
|
|
47
|
+
}
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Builds a bounded, redacted accessibility snapshot of the page, with refs. */
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, UiControlError, type UiNode, type UiSnapshot } from '../protocol.ts'
|
|
4
|
+
import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
|
|
5
|
+
import type { RefTable } from './ref-table.ts'
|
|
6
|
+
import { isProtected, isSensitive } from './sensitivity.ts'
|
|
7
|
+
|
|
8
|
+
/** Marks the driver's own UI (the indicator), which a snapshot never lists. */
|
|
9
|
+
export const DRIVER_UI_ATTRIBUTE = 'data-acryl-agent-control'
|
|
10
|
+
|
|
11
|
+
/** The most elements one snapshot considers, however large the page. */
|
|
12
|
+
const MAX_CANDIDATES = 5000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
interface Candidate {
|
|
16
|
+
readonly element: Element
|
|
17
|
+
readonly role: string
|
|
18
|
+
readonly name: string
|
|
19
|
+
readonly depth: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True when the element (with layout) is at least partly inside the window; unknown layout counts as inside. */
|
|
23
|
+
function inViewport(element: Element, view: Window | null): boolean {
|
|
24
|
+
if (view === null) return true
|
|
25
|
+
const rect = element.getBoundingClientRect()
|
|
26
|
+
if (rect.width === 0 && rect.height === 0) return true
|
|
27
|
+
return rect.bottom > 0 && rect.right > 0 && rect.top < view.innerHeight && rect.left < view.innerWidth
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every element with a role, and every run of visible text, in reading order. Text that is already a name (inside a button, a link, a
|
|
32
|
+
* heading, a label) is not listed again; a run is the text between two child elements, so `<p>Left: <b>24:59</b></p>` reads as
|
|
33
|
+
* `Left:` and `24:59`.
|
|
34
|
+
*/
|
|
35
|
+
function collect(root: Element): Candidate[] {
|
|
36
|
+
const found: Candidate[] = []
|
|
37
|
+
const walk = (element: Element, depth: number, named: boolean): void => {
|
|
38
|
+
if (found.length >= MAX_CANDIDATES) return
|
|
39
|
+
if (element.hasAttribute(DRIVER_UI_ATTRIBUTE)) return
|
|
40
|
+
const tag = element.tagName.toLowerCase()
|
|
41
|
+
if (tag === 'script' || tag === 'style' || tag === 'template' || tag === 'noscript') return
|
|
42
|
+
if (isHidden(element)) return
|
|
43
|
+
// A secret or payment field is not listed, and neither is anything inside a marked area.
|
|
44
|
+
if (isSensitive(element)) return
|
|
45
|
+
const role = roleOf(element)
|
|
46
|
+
let childDepth = depth
|
|
47
|
+
if (role !== null) {
|
|
48
|
+
found.push({ element, role, name: nameOf(element, role), depth })
|
|
49
|
+
childDepth = depth + 1
|
|
50
|
+
}
|
|
51
|
+
const inName = named || tag === 'label' || (role !== null && (NAMED_BY_CONTENT.has(role) || SHOWN_AS_VALUE.has(role)))
|
|
52
|
+
let run = ''
|
|
53
|
+
const flush = (): void => {
|
|
54
|
+
const text = textRun(run)
|
|
55
|
+
run = ''
|
|
56
|
+
if (text !== '' && !inName && found.length < MAX_CANDIDATES) found.push({ element, role: TEXT_ROLE, name: text, depth: childDepth })
|
|
57
|
+
}
|
|
58
|
+
for (const child of Array.from(element.childNodes)) {
|
|
59
|
+
if (child.nodeType === Node.TEXT_NODE) run += child.textContent ?? ''
|
|
60
|
+
else if (child instanceof Element) {
|
|
61
|
+
flush()
|
|
62
|
+
walk(child, childDepth, inName)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
flush()
|
|
66
|
+
}
|
|
67
|
+
walk(root, 0, false)
|
|
68
|
+
return found
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SnapshotOptions {
|
|
72
|
+
readonly cursor?: number
|
|
73
|
+
readonly maxNodes?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Snapshots the page and remembers the full candidate list of the current generation, for paging. */
|
|
77
|
+
export class SnapshotBuilder {
|
|
78
|
+
private candidates: Candidate[] = []
|
|
79
|
+
|
|
80
|
+
private readonly refs: RefTable
|
|
81
|
+
private readonly root: () => Element
|
|
82
|
+
|
|
83
|
+
constructor(refs: RefTable, root: () => Element) {
|
|
84
|
+
this.refs = refs
|
|
85
|
+
this.root = root
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
snapshot(options: SnapshotOptions = {}): UiSnapshot {
|
|
89
|
+
const max = Math.min(options.maxNodes ?? DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES)
|
|
90
|
+
const root = this.root()
|
|
91
|
+
const view = root.ownerDocument.defaultView
|
|
92
|
+
let offset = options.cursor ?? 0
|
|
93
|
+
if (options.cursor === undefined) {
|
|
94
|
+
this.refs.begin()
|
|
95
|
+
const all = collect(root)
|
|
96
|
+
// What the user can see comes first, so a long page is cut from the bottom, not the middle of the window.
|
|
97
|
+
this.candidates = [...all.filter(c => inViewport(c.element, view)), ...all.filter(c => !inViewport(c.element, view))]
|
|
98
|
+
offset = 0
|
|
99
|
+
} else if (this.candidates.length === 0 || offset > this.candidates.length) {
|
|
100
|
+
throw new UiControlError('stale-ref', 'that page of the snapshot is gone; take a new snapshot')
|
|
101
|
+
}
|
|
102
|
+
const page = this.candidates.slice(offset, offset + max)
|
|
103
|
+
const nodes: UiNode[] = page.map((candidate) => {
|
|
104
|
+
const states = statesOf(candidate.element, candidate.role)
|
|
105
|
+
if (isProtected(candidate.element)) states.push('protected')
|
|
106
|
+
const value = valueOf(candidate.element, candidate.role)
|
|
107
|
+
const level = candidate.role === 'heading' ? levelOf(candidate.element) : undefined
|
|
108
|
+
return {
|
|
109
|
+
ref: this.refs.issue(candidate.element, candidate.role, candidate.name),
|
|
110
|
+
role: candidate.role,
|
|
111
|
+
name: candidate.name,
|
|
112
|
+
depth: candidate.depth,
|
|
113
|
+
states,
|
|
114
|
+
...(value === undefined ? {} : { value }),
|
|
115
|
+
...(level === undefined ? {} : { level }),
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
const end = offset + page.length
|
|
119
|
+
return {
|
|
120
|
+
generation: this.refs.generation,
|
|
121
|
+
title: root.ownerDocument.title,
|
|
122
|
+
nodes,
|
|
123
|
+
total: this.candidates.length,
|
|
124
|
+
...(end < this.candidates.length ? { nextCursor: end } : {}),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Drop the remembered page list (with the refs). */
|
|
129
|
+
clear(): void {
|
|
130
|
+
this.candidates = []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's page module (`main.ts` starts it; `<script type="module" src="/api/acryl-door/page.js"></script>` loads that). It drives this
|
|
3
|
+
* page's document for the agent (snapshots with refs, clicks, typing), answers the Host over the channel, and shows the indicator with
|
|
4
|
+
* the kill switch. It touches only this document, never another window and never the OS.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PageChannel } from './channel.ts'
|
|
8
|
+
import { UiDriver } from './driver/driver.ts'
|
|
9
|
+
import { mountIndicator } from './indicator.ts'
|
|
10
|
+
import { UserInputClock } from './user-input.ts'
|
|
11
|
+
|
|
12
|
+
export const DOOR_BASE = '/api/acryl-door'
|
|
13
|
+
|
|
14
|
+
/** Start the door on `document`. @returns the disposer. */
|
|
15
|
+
export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
|
|
16
|
+
const view = document.defaultView
|
|
17
|
+
if (view === null) throw new Error('acryl-door: the document has no window')
|
|
18
|
+
const clock = new UserInputClock()
|
|
19
|
+
const driver = new UiDriver({ document: () => document, msSinceUserInput: clock.msSince })
|
|
20
|
+
const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
|
|
21
|
+
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
22
|
+
const detachInput = clock.attach(document)
|
|
23
|
+
const removeIndicator = mountIndicator(driver, document)
|
|
24
|
+
const channel = new PageChannel(driver, { windowId, focused, base })
|
|
25
|
+
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
26
|
+
view.addEventListener('focus', reportFocus)
|
|
27
|
+
view.addEventListener('blur', reportFocus)
|
|
28
|
+
document.addEventListener('visibilitychange', reportFocus)
|
|
29
|
+
channel.connect()
|
|
30
|
+
return () => {
|
|
31
|
+
view.removeEventListener('focus', reportFocus)
|
|
32
|
+
view.removeEventListener('blur', reportFocus)
|
|
33
|
+
document.removeEventListener('visibilitychange', reportFocus)
|
|
34
|
+
channel.dispose()
|
|
35
|
+
removeIndicator()
|
|
36
|
+
detachInput()
|
|
37
|
+
// Pending calls settle as `unloaded` and every ref goes stale.
|
|
38
|
+
driver.dispose()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown while
|
|
3
|
+
* a call runs and after the person stopped the agent. It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
|
|
4
|
+
* it can never press its own Stop or Allow buttons). Escape stops the agent too, while it is working.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DriverActivity, UiDriver } from './driver/driver.ts'
|
|
8
|
+
|
|
9
|
+
const LABELS: Record<string, string> = { snapshot: 'looking at the page', click: 'clicking', type: 'typing', select: 'choosing an option', press: 'pressing a key', scroll: 'scrolling', wait: 'waiting' }
|
|
10
|
+
|
|
11
|
+
const STYLE_ID = 'acryl-door-indicator-style'
|
|
12
|
+
const STYLES = `
|
|
13
|
+
.acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
|
|
14
|
+
padding: 8px 12px; border-radius: 8px; font: 13px/1.4 system-ui, sans-serif; color: #fff; background: #1f2937; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
|
15
|
+
.acrylDoorBar[data-killed] { background: #7f1d1d; }
|
|
16
|
+
.acrylDoorBar button { font: inherit; color: inherit; background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.35); border-radius: 6px; padding: 2px 10px; cursor: pointer; }
|
|
17
|
+
.acrylDoorDot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; }
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
/** Show the bar for `driver` in `document`. @returns the disposer, which removes the bar, its style and its listeners. */
|
|
21
|
+
export function mountIndicator(driver: UiDriver, document: Document): () => void {
|
|
22
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
23
|
+
const style = document.createElement('style')
|
|
24
|
+
style.id = STYLE_ID
|
|
25
|
+
style.textContent = STYLES
|
|
26
|
+
document.head.append(style)
|
|
27
|
+
}
|
|
28
|
+
const bar = document.createElement('div')
|
|
29
|
+
bar.className = 'acrylDoorBar'
|
|
30
|
+
bar.setAttribute('data-acryl-agent-control', '')
|
|
31
|
+
bar.setAttribute('data-acryl-no-agent', '')
|
|
32
|
+
bar.setAttribute('role', 'status')
|
|
33
|
+
bar.setAttribute('aria-live', 'polite')
|
|
34
|
+
|
|
35
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
36
|
+
if (event.key === 'Escape' && event.isTrusted && driver.activity.busy) driver.kill()
|
|
37
|
+
}
|
|
38
|
+
document.addEventListener('keydown', onKey, true)
|
|
39
|
+
|
|
40
|
+
const render = (activity: DriverActivity): void => {
|
|
41
|
+
if (!activity.busy && !activity.killed) { bar.remove(); return }
|
|
42
|
+
const button = document.createElement('button')
|
|
43
|
+
button.type = 'button'
|
|
44
|
+
const text = document.createElement('span')
|
|
45
|
+
if (activity.killed) {
|
|
46
|
+
bar.setAttribute('data-killed', '')
|
|
47
|
+
text.textContent = 'Agent stopped. It cannot use the page until you allow it.'
|
|
48
|
+
button.textContent = 'Allow again'
|
|
49
|
+
button.addEventListener('click', () => { driver.resume() })
|
|
50
|
+
bar.replaceChildren(text, button)
|
|
51
|
+
} else {
|
|
52
|
+
bar.removeAttribute('data-killed')
|
|
53
|
+
const dot = document.createElement('span')
|
|
54
|
+
dot.className = 'acrylDoorDot'
|
|
55
|
+
dot.setAttribute('aria-hidden', 'true')
|
|
56
|
+
text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`
|
|
57
|
+
button.textContent = 'Stop'
|
|
58
|
+
button.addEventListener('click', () => { driver.kill() })
|
|
59
|
+
bar.replaceChildren(dot, text, button)
|
|
60
|
+
}
|
|
61
|
+
if (!bar.isConnected) document.body.append(bar)
|
|
62
|
+
}
|
|
63
|
+
const unsubscribe = driver.subscribe(render)
|
|
64
|
+
render(driver.activity)
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
unsubscribe()
|
|
68
|
+
document.removeEventListener('keydown', onKey, true)
|
|
69
|
+
bar.remove()
|
|
70
|
+
document.getElementById(STYLE_ID)?.remove()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The page's script: starts the door once on this document, even if the script tag appears twice. */
|
|
2
|
+
|
|
3
|
+
import { startDoor } from './index.ts'
|
|
4
|
+
|
|
5
|
+
const flag = '__acrylDoor'
|
|
6
|
+
const host = globalThis as { [flag]?: () => void }
|
|
7
|
+
if (host[flag] === undefined) host[flag] = startDoor(document)
|