@jini-ai/agentic 0.4.4 → 0.4.5
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/CHANGELOG.md +4 -0
- package/dist/core/a2ui/json-pointer.js +94 -73
- package/dist/core/a2ui/resolve.js +45 -23
- package/dist/core/a2ui/tree.js +8 -5
- package/dist/core/capability.js +21 -12
- package/dist/core/dom/dom-page-driver.js +89 -74
- package/dist/core/gen-ui/encoder.js +31 -31
- package/dist/core/guards.js +13 -6
- package/dist/core/mcp-ui-apps.js +12 -6
- package/dist/core/page-executor.js +131 -111
- package/dist/core/site-guide/site-guide.js +89 -71
- package/dist/skills/install/archive.js +42 -31
- package/dist/skills/install/github.js +102 -63
- package/dist/skills/install/layout.js +3 -3
- package/dist/skills/install/node-filesystem.js +3 -1
- package/dist/skills/install/state.js +18 -7
- package/dist/skills/install/validation.js +83 -50
- package/package.json +2 -2
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/** `stage_start` / `stage_end` share one shape; `iteration` is omitted entirely (not set to `undefined`) when absent. */
|
|
2
|
+
function encodeStage(payload, base) {
|
|
3
|
+
return {
|
|
4
|
+
...base,
|
|
5
|
+
kind: 'run.lifecycle',
|
|
6
|
+
status: payload.type === 'stage_start' ? 'pipeline_stage_started' : 'pipeline_stage_completed',
|
|
7
|
+
stageId: payload.stageId,
|
|
8
|
+
...(payload.iteration !== undefined ? { iteration: payload.iteration } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* Creates a fresh `GenUiEncoder` with its own, empty tool-call correlation map.
|
|
3
13
|
* @complexity `encode` is O(1) per call (one `Map` get/set/delete); memory is O(k) in the number
|
|
@@ -10,6 +20,24 @@ export function createGenUiEncoder({ clock }, _optional = {}) {
|
|
|
10
20
|
const ts = ctx.now ? ctx.now({}) : clock.nowMs();
|
|
11
21
|
return ctx.seq !== undefined ? { runId: ctx.runId, ts, seq: ctx.seq } : { runId: ctx.runId, ts };
|
|
12
22
|
}
|
|
23
|
+
/** Resolves a `tool_result` against (and removes) its correlated `tool_use`. */
|
|
24
|
+
function encodeToolResult(payload, base) {
|
|
25
|
+
const pending = pendingToolCalls.get(payload.toolUseId);
|
|
26
|
+
pendingToolCalls.delete(payload.toolUseId);
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
kind: 'tool_call',
|
|
30
|
+
// A tool_result with no matching prior tool_use (never observed, or already resolved/
|
|
31
|
+
// cleared — e.g. after a run-ending 'end' event, see below) falls back to an 'unknown'
|
|
32
|
+
// name and null args rather than throwing: the result itself is still real and worth
|
|
33
|
+
// relaying even when its originating call wasn't captured.
|
|
34
|
+
toolName: pending?.toolName ?? 'unknown',
|
|
35
|
+
args: pending?.args ?? null,
|
|
36
|
+
callId: payload.toolUseId,
|
|
37
|
+
status: payload.isError ? 'failed' : 'completed',
|
|
38
|
+
result: payload.content,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
13
41
|
function encodeAgentPayload(payload, ctx) {
|
|
14
42
|
const base = baseFields(ctx);
|
|
15
43
|
switch (payload.type) {
|
|
@@ -21,42 +49,14 @@ export function createGenUiEncoder({ clock }, _optional = {}) {
|
|
|
21
49
|
// and there is no protocol-level guarantee ids are never reused within one run.
|
|
22
50
|
pendingToolCalls.set(payload.id, { toolName: payload.name, args: payload.input });
|
|
23
51
|
return { ...base, kind: 'tool_call', toolName: payload.name, args: payload.input, callId: payload.id, status: 'started' };
|
|
24
|
-
case 'tool_result':
|
|
25
|
-
|
|
26
|
-
pendingToolCalls.delete(payload.toolUseId);
|
|
27
|
-
return {
|
|
28
|
-
...base,
|
|
29
|
-
kind: 'tool_call',
|
|
30
|
-
// A tool_result with no matching prior tool_use (never observed, or already resolved/
|
|
31
|
-
// cleared — e.g. after a run-ending 'end' event, see below) falls back to an 'unknown'
|
|
32
|
-
// name and null args rather than throwing: the result itself is still real and worth
|
|
33
|
-
// relaying even when its originating call wasn't captured.
|
|
34
|
-
toolName: pending?.toolName ?? 'unknown',
|
|
35
|
-
args: pending?.args ?? null,
|
|
36
|
-
callId: payload.toolUseId,
|
|
37
|
-
status: payload.isError ? 'failed' : 'completed',
|
|
38
|
-
result: payload.content,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
52
|
+
case 'tool_result':
|
|
53
|
+
return encodeToolResult(payload, base);
|
|
41
54
|
// Generalization (see archived provenance ledger): pipeline_stage_started/completed generalized into a
|
|
42
55
|
// named-stage boundary any multi-step driver can emit, flowing through the same 'agent'
|
|
43
56
|
// channel as tool_use/tool_result.
|
|
44
57
|
case 'stage_start':
|
|
45
|
-
return {
|
|
46
|
-
...base,
|
|
47
|
-
kind: 'run.lifecycle',
|
|
48
|
-
status: 'pipeline_stage_started',
|
|
49
|
-
stageId: payload.stageId,
|
|
50
|
-
...(payload.iteration !== undefined ? { iteration: payload.iteration } : {}),
|
|
51
|
-
};
|
|
52
58
|
case 'stage_end':
|
|
53
|
-
return
|
|
54
|
-
...base,
|
|
55
|
-
kind: 'run.lifecycle',
|
|
56
|
-
status: 'pipeline_stage_completed',
|
|
57
|
-
stageId: payload.stageId,
|
|
58
|
-
...(payload.iteration !== undefined ? { iteration: payload.iteration } : {}),
|
|
59
|
-
};
|
|
59
|
+
return encodeStage(payload, base);
|
|
60
60
|
// Generalization (see archived provenance ledger): genui_surface_request/response generalized into a
|
|
61
61
|
// generic human-in-the-loop ask/answer pair.
|
|
62
62
|
case 'surface_request':
|
package/dist/core/guards.js
CHANGED
|
@@ -81,6 +81,13 @@ export function findFieldReadRefusal({ field }, _optional = {}) {
|
|
|
81
81
|
const type = field.type?.toLowerCase();
|
|
82
82
|
if (type !== undefined && DENIED_TYPES.has(type))
|
|
83
83
|
return 'denied-type';
|
|
84
|
+
if (hasDeniedAutocomplete({ field }))
|
|
85
|
+
return 'denied-autocomplete';
|
|
86
|
+
if (hasSuspiciousFieldName({ field }))
|
|
87
|
+
return 'suspicious-name';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function hasDeniedAutocomplete({ field }, _optional = {}) {
|
|
84
91
|
const autocomplete = field.autocomplete?.toLowerCase();
|
|
85
92
|
if (autocomplete !== undefined) {
|
|
86
93
|
// The real grammar is space-separated tokens, but a comma-joined value
|
|
@@ -90,10 +97,13 @@ export function findFieldReadRefusal({ field }, _optional = {}) {
|
|
|
90
97
|
// worked as a plain text input while looking unguarded.
|
|
91
98
|
for (const token of autocomplete.split(/[\s,]+/)) {
|
|
92
99
|
if (DENIED_AUTOCOMPLETE.has(token))
|
|
93
|
-
return
|
|
100
|
+
return true;
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
|
-
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
function hasSuspiciousFieldName({ field }, _optional = {}) {
|
|
106
|
+
return (SUSPICIOUS_NAME.test(squashSeparators(field.name ?? ''))
|
|
97
107
|
|| SUSPICIOUS_NAME.test(squashSeparators(field.id ?? ''))
|
|
98
108
|
// `name`/`id` are the *machine* names; a form builder or CMS that emits `name="field_47"`
|
|
99
109
|
// next to a visible "Card number" label left the guard nothing to judge until this existed.
|
|
@@ -102,10 +112,7 @@ export function findFieldReadRefusal({ field }, _optional = {}) {
|
|
|
102
112
|
// deletes the separators, so a join would let two innocuous labels form a trigger word across
|
|
103
113
|
// their boundary ("Address" + "Snapshot" -> "addresssnapshot", which contains "ssn"). This
|
|
104
114
|
// guard is deliberately over-inclusive, but not at the cost of matching text no page wrote.
|
|
105
|
-
|| (field.accessibleLabels ?? []).some((label) => SUSPICIOUS_NAME.test(squashSeparators(label))))
|
|
106
|
-
return 'suspicious-name';
|
|
107
|
-
}
|
|
108
|
-
return null;
|
|
115
|
+
|| (field.accessibleLabels ?? []).some((label) => SUSPICIOUS_NAME.test(squashSeparators(label))));
|
|
109
116
|
}
|
|
110
117
|
/**
|
|
111
118
|
* Why an automated fill must be refused for this field, or `null` when it is safe to write.
|
package/dist/core/mcp-ui-apps.js
CHANGED
|
@@ -194,14 +194,10 @@ export function isJsonRpcMessage(required, _optional = {}) {
|
|
|
194
194
|
const { value } = required;
|
|
195
195
|
if (!isRecord(value) || value['jsonrpc'] !== '2.0')
|
|
196
196
|
return false;
|
|
197
|
-
if (
|
|
197
|
+
if (!hasValidJsonRpcId({ value }))
|
|
198
198
|
return false;
|
|
199
199
|
if ('method' in value) {
|
|
200
|
-
|
|
201
|
-
return typeof value['method'] === 'string'
|
|
202
|
-
&& hasValidParams(value)
|
|
203
|
-
&& !('result' in value)
|
|
204
|
-
&& !('error' in value);
|
|
200
|
+
return isJsonRpcCallShape({ value });
|
|
205
201
|
}
|
|
206
202
|
// Response: correlated by `id`, carrying exactly one of `result` / `error`.
|
|
207
203
|
if (!('id' in value))
|
|
@@ -212,6 +208,16 @@ export function isJsonRpcMessage(required, _optional = {}) {
|
|
|
212
208
|
return false;
|
|
213
209
|
return hasResult || isJsonRpcErrorObject(value['error']);
|
|
214
210
|
}
|
|
211
|
+
function hasValidJsonRpcId({ value }, _optional = {}) {
|
|
212
|
+
return !('id' in value) || typeof value['id'] === 'number' || typeof value['id'] === 'string';
|
|
213
|
+
}
|
|
214
|
+
function isJsonRpcCallShape({ value }, _optional = {}) {
|
|
215
|
+
// Request or notification: `id` present or not, but never a response's members alongside.
|
|
216
|
+
return typeof value['method'] === 'string'
|
|
217
|
+
&& hasValidParams(value)
|
|
218
|
+
&& !('result' in value)
|
|
219
|
+
&& !('error' in value);
|
|
220
|
+
}
|
|
215
221
|
/** Whether a validated message expects a response (as opposed to being a notification). */
|
|
216
222
|
export function isJsonRpcRequest(required, _optional = {}) {
|
|
217
223
|
const { message } = required;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Nothing here touches the DOM. That is what makes the interesting half testable without a
|
|
12
12
|
* browser: "does it refuse to type into a password box" is a unit test, not an integration test.
|
|
13
13
|
*/
|
|
14
|
-
import { findCapabilityInputError, } from './capability.js';
|
|
14
|
+
import { findCapability, findCapabilityInputError, } from './capability.js';
|
|
15
15
|
import { AGENT_ELEMENT_ROLES, isValidElementHandle, } from './element-handles.js';
|
|
16
16
|
import { describeFieldReadRefusal, describeFieldRefusal, findFieldFillRefusal, findFieldReadRefusal, normalizeAgentLabel, } from './guards.js';
|
|
17
17
|
import { PAGE_CAPABILITIES } from './page-capabilities.js';
|
|
@@ -69,19 +69,28 @@ export function projectElementState({ raw }, _optional = {}) {
|
|
|
69
69
|
// what the option is called; withholding the label too would blind a caller to the page's own
|
|
70
70
|
// ontology for no reduction in what actually leaks.
|
|
71
71
|
const refusal = raw.field !== undefined ? findFieldReadRefusal({ field: raw.field }) : null;
|
|
72
|
+
const withheldText = findWithheldText({ raw, refusal });
|
|
73
|
+
const base = {
|
|
74
|
+
text: withheldText === undefined ? text.text : '',
|
|
75
|
+
textTruncated: withheldText === undefined ? text.truncated : false,
|
|
76
|
+
...(withheldText !== undefined ? { textWithheld: withheldText } : {}),
|
|
77
|
+
...projectOptionalState({ raw, refusal }),
|
|
78
|
+
};
|
|
79
|
+
return projectFieldValue({ raw, refusal, base });
|
|
80
|
+
}
|
|
81
|
+
function findWithheldText({ raw, refusal }, _optional = {}) {
|
|
72
82
|
// The one element whose `text` is NOT page-authored chrome: an editable region, whose contents
|
|
73
83
|
// are the user's data and whose driver has no `value` to report them as. It is gated by exactly
|
|
74
84
|
// the rules `value` is gated by below, including the "arrived with nothing to check it against"
|
|
75
85
|
// one — an unverifiable secret is withheld, not trusted.
|
|
76
|
-
|
|
86
|
+
return raw.textIsValue !== true
|
|
77
87
|
? undefined
|
|
78
88
|
: raw.field === undefined
|
|
79
89
|
? 'this element reported contents without the attributes needed to check them for secrets'
|
|
80
90
|
: refusal !== null ? describeFieldReadRefusal({ refusal }) : undefined;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
...(withheldText !== undefined ? { textWithheld: withheldText } : {}),
|
|
91
|
+
}
|
|
92
|
+
function projectOptionalState({ raw, refusal }, _optional = {}) {
|
|
93
|
+
return {
|
|
85
94
|
...(raw.checked !== undefined && refusal === null ? { checked: raw.checked } : {}),
|
|
86
95
|
...(raw.disabled !== undefined ? { disabled: raw.disabled } : {}),
|
|
87
96
|
...(raw.visible !== undefined ? { visible: raw.visible } : {}),
|
|
@@ -91,6 +100,8 @@ export function projectElementState({ raw }, _optional = {}) {
|
|
|
91
100
|
? { options: raw.options.map((option) => normalizeAgentLabel({ raw: option }).text) }
|
|
92
101
|
: {}),
|
|
93
102
|
};
|
|
103
|
+
}
|
|
104
|
+
function projectFieldValue({ raw, refusal, base }, _optional = {}) {
|
|
94
105
|
if (raw.value === undefined)
|
|
95
106
|
return base;
|
|
96
107
|
if (raw.field === undefined) {
|
|
@@ -176,66 +187,10 @@ async function summarizePage(driver) {
|
|
|
176
187
|
* purpose: a caller must be told why, not handed a silent no-op it will retry forever.
|
|
177
188
|
*/
|
|
178
189
|
export async function executePageCapability({ driver, capabilityId, input }, _optional = {}) {
|
|
179
|
-
|
|
180
|
-
if (capability === undefined)
|
|
181
|
-
throw new Error(`unknown page capability: ${capabilityId}`);
|
|
182
|
-
const inputError = findCapabilityInputError({ capability, input });
|
|
183
|
-
if (inputError !== null) {
|
|
184
|
-
// The schema rides along with the refusal rather than making a caller spend a second
|
|
185
|
-
// round trip on describe_tool to learn what it already tried to guess — ai-control-plane.md
|
|
186
|
-
// §29.4's "schema-on-error" requirement, empirically forced by a real run: page.fill was
|
|
187
|
-
// called with `handle` instead of the argument's old name, `element`, twice before the caller
|
|
188
|
-
// gave up and looked the schema up separately. One JSON-Schema object, embedded once, turned
|
|
189
|
-
// that into a same-turn self-correction for every page.* verb this gate guards, not just the
|
|
190
|
-
// one that got measured — self-correction is why that mismatch was survivable at all, given
|
|
191
|
-
// every verb here needs a handle from `page.find_elements`, whose own response has always
|
|
192
|
-
// called this same value `handle` (`AgentElementDescriptor.handle`). The argument itself was
|
|
193
|
-
// later renamed from `element` to `handle` to match, closing the gap this comment describes
|
|
194
|
-
// rather than merely papering over it turn by turn.
|
|
195
|
-
throw new Error(`${capabilityId}: ${inputError}. Expected input: ${JSON.stringify(capability.inputSchema)}`);
|
|
196
|
-
}
|
|
190
|
+
validatePageCapability({ capabilityId, input });
|
|
197
191
|
switch (capabilityId) {
|
|
198
|
-
case 'page.find_elements':
|
|
199
|
-
|
|
200
|
-
// The schema's enum check has already run; this narrows the type for the driver.
|
|
201
|
-
const filter = {
|
|
202
|
-
...(isAgentElementRole(role) ? { role } : {}),
|
|
203
|
-
...(typeof input['query'] === 'string' ? { query: input['query'] } : {}),
|
|
204
|
-
};
|
|
205
|
-
const [found, rawPages] = await Promise.all([driver.findElements({}, filter), driver.listPages({})]);
|
|
206
|
-
const withState = input['withState'] === true;
|
|
207
|
-
const observing = withState && driver.describeState !== undefined;
|
|
208
|
-
const elements = await Promise.all(found.map(async (element, index) => {
|
|
209
|
-
const label = normalizeAgentLabel({ raw: element.label });
|
|
210
|
-
const base = {
|
|
211
|
-
handle: element.handle,
|
|
212
|
-
role: element.role,
|
|
213
|
-
label: label.text,
|
|
214
|
-
labelTruncated: label.truncated,
|
|
215
|
-
page: element.page,
|
|
216
|
-
};
|
|
217
|
-
if (!observing || index >= MAX_STATEFUL_ELEMENTS)
|
|
218
|
-
return base;
|
|
219
|
-
const state = await observeElement(driver, element.handle);
|
|
220
|
-
return state === undefined ? base : { ...base, state };
|
|
221
|
-
}));
|
|
222
|
-
// `label` is page-authored the same way an element's is — bounded here for the same reason
|
|
223
|
-
// `page.navigate`'s refusal message bounds it below, rather than trusting a host to have kept
|
|
224
|
-
// every `data-agent-page` label short and clean.
|
|
225
|
-
const pages = rawPages.map((page) => ({
|
|
226
|
-
id: page.id,
|
|
227
|
-
label: normalizeAgentLabel({ raw: page.label }).text,
|
|
228
|
-
}));
|
|
229
|
-
return {
|
|
230
|
-
elements,
|
|
231
|
-
pages,
|
|
232
|
-
untrustedFields: observing
|
|
233
|
-
? ['elements[].label', 'elements[].state.text', 'elements[].state.value', 'pages[].label']
|
|
234
|
-
: ['elements[].label', 'pages[].label'],
|
|
235
|
-
...(withState && !observing ? { stateUnavailable: true } : {}),
|
|
236
|
-
...(observing && found.length > MAX_STATEFUL_ELEMENTS ? { stateTruncated: true } : {}),
|
|
237
|
-
};
|
|
238
|
-
}
|
|
192
|
+
case 'page.find_elements':
|
|
193
|
+
return findPageElements({ driver, input });
|
|
239
194
|
case 'page.highlight': {
|
|
240
195
|
const handle = requireHandle(input, 'handle');
|
|
241
196
|
const durationMs = clampHighlightDuration(input['durationMs']);
|
|
@@ -252,30 +207,15 @@ export async function executePageCapability({ driver, capabilityId, input }, _op
|
|
|
252
207
|
const observation = await observeWrite(driver, handle, () => driver.click({ handle }));
|
|
253
208
|
return { clicked: handle, ...observation };
|
|
254
209
|
}
|
|
255
|
-
case 'page.fill':
|
|
256
|
-
|
|
257
|
-
// Declared required and typed `string` in the manifest, and `findCapabilityInputError`
|
|
258
|
-
// enforced both above — re-checking here would be an unreachable branch, not a safeguard.
|
|
259
|
-
const text = input['text'];
|
|
260
|
-
// Ask the page what this field actually is before writing to it. A handle proves the page
|
|
261
|
-
// published the element; it says nothing about whether it holds a password.
|
|
262
|
-
const field = await driver.describeField({ handle });
|
|
263
|
-
if (field === null)
|
|
264
|
-
throw new Error(`"${handle}" is not a fillable field`);
|
|
265
|
-
const refusal = findFieldFillRefusal({ field });
|
|
266
|
-
if (refusal !== null) {
|
|
267
|
-
throw new Error(`refusing to fill "${handle}": ${describeFieldRefusal({ refusal })}`);
|
|
268
|
-
}
|
|
269
|
-
const observation = await observeWrite(driver, handle, () => driver.fill({ handle, text }));
|
|
270
|
-
return { filled: handle, ...observation };
|
|
271
|
-
}
|
|
210
|
+
case 'page.fill':
|
|
211
|
+
return fillPageField({ driver, input });
|
|
272
212
|
case 'page.select_option': {
|
|
273
213
|
const handle = requireHandle(input, 'handle');
|
|
274
214
|
// Required and string-typed by the manifest, already enforced above. See page.fill.
|
|
275
215
|
const option = input['option'];
|
|
276
216
|
// Optional and boolean-typed by the manifest; absent means "select it", same as before this
|
|
277
217
|
// argument existed.
|
|
278
|
-
const selected = input['selected']
|
|
218
|
+
const selected = input['selected'] !== false;
|
|
279
219
|
// No field guard: a dropdown's options are authored by the page, so choosing one reveals
|
|
280
220
|
// nothing the page had not already published, and none of the credential field types this
|
|
281
221
|
// surface refuses can be a `<select>`.
|
|
@@ -284,37 +224,117 @@ export async function executePageCapability({ driver, capabilityId, input }, _op
|
|
|
284
224
|
// "the handle this call acted on" for every verb in this switch (`clicked`, `filled`, …).
|
|
285
225
|
return { selected: handle, option, optionSelected: selected, ...observation };
|
|
286
226
|
}
|
|
287
|
-
case 'page.navigate':
|
|
288
|
-
|
|
289
|
-
const page = input['page'];
|
|
290
|
-
const pages = await driver.listPages({});
|
|
291
|
-
if (!pages.some((candidate) => candidate.id === page)) {
|
|
292
|
-
// Every other piece of page-authored (or, here, caller-authored-but-page-shaped) text
|
|
293
|
-
// this system hands to a model goes through normalizeAgentLabel first — bounded, control-
|
|
294
|
-
// and bidi-stripped. This refusal was the one path that skipped it: the raw `page`
|
|
295
|
-
// argument, and the `pages` this host actually publishes (itself read verbatim off
|
|
296
|
-
// `data-agent-page` attributes with no length bound), were interpolated straight into the
|
|
297
|
-
// thrown message. A 5000+-char bidi payload survived verbatim as a result. The lookup
|
|
298
|
-
// above still runs on the raw values — only the text that reaches the message is bounded.
|
|
299
|
-
//
|
|
300
|
-
// `id (label)`, not just the id: a refusal is the one moment a caller that guessed wrong
|
|
301
|
-
// is looking straight at the real menu, so it is worth spending the label here even though
|
|
302
|
-
// `page.find_elements` already carries the same information — a caller that jumped
|
|
303
|
-
// straight to `page.navigate` without calling that first still lands on the right id.
|
|
304
|
-
const safePage = normalizeAgentLabel({ raw: page }).text;
|
|
305
|
-
const safePages = pages.map((candidate) => `${normalizeAgentLabel({ raw: candidate.id }).text} (${normalizeAgentLabel({ raw: candidate.label }).text})`);
|
|
306
|
-
throw new Error(`"${safePage}" is not a published page. Available: ${safePages.length > 0 ? safePages.join(', ') : '(none)'}`);
|
|
307
|
-
}
|
|
308
|
-
const before = await summarizePage(driver);
|
|
309
|
-
await driver.navigate({ page });
|
|
310
|
-
if (driver.settle !== undefined)
|
|
311
|
-
await driver.settle({});
|
|
312
|
-
const after = await summarizePage(driver);
|
|
313
|
-
return { navigatedTo: page, before, after };
|
|
314
|
-
}
|
|
227
|
+
case 'page.navigate':
|
|
228
|
+
return navigatePage({ driver, input });
|
|
315
229
|
/* c8 ignore next 2 -- unreachable: the id was matched against PAGE_CAPABILITIES above. */
|
|
316
230
|
default:
|
|
317
231
|
throw new Error(`unhandled page capability: ${capabilityId}`);
|
|
318
232
|
}
|
|
319
233
|
}
|
|
234
|
+
function validatePageCapability({ capabilityId, input }, _optional = {}) {
|
|
235
|
+
const capability = findCapability({ capabilities: PAGE_CAPABILITIES, id: capabilityId });
|
|
236
|
+
if (capability === undefined)
|
|
237
|
+
throw new Error(`unknown page capability: ${capabilityId}`);
|
|
238
|
+
const inputError = findCapabilityInputError({ capability, input });
|
|
239
|
+
if (inputError !== null) {
|
|
240
|
+
// The schema rides along with the refusal rather than making a caller spend a second
|
|
241
|
+
// round trip on describe_tool to learn what it already tried to guess — ai-control-plane.md
|
|
242
|
+
// §29.4's "schema-on-error" requirement, empirically forced by a real run: page.fill was
|
|
243
|
+
// called with `handle` instead of the argument's old name, `element`, twice before the caller
|
|
244
|
+
// gave up and looked the schema up separately. One JSON-Schema object, embedded once, turned
|
|
245
|
+
// that into a same-turn self-correction for every page.* verb this gate guards, not just the
|
|
246
|
+
// one that got measured — self-correction is why that mismatch was survivable at all, given
|
|
247
|
+
// every verb here needs a handle from `page.find_elements`, whose own response has always
|
|
248
|
+
// called this same value `handle` (`AgentElementDescriptor.handle`). The argument itself was
|
|
249
|
+
// later renamed from `element` to `handle` to match, closing the gap this comment describes
|
|
250
|
+
// rather than merely papering over it turn by turn.
|
|
251
|
+
throw new Error(`${capabilityId}: ${inputError}. Expected input: ${JSON.stringify(capability.inputSchema)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function findPageElements({ driver, input }, _optional = {}) {
|
|
255
|
+
const role = input['role'];
|
|
256
|
+
// The schema's enum check has already run; this narrows the type for the driver.
|
|
257
|
+
const filter = {
|
|
258
|
+
...(isAgentElementRole(role) ? { role } : {}),
|
|
259
|
+
...(typeof input['query'] === 'string' ? { query: input['query'] } : {}),
|
|
260
|
+
};
|
|
261
|
+
const [found, rawPages] = await Promise.all([driver.findElements({}, filter), driver.listPages({})]);
|
|
262
|
+
const withState = input['withState'] === true;
|
|
263
|
+
const observing = withState && driver.describeState !== undefined;
|
|
264
|
+
const elements = await Promise.all(found.map(async (element, index) => {
|
|
265
|
+
const label = normalizeAgentLabel({ raw: element.label });
|
|
266
|
+
const base = {
|
|
267
|
+
handle: element.handle,
|
|
268
|
+
role: element.role,
|
|
269
|
+
label: label.text,
|
|
270
|
+
labelTruncated: label.truncated,
|
|
271
|
+
page: element.page,
|
|
272
|
+
};
|
|
273
|
+
if (!observing || index >= MAX_STATEFUL_ELEMENTS)
|
|
274
|
+
return base;
|
|
275
|
+
const state = await observeElement(driver, element.handle);
|
|
276
|
+
return state === undefined ? base : { ...base, state };
|
|
277
|
+
}));
|
|
278
|
+
// `label` is page-authored the same way an element's is — bounded here for the same reason
|
|
279
|
+
// `page.navigate`'s refusal message bounds it below, rather than trusting a host to have kept
|
|
280
|
+
// every `data-agent-page` label short and clean.
|
|
281
|
+
const pages = rawPages.map((page) => ({
|
|
282
|
+
id: page.id,
|
|
283
|
+
label: normalizeAgentLabel({ raw: page.label }).text,
|
|
284
|
+
}));
|
|
285
|
+
return {
|
|
286
|
+
elements,
|
|
287
|
+
pages,
|
|
288
|
+
untrustedFields: observing
|
|
289
|
+
? ['elements[].label', 'elements[].state.text', 'elements[].state.value', 'pages[].label']
|
|
290
|
+
: ['elements[].label', 'pages[].label'],
|
|
291
|
+
...(withState && !observing ? { stateUnavailable: true } : {}),
|
|
292
|
+
...(observing && found.length > MAX_STATEFUL_ELEMENTS ? { stateTruncated: true } : {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async function fillPageField({ driver, input }, _optional = {}) {
|
|
296
|
+
const handle = requireHandle(input, 'handle');
|
|
297
|
+
// Declared required and typed `string` in the manifest, and `findCapabilityInputError`
|
|
298
|
+
// enforced both above — re-checking here would be an unreachable branch, not a safeguard.
|
|
299
|
+
const text = input['text'];
|
|
300
|
+
// Ask the page what this field actually is before writing to it. A handle proves the page
|
|
301
|
+
// published the element; it says nothing about whether it holds a password.
|
|
302
|
+
const field = await driver.describeField({ handle });
|
|
303
|
+
if (field === null)
|
|
304
|
+
throw new Error(`"${handle}" is not a fillable field`);
|
|
305
|
+
const refusal = findFieldFillRefusal({ field });
|
|
306
|
+
if (refusal !== null) {
|
|
307
|
+
throw new Error(`refusing to fill "${handle}": ${describeFieldRefusal({ refusal })}`);
|
|
308
|
+
}
|
|
309
|
+
const observation = await observeWrite(driver, handle, () => driver.fill({ handle, text }));
|
|
310
|
+
return { filled: handle, ...observation };
|
|
311
|
+
}
|
|
312
|
+
async function navigatePage({ driver, input }, _optional = {}) {
|
|
313
|
+
// Required and string-typed by the manifest, already enforced above. See page.fill.
|
|
314
|
+
const page = input['page'];
|
|
315
|
+
const pages = await driver.listPages({});
|
|
316
|
+
if (!pages.some((candidate) => candidate.id === page)) {
|
|
317
|
+
// Every other piece of page-authored (or, here, caller-authored-but-page-shaped) text
|
|
318
|
+
// this system hands to a model goes through normalizeAgentLabel first — bounded, control-
|
|
319
|
+
// and bidi-stripped. This refusal was the one path that skipped it: the raw `page`
|
|
320
|
+
// argument, and the `pages` this host actually publishes (itself read verbatim off
|
|
321
|
+
// `data-agent-page` attributes with no length bound), were interpolated straight into the
|
|
322
|
+
// thrown message. A 5000+-char bidi payload survived verbatim as a result. The lookup
|
|
323
|
+
// above still runs on the raw values — only the text that reaches the message is bounded.
|
|
324
|
+
//
|
|
325
|
+
// `id (label)`, not just the id: a refusal is the one moment a caller that guessed wrong
|
|
326
|
+
// is looking straight at the real menu, so it is worth spending the label here even though
|
|
327
|
+
// `page.find_elements` already carries the same information — a caller that jumped
|
|
328
|
+
// straight to `page.navigate` without calling that first still lands on the right id.
|
|
329
|
+
const safePage = normalizeAgentLabel({ raw: page }).text;
|
|
330
|
+
const safePages = pages.map((candidate) => `${normalizeAgentLabel({ raw: candidate.id }).text} (${normalizeAgentLabel({ raw: candidate.label }).text})`);
|
|
331
|
+
throw new Error(`"${safePage}" is not a published page. Available: ${safePages.length > 0 ? safePages.join(', ') : '(none)'}`);
|
|
332
|
+
}
|
|
333
|
+
const before = await summarizePage(driver);
|
|
334
|
+
await driver.navigate({ page });
|
|
335
|
+
if (driver.settle !== undefined)
|
|
336
|
+
await driver.settle({});
|
|
337
|
+
const after = await summarizePage(driver);
|
|
338
|
+
return { navigatedTo: page, before, after };
|
|
339
|
+
}
|
|
320
340
|
//# sourceMappingURL=page-executor.js.map
|
|
@@ -62,83 +62,101 @@ export function createSiteGuide(ports, options = {}) {
|
|
|
62
62
|
if (builtIns.length > 256 || new Set(builtIns.map(topic => topic.id)).size !== builtIns.length)
|
|
63
63
|
throw new SiteGuideInputError({ message: 'Built-in topics must have unique ids and contain at most 256 cards' });
|
|
64
64
|
return { async run(input, _options = {}) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
let contributions = [];
|
|
73
|
-
let enrichment = 'ok';
|
|
74
|
-
if (ports.loadContributions) {
|
|
75
|
-
try {
|
|
76
|
-
const loaded = await boundedRead(() => ports.loadContributions({ caller: input.caller }), timeoutMs);
|
|
77
|
-
if (loaded.items.reduce((count, item) => count + item.topics.length, 0) > 256)
|
|
78
|
-
throw new SiteGuideInputError({ message: 'Too many contributed topics' });
|
|
79
|
-
contributions = loaded.items;
|
|
80
|
-
if (loaded.refusals.length)
|
|
81
|
-
enrichment = 'partial';
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
enrichment = 'unavailable';
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
const combined = combineTopics(builtIns, contributions);
|
|
88
|
-
if (combined.conflicts)
|
|
89
|
-
enrichment = 'partial';
|
|
90
|
-
const decisions = new Map();
|
|
91
|
-
const allowed = (permission) => {
|
|
92
|
-
let decision = decisions.get(permission);
|
|
93
|
-
if (!decision) {
|
|
94
|
-
// Failed authorization is denial, never an excuse to expose a card or run its read.
|
|
95
|
-
decision = boundedRead(() => ports.authorize({ caller: input.caller, permission }), timeoutMs).catch(() => false);
|
|
96
|
-
decisions.set(permission, decision);
|
|
97
|
-
}
|
|
98
|
-
return decision;
|
|
99
|
-
};
|
|
100
|
-
const permitted = async (permissions) => (await Promise.all(permissions.map(allowed))).every(Boolean);
|
|
101
|
-
const views = [];
|
|
102
|
-
for (const entry of combined.topics) {
|
|
103
|
-
if (input.operation === 'explain' && entry.topic.id !== input.topic)
|
|
104
|
-
continue;
|
|
105
|
-
if (!await permitted(entry.topic.permissions))
|
|
106
|
-
continue;
|
|
107
|
-
const view = { id: entry.topic.id, title: entry.topic.title, summary: entry.topic.summary, source: entry.source };
|
|
108
|
-
if (input.operation === 'explain') {
|
|
109
|
-
const pointers = [];
|
|
110
|
-
for (const { permissions, ...pointer } of entry.topic.pointers)
|
|
111
|
-
if (await permitted(permissions))
|
|
112
|
-
pointers.push(pointer);
|
|
113
|
-
const workflows = [];
|
|
114
|
-
for (const { permissions, ...workflow } of entry.topic.workflows)
|
|
115
|
-
if (await permitted(permissions))
|
|
116
|
-
workflows.push(workflow);
|
|
117
|
-
views.push({ ...view, explanation: entry.topic.explanation, pointers, workflows });
|
|
118
|
-
}
|
|
119
|
-
else
|
|
120
|
-
views.push(view);
|
|
121
|
-
}
|
|
65
|
+
assertGuideInput(input);
|
|
66
|
+
const loaded = await loadContributions({ ports, caller: input.caller, timeoutMs });
|
|
67
|
+
const combined = combineTopics(builtIns, loaded.contributions);
|
|
68
|
+
const enrichment = combined.conflicts ? 'partial' : loaded.enrichment;
|
|
69
|
+
const gate = createPermissionGate({ ports, caller: input.caller, timeoutMs });
|
|
70
|
+
const views = await buildViews({ topics: combined.topics, input, permitted: gate.permitted });
|
|
122
71
|
if (input.operation === 'explain' && views.length === 0)
|
|
123
72
|
throw new SiteGuideInputError({ message: 'Topic unavailable. Call site_guide with operation overview for topics you may read.' });
|
|
124
|
-
let facts = {};
|
|
125
|
-
const topicIds = views.map(view => view.id);
|
|
126
73
|
// Overview is discovery only; read fresh domain facts when a visible topic is explained.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
74
|
+
const facts = input.operation === 'explain' ? await readFacts({ ports, caller: input.caller, topicIds: views.map(view => view.id), timeoutMs }) : {};
|
|
75
|
+
return { operation: input.operation, topics: views, facts, callerPermissions: await gate.granted(), enrichment };
|
|
76
|
+
} };
|
|
77
|
+
}
|
|
78
|
+
/** Refuses unknown operations, a topic on overview, and explain without a well-formed topic id. */
|
|
79
|
+
function assertGuideInput(input) {
|
|
80
|
+
if (input.operation !== 'overview' && input.operation !== 'explain')
|
|
81
|
+
throw new SiteGuideInputError({ message: 'operation must be overview or explain' });
|
|
82
|
+
if (input.operation === 'overview' && input.topic !== undefined)
|
|
83
|
+
throw new SiteGuideInputError({ message: 'topic is only accepted with operation explain' });
|
|
84
|
+
if (input.operation === 'explain' && (typeof input.topic !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(input.topic))) {
|
|
85
|
+
throw new SiteGuideInputError({ message: 'explain requires a topic id from overview' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Reads plugin contributions within the deadline; any failure or over-limit result degrades to none. */
|
|
89
|
+
async function loadContributions({ ports, caller, timeoutMs }) {
|
|
90
|
+
if (!ports.loadContributions)
|
|
91
|
+
return { contributions: [], enrichment: 'ok' };
|
|
92
|
+
try {
|
|
93
|
+
const loaded = await boundedRead(() => ports.loadContributions({ caller }), timeoutMs);
|
|
94
|
+
if (loaded.items.reduce((count, item) => count + item.topics.length, 0) > 256)
|
|
95
|
+
throw new SiteGuideInputError({ message: 'Too many contributed topics' });
|
|
96
|
+
return { contributions: loaded.items, enrichment: loaded.refusals.length ? 'partial' : 'ok' };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { contributions: [], enrichment: 'unavailable' };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Per-request authorization cache: each permission is asked at most once, in first-use order. */
|
|
103
|
+
function createPermissionGate({ ports, caller, timeoutMs }) {
|
|
104
|
+
const decisions = new Map();
|
|
105
|
+
const allowed = (permission) => {
|
|
106
|
+
let decision = decisions.get(permission);
|
|
107
|
+
if (!decision) {
|
|
108
|
+
// Failed authorization is denial, never an excuse to expose a card or run its read.
|
|
109
|
+
decision = boundedRead(() => ports.authorize({ caller, permission }), timeoutMs).catch(() => false);
|
|
110
|
+
decisions.set(permission, decision);
|
|
111
|
+
}
|
|
112
|
+
return decision;
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
permitted: async (permissions) => (await Promise.all(permissions.map(allowed))).every(Boolean),
|
|
116
|
+
granted: async () => {
|
|
137
117
|
const callerPermissions = [];
|
|
138
118
|
for (const [permission, decision] of decisions)
|
|
139
119
|
if (await decision)
|
|
140
120
|
callerPermissions.push(permission);
|
|
141
|
-
return
|
|
142
|
-
}
|
|
121
|
+
return callerPermissions;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Visible cards in topic order; explain narrows to the requested id and adds its permitted details. */
|
|
126
|
+
async function buildViews({ topics, input, permitted }) {
|
|
127
|
+
const views = [];
|
|
128
|
+
for (const entry of topics) {
|
|
129
|
+
if (input.operation === 'explain' && entry.topic.id !== input.topic)
|
|
130
|
+
continue;
|
|
131
|
+
if (!await permitted(entry.topic.permissions))
|
|
132
|
+
continue;
|
|
133
|
+
const view = { id: entry.topic.id, title: entry.topic.title, summary: entry.topic.summary, source: entry.source };
|
|
134
|
+
views.push(input.operation === 'explain' ? await explainView({ view, topic: entry.topic, permitted }) : view);
|
|
135
|
+
}
|
|
136
|
+
return views;
|
|
137
|
+
}
|
|
138
|
+
async function explainView({ view, topic, permitted }) {
|
|
139
|
+
const pointers = await withoutForbidden({ items: topic.pointers, permitted });
|
|
140
|
+
const workflows = await withoutForbidden({ items: topic.workflows, permitted });
|
|
141
|
+
return { ...view, explanation: topic.explanation, pointers, workflows };
|
|
142
|
+
}
|
|
143
|
+
/** Keeps items whose permissions are all granted, asked one item at a time, minus their gates. */
|
|
144
|
+
async function withoutForbidden({ items, permitted }) {
|
|
145
|
+
const kept = [];
|
|
146
|
+
for (const { permissions, ...item } of items)
|
|
147
|
+
if (await permitted(permissions))
|
|
148
|
+
kept.push(item);
|
|
149
|
+
return kept;
|
|
150
|
+
}
|
|
151
|
+
/** One bulk facts read for the visible topics; failure marks each of them unavailable. */
|
|
152
|
+
async function readFacts({ ports, caller, topicIds, timeoutMs }) {
|
|
153
|
+
try {
|
|
154
|
+
const read = await boundedRead(() => ports.facts.read({ caller, topics: topicIds }), timeoutMs);
|
|
155
|
+
// A facts adapter returning an unrelated section cannot disclose it through this tool.
|
|
156
|
+
return Object.fromEntries(topicIds.map(id => [id, read[id] ?? { status: 'unavailable' }]));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return Object.fromEntries(topicIds.map(id => [id, { status: 'unavailable' }]));
|
|
160
|
+
}
|
|
143
161
|
}
|
|
144
162
|
//# sourceMappingURL=site-guide.js.map
|