@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- 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
|
+
}
|