@jini-ai/agentic 0.4.3 → 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.
Files changed (94) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/a2ui/json-pointer.js +94 -73
  3. package/dist/core/a2ui/resolve.js +45 -23
  4. package/dist/core/a2ui/tree.js +8 -5
  5. package/dist/core/capability.js +21 -12
  6. package/dist/core/dom/dom-page-driver.js +89 -74
  7. package/dist/core/gen-ui/encoder.js +31 -31
  8. package/dist/core/guards.js +13 -6
  9. package/dist/core/handle.d.ts +13 -0
  10. package/dist/core/handle.js +19 -0
  11. package/dist/core/index.d.ts +2 -1
  12. package/dist/core/index.js +2 -1
  13. package/dist/core/mcp-ui-apps.js +12 -6
  14. package/dist/core/page-executor.js +131 -111
  15. package/dist/core/site-guide/index.d.ts +4 -0
  16. package/dist/core/site-guide/index.js +4 -0
  17. package/dist/core/site-guide/site-guide.d.ts +18 -0
  18. package/dist/core/site-guide/site-guide.js +162 -0
  19. package/dist/core/site-guide/topic-schema.d.ts +16 -0
  20. package/dist/core/site-guide/topic-schema.js +68 -0
  21. package/dist/core/site-guide/types.d.ts +83 -0
  22. package/dist/core/site-guide/types.js +2 -0
  23. package/dist/skills/install/archive.js +42 -30
  24. package/dist/skills/install/github.js +102 -63
  25. package/dist/skills/install/index.d.ts +1 -0
  26. package/dist/skills/install/index.js +1 -0
  27. package/dist/skills/install/install-service.d.ts +20 -1
  28. package/dist/skills/install/install-service.js +23 -11
  29. package/dist/skills/install/layout.d.ts +8 -0
  30. package/dist/skills/install/layout.js +26 -1
  31. package/dist/skills/install/live-registration.js +1 -0
  32. package/dist/skills/install/node-filesystem.js +3 -1
  33. package/dist/skills/install/state.js +18 -7
  34. package/dist/skills/install/validation.d.ts +5 -1
  35. package/dist/skills/install/validation.js +85 -52
  36. package/dist/skills/install/yauzl-archive-reader.d.ts +29 -0
  37. package/dist/skills/install/yauzl-archive-reader.js +31 -0
  38. package/package.json +4 -4
  39. package/dist/a2ui/agent-to-renderer.d.ts +0 -613
  40. package/dist/a2ui/agent-to-renderer.js +0 -191
  41. package/dist/a2ui/catalog.d.ts +0 -73
  42. package/dist/a2ui/catalog.js +0 -304
  43. package/dist/a2ui/common-types.d.ts +0 -401
  44. package/dist/a2ui/common-types.js +0 -130
  45. package/dist/a2ui/index.d.ts +0 -16
  46. package/dist/a2ui/index.js +0 -16
  47. package/dist/a2ui/interpreter.d.ts +0 -71
  48. package/dist/a2ui/interpreter.js +0 -337
  49. package/dist/a2ui/json-pointer.d.ts +0 -48
  50. package/dist/a2ui/json-pointer.js +0 -174
  51. package/dist/a2ui/renderer-to-agent.d.ts +0 -342
  52. package/dist/a2ui/renderer-to-agent.js +0 -111
  53. package/dist/a2ui/resolve.d.ts +0 -28
  54. package/dist/a2ui/resolve.js +0 -144
  55. package/dist/a2ui/tree.d.ts +0 -66
  56. package/dist/a2ui/tree.js +0 -74
  57. package/dist/ag-ui.d.ts +0 -82
  58. package/dist/ag-ui.js +0 -85
  59. package/dist/agui/encoder.d.ts +0 -50
  60. package/dist/agui/encoder.js +0 -97
  61. package/dist/agui/events.d.ts +0 -59
  62. package/dist/agui/events.js +0 -15
  63. package/dist/capability.d.ts +0 -81
  64. package/dist/capability.js +0 -79
  65. package/dist/dom/dom-page-driver.d.ts +0 -36
  66. package/dist/dom/dom-page-driver.js +0 -601
  67. package/dist/dom/index.d.ts +0 -17
  68. package/dist/dom/index.js +0 -17
  69. package/dist/dom/model-context.d.ts +0 -69
  70. package/dist/dom/model-context.js +0 -44
  71. package/dist/element-handles.d.ts +0 -120
  72. package/dist/element-handles.js +0 -39
  73. package/dist/gen-ui/encoder.d.ts +0 -50
  74. package/dist/gen-ui/encoder.js +0 -97
  75. package/dist/gen-ui/events.d.ts +0 -59
  76. package/dist/gen-ui/events.js +0 -15
  77. package/dist/gen-ui/index.d.ts +0 -18
  78. package/dist/gen-ui/index.js +0 -17
  79. package/dist/guards.d.ts +0 -96
  80. package/dist/guards.js +0 -186
  81. package/dist/handle.d.ts +0 -41
  82. package/dist/handle.js +0 -41
  83. package/dist/mcp-ui-apps.d.ts +0 -223
  84. package/dist/mcp-ui-apps.js +0 -252
  85. package/dist/mcp-ui.d.ts +0 -133
  86. package/dist/mcp-ui.js +0 -134
  87. package/dist/page-capabilities.d.ts +0 -14
  88. package/dist/page-capabilities.js +0 -129
  89. package/dist/page-driver.d.ts +0 -120
  90. package/dist/page-driver.js +0 -2
  91. package/dist/page-executor.d.ts +0 -94
  92. package/dist/page-executor.js +0 -320
  93. package/dist/webmcp.d.ts +0 -202
  94. package/dist/webmcp.js +0 -173
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @jini-ai/agentic
2
2
 
3
+ ## 0.4.5 — 2026-10-22
4
+
5
+ - 2026-10-22: Complexity refactors with characterization pins, coverage.
6
+
3
7
  ## 0.4.3
4
8
 
5
9
  ### Patch Changes
@@ -64,42 +64,108 @@ export function parsePointerTokens({ pointer }, _optional = {}) {
64
64
  return pointer.slice(1).split('/').map(unescapeToken);
65
65
  }
66
66
  export function joinPointer({ base, tokens }, _optional = {}) {
67
- const baseTokens = base === '' || base === '/' ? [] : parsePointerTokens({ pointer: base });
67
+ const baseTokens = isRootPointer(base) ? [] : parsePointerTokens({ pointer: base });
68
68
  return '/' + [...baseTokens, ...tokens].map(escapeToken).join('/');
69
69
  }
70
- /** Reads the value at `pointer` in `doc`. Never throws on a non-resolving path — `found: false` degrades sanely, matching this port's "a bad binding must not crash the renderer" adversarial requirement (see `resolve.ts`). */
71
- export function getAtPointer({ doc, pointer }, _optional = {}) {
72
- if (isRootPointer(pointer))
73
- return { found: true, value: doc };
74
- let tokens;
70
+ /** The pointer's tokens, or `null` for a malformed pointer — the read and write paths both degrade on that instead of throwing. */
71
+ function tryParsePointerTokens(pointer) {
75
72
  try {
76
- tokens = parsePointerTokens({ pointer });
73
+ return parsePointerTokens({ pointer });
77
74
  }
78
75
  catch {
79
- return { found: false, value: undefined };
76
+ return null;
77
+ }
78
+ }
79
+ /** The array index `token` names, or `undefined` unless it is a canonical RFC 6901 index (`0`, or digits with no leading zero — never `-`, signs, exponents or whitespace). */
80
+ function arrayIndexOf(token) {
81
+ return /^(0|[1-9]\d*)$/.test(token) ? Number(token) : undefined;
82
+ }
83
+ function isObjectLike(value) {
84
+ return value !== null && typeof value === 'object';
85
+ }
86
+ function notFound() {
87
+ return { found: false, value: undefined };
88
+ }
89
+ /** One step of a read: the member `token` names in `current`, if `current` itself (never its prototype chain) has it. */
90
+ function readMember(current, token) {
91
+ if (Array.isArray(current)) {
92
+ const index = arrayIndexOf(token);
93
+ return index !== undefined && index < current.length ? { found: true, value: current[index] } : notFound();
80
94
  }
95
+ return isObjectLike(current) && hasOwnToken(current, token) ? { found: true, value: current[token] } : notFound();
96
+ }
97
+ /** Reads the value at `pointer` in `doc`. Never throws on a non-resolving path — `found: false` degrades sanely, matching this port's "a bad binding must not crash the renderer" adversarial requirement (see `resolve.ts`). */
98
+ export function getAtPointer({ doc, pointer }, _optional = {}) {
99
+ if (isRootPointer(pointer))
100
+ return { found: true, value: doc };
101
+ const tokens = tryParsePointerTokens(pointer);
102
+ if (tokens === null)
103
+ return notFound();
81
104
  let current = doc;
82
105
  for (const token of tokens) {
83
- if (Array.isArray(current)) {
84
- if (!/^(0|[1-9]\d*)$/.test(token))
85
- return { found: false, value: undefined };
86
- const index = Number(token);
87
- if (index >= current.length)
88
- return { found: false, value: undefined };
89
- current = current[index];
90
- continue;
91
- }
92
- if (current !== null && typeof current === 'object') {
93
- const record = current;
94
- if (!hasOwnToken(record, token))
95
- return { found: false, value: undefined };
96
- current = record[token];
97
- continue;
98
- }
99
- return { found: false, value: undefined };
106
+ const step = readMember(current, token);
107
+ if (!step.found)
108
+ return step;
109
+ current = step.value;
100
110
  }
101
111
  return { found: true, value: current };
102
112
  }
113
+ /** A shallow copy of `current` when it is an object, otherwise a fresh empty object to write into. */
114
+ function copyRecord(current) {
115
+ return isObjectLike(current) ? { ...current } : {};
116
+ }
117
+ /** The final write/delete into an array parent. A non-index token leaves the array untouched (same instance). */
118
+ function writeArrayLeaf(array, token, value) {
119
+ const index = arrayIndexOf(token);
120
+ if (index === undefined)
121
+ return array;
122
+ const next = array.slice();
123
+ // `splice` past the end removes nothing, so an out-of-range delete needs no guard of its own.
124
+ if (value === null) {
125
+ next.splice(index, 1);
126
+ }
127
+ else {
128
+ next[index] = value;
129
+ }
130
+ return next;
131
+ }
132
+ /** The final write/delete into an object parent (a non-object parent is replaced by a fresh object). */
133
+ function writeRecordLeaf(current, token, value) {
134
+ const record = copyRecord(current);
135
+ if (value === null) {
136
+ delete record[token];
137
+ }
138
+ else {
139
+ defineOwn(record, token, value);
140
+ }
141
+ return record;
142
+ }
143
+ /** Returns a copy of `current` with `leaf` applied at `remaining` (the parent tokens still to walk). */
144
+ function writePath(current, remaining, leaf) {
145
+ if (remaining.length === 0) {
146
+ // `current` here is the parent container; apply the final write/delete.
147
+ return Array.isArray(current) ? writeArrayLeaf(current, leaf.token, leaf.value) : writeRecordLeaf(current, leaf.token, leaf.value);
148
+ }
149
+ const [head, ...rest] = remaining;
150
+ return Array.isArray(current) ? writeArrayBranch(current, head, rest, leaf) : writeRecordBranch(current, head, rest, leaf);
151
+ }
152
+ /** An intermediate array segment. A non-index token (the empty token included) leaves the array untouched. */
153
+ function writeArrayBranch(array, head, rest, leaf) {
154
+ const index = arrayIndexOf(head);
155
+ if (index === undefined)
156
+ return array;
157
+ const next = array.slice();
158
+ next[index] = writePath(index < next.length ? next[index] : {}, rest, leaf);
159
+ return next;
160
+ }
161
+ /** An intermediate object segment. An empty-string token is a documented no-op (see the `"//x"` test): the copy is returned unwritten. */
162
+ function writeRecordBranch(current, head, rest, leaf) {
163
+ const record = copyRecord(current);
164
+ if (!head)
165
+ return record;
166
+ defineOwn(record, head, writePath(hasOwnToken(record, head) ? record[head] : {}, rest, leaf));
167
+ return record;
168
+ }
103
169
  /**
104
170
  * Writes `value` at `pointer` in `doc`, returning a **new** document (the original is never
105
171
  * mutated — every intermediate object on the path is shallow-copied). Missing intermediate
@@ -121,54 +187,9 @@ export function getAtPointer({ doc, pointer }, _optional = {}) {
121
187
  export function setAtPointer({ doc, pointer, value }, _optional = {}) {
122
188
  if (isRootPointer(pointer))
123
189
  return value;
124
- let tokens;
125
- try {
126
- tokens = parsePointerTokens({ pointer });
127
- }
128
- catch {
190
+ const tokens = tryParsePointerTokens(pointer);
191
+ if (tokens === null)
129
192
  return doc;
130
- }
131
- const parentTokens = tokens.slice(0, -1);
132
- const lastToken = tokens[tokens.length - 1];
133
- function recurse(current, remaining) {
134
- if (remaining.length === 0) {
135
- // `current` here is the parent container; apply the final write/delete.
136
- if (Array.isArray(current)) {
137
- if (!/^(0|[1-9]\d*)$/.test(lastToken))
138
- return current;
139
- const index = Number(lastToken);
140
- const next = current.slice();
141
- if (value === null) {
142
- if (index < next.length)
143
- next.splice(index, 1);
144
- return next;
145
- }
146
- next[index] = value;
147
- return next;
148
- }
149
- const record = current !== null && typeof current === 'object' ? { ...current } : {};
150
- if (value === null) {
151
- delete record[lastToken];
152
- return record;
153
- }
154
- defineOwn(record, lastToken, value);
155
- return record;
156
- }
157
- const [head, ...rest] = remaining;
158
- if (Array.isArray(current)) {
159
- if (!head || !/^(0|[1-9]\d*)$/.test(head))
160
- return current;
161
- const index = Number(head);
162
- const next = current.slice();
163
- next[index] = recurse(index < next.length ? next[index] : {}, rest);
164
- return next;
165
- }
166
- const record = current !== null && typeof current === 'object' ? { ...current } : {};
167
- if (!head)
168
- return record;
169
- defineOwn(record, head, recurse(hasOwnToken(record, head) ? record[head] : {}, rest));
170
- return record;
171
- }
172
- return recurse(doc, parentTokens);
193
+ return writePath(doc, tokens.slice(0, -1), { token: tokens[tokens.length - 1], value });
173
194
  }
174
195
  //# sourceMappingURL=json-pointer.js.map
@@ -56,41 +56,60 @@ function resolveBindingPath(path, ctx) {
56
56
  }
57
57
  return { ok: true, value: result.value };
58
58
  }
59
- function resolveFunctionCall(call, ctx) {
60
- if (call.call === '@index') {
61
- if (!ctx.itemScope) {
62
- return { ok: false, reason: 'INDEX_OUTSIDE_LIST_CONTEXT', detail: '@index used outside a list-template item context' };
63
- }
64
- const offsetArg = call.args?.offset;
65
- const offsetResult = offsetArg === undefined ? { ok: true, value: 0 } : resolveDynamicValue({ value: offsetArg, ctx: ctx }, { itemScope: ctx.itemScope });
66
- if (!offsetResult.ok)
67
- return offsetResult;
68
- const offset = typeof offsetResult.value === 'number' ? offsetResult.value : 0;
69
- return { ok: true, value: ctx.itemScope.index + offset };
59
+ /** `@index` (the one system function, answered without the catalog): the item scope's index plus an optional, resolved `offset` arg (non-numeric counts as 0). */
60
+ function resolveIndexCall(call, ctx) {
61
+ if (!ctx.itemScope) {
62
+ return { ok: false, reason: 'INDEX_OUTSIDE_LIST_CONTEXT', detail: '@index used outside a list-template item context' };
70
63
  }
64
+ const offsetArg = call.args?.offset;
65
+ const offsetResult = offsetArg === undefined ? { ok: true, value: 0 } : resolveValue(offsetArg, ctx);
66
+ if (!offsetResult.ok)
67
+ return offsetResult;
68
+ const offset = typeof offsetResult.value === 'number' ? offsetResult.value : 0;
69
+ return { ok: true, value: ctx.itemScope.index + offset };
70
+ }
71
+ function isCallableFromSide(callableFrom, side) {
72
+ return callableFrom === 'rendererOrAgent' || (callableFrom === 'rendererOnly' && side === 'renderer') || (callableFrom === 'agentOnly' && side === 'agent');
73
+ }
74
+ /** The catalog-side refusals, in the order they apply: not registered, then the `callableFrom` boundary, then no implementation. Returns the implementation when all three pass. */
75
+ function lookUpCallableImpl(call, ctx) {
71
76
  if (!isFunctionRegistered({ catalog: ctx.catalog, functionName: call.call })) {
72
77
  return { ok: false, reason: 'FUNCTION_NOT_REGISTERED', detail: `function "${call.call}" is not registered in catalog "${ctx.catalog.catalogId}"` };
73
78
  }
74
79
  const callableFrom = callableFromOf({ catalog: ctx.catalog, functionName: call.call });
75
- const allowed = callableFrom === 'rendererOrAgent' || (callableFrom === 'rendererOnly' && ctx.side === 'renderer') || (callableFrom === 'agentOnly' && ctx.side === 'agent');
76
- if (!allowed) {
80
+ if (!isCallableFromSide(callableFrom, ctx.side)) {
77
81
  return {
78
82
  ok: false,
79
83
  reason: 'FUNCTION_NOT_CALLABLE_FROM_SIDE',
80
84
  detail: `function "${call.call}" has callableFrom="${callableFrom}", not callable from the "${ctx.side}" side`,
81
85
  };
82
86
  }
83
- const spec = ctx.catalog.functions.get(call.call);
84
- if (!spec?.impl) {
87
+ const impl = ctx.catalog.functions.get(call.call)?.impl;
88
+ if (!impl) {
85
89
  return { ok: false, reason: 'FUNCTION_NOT_IMPLEMENTED', detail: `function "${call.call}" is registered but this renderer has no implementation for it` };
86
90
  }
91
+ return { ok: true, impl };
92
+ }
93
+ /** Resolves every arg value in key order, stopping at the first failure. */
94
+ function resolveArgs(args, ctx) {
87
95
  const resolvedArgs = {};
88
- for (const [key, argValue] of Object.entries(call.args ?? {})) {
96
+ for (const [key, argValue] of Object.entries(args ?? {})) {
89
97
  const argResult = resolveArgValue(argValue, ctx);
90
98
  if (!argResult.ok)
91
99
  return argResult;
92
100
  resolvedArgs[key] = argResult.value;
93
101
  }
102
+ return { ok: true, value: resolvedArgs };
103
+ }
104
+ function resolveFunctionCall(call, ctx) {
105
+ if (call.call === '@index')
106
+ return resolveIndexCall(call, ctx);
107
+ const lookup = lookUpCallableImpl(call, ctx);
108
+ if (!lookup.ok)
109
+ return lookup;
110
+ const argsResult = resolveArgs(call.args, ctx);
111
+ if (!argsResult.ok)
112
+ return argsResult;
94
113
  // `impl` is host-supplied code handed agent-supplied arguments, and this module validates
95
114
  // neither against the other (the catalog's per-function arg schemas are not implemented — see
96
115
  // `catalog.ts`'s module doc). An entirely ordinary implementation therefore throws the moment an
@@ -98,7 +117,7 @@ function resolveFunctionCall(call, ctx) {
98
117
  // travel out through `applyAgentMessage` into the host's render, where nothing catches it. It is
99
118
  // a resolution failure like any other: reported, not propagated.
100
119
  try {
101
- return { ok: true, value: spec.impl(resolvedArgs) };
120
+ return { ok: true, value: lookup.impl(argsResult.value) };
102
121
  }
103
122
  catch (error) {
104
123
  const detail = error instanceof Error ? error.message : String(error);
@@ -131,15 +150,18 @@ function resolveArgValue(argValue, ctx) {
131
150
  }
132
151
  return { ok: true, value: resolvedItems };
133
152
  }
134
- return resolveDynamicValue({ value: argValue, ctx: ctx }, { itemScope: ctx.itemScope });
153
+ return resolveValue(argValue, ctx);
135
154
  }
136
- /** Resolves any `Dynamic*` value (`DynamicString`/`DynamicNumber`/`DynamicBoolean`/`DynamicStringList`/`DynamicValue` all share this one shape: literal | `DataBinding` | `FunctionCall`). */
137
- export function resolveDynamicValue({ value, ctx }, { itemScope } = {}) {
138
- const context = { ...ctx, ...(itemScope === undefined ? {} : { itemScope }) };
155
+ /** Resolves one `Dynamic*` value in an already-built context (the item scope, if any, is in `ctx`). */
156
+ function resolveValue(value, ctx) {
139
157
  if (isDataBinding(value))
140
- return resolveBindingPath(value.path, context);
158
+ return resolveBindingPath(value.path, ctx);
141
159
  if (isFunctionCall(value))
142
- return resolveFunctionCall(value, context);
160
+ return resolveFunctionCall(value, ctx);
143
161
  return { ok: true, value };
144
162
  }
163
+ /** Resolves any `Dynamic*` value (`DynamicString`/`DynamicNumber`/`DynamicBoolean`/`DynamicStringList`/`DynamicValue` all share this one shape: literal | `DataBinding` | `FunctionCall`). */
164
+ export function resolveDynamicValue({ value, ctx }, { itemScope } = {}) {
165
+ return resolveValue(value, { ...ctx, ...(itemScope === undefined ? {} : { itemScope }) });
166
+ }
145
167
  //# sourceMappingURL=resolve.js.map
@@ -11,6 +11,13 @@
11
11
  * is orders of magnitude smaller), so this is a fail-safe, not a design budget.
12
12
  */
13
13
  export const MAX_RENDER_NODES = 50_000;
14
+ /** Pushes `childIds` onto the walk's stack at `depth`. */
15
+ function pushChildren(stack, childIds, depth) {
16
+ // Pushed in reverse so the first child is popped first, preserving pre-order.
17
+ for (let index = childIds.length - 1; index >= 0; index -= 1) {
18
+ stack.push({ id: childIds[index], depth });
19
+ }
20
+ }
14
21
  /**
15
22
  * @param components The full component map (id -> instance).
16
23
  * @param rootId The id to start walking from (typically `'root'`, but not assumed — a surface
@@ -63,11 +70,7 @@ export function flattenRenderTree({ components, rootId, getChildIds }, _optional
63
70
  output.push({ id, depth, status: 'ok' });
64
71
  ancestors.add(id);
65
72
  stack.push({ leave: id });
66
- // Pushed in reverse so the first child is popped first, preserving pre-order.
67
- const childIds = getChildIds({ component });
68
- for (let index = childIds.length - 1; index >= 0; index -= 1) {
69
- stack.push({ id: childIds[index], depth: depth + 1 });
70
- }
73
+ pushChildren(stack, getChildIds({ component }), depth + 1);
71
74
  }
72
75
  return output;
73
76
  }
@@ -40,19 +40,28 @@ export function findCapabilityInputError({ capability, input }, _optional = {})
40
40
  return `"${name}" is required`;
41
41
  }
42
42
  if (additionalProperties === false) {
43
- // `key in properties` walks the whole prototype chain, so a key that happens to be inherited
44
- // from Object.prototype (`__proto__`, `constructor`, `toString`, `valueOf`, …) reads as
45
- // "known" regardless of whether the schema actually lists it — `'constructor' in {}` is
46
- // true even on an empty object. `JSON.parse('{"__proto__":...}')` creates a real own data
47
- // property (not the accessor), so this was a live way for such a key to escape the
48
- // "unknown argument" refusal entirely. hasOwnProperty ignores the prototype chain, so only
49
- // keys the schema actually declares count as known.
50
- const unknown = Object.keys(input)
51
- .filter((key) => !Object.prototype.hasOwnProperty.call(properties, key));
52
- if (unknown.length > 0) {
53
- return `unknown ${unknown.length === 1 ? 'argument' : 'arguments'}: ${unknown.sort().join(', ')}`;
54
- }
43
+ const unknownError = findUnknownArgumentError({ properties, input });
44
+ if (unknownError !== null)
45
+ return unknownError;
46
+ }
47
+ return findPropertyInputError({ properties, input });
48
+ }
49
+ function findUnknownArgumentError({ properties, input }, _optional = {}) {
50
+ // `key in properties` walks the whole prototype chain, so a key that happens to be inherited
51
+ // from Object.prototype (`__proto__`, `constructor`, `toString`, `valueOf`, …) reads as
52
+ // "known" regardless of whether the schema actually lists it — `'constructor' in {}` is
53
+ // true even on an empty object. `JSON.parse('{"__proto__":...}')` creates a real own data
54
+ // property (not the accessor), so this was a live way for such a key to escape the
55
+ // "unknown argument" refusal entirely. hasOwnProperty ignores the prototype chain, so only
56
+ // keys the schema actually declares count as known.
57
+ const unknown = Object.keys(input)
58
+ .filter((key) => !Object.prototype.hasOwnProperty.call(properties, key));
59
+ if (unknown.length > 0) {
60
+ return `unknown ${unknown.length === 1 ? 'argument' : 'arguments'}: ${unknown.sort().join(', ')}`;
55
61
  }
62
+ return null;
63
+ }
64
+ function findPropertyInputError({ properties, input }, _optional = {}) {
56
65
  for (const [name, schema] of Object.entries(properties)) {
57
66
  const value = input[name];
58
67
  if (value === undefined)
@@ -103,25 +103,28 @@ function accessibleLabelsOf(control) {
103
103
  ];
104
104
  return [...new Set(candidates.filter((text) => !!text))];
105
105
  }
106
+ function editableFieldDescriptorOf({ control }, _optional = {}) {
107
+ // Reported as a field so the same guards run: a contenteditable div named `card-number` is
108
+ // no more fillable than an input would be. `type` is not a real HTML input type, and is not
109
+ // in any denied set, which is correct — the guard should judge it by name and attributes.
110
+ return {
111
+ type: 'contenteditable',
112
+ // Read for the same reason `name` is: neither attribute is standard on a div, but a page
113
+ // that bothers to set one is telling the guard what the field holds, and honouring `name`
114
+ // while ignoring `autocomplete` would refuse `name="card_number"` and accept the
115
+ // `autocomplete="cc-number"` spelling of the same field.
116
+ autocomplete: control.getAttribute('autocomplete')?.toLowerCase() || undefined,
117
+ name: control.getAttribute('name') || undefined,
118
+ id: control.id || undefined,
119
+ accessibleLabels: accessibleLabelsOf(control),
120
+ readOnly: control.getAttribute('aria-readonly') === 'true',
121
+ disabled: control.getAttribute('aria-disabled') === 'true',
122
+ };
123
+ }
106
124
  /** The field attributes the guards need, or `null` when the control is not a text-bearing input. */
107
125
  function fieldDescriptorOf(control) {
108
126
  if (isEditableRegion(control)) {
109
- // Reported as a field so the same guards run: a contenteditable div named `card-number` is
110
- // no more fillable than an input would be. `type` is not a real HTML input type, and is not
111
- // in any denied set, which is correct — the guard should judge it by name and attributes.
112
- return {
113
- type: 'contenteditable',
114
- // Read for the same reason `name` is: neither attribute is standard on a div, but a page
115
- // that bothers to set one is telling the guard what the field holds, and honouring `name`
116
- // while ignoring `autocomplete` would refuse `name="card_number"` and accept the
117
- // `autocomplete="cc-number"` spelling of the same field.
118
- autocomplete: control.getAttribute('autocomplete')?.toLowerCase() || undefined,
119
- name: control.getAttribute('name') || undefined,
120
- id: control.id || undefined,
121
- accessibleLabels: accessibleLabelsOf(control),
122
- readOnly: control.getAttribute('aria-readonly') === 'true',
123
- disabled: control.getAttribute('aria-disabled') === 'true',
124
- };
127
+ return editableFieldDescriptorOf({ control });
125
128
  }
126
129
  if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLTextAreaElement)) {
127
130
  return null;
@@ -139,6 +142,48 @@ function fieldDescriptorOf(control) {
139
142
  disabled: control.matches(':disabled'),
140
143
  };
141
144
  }
145
+ function dropdownFieldDescriptorOf({ dropdown }, _optional = {}) {
146
+ if (dropdown === undefined)
147
+ return null;
148
+ return {
149
+ type: 'select',
150
+ name: dropdown.name || undefined,
151
+ id: dropdown.id || undefined,
152
+ // Same reason `fieldDescriptorOf` carries these: a `<select aria-label="Card number"
153
+ // name="f9">` states its sensitivity nowhere else, and this descriptor is built by hand
154
+ // rather than going through that function, so it needs the labels wired up separately.
155
+ accessibleLabels: accessibleLabelsOf(dropdown),
156
+ // Same `:disabled` reasoning as `disabledOf` — a `<select>` inside a disabled `<fieldset>`
157
+ // is not itself flagged by the `.disabled` IDL property.
158
+ disabled: dropdown.matches(':disabled'),
159
+ };
160
+ }
161
+ function rawElementStateOf({ element, control, checkable, readable, value, dropdown }, _optional = {}) {
162
+ const text = publicTextOf(element);
163
+ return {
164
+ text,
165
+ // A contenteditable region has no `.value`; its content IS its value, so `text` is the
166
+ // one channel carrying it and the executor has to gate it exactly like a value. Reported
167
+ // as a mechanical fact about the element, not as a judgment about the content — the
168
+ // decision of whether it may be shown stays in `projectElementState`, with every other
169
+ // secrecy rule.
170
+ ...(isEditableRegion(control) ? { textIsValue: true } : {}),
171
+ // Reported raw and unconditionally; `projectElementState` applies the read guard. A
172
+ // driver deciding here which values are secret is a driver holding policy.
173
+ ...(value !== undefined ? { value } : {}),
174
+ ...(checkable ? { checked: control.checked } : {}),
175
+ ...(disabledOf(control) !== undefined ? { disabled: disabledOf(control) } : {}),
176
+ ...(visibilityOf(element) !== undefined ? { visible: visibilityOf(element) } : {}),
177
+ ...(readable !== null ? { field: readable } : {}),
178
+ ...(dropdown !== undefined
179
+ ? {
180
+ options: Array.from(dropdown.options)
181
+ .map((entry) => entry.text.trim())
182
+ .filter((entry) => entry.length > 0),
183
+ }
184
+ : {}),
185
+ };
186
+ }
142
187
  /**
143
188
  * Whether the element is actually rendered, or `undefined` when the platform cannot say.
144
189
  *
@@ -222,6 +267,30 @@ function describe(element, page) {
222
267
  page,
223
268
  };
224
269
  }
270
+ function findSelectableOption({ control, handle, option }, _optional = {}) {
271
+ // Visible text first, then value: the text is what a caller saw in `find_elements`, and
272
+ // matching value first would make an option whose value collides with another's label
273
+ // resolve to the wrong entry.
274
+ // A disabled option is not selectable by the user either, for the same reason.
275
+ const options = Array.from(control.options).filter((entry) => !entry.disabled);
276
+ const match = options.find((entry) => entry.text.trim() === option)
277
+ ?? options.find((entry) => entry.value === option);
278
+ if (match === undefined) {
279
+ // Every other piece of page-authored text this system hands to a model goes through
280
+ // normalizeAgentLabel first — bounded, control- and bidi-stripped. `available` is exactly
281
+ // that: the page's own <option> texts, read verbatim with no length bound of their own.
282
+ // The caller's `option` is sanitized too, for the same reason page.navigate's caller-
283
+ // supplied `page` now is — an unbounded value reaching a thrown message a model reads is
284
+ // the same shape of problem regardless of which side authored it. `handle` needs no
285
+ // treatment: it is already validated ASCII, length-capped by isValidElementHandle before
286
+ // this driver is ever called.
287
+ const safeOption = normalizeAgentLabel({ raw: option }).text;
288
+ const available = options.map((entry) => normalizeAgentLabel({ raw: entry.text.trim() }).text)
289
+ .filter((text) => text.length > 0);
290
+ throw new Error(`"${safeOption}" is not an option of "${handle}". Available: ${available.length > 0 ? available.join(', ') : '(none)'}`);
291
+ }
292
+ return match;
293
+ }
225
294
  /**
226
295
  * Builds a driver over `root`.
227
296
  *
@@ -355,45 +424,11 @@ export function createDomPageDriver({ root, pages }, options = {}) {
355
424
  // avoided by looking. It carries a descriptor too, so the read guard still runs — a
356
425
  // `<select name="secret_token">` is no more readable than an input of the same name.
357
426
  const dropdown = control instanceof HTMLSelectElement ? control : undefined;
358
- const readable = field ?? (dropdown === undefined ? null : {
359
- type: 'select',
360
- name: dropdown.name || undefined,
361
- id: dropdown.id || undefined,
362
- // Same reason `fieldDescriptorOf` carries these: a `<select aria-label="Card number"
363
- // name="f9">` states its sensitivity nowhere else, and this descriptor is built by hand
364
- // rather than going through that function, so it needs the labels wired up separately.
365
- accessibleLabels: accessibleLabelsOf(dropdown),
366
- // Same `:disabled` reasoning as `disabledOf` — a `<select>` inside a disabled `<fieldset>`
367
- // is not itself flagged by the `.disabled` IDL property.
368
- disabled: dropdown.matches(':disabled'),
369
- });
427
+ const readable = field ?? dropdownFieldDescriptorOf({ dropdown });
370
428
  const value = field !== null
371
429
  ? control.value
372
430
  : dropdown?.value;
373
- const text = publicTextOf(element);
374
- return {
375
- text,
376
- // A contenteditable region has no `.value`; its content IS its value, so `text` is the
377
- // one channel carrying it and the executor has to gate it exactly like a value. Reported
378
- // as a mechanical fact about the element, not as a judgment about the content — the
379
- // decision of whether it may be shown stays in `projectElementState`, with every other
380
- // secrecy rule.
381
- ...(isEditableRegion(control) ? { textIsValue: true } : {}),
382
- // Reported raw and unconditionally; `projectElementState` applies the read guard. A
383
- // driver deciding here which values are secret is a driver holding policy.
384
- ...(value !== undefined ? { value } : {}),
385
- ...(checkable ? { checked: control.checked } : {}),
386
- ...(disabledOf(control) !== undefined ? { disabled: disabledOf(control) } : {}),
387
- ...(visibilityOf(element) !== undefined ? { visible: visibilityOf(element) } : {}),
388
- ...(readable !== null ? { field: readable } : {}),
389
- ...(dropdown !== undefined
390
- ? {
391
- options: Array.from(dropdown.options)
392
- .map((entry) => entry.text.trim())
393
- .filter((entry) => entry.length > 0),
394
- }
395
- : {}),
396
- };
431
+ return rawElementStateOf({ element, control, checkable, readable, value, dropdown });
397
432
  },
398
433
  /**
399
434
  * Two animation frames — long enough for a framework to have re-rendered and the browser to
@@ -489,7 +524,7 @@ export function createDomPageDriver({ root, pages }, options = {}) {
489
524
  }
490
525
  // A disabled control is not one a user could touch, so writing to it is a way around the
491
526
  // page's own rule and the resulting selection cannot be submitted. The per-`<option>` filter
492
- // below reads as if it enforced that, but only ever looked at each option — a
527
+ // in `findSelectableOption` reads as if it enforced that, but only ever looked at each option — a
493
528
  // `<select disabled>` (and a `<select>` inside a `<fieldset disabled>`, which `:disabled`
494
529
  // also covers and `.disabled` does not) was written to happily, and the executor then
495
530
  // reported `targetChanged: true` because the value really had changed.
@@ -501,27 +536,7 @@ export function createDomPageDriver({ root, pages }, options = {}) {
501
536
  throw new Error(`"${handle}" is disabled, so its selection cannot be changed — `
502
537
  + 'a user cannot change it either, and its value would not be submitted');
503
538
  }
504
- // Visible text first, then value: the text is what a caller saw in `find_elements`, and
505
- // matching value first would make an option whose value collides with another's label
506
- // resolve to the wrong entry.
507
- // A disabled option is not selectable by the user either, for the same reason.
508
- const options = Array.from(control.options).filter((entry) => !entry.disabled);
509
- const match = options.find((entry) => entry.text.trim() === option)
510
- ?? options.find((entry) => entry.value === option);
511
- if (match === undefined) {
512
- // Every other piece of page-authored text this system hands to a model goes through
513
- // normalizeAgentLabel first — bounded, control- and bidi-stripped. `available` is exactly
514
- // that: the page's own <option> texts, read verbatim with no length bound of their own.
515
- // The caller's `option` is sanitized too, for the same reason page.navigate's caller-
516
- // supplied `page` now is — an unbounded value reaching a thrown message a model reads is
517
- // the same shape of problem regardless of which side authored it. `handle` needs no
518
- // treatment: it is already validated ASCII, length-capped by isValidElementHandle before
519
- // this driver is ever called.
520
- const safeOption = normalizeAgentLabel({ raw: option }).text;
521
- const available = options.map((entry) => normalizeAgentLabel({ raw: entry.text.trim() }).text)
522
- .filter((text) => text.length > 0);
523
- throw new Error(`"${safeOption}" is not an option of "${handle}". Available: ${available.length > 0 ? available.join(', ') : '(none)'}`);
524
- }
539
+ const match = findSelectableOption({ control, handle, option });
525
540
  if (control.multiple) {
526
541
  // A multi-select accumulates by default. Assigning `value` would silently clear every
527
542
  // prior choice, so a caller building up a selection would end with only its last call's