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,163 @@
|
|
|
1
|
+
/** Roles, accessible names and states of DOM elements, in the small subset a snapshot needs. */
|
|
2
|
+
|
|
3
|
+
import { isSensitive } from './sensitivity.ts'
|
|
4
|
+
|
|
5
|
+
const INPUT_ROLES: Record<string, string> = {
|
|
6
|
+
checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button', image: 'button',
|
|
7
|
+
range: 'slider', number: 'spinbutton', search: 'searchbox', text: 'textbox', email: 'textbox', url: 'textbox', tel: 'textbox', password: 'textbox',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LANDMARKS: Record<string, string> = { nav: 'navigation', main: 'main', aside: 'complementary', dialog: 'dialog', form: 'form' }
|
|
11
|
+
|
|
12
|
+
/** ARIA roles an element may declare that the driver treats as interactive or structural. */
|
|
13
|
+
const EXPLICIT_ROLES = new Set([
|
|
14
|
+
'button', 'link', 'tab', 'tabpanel', 'menu', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'checkbox', 'radio', 'switch', 'textbox',
|
|
15
|
+
'searchbox', 'combobox', 'listbox', 'option', 'slider', 'spinbutton', 'dialog', 'alertdialog', 'alert', 'status', 'navigation', 'main',
|
|
16
|
+
'heading', 'tree', 'treeitem', 'row', 'gridcell', 'tablist', 'toolbar', 'region', 'form', 'img',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
/** @returns the element's role, or null when it is not something a snapshot lists. */
|
|
20
|
+
export function roleOf(el: Element): string | null {
|
|
21
|
+
const explicit = el.getAttribute('role')?.trim().toLowerCase().split(/\s+/)[0]
|
|
22
|
+
if (explicit !== undefined && explicit !== '' && EXPLICIT_ROLES.has(explicit)) return explicit
|
|
23
|
+
const tag = el.tagName.toLowerCase()
|
|
24
|
+
if (tag === 'a') return el.hasAttribute('href') ? 'link' : null
|
|
25
|
+
if (tag === 'button' || tag === 'summary') return 'button'
|
|
26
|
+
if (tag === 'select') return (el as HTMLSelectElement).multiple ? 'listbox' : 'combobox'
|
|
27
|
+
if (tag === 'textarea') return 'textbox'
|
|
28
|
+
if (/^h[1-6]$/.test(tag)) return 'heading'
|
|
29
|
+
if (tag === 'img') return el.getAttribute('alt')?.trim() ? 'img' : null
|
|
30
|
+
if (tag === 'input') {
|
|
31
|
+
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
|
|
32
|
+
if (type === 'hidden') return null
|
|
33
|
+
return INPUT_ROLES[type] ?? 'textbox'
|
|
34
|
+
}
|
|
35
|
+
if (el.hasAttribute('contenteditable') && el.getAttribute('contenteditable') !== 'false') return 'textbox'
|
|
36
|
+
// A form is only a landmark when it has a name; an unnamed one is noise in a snapshot.
|
|
37
|
+
if (tag === 'form') return el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'form' : null
|
|
38
|
+
return LANDMARKS[tag] ?? null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Roles whose accessible name is the text they contain, so that text is not listed again on its own. */
|
|
42
|
+
export const NAMED_BY_CONTENT: ReadonlySet<string> = new Set(['button', 'link', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'heading', 'switch', 'treeitem', 'checkbox', 'radio', 'alert', 'status'])
|
|
43
|
+
|
|
44
|
+
/** Roles that show their content as a value (a field, a native select), so the text inside them is not listed on its own either. */
|
|
45
|
+
export const SHOWN_AS_VALUE: ReadonlySet<string> = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton'])
|
|
46
|
+
|
|
47
|
+
export const collapse = (value: string): string => value.replace(/\s+/g, ' ').trim()
|
|
48
|
+
export const clip = (value: string, max: number): string => (value.length > max ? `${value.slice(0, max - 1)}…` : value)
|
|
49
|
+
|
|
50
|
+
/** The text a reader would hear: like `textContent`, but leaving out what is `aria-hidden` or `hidden` (a check mark, an icon glyph). */
|
|
51
|
+
function readableText(root: Node): string {
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
const walk = (node: Node): void => {
|
|
54
|
+
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
|
55
|
+
else if (!(node instanceof Element && (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')))) node.childNodes.forEach(walk)
|
|
56
|
+
}
|
|
57
|
+
// The element being named is never skipped itself; only what it contains.
|
|
58
|
+
root.childNodes.forEach(walk)
|
|
59
|
+
return parts.join('')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const textOf = (el: Element, max: number): string => clip(collapse(readableText(el)), max)
|
|
63
|
+
|
|
64
|
+
/** The role of a run of visible text that is not already a control's name or a field's value: what an agent reads a value from. */
|
|
65
|
+
export const TEXT_ROLE = 'text'
|
|
66
|
+
|
|
67
|
+
/** The longest text run a snapshot carries. */
|
|
68
|
+
export const MAX_TEXT = 200
|
|
69
|
+
|
|
70
|
+
/** One run of text as a snapshot lists it: collapsed and clipped, empty when there is nothing to read. */
|
|
71
|
+
export const textRun = (raw: string): string => clip(collapse(raw), MAX_TEXT)
|
|
72
|
+
|
|
73
|
+
/** The runs of text directly inside an element (between its child elements), as a snapshot lists them. */
|
|
74
|
+
export function textRunsOf(el: Element): string[] {
|
|
75
|
+
const runs: string[] = []
|
|
76
|
+
let run = ''
|
|
77
|
+
for (const child of Array.from(el.childNodes)) {
|
|
78
|
+
if (child.nodeType === Node.TEXT_NODE) run += child.textContent ?? ''
|
|
79
|
+
else if (child instanceof Element) { runs.push(textRun(run)); run = '' }
|
|
80
|
+
}
|
|
81
|
+
runs.push(textRun(run))
|
|
82
|
+
return runs.filter(text => text !== '')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** ARIA-style accessible name, kept short. */
|
|
86
|
+
export function nameOf(el: Element, role: string): string {
|
|
87
|
+
const aria = el.getAttribute('aria-label')?.trim()
|
|
88
|
+
if (aria !== undefined && aria !== '') return clip(collapse(aria), 120)
|
|
89
|
+
const by = el.getAttribute('aria-labelledby')
|
|
90
|
+
if (by !== null && by.trim() !== '') {
|
|
91
|
+
const joined = collapse(by.split(/\s+/).map(id => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))
|
|
92
|
+
if (joined !== '') return clip(joined, 120)
|
|
93
|
+
}
|
|
94
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
|
|
95
|
+
const id = el.id
|
|
96
|
+
const explicit = id === '' ? null : el.ownerDocument.querySelector(`label[for="${CSS.escape(id)}"]`)
|
|
97
|
+
const wrapping = el.closest('label')
|
|
98
|
+
const label = explicit ?? wrapping
|
|
99
|
+
const labelText = label === null ? '' : collapse(readableText(label))
|
|
100
|
+
if (labelText !== '') return clip(labelText, 120)
|
|
101
|
+
if (el instanceof HTMLInputElement && (el.type === 'button' || el.type === 'submit' || el.type === 'reset') && el.value !== '') return clip(el.value, 120)
|
|
102
|
+
const placeholder = el.getAttribute('placeholder')?.trim()
|
|
103
|
+
if (placeholder !== undefined && placeholder !== '') return clip(placeholder, 120)
|
|
104
|
+
}
|
|
105
|
+
if (role === 'img') return clip(collapse(el.getAttribute('alt') ?? ''), 120)
|
|
106
|
+
if (NAMED_BY_CONTENT.has(role)) {
|
|
107
|
+
const text = textOf(el, 120)
|
|
108
|
+
if (text !== '') return text
|
|
109
|
+
}
|
|
110
|
+
if (role === 'alert' || role === 'status' || role === 'dialog' || role === 'alertdialog') {
|
|
111
|
+
const text = textOf(el, 160)
|
|
112
|
+
if (text !== '') return text
|
|
113
|
+
}
|
|
114
|
+
return clip(collapse(el.getAttribute('title') ?? ''), 120)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The state words a snapshot shows for an element. */
|
|
118
|
+
export function statesOf(el: Element, role: string): string[] {
|
|
119
|
+
const states: string[] = []
|
|
120
|
+
const aria = (name: string): string | null => el.getAttribute(name)
|
|
121
|
+
if ((el as HTMLButtonElement).disabled === true || aria('aria-disabled') === 'true') states.push('disabled')
|
|
122
|
+
if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) { if (el.checked) states.push('checked') } else if (aria('aria-checked') === 'true') states.push('checked')
|
|
123
|
+
if (aria('aria-expanded') === 'true') states.push('expanded')
|
|
124
|
+
if (aria('aria-expanded') === 'false') states.push('collapsed')
|
|
125
|
+
if (aria('aria-selected') === 'true') states.push('selected')
|
|
126
|
+
if (aria('aria-pressed') === 'true') states.push('pressed')
|
|
127
|
+
if (aria('aria-current') !== null && aria('aria-current') !== 'false') states.push('current')
|
|
128
|
+
if (aria('aria-invalid') === 'true') states.push('invalid')
|
|
129
|
+
if ((el as HTMLInputElement).required === true || aria('aria-required') === 'true') states.push('required')
|
|
130
|
+
if ((el as HTMLInputElement).readOnly === true || aria('aria-readonly') === 'true') states.push('readonly')
|
|
131
|
+
if (el.ownerDocument.activeElement === el) states.push('focused')
|
|
132
|
+
void role
|
|
133
|
+
return states
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The visible value of an editable field, or undefined when it is empty, not editable, or sensitive. */
|
|
137
|
+
export function valueOf(el: Element, role: string): string | undefined {
|
|
138
|
+
if (role !== 'textbox' && role !== 'searchbox' && role !== 'combobox' && role !== 'spinbutton') return undefined
|
|
139
|
+
if (isSensitive(el)) return undefined
|
|
140
|
+
let value: string | undefined
|
|
141
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) value = el.value
|
|
142
|
+
else if (el instanceof HTMLSelectElement) value = el.selectedOptions[0]?.textContent ?? undefined
|
|
143
|
+
else if (el.hasAttribute('contenteditable')) value = el.textContent ?? undefined
|
|
144
|
+
return value === undefined || value === '' ? undefined : clip(value, 100)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Heading level, when the element is a heading. */
|
|
148
|
+
export function levelOf(el: Element): number | undefined {
|
|
149
|
+
const explicit = Number(el.getAttribute('aria-level'))
|
|
150
|
+
if (Number.isInteger(explicit) && explicit >= 1 && explicit <= 6) return explicit
|
|
151
|
+
const match = /^h([1-6])$/.exec(el.tagName.toLowerCase())
|
|
152
|
+
return match === null ? undefined : Number(match[1])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Hidden from everyone: the driver skips it and everything inside it. */
|
|
156
|
+
export function isHidden(el: Element): boolean {
|
|
157
|
+
if (el.hasAttribute('hidden') || el.getAttribute('aria-hidden') === 'true') return true
|
|
158
|
+
if (el instanceof HTMLInputElement && el.type === 'hidden') return true
|
|
159
|
+
const view = el.ownerDocument.defaultView
|
|
160
|
+
if (view === null) return false
|
|
161
|
+
const style = view.getComputedStyle(el)
|
|
162
|
+
return style.display === 'none' || style.visibility === 'hidden'
|
|
163
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** The actions the driver performs on the page, each guarded by the same rules. */
|
|
2
|
+
|
|
3
|
+
import { MAX_WAIT_MS, UiControlError, type UiActionResult, type UiRequest } from '../protocol.ts'
|
|
4
|
+
import { isHidden, nameOf, roleOf } from './accessibility.ts'
|
|
5
|
+
import type { RefTable } from './ref-table.ts'
|
|
6
|
+
import { isProtected, isSensitive } from './sensitivity.ts'
|
|
7
|
+
import { DRIVER_UI_ATTRIBUTE } from './snapshot.ts'
|
|
8
|
+
|
|
9
|
+
type Request<Op extends UiRequest['op']> = Extract<UiRequest, { op: Op }>
|
|
10
|
+
|
|
11
|
+
const EDITABLE_ROLES = new Set(['textbox', 'searchbox', 'spinbutton', 'combobox'])
|
|
12
|
+
const POLL_MS = 50
|
|
13
|
+
|
|
14
|
+
export interface ActionEnvironment {
|
|
15
|
+
readonly refs: RefTable
|
|
16
|
+
readonly document: () => Document
|
|
17
|
+
/** Resolves after `ms`; rejects when `signal` aborts. Injectable so tests need no real time. */
|
|
18
|
+
readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const target = (role: string, name: string): { role: string; name: string } => ({ role, name })
|
|
22
|
+
|
|
23
|
+
function effectivelyHidden(element: Element): boolean {
|
|
24
|
+
for (let node: Element | null = element; node !== null; node = node.parentElement) if (isHidden(node)) return true
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Everything an action checks before touching an element. */
|
|
29
|
+
function prepare(env: ActionEnvironment, ref: string): { element: Element; role: string; name: string } {
|
|
30
|
+
const resolved = env.refs.resolve(ref)
|
|
31
|
+
const { element } = resolved
|
|
32
|
+
if (isSensitive(element)) throw new UiControlError('sensitive', 'that is a password, token or payment field; the agent does not read or type into it')
|
|
33
|
+
if (isProtected(element)) throw new UiControlError('protected', `"${resolved.name}" belongs to approval, permissions or Agent Control itself; only the user operates it`)
|
|
34
|
+
if (effectivelyHidden(element)) throw new UiControlError('not-actionable', `"${resolved.name}" is not visible`)
|
|
35
|
+
if ((element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true') {
|
|
36
|
+
throw new UiControlError('not-actionable', `"${resolved.name}" is disabled`)
|
|
37
|
+
}
|
|
38
|
+
return resolved
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mouse(element: Element, type: string): void {
|
|
42
|
+
element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** xterm.js reads neither the value of its input textarea nor a bare synthetic key: text arrives as a paste, and key handling reads the legacy `keyCode`. */
|
|
46
|
+
function isTerminalInput(element: Element): boolean {
|
|
47
|
+
return element.classList.contains('xterm-helper-textarea')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pasteInto(element: Element, text: string): void {
|
|
51
|
+
const clipboardData = new DataTransfer()
|
|
52
|
+
clipboardData.setData('text/plain', text)
|
|
53
|
+
element.dispatchEvent(new ClipboardEvent('paste', { clipboardData, bubbles: true, cancelable: true }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LEGACY_KEY_CODES: Readonly<Record<string, number>> = {
|
|
57
|
+
Backspace: 8, Tab: 9, Enter: 13, Escape: 27, ' ': 32, PageUp: 33, PageDown: 34, End: 35, Home: 36,
|
|
58
|
+
ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, Delete: 46,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setNativeValue(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
62
|
+
const proto = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
63
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
64
|
+
if (setter === undefined) element.value = value
|
|
65
|
+
else setter.call(element, value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function click(env: ActionEnvironment, request: Request<'click'>): UiActionResult {
|
|
69
|
+
const { element, role, name } = prepare(env, request.ref)
|
|
70
|
+
;(element as HTMLElement).scrollIntoView?.({ block: 'nearest' })
|
|
71
|
+
;(element as HTMLElement).focus?.()
|
|
72
|
+
mouse(element, 'mousedown')
|
|
73
|
+
mouse(element, 'mouseup')
|
|
74
|
+
;(element as HTMLElement).click()
|
|
75
|
+
return { ok: true, target: target(role, name) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function typeText(env: ActionEnvironment, request: Request<'type'>): UiActionResult {
|
|
79
|
+
const { element, role, name } = prepare(env, request.ref)
|
|
80
|
+
if (!EDITABLE_ROLES.has(role) || (role === 'combobox' && element instanceof HTMLSelectElement)) {
|
|
81
|
+
throw new UiControlError('not-actionable', `"${name}" is a ${role}, not a text field; use select or click`)
|
|
82
|
+
}
|
|
83
|
+
if ((element as HTMLInputElement).readOnly === true || element.getAttribute('aria-readonly') === 'true') {
|
|
84
|
+
throw new UiControlError('not-actionable', `"${name}" is read-only`)
|
|
85
|
+
}
|
|
86
|
+
const clear = request.clear ?? true
|
|
87
|
+
;(element as HTMLElement).focus?.()
|
|
88
|
+
if (isTerminalInput(element)) {
|
|
89
|
+
// A terminal has no text to clear: what is typed goes to the process that is running in it.
|
|
90
|
+
pasteInto(element, request.text)
|
|
91
|
+
} else if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
92
|
+
setNativeValue(element, clear ? request.text : element.value + request.text)
|
|
93
|
+
element.dispatchEvent(new Event('input', { bubbles: true }))
|
|
94
|
+
element.dispatchEvent(new Event('change', { bubbles: true }))
|
|
95
|
+
} else {
|
|
96
|
+
element.textContent = clear ? request.text : (element.textContent ?? '') + request.text
|
|
97
|
+
element.dispatchEvent(new Event('input', { bubbles: true }))
|
|
98
|
+
}
|
|
99
|
+
if (request.submit === true) pressKey(env, { op: 'press', key: 'Enter', ref: request.ref })
|
|
100
|
+
return { ok: true, target: target(role, name), detail: `typed ${String(request.text.length)} characters` }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function selectOption(env: ActionEnvironment, request: Request<'select'>): UiActionResult {
|
|
104
|
+
const { element, role, name } = prepare(env, request.ref)
|
|
105
|
+
if (!(element instanceof HTMLSelectElement)) throw new UiControlError('not-actionable', `"${name}" is a ${role}, not a select; click it and pick the option`)
|
|
106
|
+
const wanted = request.option.trim().toLowerCase()
|
|
107
|
+
const options = Array.from(element.options)
|
|
108
|
+
const match = options.find(option => option.value.toLowerCase() === wanted) ?? options.find(option => (option.textContent ?? '').trim().toLowerCase() === wanted)
|
|
109
|
+
if (match === undefined) throw new UiControlError('not-found', `"${name}" has no option "${request.option}"`)
|
|
110
|
+
if (match.disabled) throw new UiControlError('not-actionable', `the option "${request.option}" is disabled`)
|
|
111
|
+
element.value = match.value
|
|
112
|
+
element.dispatchEvent(new Event('input', { bubbles: true }))
|
|
113
|
+
element.dispatchEvent(new Event('change', { bubbles: true }))
|
|
114
|
+
return { ok: true, target: target(role, name), detail: `selected ${match.textContent?.trim() ?? match.value}` }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function pressKey(env: ActionEnvironment, request: Request<'press'>): UiActionResult {
|
|
118
|
+
const doc = env.document()
|
|
119
|
+
let element: Element
|
|
120
|
+
let role: string
|
|
121
|
+
let name: string
|
|
122
|
+
if (request.ref !== undefined) {
|
|
123
|
+
({ element, role, name } = prepare(env, request.ref))
|
|
124
|
+
} else {
|
|
125
|
+
element = doc.activeElement ?? doc.body
|
|
126
|
+
if (isSensitive(element)) throw new UiControlError('sensitive', 'the focused field is a password, token or payment field')
|
|
127
|
+
if (isProtected(element)) throw new UiControlError('protected', 'the focused control belongs to approval, permissions or Agent Control itself')
|
|
128
|
+
role = roleOf(element) ?? 'page'
|
|
129
|
+
name = roleOf(element) === null ? '' : nameOf(element, role)
|
|
130
|
+
}
|
|
131
|
+
const key = request.key === 'Space' ? ' ' : request.key
|
|
132
|
+
const keyCode = LEGACY_KEY_CODES[key]
|
|
133
|
+
const init = { key, bubbles: true, cancelable: true, ...(keyCode === undefined ? {} : { keyCode, which: keyCode }) }
|
|
134
|
+
;(element as HTMLElement).focus?.()
|
|
135
|
+
const proceed = element.dispatchEvent(new KeyboardEvent('keydown', init))
|
|
136
|
+
if (proceed) {
|
|
137
|
+
// A synthetic key event does not do what a real one does, so the two defaults that matter are done here.
|
|
138
|
+
if ((key === 'Enter' || key === ' ') && (role === 'button' || role === 'link')) (element as HTMLElement).click()
|
|
139
|
+
else if (key === 'Enter' && element instanceof HTMLInputElement && element.form !== null) element.form.requestSubmit?.()
|
|
140
|
+
}
|
|
141
|
+
element.dispatchEvent(new KeyboardEvent('keyup', init))
|
|
142
|
+
return { ok: true, target: target(role, name), detail: `pressed ${request.key}` }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function scroll(env: ActionEnvironment, request: Request<'scroll'>): UiActionResult {
|
|
146
|
+
const doc = env.document()
|
|
147
|
+
const amount = request.amount ?? 400
|
|
148
|
+
const dx = request.direction === 'left' ? -amount : request.direction === 'right' ? amount : 0
|
|
149
|
+
const dy = request.direction === 'up' ? -amount : request.direction === 'down' ? amount : 0
|
|
150
|
+
if (request.ref !== undefined) {
|
|
151
|
+
const { element, role, name } = env.refs.resolve(request.ref)
|
|
152
|
+
;(element as HTMLElement).scrollBy?.({ left: dx, top: dy })
|
|
153
|
+
if (typeof (element as HTMLElement).scrollBy !== 'function') { element.scrollTop += dy; element.scrollLeft += dx }
|
|
154
|
+
return { ok: true, target: target(role, name), detail: `scrolled ${request.direction}` }
|
|
155
|
+
}
|
|
156
|
+
const view = doc.defaultView
|
|
157
|
+
view?.scrollBy?.({ left: dx, top: dy })
|
|
158
|
+
return { ok: true, detail: `scrolled the page ${request.direction}` }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when something visible on the page matches `request`. */
|
|
162
|
+
function present(env: ActionEnvironment, request: Request<'wait'>): boolean {
|
|
163
|
+
const doc = env.document()
|
|
164
|
+
const walk = (element: Element): boolean => {
|
|
165
|
+
if (element.hasAttribute(DRIVER_UI_ATTRIBUTE) || isHidden(element) || isSensitive(element)) return false
|
|
166
|
+
const role = roleOf(element)
|
|
167
|
+
if (request.role !== undefined || request.name !== undefined) {
|
|
168
|
+
if (role !== null && (request.role === undefined || role === request.role.toLowerCase()) && (request.name === undefined || nameOf(element, role).toLowerCase().includes(request.name.toLowerCase()))) return true
|
|
169
|
+
}
|
|
170
|
+
if (request.text !== undefined && element.children.length === 0 && (element.textContent ?? '').toLowerCase().includes(request.text.toLowerCase())) return true
|
|
171
|
+
return Array.from(element.children).some(walk)
|
|
172
|
+
}
|
|
173
|
+
return walk(doc.body)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function wait(env: ActionEnvironment, request: Request<'wait'>, signal: AbortSignal): Promise<UiActionResult> {
|
|
177
|
+
const budget = Math.min(request.timeoutMs ?? 3000, MAX_WAIT_MS)
|
|
178
|
+
let waited = 0
|
|
179
|
+
const wantGone = request.gone === true
|
|
180
|
+
for (;;) {
|
|
181
|
+
if (present(env, request) !== wantGone) return { ok: true, detail: wantGone ? 'it is gone' : 'it is there' }
|
|
182
|
+
if (waited >= budget) throw new UiControlError('not-found', wantGone ? 'it did not go away in time' : 'it did not appear in time')
|
|
183
|
+
await env.sleep(POLL_MS, signal)
|
|
184
|
+
waited += POLL_MS
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -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
|
+
}
|