@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.
@@ -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
- 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
- }
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':
@@ -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 'denied-autocomplete';
100
+ return true;
94
101
  }
95
102
  }
96
- if (SUSPICIOUS_NAME.test(squashSeparators(field.name ?? ''))
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.
@@ -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 ('id' in value && typeof value['id'] !== 'number' && typeof value['id'] !== 'string')
197
+ if (!hasValidJsonRpcId({ value }))
198
198
  return false;
199
199
  if ('method' in value) {
200
- // Request or notification: `id` present or not, but never a response's members alongside.
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
- const withheldText = raw.textIsValue !== true
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
- const base = {
82
- text: withheldText === undefined ? text.text : '',
83
- textTruncated: withheldText === undefined ? text.truncated : false,
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
- const capability = PAGE_CAPABILITIES.find((entry) => entry.id === capabilityId);
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
- const role = input['role'];
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
- const handle = requireHandle(input, 'handle');
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'] === false ? false : true;
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
- // Required and string-typed by the manifest, already enforced above. See page.fill.
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
- if (input.operation !== 'overview' && input.operation !== 'explain')
66
- throw new SiteGuideInputError({ message: 'operation must be overview or explain' });
67
- if (input.operation === 'overview' && input.topic !== undefined)
68
- throw new SiteGuideInputError({ message: 'topic is only accepted with operation explain' });
69
- if (input.operation === 'explain' && (typeof input.topic !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(input.topic))) {
70
- throw new SiteGuideInputError({ message: 'explain requires a topic id from overview' });
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
- if (input.operation === 'explain') {
128
- try {
129
- const read = await boundedRead(() => ports.facts.read({ caller: input.caller, topics: topicIds }), timeoutMs);
130
- // A facts adapter returning an unrelated section cannot disclose it through this tool.
131
- facts = Object.fromEntries(topicIds.map(id => [id, read[id] ?? { status: 'unavailable' }]));
132
- }
133
- catch {
134
- facts = Object.fromEntries(topicIds.map(id => [id, { status: 'unavailable' }]));
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 { operation: input.operation, topics: views, facts, callerPermissions, enrichment };
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