@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.1

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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +1 -0
  3. package/README.md +124 -2
  4. package/THIRD_PARTY_NOTICES.md +66 -0
  5. package/ZCode-LICENSE +202 -0
  6. package/dist/index.mjs +4871 -0
  7. package/dist/native-command-plan.LICENSE.txt +221 -0
  8. package/dist/native-command-plan.json +16 -0
  9. package/dist/native-command-plan.mjs +8 -0
  10. package/dist/rpc-entry.mjs +5682 -0
  11. package/package.json +169 -4
  12. package/src/native/a2a-receipt-source.mjs +19 -0
  13. package/src/native/a2a-tools.mjs +517 -0
  14. package/src/native/activity-status.mjs +30 -0
  15. package/src/native/artifact-codec.mjs +481 -0
  16. package/src/native/artifact-store.mjs +1162 -0
  17. package/src/native/assistant-stream.mjs +76 -0
  18. package/src/native/background-command-port.mjs +95 -0
  19. package/src/native/browser-action-observation.mjs +43 -0
  20. package/src/native/browser-availability.mjs +22 -0
  21. package/src/native/browser-canvas-metrics.mjs +71 -0
  22. package/src/native/browser-dom.mjs +193 -0
  23. package/src/native/browser-playwright.mjs +1249 -0
  24. package/src/native/browser-vision.mjs +369 -0
  25. package/src/native/browser-workspace-policy.mjs +22 -0
  26. package/src/native/browser-workspace-protocol.mjs +95 -0
  27. package/src/native/browser-workspace-server.mjs +164 -0
  28. package/src/native/browser-workspace.mjs +355 -0
  29. package/src/native/captured-source.mjs +3 -0
  30. package/src/native/child-budget-allocation.mjs +87 -0
  31. package/src/native/child-capabilities.mjs +35 -0
  32. package/src/native/child-command-resource.mjs +20 -0
  33. package/src/native/child-history.mjs +188 -0
  34. package/src/native/child-merge.mjs +98 -0
  35. package/src/native/child-model-surface.mjs +19 -0
  36. package/src/native/child-observation.mjs +100 -0
  37. package/src/native/child-owner.mjs +620 -0
  38. package/src/native/child-publication.mjs +76 -0
  39. package/src/native/child-readers.mjs +366 -0
  40. package/src/native/child-tools.mjs +46 -0
  41. package/src/native/child-workspace-binding.mjs +29 -0
  42. package/src/native/child-workspace-tools.mjs +1 -0
  43. package/src/native/child-workspaces.mjs +655 -0
  44. package/src/native/children.mjs +365 -0
  45. package/src/native/command-plan-worker.mjs +14 -0
  46. package/src/native/command-plan.mjs +31 -0
  47. package/src/native/command-progress.mjs +64 -0
  48. package/src/native/command-shell.mjs +9 -0
  49. package/src/native/command-workspace-observation.mjs +36 -0
  50. package/src/native/component-tools.mjs +280 -0
  51. package/src/native/context-counter.mjs +133 -0
  52. package/src/native/context-markers.mjs +51 -0
  53. package/src/native/context-profile.mjs +72 -0
  54. package/src/native/context-status.mjs +27 -0
  55. package/src/native/conversation-command-control.mjs +133 -0
  56. package/src/native/delivery-timeline.mjs +39 -0
  57. package/src/native/dsh-background-ownership.mjs +102 -0
  58. package/src/native/dsh-managed-ownership.mjs +23 -0
  59. package/src/native/dsh-turn-contract.mjs +3 -0
  60. package/src/native/dsh-turn-ownership.mjs +54 -0
  61. package/src/native/dsh-turn-runtime.mjs +498 -0
  62. package/src/native/e2b-background-supervisor.mjs +347 -0
  63. package/src/native/e2b-background.mjs +368 -0
  64. package/src/native/e2b-command-state.mjs +40 -0
  65. package/src/native/e2b-command-tools.mjs +486 -0
  66. package/src/native/e2b-sandbox-owner.mjs +227 -0
  67. package/src/native/hook-executor.mjs +249 -0
  68. package/src/native/instruction-assembly.mjs +60 -0
  69. package/src/native/instruction-context.mjs +54 -0
  70. package/src/native/local-background-supervisor.mjs +108 -0
  71. package/src/native/local-background.mjs +207 -0
  72. package/src/native/local-command-evidence.mjs +57 -0
  73. package/src/native/local-command-policy-worker.mjs +197 -0
  74. package/src/native/local-command-policy.mjs +46 -0
  75. package/src/native/local-command-state.mjs +177 -0
  76. package/src/native/local-command-tools.mjs +302 -0
  77. package/src/native/local-execution-profile.mjs +36 -0
  78. package/src/native/local-output-policy.mjs +92 -0
  79. package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
  80. package/src/native/local-process-bin/darwin-x64/launcher +0 -0
  81. package/src/native/local-process-bin/linux-arm64/launcher +0 -0
  82. package/src/native/local-process-bin/linux-x64/launcher +0 -0
  83. package/src/native/local-process-launcher.c +183 -0
  84. package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
  85. package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
  86. package/src/native/local-process-licenses/NOTICE +21 -0
  87. package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
  88. package/src/native/local-process-manifest.json +32 -0
  89. package/src/native/local-process.mjs +246 -0
  90. package/src/native/local-resource-profile.mjs +32 -0
  91. package/src/native/memory-candidate.mjs +121 -0
  92. package/src/native/memory-extraction-run.mjs +60 -0
  93. package/src/native/memory-extraction-tools.mjs +116 -0
  94. package/src/native/memory-recall.mjs +65 -0
  95. package/src/native/memory-store.mjs +334 -0
  96. package/src/native/memory-tools.mjs +329 -0
  97. package/src/native/model-anthropic-cache.mjs +49 -0
  98. package/src/native/model-anthropic-tool-search.mjs +85 -0
  99. package/src/native/model-anthropic.mjs +753 -0
  100. package/src/native/model-chat-usage.mjs +24 -0
  101. package/src/native/model-inline-reasoning.mjs +111 -0
  102. package/src/native/model-media.mjs +174 -0
  103. package/src/native/model-normalize.mjs +228 -0
  104. package/src/native/model-openai-chat.mjs +561 -0
  105. package/src/native/model-port.mjs +361 -0
  106. package/src/native/model-provider-compat.mjs +129 -0
  107. package/src/native/model-replay.mjs +153 -0
  108. package/src/native/model-request.mjs +15 -0
  109. package/src/native/model-responses.mjs +1205 -0
  110. package/src/native/model-sse.mjs +171 -0
  111. package/src/native/model-surface-host.mjs +27 -0
  112. package/src/native/model-tool-search-policy.mjs +16 -0
  113. package/src/native/model-transport-observation.mjs +27 -0
  114. package/src/native/owned-child.mjs +114 -0
  115. package/src/native/owned-command.mjs +106 -0
  116. package/src/native/python-syntax-worker.mjs +60 -0
  117. package/src/native/python-syntax.mjs +50 -0
  118. package/src/native/read-interruption.mjs +16 -0
  119. package/src/native/result-stream.mjs +63 -0
  120. package/src/native/ruff-ast.mjs +95 -0
  121. package/src/native/run-observation.mjs +128 -0
  122. package/src/native/session-delivery-journal.mjs +96 -0
  123. package/src/native/session-delivery.mjs +434 -0
  124. package/src/native/shell-analysis.mjs +50 -0
  125. package/src/native/skill-disclosure.mjs +404 -0
  126. package/src/native/skill-events.mjs +164 -0
  127. package/src/native/skill-tools.mjs +813 -0
  128. package/src/native/source-page.mjs +73 -0
  129. package/src/native/sqlite-session-inbox.mjs +42 -0
  130. package/src/native/sqlite-store.mjs +617 -0
  131. package/src/native/tool-authority.mjs +99 -0
  132. package/src/native/tool-guidance.mjs +12 -0
  133. package/src/native/tool-observation.mjs +75 -0
  134. package/src/native/tool-states.mjs +14 -0
  135. package/src/native/verification-authority.mjs +37 -0
  136. package/src/native/web-tools.mjs +643 -0
  137. package/src/native/workspace-checkpoints.mjs +312 -0
  138. package/src/native/workspace-context.mjs +61 -0
  139. package/src/native/workspace-display.mjs +39 -0
  140. package/src/native/workspace-edit-match.mjs +19 -0
  141. package/src/native/workspace-edit-worker.mjs +15 -0
  142. package/src/native/workspace-editing.mjs +447 -0
  143. package/src/native/workspace-image.mjs +72 -0
  144. package/src/native/workspace-instructions.mjs +107 -0
  145. package/src/native/workspace-mutation-lock.mjs +74 -0
  146. package/src/native/workspace-patch-worker.mjs +17 -0
  147. package/src/native/workspace-patch.mjs +17 -0
  148. package/src/native/workspace-paths.mjs +225 -0
  149. package/src/native/workspace-state.mjs +272 -0
  150. package/src/native/workspace-subject-observer.mjs +84 -0
  151. package/src/native/workspace-tools.mjs +1740 -0
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Native Node web tools for the AgentKit Harness.
3
+ *
4
+ * Exports createNativeWebTools({fetch, providerConfig, ssrfPolicy, authorize,
5
+ * sessionId}) -> {definitions, execute}. `definitions` is the sealed
6
+ * ToolDefinition[] for web_fetch and web_search, ported from
7
+ * ksadk.toolsets.web @ 355059d. `execute` runs a ToolRequest and returns a
8
+ * ToolResult whose `content` is the Python-faithful dict.
9
+ *
10
+ * Authorization: web tools are read-only but still require a trusted authorize
11
+ * callback. `execute` snapshots the full locked request context (call,
12
+ * definition, scope, runId, turnId, buildId, parentRunId, taskChainId,
13
+ * operationKey, sessionId, signal identity) before the await, calls authorize
14
+ * with an isolated JSON copy (the signal identity is passed for inspection but
15
+ * cannot be serialized/mutated), and rejects if anything drifted after the
16
+ * await — no fetch runs after drift. The injected `providerConfig` and
17
+ * `ssrfPolicy` are deep-copied and frozen at factory creation; external
18
+ * mutation of the original references cannot change bound behavior, and the
19
+ * returned `definitions` are fresh copies so callers cannot mutate the formal
20
+ * map. Reads are untrusted data annotation, not a new side-effect auto-allow.
21
+ *
22
+ * Faithful to the Python semantics that matter to the model and the safety
23
+ * boundary: public-HTTP-only URL validation with SSRF blocking (the union of
24
+ * Python ipaddress is_loopback/is_private/is_link_local/is_multicast/
25
+ * is_reserved for IPv4 and IPv6, plus IPv4-mapped IPv6 and IPv6 hostnames with
26
+ * brackets), manual redirect following up to 5 hops, one bounded signal that
27
+ * spans DNS + every redirect + the body read, a hard byte ceiling on the
28
+ * streamed response, Unicode code-point budget, HTML-to-text sanitization that
29
+ * never throws on invalid entities, query validation, and the three real
30
+ * search providers (fake / http / ksyun). Credentials are used only in provider
31
+ * request Authorization headers and never returned in content, errors, the
32
+ * binding digest, or the redacted provider config; cross-origin redirects do
33
+ * not carry the Authorization header; un-sanitized error messages are not
34
+ * echoed back to the model.
35
+ *
36
+ * Host-owned concerns (artifact persistence of oversized output, the Host
37
+ * fetch dispatcher address-pinning, effect intent/receipt) are deliberately
38
+ * not reimplemented here; the production adapter
39
+ * (src/providers/native-dsh-web.mjs) wires the real fetch and env config and
40
+ * states the integration point. The SSRF check resolves DNS itself so the
41
+ * fetch dispatcher connects to an already-validated address; the Host
42
+ * dispatcher's own pinning is an additional defense-in-depth, not relied on
43
+ * alone.
44
+ */
45
+ import { lookup as dnsLookup } from 'node:dns/promises';
46
+ import { BlockList, isIPv4, isIPv6 } from 'node:net';
47
+ import { canonical, copy, invariant, scopeKey, toolInputRejection, matchesToolExecutionContract } from '@kingsoftcloud/agentkit-harness-core';
48
+ import { parse as parseHtml, defaultTreeAdapter } from 'parse5';
49
+
50
+ export const NATIVE_WEB_TOOLS_CONTRACT = 'agentkit.native-web-tools/v1';
51
+ const DEFAULT_MAX_CHARS = 50_000;
52
+ const MAX_REDIRECTS = 5;
53
+ const SEARCH_TIMEOUT_MS = 30_000;
54
+ // A hard byte ceiling on a single response body so a hostile server cannot
55
+ // stream an unbounded payload. The Python path reads response.text fully; the
56
+ // Node port bounds the read and reports a truncation marker instead of a hang.
57
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
58
+
59
+ // SSRF block list: the union of Python ipaddress is_loopback/is_private/
60
+ // is_link_local/is_multicast/is_reserved for IPv4 and IPv6 (Python 3.9
61
+ // ipaddress.py _IPv4Constants._private_networks + _reserved_network +
62
+ // _linklocal/_loopback/_multicast, and _IPv6Constants). allowPrivate === true
63
+ // (strict boolean) bypasses the check. Node's net.BlockList misreports a v4
64
+ // address when v6 subnets are also registered in the same list, so v4 and v6
65
+ // are kept in separate lists and checked by explicit family.
66
+ const SSRF_BLOCK_V4 = new BlockList();
67
+ const SSRF_BLOCK_V6 = new BlockList();
68
+ const SSRF_V4 = [
69
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16],
70
+ ['172.16.0.0', 12], ['192.0.0.0', 29], ['192.0.0.170', 31], ['192.0.2.0', 24],
71
+ ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
72
+ ['224.0.0.0', 4], ['240.0.0.0', 4], ['255.255.255.255', 32],
73
+ ['100.64.0.0', 10], // carrier-grade NAT: Python treats 100.64/10 as non-public
74
+ ];
75
+ const SSRF_V6 = [
76
+ ['::1', 128], ['::', 128], ['::ffff:0:0', 96], ['100::', 64], ['2001::', 23],
77
+ ['2001:2::', 48], ['2001:db8::', 32], ['2001:10::', 28], ['fc00::', 7], ['fe80::', 10],
78
+ ['ff00::', 8], ['fec0::', 10],
79
+ // IPv6 is_reserved networks (Python _reserved_networks):
80
+ ['::', 8], ['100::', 8], ['200::', 7], ['400::', 6], ['800::', 5], ['1000::', 4],
81
+ ['4000::', 3], ['6000::', 3], ['8000::', 3], ['a000::', 3], ['c000::', 3], ['e000::', 4],
82
+ ['f000::', 5], ['f800::', 6], ['fe00::', 9],
83
+ ];
84
+ for (const [addr, prefix] of SSRF_V4) SSRF_BLOCK_V4.addSubnet(addr, prefix, 'ipv4');
85
+ for (const [addr, prefix] of SSRF_V6) SSRF_BLOCK_V6.addSubnet(addr, prefix, 'ipv6');
86
+
87
+ // IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as IPv4 too. Node's URL
88
+ // strips brackets from hostname; an IPv6 literal like [::1] arrives as '::1'.
89
+ export function isBlockedAddress(address) {
90
+ if (isIPv4(address)) return SSRF_BLOCK_V4.check(address, 'ipv4');
91
+ if (isIPv6(address)) {
92
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(address);
93
+ if (mapped && isIPv4(mapped[1])) return SSRF_BLOCK_V4.check(mapped[1], 'ipv4') || SSRF_BLOCK_V6.check(address, 'ipv6');
94
+ return SSRF_BLOCK_V6.check(address, 'ipv6');
95
+ }
96
+ return true; // unknown family -> blocked
97
+ }
98
+
99
+ function def(name, description, parameters, opts) {
100
+ return {
101
+ name, description, parameters: copy(parameters),
102
+ readOnly: true, concurrentSafe: true, permissions: [],
103
+ timeoutMs: opts.timeoutMs, contextRole: 'none', observationRole: 'none',
104
+ progressObservation: false, contextRetention: 'normal',
105
+ };
106
+ }
107
+
108
+ export function nativeWebToolDefinitions() {
109
+ return [
110
+ def('web_fetch', 'Read a public HTTP(S) page. Long pages are paginated: use next_offset_chars to continue, or find to locate a literal section after offset_chars. URL fragments do not select sections. Returned paths/URIs are not workspace read grants.', {
111
+ type: 'object',
112
+ properties: {
113
+ url: { type: 'string' },
114
+ max_chars: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] },
115
+ timeout: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] },
116
+ offset_chars: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
117
+ find: { type: 'string', minLength: 1, maxLength: 1024 },
118
+ },
119
+ required: ['url'], additionalProperties: false,
120
+ }, { timeoutMs: 60_000 }),
121
+ def('web_search', 'Search the web and return title/url/snippet results without fetching full pages.', {
122
+ type: 'object',
123
+ properties: {
124
+ query: { type: 'string' },
125
+ max_results: { type: 'integer', default: 5, minimum: 1 },
126
+ recency_days: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] },
127
+ },
128
+ required: ['query'], additionalProperties: false,
129
+ }, { timeoutMs: 60_000 }),
130
+ ];
131
+ }
132
+
133
+ // HTML-to-text via parse5 (already a dependency): build a real DOM tree, skip
134
+ // script/style/noscript/template, and extract block/inline text with explicit
135
+ // whitespace handling. Block-level elements introduce a space boundary; text
136
+ // is unescaped via parse5's decoded child text nodes (never throws on invalid
137
+ // entities). Whitespace collapses to single spaces, matching the Python
138
+ // " ".join(soup.get_text(" ").split()) behavior.
139
+ const BLOCK_TAGS = new Set(['address','article','aside','blockquote','br','dd','div','dl','dt','fieldset','figcaption','figure','footer','form','h1','h2','h3','h4','h5','h6','header','hr','li','main','nav','ol','p','pre','section','table','tr','td','th','thead','tbody','tfoot','ul']);
140
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title', 'head', 'meta', 'link']);
141
+ function htmlToText(value) {
142
+ const source = String(value || '');
143
+ if (!source.trim()) return '';
144
+ let document;
145
+ try { document = parseHtml(source, { treeAdapter: defaultTreeAdapter, sourceCodeLocationInfo: false }); }
146
+ catch { return ''; } // malformed input -> empty, never throw
147
+ const parts = [];
148
+ const walk = node => {
149
+ if (!node) return;
150
+ const nodeName = String(node.nodeName || '').toLowerCase();
151
+ if (SKIP_TAGS.has(nodeName)) return;
152
+ if (nodeName === '#text') { const v = String(node.value ?? ''); if (v) parts.push(v); return; }
153
+ if (nodeName === '#comment' || nodeName === '#documentType') return;
154
+ if (BLOCK_TAGS.has(nodeName)) parts.push(' ');
155
+ const children = defaultTreeAdapter.getChildNodes?.(node) || node.childNodes || [];
156
+ for (const child of children) walk(child);
157
+ if (BLOCK_TAGS.has(nodeName)) parts.push(' ');
158
+ };
159
+ walk(document);
160
+ return parts.join('').split(/\s+/).filter(Boolean).join(' ');
161
+ }
162
+
163
+ // Count and slice by Unicode code points, not UTF-16 code units. Python's
164
+ // len()/[:limit] operate on code points; a JS string slice can split a
165
+ // surrogate pair. `truncated` compares code-point counts so 😀😀 at limit 1 is
166
+ // truncated (2 code points > 1), not false from a 2-char preview.length.
167
+ function toCodePoints(text) { return Array.from(String(text)); }
168
+ function budgetText(text, limit) {
169
+ const points = toCodePoints(text);
170
+ const originalChars = points.length;
171
+ const preview = points.slice(0, limit).join('');
172
+ return { text: preview, truncated: originalChars > toCodePoints(preview).length, original_chars: originalChars, preview_chars: toCodePoints(preview).length };
173
+ }
174
+
175
+ async function validatePublicHttpUrl(url, ssrfPolicy, deadline) {
176
+ let parsed;
177
+ try { parsed = new URL(String(url)); }
178
+ catch { return { ok: false, error_type: 'invalid_url', error_message: 'URL host is required', url }; }
179
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
180
+ return { ok: false, error_type: 'unsupported_scheme', error_message: 'only http and https URLs are supported', url };
181
+ }
182
+ if (!parsed.hostname) {
183
+ return { ok: false, error_type: 'invalid_url', error_message: 'URL host is required', url };
184
+ }
185
+ // Node's URL.hostname keeps brackets around IPv6 literals ([::1]); Python's
186
+ // urlparse.hostname strips them. Strip here so isIPv6/dnsLookup see a bare
187
+ // address, and a literal IPv6 host is blocked directly without DNS.
188
+ let hostname = parsed.hostname;
189
+ const bracketed = /^\[(.+)\]$/.exec(hostname);
190
+ if (bracketed) hostname = bracketed[1];
191
+ // Strict boolean: the string "false" must not enable private access.
192
+ if (ssrfPolicy.allowPrivate === true) return null;
193
+ // A literal IP host is checked directly (no DNS), matching Python's
194
+ // getaddrinfo which returns the parsed address for an IP literal.
195
+ if (isIPv4(hostname) || isIPv6(hostname)) {
196
+ if (isBlockedAddress(hostname)) {
197
+ return { ok: false, error_type: 'blocked_by_ssrf_policy', error_message: `blocked non-public address: ${hostname}`, url };
198
+ }
199
+ return null;
200
+ }
201
+ deadline.throwIfAborted?.();
202
+ // DNS resolution is raced against the same deadline that bounds the whole
203
+ // fetch, so a hanging resolver cannot outlive the tool timeout. The pure
204
+ // adapter's check here is the first gate; the production adapter's undici
205
+ // Agent connect.lookup re-validates at connect time (defense-in-depth).
206
+ let addresses, onAbort;
207
+ try {
208
+ addresses = await Promise.race([
209
+ dnsLookup(hostname, { all: true }),
210
+ new Promise((_, reject) => {
211
+ onAbort = () => reject(new Error('dns timeout'));
212
+ deadline.addEventListener('abort', onAbort, { once: true });
213
+ if (deadline.aborted) onAbort();
214
+ }),
215
+ ]);
216
+ } catch (error) {
217
+ if (deadline.aborted) return { ok: false, error_type: 'timeout', error_message: 'web_fetch timed out', url };
218
+ return { ok: false, error_type: 'dns_resolution_failed', error_message: 'DNS resolution failed', url };
219
+ } finally { if (onAbort) deadline.removeEventListener('abort', onAbort); }
220
+ deadline.throwIfAborted?.();
221
+ for (const { address } of addresses) {
222
+ if (isBlockedAddress(address)) {
223
+ return { ok: false, error_type: 'blocked_by_ssrf_policy', error_message: `blocked non-public address: ${address}`, url };
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function isRedirect(status) { return status >= 300 && status < 400 && status !== 304; }
230
+
231
+ /** Read a bounded number of bytes from a fetch Response body stream. Returns
232
+ * {text, reachedByteCeiling, fullBytes}. Cancels the stream as soon as the
233
+ * ceiling or the signal is hit so a hostile/redirect body cannot leak an open
234
+ * connection or hang the read. `text` is the decoded body up to the ceiling;
235
+ * `fullBytes` is the number of bytes actually read (capped at the ceiling)
236
+ * so the caller can report whether the full body was captured. */
237
+ async function readBoundedBody(response, { byteCeiling = MAX_RESPONSE_BYTES, signal } = {}) {
238
+ const reader = response.body?.getReader?.();
239
+ if (!reader) {
240
+ const text = await response.text();
241
+ return { text, reachedByteCeiling: false, fullBytes: Buffer.byteLength(text) };
242
+ }
243
+ const decoder = new TextDecoder('utf8', { ignoreBOM: true });
244
+ let bytes = 0, reachedByteCeiling = false;
245
+ const chunks = [];
246
+ try {
247
+ for (;;) {
248
+ signal?.throwIfAborted?.();
249
+ const { done, value } = await reader.read();
250
+ if (done) break;
251
+ bytes += value.byteLength;
252
+ if (bytes > byteCeiling) {
253
+ chunks.push(value.subarray(0, byteCeiling - (bytes - value.byteLength)));
254
+ reachedByteCeiling = true;
255
+ bytes = byteCeiling;
256
+ break;
257
+ }
258
+ chunks.push(value);
259
+ }
260
+ } finally {
261
+ try { if (reachedByteCeiling || signal?.aborted) await reader.cancel(); else reader.releaseLock?.(); } catch { /* already closed */ }
262
+ }
263
+ let total = 0;
264
+ for (const c of chunks) total += c.byteLength;
265
+ const out = new Uint8Array(total);
266
+ let off = 0;
267
+ for (const c of chunks) { out.set(c, off); off += c.byteLength; }
268
+ return { text: decoder.decode(out, { stream: false }), reachedByteCeiling, fullBytes: bytes };
269
+ }
270
+
271
+ // One bounded AbortSignal that spans DNS resolution, every redirect hop, and
272
+ // the body read. The caller's request.signal is composed in so cancellation
273
+ // propagates immediately at any stage.
274
+ function deadlineSignal(signal, timeoutMs) {
275
+ const timeout = AbortSignal.timeout(timeoutMs);
276
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
277
+ }
278
+
279
+ async function webFetch({ fetch: fetchImpl, url, maxChars, offset = 0, find, inlineMaxChars, timeout, ssrfPolicy, signal, persistOutput, request, checkCurrent }) {
280
+ const current = String(url || '').trim();
281
+ if (!current) return { ok: false, error_type: 'url_required', error_message: 'url is required' };
282
+ const requestedLimit = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : DEFAULT_MAX_CHARS;
283
+ const limit = inlineMaxChars ? Math.min(requestedLimit, inlineMaxChars) : requestedLimit;
284
+ const timeoutMs = Number.isFinite(timeout) && timeout > 0 ? timeout * 1000 : 30_000;
285
+ const deadline = deadlineSignal(signal, timeoutMs);
286
+ try {
287
+ return await fetchPage({ fetchImpl, url, current, limit, offset, find, ssrfPolicy, signal, persistOutput, request, deadline, checkCurrent });
288
+ } catch (error) {
289
+ if (signal?.aborted) throw error;
290
+ if (deadline.aborted) return { ok: false, error_type: 'timeout', error_message: 'web_fetch timed out', url };
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ async function fetchPage({ fetchImpl, url, current, limit, offset, find, ssrfPolicy, signal, persistOutput, request, deadline, checkCurrent }) {
296
+ let currentUrl = current;
297
+ for (let hop = 0; hop < MAX_REDIRECTS; hop++) {
298
+ checkCurrent?.();
299
+ const blocked = await validatePublicHttpUrl(currentUrl, ssrfPolicy, deadline);
300
+ if (blocked) return blocked;
301
+ checkCurrent?.();
302
+ let response;
303
+ try { response = await fetchImpl(currentUrl, { method: 'GET', redirect: 'manual', signal: deadline }); }
304
+ catch (error) {
305
+ if (signal?.aborted) throw error;
306
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || deadline.aborted) return { ok: false, error_type: 'timeout', error_message: 'web_fetch timed out', url: currentUrl };
307
+ return { ok: false, error_type: 'FetchError', error_message: 'web_fetch request failed', url: currentUrl };
308
+ }
309
+ if (isRedirect(response.status)) {
310
+ const location = response.headers.get('location');
311
+ try { await response.body?.cancel?.(); } catch { /* already closed */ }
312
+ if (location) { currentUrl = String(new URL(location, currentUrl)); continue; }
313
+ }
314
+ deadline.throwIfAborted?.();
315
+ const contentType = response.headers.get('content-type') || '';
316
+ const { text: fullText, reachedByteCeiling, fullBytes } = await readBoundedBody(response, { signal: deadline, returnFull: true });
317
+ deadline.throwIfAborted?.();
318
+ checkCurrent?.();
319
+ const text = /html/i.test(contentType) ? htmlToText(fullText) : fullText;
320
+ const points = toCodePoints(text);
321
+ let start = offset;
322
+ if (find) {
323
+ const index = points.slice(offset).join('').indexOf(find);
324
+ if (index < 0) return { ok: true, url, final_url: currentUrl, status_code: response.status,
325
+ content_type: contentType, text: '', match_found: false, original_chars: points.length,
326
+ offset_chars: offset, next_offset_chars: null, truncated: false, preview_chars: 0 };
327
+ start += toCodePoints(points.slice(offset).join('').slice(0, index)).length;
328
+ }
329
+ const budget = budgetText(points.slice(start).join(''), limit);
330
+ const next = start + budget.preview_chars;
331
+ const result = { ok: true, url, final_url: currentUrl, status_code: response.status, content_type: contentType, ...budget,
332
+ original_chars: points.length, offset_chars: start, next_offset_chars: next < points.length ? next : null,
333
+ ...(find ? { match_found: true } : {}),
334
+ ...(next < points.length ? { instruction: 'Continue with web_fetch using next_offset_chars, or find a literal section. Do not read temporary paths with workspace tools.' } : {}),
335
+ ...(reachedByteCeiling ? { body_truncated_by_byte_ceiling: true, full_bytes: fullBytes } : {}) };
336
+ // Persist the full output only when it exceeded the in-memory budget, using
337
+ // the trusted persistOutput callback with the original request identity and
338
+ // signal. A failure is reported honestly; we never claim persistence
339
+ // succeeded when it did not.
340
+ if (budget.truncated && typeof persistOutput === 'function') {
341
+ try {
342
+ checkCurrent?.();
343
+ const ref = await persistOutput(request, text, { toolName: 'web_fetch', mediaType: contentType || 'text/plain', fullBytes, reachedByteCeiling });
344
+ const path = ref?.uri ?? ref?.path;
345
+ if (typeof path !== 'string' || !path.trim() || path.includes('\0')) throw new Error('persist_output_reference_invalid');
346
+ result.persisted = { path, mime_type: contentType || 'text/plain' };
347
+ } catch (error) {
348
+ if (signal?.aborted) throw error;
349
+ result.persisted = { failed: true, error: 'persist_output_failed' };
350
+ }
351
+ }
352
+ checkCurrent?.();
353
+ return result;
354
+ }
355
+ return { ok: false, error_type: 'too_many_redirects', error_message: 'web_fetch exceeded redirect limit', url };
356
+ }
357
+
358
+ // Merge params into base_url preserving any existing query (httpx semantics).
359
+ function mergeQuery(baseUrl, params) {
360
+ const u = new URL(baseUrl);
361
+ for (const [key, value] of Object.entries(params)) u.searchParams.set(key, String(value));
362
+ return u.toString();
363
+ }
364
+
365
+ async function webSearchHttp({ fetch: fetchImpl, providerConfig, query, maxResults, recencyDays, signal }) {
366
+ const baseUrl = String(providerConfig.baseUrl || '').trim();
367
+ if (!baseUrl) return { ok: false, error_type: 'provider_not_configured', error_message: 'web search base URL is not configured' };
368
+ const apiKey = String(providerConfig.apiKey || '').trim();
369
+ const params = { q: query, max_results: String(Math.max(1, maxResults || 5)) };
370
+ if (recencyDays != null) params.recency_days = String(recencyDays);
371
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
372
+ const deadline = deadlineSignal(signal, SEARCH_TIMEOUT_MS);
373
+ let response, payload;
374
+ try {
375
+ response = await fetchImpl(mergeQuery(baseUrl, params), { method: 'GET', headers, redirect: 'manual', signal: deadline });
376
+ if (isRedirect(response.status)) { try { await response.body?.cancel?.(); } catch {} return { ok: false, error_type: 'provider_not_configured', error_message: 'web search provider redirected unexpectedly' }; }
377
+ if (!response.ok) {
378
+ try { await response.body?.cancel?.(); } catch { /* already closed */ }
379
+ throw Object.assign(new Error(`HTTP ${response.status}`), { name: 'HTTPStatusError' });
380
+ }
381
+ const { text } = await readBoundedBody(response, { signal: deadline });
382
+ payload = JSON.parse(text);
383
+ } catch (error) {
384
+ if (signal?.aborted) throw error;
385
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || deadline.aborted) return { ok: false, error_type: 'timeout', error_message: 'web_search timed out' };
386
+ if (error instanceof SyntaxError) return { ok: false, error_type: 'SyntaxError', error_message: 'web search returned invalid JSON' };
387
+ return { ok: false, error_type: 'FetchError', error_message: 'web search request failed' };
388
+ }
389
+ const raw = Array.isArray(payload?.results) ? payload.results
390
+ : Array.isArray(payload?.data) ? payload.data : [];
391
+ const results = [];
392
+ for (const [index, item] of raw.entries()) {
393
+ if (!item || typeof item !== 'object') continue;
394
+ results.push({
395
+ title: String(item.title || ''), url: String(item.url || item.link || ''),
396
+ snippet: String(item.snippet || item.summary || ''), rank: index + 1, provider: 'http',
397
+ });
398
+ if (results.length >= Math.max(1, maxResults || 5)) break;
399
+ }
400
+ return { ok: true, query, provider: 'http', results };
401
+ }
402
+
403
+ async function webSearchKsyun({ fetch: fetchImpl, providerConfig, query, maxResults, signal }) {
404
+ const apiKey = String(providerConfig.apiKey || '').trim();
405
+ if (!apiKey) return {
406
+ ok: false, error_type: 'provider_not_configured',
407
+ error_message: 'ksyun web search api key is not configured (KSADK_WEB_SEARCH_API_KEY / KSADK_MCP_KEY / KSC_AIPRO_API_KEY)',
408
+ };
409
+ const endpoint = String(providerConfig.baseUrl || 'https://search.aipro.ksyun.com/v1/aisearch/search').trim();
410
+ const scope = String(providerConfig.scope || 'webpage').trim().toLowerCase() || 'webpage';
411
+ const size = Math.max(1, Math.min(maxResults || 5, 200));
412
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };
413
+ const body = JSON.stringify({ q: query, scope, size });
414
+ const deadline = deadlineSignal(signal, SEARCH_TIMEOUT_MS);
415
+ let response, payload;
416
+ try {
417
+ response = await fetchImpl(endpoint, { method: 'POST', headers, body, redirect: 'manual', signal: deadline });
418
+ if (isRedirect(response.status)) { try { await response.body?.cancel?.(); } catch {} return { ok: false, error_type: 'provider_not_configured', error_message: 'web search provider redirected unexpectedly' }; }
419
+ if (!response.ok) {
420
+ try { await response.body?.cancel?.(); } catch { /* already closed */ }
421
+ throw Object.assign(new Error(`HTTP ${response.status}`), { name: 'HTTPStatusError' });
422
+ }
423
+ const { text } = await readBoundedBody(response, { signal: deadline });
424
+ payload = JSON.parse(text);
425
+ } catch (error) {
426
+ if (signal?.aborted) throw error;
427
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || deadline.aborted) return { ok: false, error_type: 'timeout', error_message: 'web_search timed out' };
428
+ if (error instanceof SyntaxError) return { ok: false, error_type: 'SyntaxError', error_message: 'web search returned invalid JSON' };
429
+ return { ok: false, error_type: 'FetchError', error_message: 'web search request failed' };
430
+ }
431
+ const raw = Array.isArray(payload?.webpages) ? payload.webpages
432
+ : Array.isArray(payload?.results) ? payload.results
433
+ : Array.isArray(payload?.data) ? payload.data : [];
434
+ const results = [];
435
+ for (const [index, item] of raw.entries()) {
436
+ if (!item || typeof item !== 'object') continue;
437
+ results.push({
438
+ title: String(item.title || ''), url: String(item.url || item.link || ''),
439
+ snippet: String(item.snippet || item.summary || ''), date: String(item.date || item.published_at || ''),
440
+ rank: index + 1, provider: 'ksyun',
441
+ });
442
+ if (results.length >= size) break;
443
+ }
444
+ return { ok: true, query, provider: 'ksyun', results };
445
+ }
446
+
447
+ async function webSearch({ fetch: fetchImpl, providerConfig, query, maxResults, recencyDays, signal }) {
448
+ const text = String(query || '').trim();
449
+ if (!text) return { ok: false, error_type: 'query_required', error_message: 'query is required' };
450
+ const provider = String(providerConfig?.provider || '').trim().toLowerCase();
451
+ if (!provider) return { ok: false, error_type: 'provider_not_configured', error_message: 'web search provider is not configured' };
452
+ if (provider === 'fake') {
453
+ const limit = Math.max(1, maxResults || 5);
454
+ return {
455
+ ok: true, query: text, provider: 'fake',
456
+ results: [{
457
+ title: `Fake result for ${text}`,
458
+ url: `https://example.com/search?q=${encodeURIComponent(text)}`,
459
+ snippet: 'Fake search result generated by ksadk test provider.', rank: 1, provider: 'fake',
460
+ }].slice(0, limit),
461
+ };
462
+ }
463
+ if (provider === 'http') return webSearchHttp({ fetch: fetchImpl, providerConfig, query: text, maxResults, recencyDays, signal });
464
+ if (provider === 'ksyun') return webSearchKsyun({ fetch: fetchImpl, providerConfig, query: text, maxResults, signal });
465
+ return { ok: false, error_type: 'provider_not_configured', error_message: `unsupported web search provider: ${provider}` };
466
+ }
467
+
468
+ /** Snapshot the full locked request context before an authorization await.
469
+ * copy/canonical require JSON values, so absent optional fields are normalized
470
+ * to null. The original AbortSignal identity and the locked definition are
471
+ * captured for drift detection; the authorize callback receives an isolated
472
+ * JSON copy that includes the signal identity (read-only, not the live signal)
473
+ * and executionFence so it can inspect the full trusted context without
474
+ * mutating the live request. */
475
+ function snapshotContext(request, definition) {
476
+ return {
477
+ sessionId: request.sessionId,
478
+ scope: request.scope == null ? null : copy(request.scope),
479
+ runId: request.runId ?? null,
480
+ turnId: request.turnId ?? null,
481
+ buildId: request.buildId ?? null,
482
+ parentRunId: request.parentRunId ?? null,
483
+ taskChainId: request.taskChainId ?? null,
484
+ operationKey: request.operationKey ?? null,
485
+ executionFence: request.executionFence ?? null,
486
+ call: copy(request.call),
487
+ definition: copy(definition),
488
+ requestDefinition: request.definition == null ? null : copy(request.definition),
489
+ signalIdentity: request.signal ?? null,
490
+ };
491
+ }
492
+ function contextDrifted(request, snapshot) {
493
+ return request.sessionId !== snapshot.sessionId
494
+ || canonical(request.scope == null ? null : request.scope) !== canonical(snapshot.scope)
495
+ || (request.runId ?? null) !== snapshot.runId
496
+ || (request.turnId ?? null) !== snapshot.turnId
497
+ || (request.buildId ?? null) !== snapshot.buildId
498
+ || (request.parentRunId ?? null) !== snapshot.parentRunId
499
+ || (request.taskChainId ?? null) !== snapshot.taskChainId
500
+ || (request.operationKey ?? null) !== snapshot.operationKey
501
+ || (request.executionFence ?? null) !== snapshot.executionFence
502
+ || canonical(request.call) !== canonical(snapshot.call)
503
+ // The request's own definition (if present) must not change during the
504
+ // authorize await; a swap to a different definition is drift.
505
+ || canonical(request.definition == null ? null : request.definition) !== canonical(snapshot.requestDefinition)
506
+ || (request.signal ?? null) !== snapshot.signalIdentity;
507
+ }
508
+
509
+ /** Shared HTTP/search implementation, after the owning Host has authorized
510
+ * the call. This transport does not grant Native authority or own a Run.
511
+ * DSH callers supply their actual Session/call identity; Native callers keep
512
+ * the full intent/lease checks in createNativeWebTools below. */
513
+ export function createWebToolTransport({ fetch: fetchImpl, searchFetch = fetchImpl, providerConfig = {},
514
+ ssrfPolicy = {}, persistOutput, inlineMaxChars } = {}) {
515
+ invariant(typeof fetchImpl === 'function', 'native_web_fetch_missing');
516
+ invariant(typeof searchFetch === 'function', 'native_web_search_fetch_missing');
517
+ invariant(inlineMaxChars === undefined || Number.isSafeInteger(inlineMaxChars) && inlineMaxChars > 0, 'native_web_inline_budget_invalid');
518
+ const sealedProvider = Object.freeze(copy(providerConfig));
519
+ const sealedSsrf = Object.freeze({ allowPrivate: ssrfPolicy?.allowPrivate === true });
520
+ const definitions = nativeWebToolDefinitions(), formal = new Map(definitions.map(item => [item.name, item]));
521
+ return {
522
+ contract: 'agentkit.web-tool-transport/v1', definitions: definitions.map(item => copy(item)),
523
+ async execute(request) {
524
+ const call = request?.call, definition = formal.get(call?.name);
525
+ if (!definition) return { status: 'error', content: { code: 'unknown_tool' } };
526
+ if (!(request.signal instanceof AbortSignal)) return { status: 'error', content: { code: 'invalid_request' } };
527
+ if (!matchesToolExecutionContract(request.definition, definition)) {
528
+ return { status: 'error', content: { code: 'web_definition_mismatch', executed: false } };
529
+ }
530
+ const rejection = toolInputRejection(definition, call);
531
+ if (rejection) return { status: 'error', content: rejection };
532
+ const signal = request.signal;
533
+ const checkCurrent = () => {
534
+ signal.throwIfAborted();
535
+ if (typeof request.assertCurrent === 'function' && request.assertCurrent() !== true)
536
+ throw Object.assign(new Error('Native Web authority expired'), { code: 'web_authority_stale' });
537
+ };
538
+ // All transport context except the signal is JSON data, captured before
539
+ // the first await. In particular persistOutput cannot borrow a changed
540
+ // Session or operation identity from the caller's mutable request.
541
+ const bound = { ...copy(Object.fromEntries(Object.entries(request).filter(([key]) => key !== 'signal' && key !== 'assertCurrent'))), signal };
542
+ const args = bound.call.arguments;
543
+ signal.throwIfAborted();
544
+ try {
545
+ const content = call.name === 'web_fetch'
546
+ ? await webFetch({ fetch: fetchImpl, url: args.url, maxChars: args.max_chars, offset: args.offset_chars, find: args.find, inlineMaxChars, timeout: args.timeout,
547
+ ssrfPolicy: sealedSsrf, signal, persistOutput, request: bound, checkCurrent })
548
+ : await webSearch({ fetch: searchFetch, providerConfig: sealedProvider, query: args.query,
549
+ maxResults: args.max_results, recencyDays: args.recency_days, signal });
550
+ checkCurrent();
551
+ return { status: content.ok ? 'ok' : 'error', content };
552
+ } catch (error) {
553
+ if (signal.aborted) throw error;
554
+ if (error?.code === 'web_authority_stale') return { status: 'error', content: { code: 'web_authority_stale', executed: false } };
555
+ return { status: 'error', content: { ok: false, error_type: 'WebToolError', error_message: 'web tool failed' } };
556
+ }
557
+ },
558
+ };
559
+ }
560
+
561
+ export function createNativeWebTools({
562
+ fetch: fetchImpl, searchFetch = fetchImpl, providerConfig = {}, authorize, ssrfPolicy = {}, sessionId, persistOutput, inlineMaxChars,
563
+ } = {}) {
564
+ invariant(typeof fetchImpl === 'function', 'native_web_fetch_missing');
565
+ invariant(typeof searchFetch === 'function', 'native_web_search_fetch_missing');
566
+ invariant(typeof authorize === 'function', 'native_web_authorize_required');
567
+ invariant(typeof sessionId === 'string' && sessionId, 'native_web_session_required');
568
+ const transport = createWebToolTransport({ fetch: fetchImpl, searchFetch, providerConfig, ssrfPolicy, persistOutput, inlineMaxChars });
569
+ const definitions = transport.definitions;
570
+ const formal = new Map(definitions.map(item => [item.name, item]));
571
+
572
+ async function execute(request) {
573
+ if (!request || typeof request !== 'object') return { status: 'error', content: { code: 'invalid_request', message: 'request object required' } };
574
+ if (request.sessionId !== sessionId) return { status: 'error', content: { code: 'web_session_mismatch' } };
575
+ const call = request.call;
576
+ if (!call?.name) return { status: 'error', content: { code: 'invalid_request', message: 'request.call.name required' } };
577
+ // A real AbortSignal is mandatory: cancellation must propagate through the
578
+ // whole fetch. A missing or non-AbortSignal signal is rejected up front.
579
+ if (!(request.signal instanceof AbortSignal)) {
580
+ return { status: 'error', content: { code: 'invalid_request', message: 'request.signal (AbortSignal) is required' } };
581
+ }
582
+ const definition = formal.get(call.name);
583
+ if (!definition) return { status: 'error', content: { code: 'unknown_tool', message: `unknown tool: ${call.name}` } };
584
+ // The Host ledger pins the formal definition for this call; if the request
585
+ // carries a definition that does not match the locked formal one, it is
586
+ // forged and must not execute.
587
+ if (!matchesToolExecutionContract(request.definition, definition)) {
588
+ return { status: 'error', content: { code: 'web_definition_mismatch', executed: false } };
589
+ }
590
+ try {
591
+ scopeKey(request.scope);
592
+ invariant([request.runId, request.turnId, request.buildId, request.taskChainId, request.operationKey, call.callId]
593
+ .every(value => typeof value === 'string' && value.trim()), 'web_identity_missing');
594
+ invariant(request.parentRunId === null || typeof request.parentRunId === 'string' && request.parentRunId.trim(), 'web_parent_missing');
595
+ invariant(Number.isSafeInteger(request.executionFence) && request.executionFence > 0, 'web_fence_missing');
596
+ } catch {
597
+ return { status: 'error', content: { code: 'invalid_request', executed: false } };
598
+ }
599
+ const rejection = toolInputRejection(definition, call);
600
+ if (rejection) return { status: 'error', content: rejection };
601
+ request.signal.throwIfAborted();
602
+ const snapshot = snapshotContext(request, request.definition);
603
+ let decision;
604
+ try {
605
+ // The authorize callback receives the full trusted context. The real
606
+ // AbortSignal is passed by identity (AbortSignal is not JSON-cloneable;
607
+ // only an AbortController can abort it, so the callback can await or race
608
+ // on it but cannot abort the fetch itself). This lets authorize cancel
609
+ // its own wait on the signal without losing the live cancellation path.
610
+ const authorizePayload = copy({
611
+ sessionId: snapshot.sessionId, scope: snapshot.scope, runId: snapshot.runId,
612
+ turnId: snapshot.turnId, buildId: snapshot.buildId, parentRunId: snapshot.parentRunId,
613
+ taskChainId: snapshot.taskChainId, operationKey: snapshot.operationKey,
614
+ executionFence: snapshot.executionFence, call: snapshot.call, definition: snapshot.definition,
615
+ });
616
+ authorizePayload.signal = snapshot.signalIdentity;
617
+ decision = await authorize(authorizePayload);
618
+ }
619
+ catch (error) {
620
+ if (request.signal.aborted) throw error;
621
+ return { status: 'error', content: { code: 'authorize_failed', message: 'authorization failed' } };
622
+ }
623
+ // Reject drift after the await: if anything in the trusted context (scope,
624
+ // run, turn, build, parent, taskChain, operationKey, executionFence, call,
625
+ // definition, or the original signal identity) changed, do NOT fetch.
626
+ if (contextDrifted(request, snapshot)) {
627
+ return { status: 'error', content: { code: 'web_context_drift', executed: false } };
628
+ }
629
+ request.signal.throwIfAborted();
630
+ if (decision !== 'allow') {
631
+ if (decision?.status === 'approval_required' && typeof decision.interactionId === 'string') return decision;
632
+ return { status: 'error', content: { code: 'policy_denied', executed: false } };
633
+ }
634
+ if (typeof request.assertCurrent === 'function' && request.assertCurrent() !== true)
635
+ return { status: 'error', content: { code: 'web_authority_stale', executed: false } };
636
+ return transport.execute(request);
637
+ }
638
+
639
+ // Return fresh deep copies of the definitions so callers cannot mutate the
640
+ // formal map through the returned array (the formal map stays the trusted
641
+ // source of truth for every execute).
642
+ return { definitions: definitions.map(item => copy(item)), execute, contract: NATIVE_WEB_TOOLS_CONTRACT };
643
+ }