handhold-sdk 1.0.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/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connector.js — BYOK LLM client with two drivers:
|
|
3
|
+
*
|
|
4
|
+
* 1. 'openai-compatible' — one driver covers:
|
|
5
|
+
* • OpenAI baseURL: https://api.openai.com/v1 (apiKey required)
|
|
6
|
+
* • OpenRouter baseURL: https://openrouter.ai/api/v1 (apiKey required)
|
|
7
|
+
* • Ollama (local) baseURL: http://localhost:11434/v1 (no key)
|
|
8
|
+
* • LM Studio (local) baseURL: http://localhost:1234/v1 (no key)
|
|
9
|
+
* • vLLM (local/LAN) baseURL: http://<host>:8000/v1 (key optional)
|
|
10
|
+
* 2. 'anthropic' — Claude via api.anthropic.com/v1/messages.
|
|
11
|
+
*
|
|
12
|
+
* Security properties:
|
|
13
|
+
* - Keys live only in the object you construct; nothing here persists or
|
|
14
|
+
* forwards them anywhere except the endpoint you configured.
|
|
15
|
+
* - Plain-http endpoints are REJECTED unless the host is localhost/127.x/
|
|
16
|
+
* ::1/private-LAN (10.x, 172.16-31.x, 192.168.x). Sending page content or
|
|
17
|
+
* an API key over plaintext to a public host is a config mistake this
|
|
18
|
+
* module refuses to make.
|
|
19
|
+
* - No streaming, no retries-with-backdoor, no telemetry. One POST per call.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const PRIVATE_HOST_RE = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/i
|
|
23
|
+
|
|
24
|
+
function assertSafeBaseURL(baseURL) {
|
|
25
|
+
let u
|
|
26
|
+
try {
|
|
27
|
+
u = new URL(baseURL)
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`connector: invalid baseURL "${baseURL}"`)
|
|
30
|
+
}
|
|
31
|
+
if (u.protocol === 'https:') return
|
|
32
|
+
if (u.protocol === 'http:' && PRIVATE_HOST_RE.test(u.hostname)) return
|
|
33
|
+
throw new Error(
|
|
34
|
+
`connector: refusing plain-http baseURL "${baseURL}" for a non-local host. ` +
|
|
35
|
+
'Use https, or a localhost/private-LAN address for locally hosted models.'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} config
|
|
41
|
+
* @param {'openai-compatible'|'anthropic'} config.provider
|
|
42
|
+
* @param {string} [config.baseURL] required for openai-compatible
|
|
43
|
+
* @param {string} [config.apiKey] optional for local endpoints
|
|
44
|
+
* @param {string} config.model
|
|
45
|
+
* @param {number} [config.temperature=0]
|
|
46
|
+
* @param {number} [config.maxTokens=2000]
|
|
47
|
+
* @param {typeof fetch} [config.fetchImpl] injectable for tests
|
|
48
|
+
* @returns {{ complete: (args: {system: string, messages: {role:string,content:string}[], signal?: AbortSignal}) => Promise<{text: string, raw: any}> }}
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Named presets that normalize to the openai-compatible driver.
|
|
52
|
+
* keyRequired presets throw early rather than failing with a confusing 401.
|
|
53
|
+
*/
|
|
54
|
+
const PRESETS = {
|
|
55
|
+
deepseek: { baseURL: 'https://api.deepseek.com/v1', keyRequired: true },
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createLLM(config) {
|
|
59
|
+
// Resolve presets (e.g. provider:'deepseek') to the generic driver.
|
|
60
|
+
if (config.provider in PRESETS) {
|
|
61
|
+
const preset = PRESETS[config.provider]
|
|
62
|
+
if (preset.keyRequired && !config.apiKey) {
|
|
63
|
+
throw new Error(`connector: apiKey is required for the ${config.provider} provider`)
|
|
64
|
+
}
|
|
65
|
+
config = { ...config, provider: 'openai-compatible', baseURL: config.baseURL ?? preset.baseURL }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
provider, apiKey, model,
|
|
70
|
+
temperature = 0, maxTokens = 2000,
|
|
71
|
+
fetchImpl = globalThis.fetch,
|
|
72
|
+
} = config
|
|
73
|
+
|
|
74
|
+
if (!model) throw new Error('connector: model is required')
|
|
75
|
+
|
|
76
|
+
if (provider === 'openai-compatible') {
|
|
77
|
+
if (!config.baseURL) throw new Error('connector: baseURL is required for openai-compatible provider')
|
|
78
|
+
assertSafeBaseURL(config.baseURL)
|
|
79
|
+
const baseURL = config.baseURL.replace(/\/+$/, '')
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
async complete({ system, messages, signal }) {
|
|
83
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
84
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
85
|
+
|
|
86
|
+
const res = await fetchImpl(`${baseURL}/chat/completions`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers,
|
|
89
|
+
signal,
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
model,
|
|
92
|
+
temperature,
|
|
93
|
+
max_tokens: maxTokens,
|
|
94
|
+
messages: [{ role: 'system', content: system }, ...messages],
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const body = await res.text().catch(() => '')
|
|
99
|
+
throw new Error(`LLM request failed: HTTP ${res.status} ${body.slice(0, 300)}`)
|
|
100
|
+
}
|
|
101
|
+
const data = await res.json()
|
|
102
|
+
const text = data?.choices?.[0]?.message?.content ?? ''
|
|
103
|
+
return { text, raw: data }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (provider === 'anthropic') {
|
|
109
|
+
if (!apiKey) throw new Error('connector: apiKey is required for the anthropic provider')
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
async complete({ system, messages, signal }) {
|
|
113
|
+
const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
signal,
|
|
116
|
+
headers: {
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
'x-api-key': apiKey,
|
|
119
|
+
'anthropic-version': '2023-06-01',
|
|
120
|
+
// allows calling from a browser page (official CORS opt-in header)
|
|
121
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
model,
|
|
125
|
+
max_tokens: maxTokens,
|
|
126
|
+
temperature,
|
|
127
|
+
system,
|
|
128
|
+
messages,
|
|
129
|
+
}),
|
|
130
|
+
})
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const body = await res.text().catch(() => '')
|
|
133
|
+
throw new Error(`LLM request failed: HTTP ${res.status} ${body.slice(0, 300)}`)
|
|
134
|
+
}
|
|
135
|
+
const data = await res.json()
|
|
136
|
+
const text = (data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('\n')
|
|
137
|
+
return { text, raw: data }
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw new Error(`connector: unknown provider "${provider}"`)
|
|
143
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PolicyGuard.js — the enforcement layer upstream doesn't have.
|
|
3
|
+
*
|
|
4
|
+
* Every action the LLM proposes goes through guard.check() BEFORE it touches
|
|
5
|
+
* the DOM. The guard is deliberately independent of the agent loop so a bug
|
|
6
|
+
* (or a prompt-injected detour) in the loop cannot bypass it.
|
|
7
|
+
*
|
|
8
|
+
* Guarantees:
|
|
9
|
+
* 1. Origin pinning — actions run only if the current window origin is on
|
|
10
|
+
* an explicit allowlist. Constructor THROWS if the list is missing/empty:
|
|
11
|
+
* there is no "allow everywhere" default to forget about.
|
|
12
|
+
* 2. Fails closed — sensitive actions (payments, deletion, submission,
|
|
13
|
+
* credentials...) are BLOCKED unless a human onConfirm() callback
|
|
14
|
+
* explicitly returns true. No handler => no sensitive actions, period.
|
|
15
|
+
* 3. Rate limiting — hard ceiling on actions/minute, independent of the
|
|
16
|
+
* LLM loop's own maxSteps.
|
|
17
|
+
* 4. Audit log — timestamped record of every decision (allowed,
|
|
18
|
+
* blocked, sensitive, reason) for post-hoc review.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SENSITIVE_KEYWORDS = [
|
|
22
|
+
// destructive
|
|
23
|
+
'delete', 'remove', 'destroy', 'terminate', 'deactivate', 'revoke', 'cancel',
|
|
24
|
+
// financial
|
|
25
|
+
'pay', 'payment', 'purchase', 'buy', 'checkout', 'transfer', 'withdraw',
|
|
26
|
+
'send money', 'disbursement', 'payout', 'refund',
|
|
27
|
+
// commitment
|
|
28
|
+
'submit', 'confirm', 'approve', 'sign', 'agree', 'accept terms', 'place order',
|
|
29
|
+
// account/session
|
|
30
|
+
'logout', 'log out', 'sign out', 'unsubscribe', 'close account',
|
|
31
|
+
// comms
|
|
32
|
+
'send', 'publish', 'post', 'share',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const SENSITIVE_INPUT_TYPES = new Set(['password'])
|
|
36
|
+
const SENSITIVE_INPUT_AUTOCOMPLETE = ['cc-number', 'cc-csc', 'cc-exp', 'current-password', 'new-password', 'one-time-code']
|
|
37
|
+
|
|
38
|
+
export class PolicyGuard {
|
|
39
|
+
#allowedOrigins
|
|
40
|
+
#getOrigin
|
|
41
|
+
#onConfirm
|
|
42
|
+
#keywords
|
|
43
|
+
#maxPerMinute
|
|
44
|
+
#actionTimestamps = []
|
|
45
|
+
#auditLog = []
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} config
|
|
49
|
+
* @param {string[]} config.allowedOrigins REQUIRED, non-empty. e.g. ['https://app.example.com']
|
|
50
|
+
* @param {() => string} [config.getOrigin] defaults to window.location.origin
|
|
51
|
+
* @param {(pending: {name:string,input:Object,reason:string}) => Promise<boolean>} [config.onConfirm]
|
|
52
|
+
* @param {string[]} [config.extraSensitiveKeywords]
|
|
53
|
+
* @param {number} [config.maxActionsPerMinute=30]
|
|
54
|
+
*/
|
|
55
|
+
constructor(config = {}) {
|
|
56
|
+
if (!Array.isArray(config.allowedOrigins) || config.allowedOrigins.length === 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'PolicyGuard: allowedOrigins is required and must be a non-empty array. ' +
|
|
59
|
+
'There is intentionally no default — decide where this agent may act.'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
this.#allowedOrigins = config.allowedOrigins.map((o) => o.replace(/\/+$/, ''))
|
|
63
|
+
this.#getOrigin = config.getOrigin ?? (() => globalThis.window?.location?.origin ?? 'unknown://')
|
|
64
|
+
this.#onConfirm = config.onConfirm ?? null
|
|
65
|
+
this.#keywords = [
|
|
66
|
+
...DEFAULT_SENSITIVE_KEYWORDS,
|
|
67
|
+
...(config.extraSensitiveKeywords ?? []).map((k) => k.toLowerCase()),
|
|
68
|
+
]
|
|
69
|
+
this.#maxPerMinute = config.maxActionsPerMinute ?? 30
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {Object} action
|
|
74
|
+
* @param {string} action.name e.g. 'click', 'input_text'
|
|
75
|
+
* @param {Object} action.input tool input from the LLM
|
|
76
|
+
* @param {string} [action.elementText] visible text of the target element
|
|
77
|
+
* @param {Record<string,string>} [action.elementAttributes]
|
|
78
|
+
* @returns {Promise<{allowed: boolean, sensitive: boolean, reason: string}>}
|
|
79
|
+
*/
|
|
80
|
+
async check(action) {
|
|
81
|
+
const verdict = await this.#evaluate(action)
|
|
82
|
+
this.#auditLog.push({
|
|
83
|
+
ts: Date.now(),
|
|
84
|
+
name: action.name,
|
|
85
|
+
input: action.input,
|
|
86
|
+
elementText: action.elementText ?? '',
|
|
87
|
+
allowed: verdict.allowed,
|
|
88
|
+
sensitive: verdict.sensitive,
|
|
89
|
+
reason: verdict.reason,
|
|
90
|
+
})
|
|
91
|
+
return verdict
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async #evaluate(action) {
|
|
95
|
+
// 1. origin pin
|
|
96
|
+
const origin = this.#getOrigin().replace(/\/+$/, '')
|
|
97
|
+
if (!this.#allowedOrigins.includes(origin)) {
|
|
98
|
+
return { allowed: false, sensitive: false, reason: `Origin ${origin} is not on the allowlist.` }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. rate limit
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
this.#actionTimestamps = this.#actionTimestamps.filter((t) => now - t < 60_000)
|
|
104
|
+
if (this.#actionTimestamps.length >= this.#maxPerMinute) {
|
|
105
|
+
return { allowed: false, sensitive: false, reason: `Rate limit: more than ${this.#maxPerMinute} actions/minute.` }
|
|
106
|
+
}
|
|
107
|
+
this.#actionTimestamps.push(now)
|
|
108
|
+
|
|
109
|
+
// 3. sensitivity gate (fails closed)
|
|
110
|
+
const sensitiveReason = this.#sensitiveReason(action)
|
|
111
|
+
if (sensitiveReason) {
|
|
112
|
+
if (!this.#onConfirm) {
|
|
113
|
+
return {
|
|
114
|
+
allowed: false, sensitive: true,
|
|
115
|
+
reason: `${sensitiveReason} — blocked: no onConfirm handler configured (fails closed).`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let ok = false
|
|
119
|
+
try {
|
|
120
|
+
ok = (await this.#onConfirm({ name: action.name, input: action.input, reason: sensitiveReason })) === true
|
|
121
|
+
} catch {
|
|
122
|
+
ok = false
|
|
123
|
+
}
|
|
124
|
+
return ok
|
|
125
|
+
? { allowed: true, sensitive: true, reason: `${sensitiveReason} — approved by human.` }
|
|
126
|
+
: { allowed: false, sensitive: true, reason: `${sensitiveReason} — denied by human.` }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { allowed: true, sensitive: false, reason: 'ok' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#sensitiveReason(action) {
|
|
133
|
+
/**
|
|
134
|
+
* @handhold-patch H6 (see sdk/src/agent/VENDOR.md) — forced sensitivity.
|
|
135
|
+
* Callers that KNOW an action is sensitive (e.g. navigation to a page
|
|
136
|
+
* the human-reviewed manifest marks sensitive:true) pass forceSensitive
|
|
137
|
+
* with the reason. This can only ADD the confirm gate, never remove it:
|
|
138
|
+
* keyword/attribute detection below still runs for everything else.
|
|
139
|
+
*/
|
|
140
|
+
if (typeof action.forceSensitive === 'string' && action.forceSensitive) {
|
|
141
|
+
return action.forceSensitive
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const attrs = action.elementAttributes ?? {}
|
|
145
|
+
const type = (attrs.type ?? '').toLowerCase()
|
|
146
|
+
const autocomplete = (attrs.autocomplete ?? '').toLowerCase()
|
|
147
|
+
|
|
148
|
+
if (SENSITIVE_INPUT_TYPES.has(type)) return `Target is a ${type} field`
|
|
149
|
+
if (SENSITIVE_INPUT_AUTOCOMPLETE.some((v) => autocomplete.includes(v))) {
|
|
150
|
+
return `Target autocomplete="${autocomplete}" indicates credentials/payment data`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const haystack = [
|
|
154
|
+
action.elementText ?? '',
|
|
155
|
+
attrs['aria-label'] ?? '',
|
|
156
|
+
attrs.name ?? '',
|
|
157
|
+
attrs.value ?? '',
|
|
158
|
+
attrs.title ?? '',
|
|
159
|
+
].join(' ').toLowerCase()
|
|
160
|
+
|
|
161
|
+
for (const kw of this.#keywords) {
|
|
162
|
+
// word-boundary match so 'send' doesn't fire on 'ascending'
|
|
163
|
+
const re = new RegExp(`(^|[^a-z])${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z]|$)`)
|
|
164
|
+
if (re.test(haystack)) return `Action targets "${(action.elementText ?? '').slice(0, 60)}" (matched sensitive keyword "${kw}")`
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getAuditLog() {
|
|
170
|
+
return this.#auditLog.slice()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* site/manifest.js — Handhold whole-site addition (H6.1; VENDOR.md patch log).
|
|
3
|
+
*
|
|
4
|
+
* The site manifest is the HUMAN-REVIEWED map of pages the agent may know and
|
|
5
|
+
* navigate (specs/whole-site-control-design.md §5). This module is the
|
|
6
|
+
* client-side half of that contract:
|
|
7
|
+
*
|
|
8
|
+
* - `reviewed: true` is REQUIRED to load. The backend already refuses to
|
|
9
|
+
* serve unreviewed manifests (GET /api/v1/manifest -> 404); enforcing it
|
|
10
|
+
* here again means even a tampered/bypassed transport cannot hand the
|
|
11
|
+
* agent an unapproved map (defense in depth).
|
|
12
|
+
* - Page paths are same-origin absolute: leading '/', no '//', no
|
|
13
|
+
* scheme/host, no '..', no whitespace. `:param` segments are allowed
|
|
14
|
+
* (/employees/:id). A manifest with any bad path is refused whole, with
|
|
15
|
+
* every offending path in the error.
|
|
16
|
+
* - Pages/states not listed DO NOT EXIST for the agent: matchManifestPath
|
|
17
|
+
* returns null and the navigate tool refuses before the guard.
|
|
18
|
+
*
|
|
19
|
+
* Trust note: the manifest is trusted config (human-reviewed), unlike page
|
|
20
|
+
* content — it may appear in the system prompt outside untrusted tags.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function pathError(path) {
|
|
24
|
+
if (typeof path !== 'string' || path.length === 0) return 'path must be a non-empty string'
|
|
25
|
+
if (!path.startsWith('/')) return `path must start with '/' (got "${path}")`
|
|
26
|
+
if (path.startsWith('//')) return `path must not be protocol-relative (got "${path}")`
|
|
27
|
+
if (path.includes('://')) return `path must not contain a scheme or host (got "${path}")`
|
|
28
|
+
if (path.includes('..')) return `path must not contain '..' (got "${path}")`
|
|
29
|
+
if (/\s/.test(path)) return `path must not contain whitespace (got "${path}")`
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate and normalize a raw manifest JSON (as served by the Handhold
|
|
35
|
+
* backend). Throws on anything that must never reach the agent.
|
|
36
|
+
* @returns {{version:number, origin:string, pages:Array}} normalized manifest
|
|
37
|
+
*/
|
|
38
|
+
export function loadSiteManifest(raw) {
|
|
39
|
+
if (!raw || typeof raw !== 'object') {
|
|
40
|
+
throw new Error('site manifest: expected an object')
|
|
41
|
+
}
|
|
42
|
+
if (raw.reviewed !== true) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'site manifest: refused — "reviewed" must be exactly true. ' +
|
|
45
|
+
'A human must review the site map before the agent may use it.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const pages = Array.isArray(raw.pages) ? raw.pages : []
|
|
49
|
+
const errors = []
|
|
50
|
+
const normalized = pages.map((page, i) => {
|
|
51
|
+
const err = pathError(page?.path)
|
|
52
|
+
if (err) {
|
|
53
|
+
errors.push(`pages[${i}]: ${err}`)
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
path: page.path,
|
|
58
|
+
name: String(page.name ?? page.path),
|
|
59
|
+
purpose: page.purpose != null ? String(page.purpose) : '',
|
|
60
|
+
sensitive: page.sensitive === true,
|
|
61
|
+
states: Array.isArray(page.states)
|
|
62
|
+
? page.states.map((s) => ({
|
|
63
|
+
name: String(s?.name ?? ''),
|
|
64
|
+
steps: Array.isArray(s?.steps)
|
|
65
|
+
? s.steps.map((st) => ({
|
|
66
|
+
type: String(st?.type ?? ''),
|
|
67
|
+
role: st?.role != null ? String(st.role) : '',
|
|
68
|
+
name: st?.name != null ? String(st.name) : '',
|
|
69
|
+
}))
|
|
70
|
+
: [],
|
|
71
|
+
}))
|
|
72
|
+
: [],
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
if (errors.length) {
|
|
76
|
+
throw new Error(`site manifest: refused — invalid page paths:\n${errors.join('\n')}`)
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
version: Number(raw.version ?? 1),
|
|
80
|
+
origin: String(raw.origin ?? ''),
|
|
81
|
+
pages: normalized,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strip query/hash and a trailing slash (except root) from a concrete path. */
|
|
86
|
+
function normalizeConcretePath(path) {
|
|
87
|
+
let p = String(path).split('?')[0].split('#')[0]
|
|
88
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
|
|
89
|
+
return p
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a concrete path against the manifest. Supports `:param` pattern
|
|
94
|
+
* segments (each matches exactly one non-empty segment). Returns the page or
|
|
95
|
+
* null — null means the page DOES NOT EXIST for the agent.
|
|
96
|
+
*/
|
|
97
|
+
export function matchManifestPath(manifest, path) {
|
|
98
|
+
if (!manifest || !Array.isArray(manifest.pages)) return null
|
|
99
|
+
const target = normalizeConcretePath(path)
|
|
100
|
+
if (pathError(target)) return null
|
|
101
|
+
const targetSegs = target.split('/').filter(Boolean)
|
|
102
|
+
|
|
103
|
+
for (const page of manifest.pages) {
|
|
104
|
+
const patternSegs = page.path.split('/').filter(Boolean)
|
|
105
|
+
if (patternSegs.length !== targetSegs.length) {
|
|
106
|
+
// exact-root special case: '/' matches '/'
|
|
107
|
+
if (page.path === '/' && target === '/') return page
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
let ok = true
|
|
111
|
+
for (let i = 0; i < patternSegs.length; i++) {
|
|
112
|
+
const pat = patternSegs[i]
|
|
113
|
+
if (pat.startsWith(':')) {
|
|
114
|
+
if (!targetSegs[i]) { ok = false; break }
|
|
115
|
+
} else if (pat !== targetSegs[i]) {
|
|
116
|
+
ok = false
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (ok) return page
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every page path — used in navigate-rejection errors for self-correction. */
|
|
126
|
+
export function listManifestPaths(manifest) {
|
|
127
|
+
if (!manifest || !Array.isArray(manifest.pages)) return []
|
|
128
|
+
return manifest.pages.map((p) => p.path)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a full URL to a human label "name (path)" for the state message's
|
|
133
|
+
* `Current page:` line. Null when the URL is not a manifest page (caller
|
|
134
|
+
* falls back to the raw URL).
|
|
135
|
+
*/
|
|
136
|
+
export function describeCurrentPage(manifest, url) {
|
|
137
|
+
let pathname
|
|
138
|
+
try {
|
|
139
|
+
pathname = new URL(url).pathname
|
|
140
|
+
} catch {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
const page = matchManifestPath(manifest, pathname)
|
|
144
|
+
return page ? `${page.name} (${page.path})` : null
|
|
145
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatWidget.js — the "chatbot bubble" UI: a floating button bottom-right
|
|
3
|
+
* that opens a chat panel. Zero dependencies, injects its own styles once.
|
|
4
|
+
*
|
|
5
|
+
* Security notes (this is UI, but it sits in the trust path):
|
|
6
|
+
* - All content is rendered with textContent, NEVER innerHTML — model output
|
|
7
|
+
* and page-derived strings cannot inject markup into the host page.
|
|
8
|
+
* - requestConfirm() renders the PolicyGuard reason with explicit
|
|
9
|
+
* Allow/Deny buttons, so the human gate is a real UI affordance instead
|
|
10
|
+
* of a window.confirm() that people click through.
|
|
11
|
+
*
|
|
12
|
+
* Wire-up (see TrustedPageAgent.attachWidget for the one-liner):
|
|
13
|
+
* const widget = new ChatWidget({ title: 'Assistant', onTask: run })
|
|
14
|
+
* widget.mount()
|
|
15
|
+
* agent config: onConfirm: (p) => widget.requestConfirm(p),
|
|
16
|
+
* onAskUser: (q) => widget.askUser(q),
|
|
17
|
+
* onActivity: (ev) => widget.showActivity(ev)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const STYLE = `
|
|
21
|
+
.tpa-bubble{position:fixed;right:20px;bottom:20px;width:56px;height:56px;border-radius:50%;
|
|
22
|
+
border:0;background:#1a73e8;color:#fff;font-size:24px;cursor:pointer;z-index:2147483000;
|
|
23
|
+
box-shadow:0 4px 14px rgba(0,0,0,.25);transition:transform .15s}
|
|
24
|
+
.tpa-bubble:hover{transform:scale(1.06)}
|
|
25
|
+
.tpa-panel{position:fixed;right:20px;bottom:88px;width:340px;max-width:calc(100vw - 40px);
|
|
26
|
+
height:460px;max-height:calc(100vh - 120px);display:none;flex-direction:column;
|
|
27
|
+
background:#fff;border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:2147483000;
|
|
28
|
+
font-family:system-ui,sans-serif;overflow:hidden}
|
|
29
|
+
.tpa-panel.tpa-open{display:flex}
|
|
30
|
+
.tpa-header{background:#1a73e8;color:#fff;padding:12px 14px;font-weight:600;display:flex;justify-content:space-between;align-items:center}
|
|
31
|
+
.tpa-close{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}
|
|
32
|
+
.tpa-messages{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px;background:#f5f7fa}
|
|
33
|
+
.tpa-msg{max-width:82%;padding:8px 12px;border-radius:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
|
34
|
+
.tpa-user{align-self:flex-end;background:#1a73e8;color:#fff;border-bottom-right-radius:4px}
|
|
35
|
+
.tpa-agent{align-self:flex-start;background:#fff;color:#222;border:1px solid #e3e6ea;border-bottom-left-radius:4px}
|
|
36
|
+
.tpa-status{align-self:flex-start;color:#777;font-size:11px;font-style:italic;padding:0 4px}
|
|
37
|
+
.tpa-confirm{align-self:flex-start;background:#fff8e6;border:1px solid #f0d489;border-radius:12px;padding:10px 12px;font-size:13px;max-width:88%}
|
|
38
|
+
.tpa-confirm-actions{margin-top:8px;display:flex;gap:8px}
|
|
39
|
+
.tpa-allow,.tpa-deny{border:0;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer}
|
|
40
|
+
.tpa-allow{background:#1a8f3c;color:#fff}
|
|
41
|
+
.tpa-deny{background:#c0392b;color:#fff}
|
|
42
|
+
.tpa-inputrow{display:flex;gap:8px;padding:10px;border-top:1px solid #e3e6ea;background:#fff}
|
|
43
|
+
.tpa-input{flex:1;border:1px solid #cfd6dd;border-radius:10px;padding:8px 10px;font:inherit;font-size:13px}
|
|
44
|
+
.tpa-send{border:0;border-radius:10px;background:#1a73e8;color:#fff;padding:8px 14px;cursor:pointer;font-size:13px}
|
|
45
|
+
.tpa-send:disabled{background:#9db8dd;cursor:default}
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
export class ChatWidget {
|
|
49
|
+
#root = null
|
|
50
|
+
#messages = null
|
|
51
|
+
#input = null
|
|
52
|
+
#send = null
|
|
53
|
+
#panel = null
|
|
54
|
+
#onTask
|
|
55
|
+
#title
|
|
56
|
+
#pendingAsk = null // {resolve} — next send resolves an askUser instead of firing onTask
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Object} cfg
|
|
60
|
+
* @param {string} [cfg.title='Page Assistant']
|
|
61
|
+
* @param {(task: string) => void} [cfg.onTask] called when the user sends a message
|
|
62
|
+
*/
|
|
63
|
+
constructor(cfg = {}) {
|
|
64
|
+
this.#title = cfg.title ?? 'Page Assistant'
|
|
65
|
+
this.#onTask = cfg.onTask ?? (() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mount(parent = document.body) {
|
|
69
|
+
if (this.#root) return this
|
|
70
|
+
|
|
71
|
+
if (!document.getElementById('tpa-style')) {
|
|
72
|
+
const style = document.createElement('style')
|
|
73
|
+
style.id = 'tpa-style'
|
|
74
|
+
style.textContent = STYLE
|
|
75
|
+
document.head.appendChild(style)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.#root = document.createElement('div')
|
|
79
|
+
|
|
80
|
+
const bubble = document.createElement('button')
|
|
81
|
+
bubble.className = 'tpa-bubble'
|
|
82
|
+
bubble.setAttribute('aria-label', 'Open assistant')
|
|
83
|
+
bubble.textContent = '💬'
|
|
84
|
+
bubble.addEventListener('click', () => this.toggle())
|
|
85
|
+
|
|
86
|
+
this.#panel = document.createElement('div')
|
|
87
|
+
this.#panel.className = 'tpa-panel'
|
|
88
|
+
|
|
89
|
+
const header = document.createElement('div')
|
|
90
|
+
header.className = 'tpa-header'
|
|
91
|
+
const titleEl = document.createElement('span')
|
|
92
|
+
titleEl.textContent = this.#title
|
|
93
|
+
const close = document.createElement('button')
|
|
94
|
+
close.className = 'tpa-close'
|
|
95
|
+
close.setAttribute('aria-label', 'Close')
|
|
96
|
+
close.textContent = '✕'
|
|
97
|
+
close.addEventListener('click', () => this.toggle(false))
|
|
98
|
+
header.append(titleEl, close)
|
|
99
|
+
|
|
100
|
+
this.#messages = document.createElement('div')
|
|
101
|
+
this.#messages.className = 'tpa-messages'
|
|
102
|
+
|
|
103
|
+
const inputRow = document.createElement('div')
|
|
104
|
+
inputRow.className = 'tpa-inputrow'
|
|
105
|
+
this.#input = document.createElement('input')
|
|
106
|
+
this.#input.className = 'tpa-input'
|
|
107
|
+
this.#input.placeholder = 'Describe a task…'
|
|
108
|
+
this.#input.addEventListener('keydown', (e) => {
|
|
109
|
+
if (e.key === 'Enter') this.#submit()
|
|
110
|
+
})
|
|
111
|
+
this.#send = document.createElement('button')
|
|
112
|
+
this.#send.className = 'tpa-send'
|
|
113
|
+
this.#send.textContent = 'Send'
|
|
114
|
+
this.#send.addEventListener('click', () => this.#submit())
|
|
115
|
+
inputRow.append(this.#input, this.#send)
|
|
116
|
+
|
|
117
|
+
this.#panel.append(header, this.#messages, inputRow)
|
|
118
|
+
this.#root.append(bubble, this.#panel)
|
|
119
|
+
parent.appendChild(this.#root)
|
|
120
|
+
return this
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
toggle(force) {
|
|
124
|
+
const open = force ?? !this.#panel.classList.contains('tpa-open')
|
|
125
|
+
this.#panel.classList.toggle('tpa-open', open)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#submit() {
|
|
129
|
+
const text = this.#input.value.trim()
|
|
130
|
+
if (!text) return
|
|
131
|
+
this.#input.value = ''
|
|
132
|
+
this.addMessage('user', text)
|
|
133
|
+
if (this.#pendingAsk) {
|
|
134
|
+
const { resolve } = this.#pendingAsk
|
|
135
|
+
this.#pendingAsk = null
|
|
136
|
+
this.#input.placeholder = 'Describe a task…'
|
|
137
|
+
resolve(text)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
this.#onTask(text)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Append a chat bubble. role: 'user' | 'agent'. XSS-safe (textContent). */
|
|
144
|
+
addMessage(role, text) {
|
|
145
|
+
const el = document.createElement('div')
|
|
146
|
+
el.className = `tpa-msg ${role === 'user' ? 'tpa-user' : 'tpa-agent'}`
|
|
147
|
+
el.textContent = text
|
|
148
|
+
this.#messages.appendChild(el)
|
|
149
|
+
this.#messages.scrollTop = this.#messages.scrollHeight
|
|
150
|
+
return el
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Small italic status line ("step 2: clicking [3]…"). */
|
|
154
|
+
addStatus(text) {
|
|
155
|
+
const el = document.createElement('div')
|
|
156
|
+
el.className = 'tpa-status'
|
|
157
|
+
el.textContent = text
|
|
158
|
+
this.#messages.appendChild(el)
|
|
159
|
+
this.#messages.scrollTop = this.#messages.scrollHeight
|
|
160
|
+
return el
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Render agent activity events (drop-in for onActivity). */
|
|
164
|
+
showActivity(ev) {
|
|
165
|
+
if (ev.type === 'thinking') this.addStatus(`step ${ev.step}: thinking…`)
|
|
166
|
+
if (ev.type === 'executing') this.addStatus(`step ${ev.step}: ${ev.actionName} — ${ev.nextGoal ?? ''}`)
|
|
167
|
+
if (ev.type === 'executed') this.addStatus(`→ ${ev.resultMessage}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Human gate UI (drop-in for PolicyGuard onConfirm).
|
|
172
|
+
* Resolves true only if the user clicks Allow.
|
|
173
|
+
*/
|
|
174
|
+
requestConfirm({ reason }) {
|
|
175
|
+
this.toggle(true)
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
const box = document.createElement('div')
|
|
178
|
+
box.className = 'tpa-confirm'
|
|
179
|
+
const msg = document.createElement('div')
|
|
180
|
+
msg.textContent = `⚠️ Sensitive action needs your approval:\n${reason}`
|
|
181
|
+
const actions = document.createElement('div')
|
|
182
|
+
actions.className = 'tpa-confirm-actions'
|
|
183
|
+
const allow = document.createElement('button')
|
|
184
|
+
allow.className = 'tpa-allow'
|
|
185
|
+
allow.textContent = 'Allow'
|
|
186
|
+
const deny = document.createElement('button')
|
|
187
|
+
deny.className = 'tpa-deny'
|
|
188
|
+
deny.textContent = 'Deny'
|
|
189
|
+
const decide = (v) => {
|
|
190
|
+
actions.remove()
|
|
191
|
+
this.addStatus(v ? 'approved by you' : 'denied by you')
|
|
192
|
+
resolve(v)
|
|
193
|
+
}
|
|
194
|
+
allow.addEventListener('click', () => decide(true))
|
|
195
|
+
deny.addEventListener('click', () => decide(false))
|
|
196
|
+
actions.append(allow, deny)
|
|
197
|
+
box.append(msg, actions)
|
|
198
|
+
this.#messages.appendChild(box)
|
|
199
|
+
this.#messages.scrollTop = this.#messages.scrollHeight
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* ask_user tool UI (drop-in for onAskUser): shows the question as an agent
|
|
205
|
+
* bubble; the next message the user sends resolves it.
|
|
206
|
+
*/
|
|
207
|
+
askUser(question) {
|
|
208
|
+
this.toggle(true)
|
|
209
|
+
this.addMessage('agent', question)
|
|
210
|
+
this.#input.placeholder = 'Answer the question…'
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
this.#pendingAsk = { resolve }
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setBusy(busy) {
|
|
217
|
+
if (this.#send) this.#send.disabled = busy
|
|
218
|
+
}
|
|
219
|
+
}
|