@theokit/sdk-tools 0.27.0 → 0.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,220 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e3f2a82: Public-API documentation reviewed file by file, and corrected wherever it disagreed
8
+ with the code. The docblocks ship in the `.d.ts`, so these read as behaviour changes
9
+ in an editor even though no behaviour changed.
10
+
11
+ The corrections that change what a caller would do:
12
+
13
+ - **`sdk-cache` documented its own premise backwards.** The header example labelled a
14
+ semantic hit as if it avoided the provider call. `asPlugin()` returns the cached
15
+ answer as `recalledContext`, which the agent loop injects as a `<memory-context>`
16
+ block _before_ the prompt — the request still goes to the provider. The two modes
17
+ are now labelled separately, with a table saying which one short-circuits and which
18
+ one seeds.
19
+ - **`sdk-handoff`'s five error classes said "throw".** Under the plugin wiring the
20
+ handler never throws; every failure becomes a tool result `{"ok":false,…}` handed
21
+ back to the model. Each class now says where it is actually observable. The header
22
+ also told readers to `import { Handoff } from "@theokit/sdk"`, from which it was
23
+ extracted.
24
+ - **`sdk-budget`'s `charge()` claimed idempotency across concurrent calls.** The mutex
25
+ serialises, it does not deduplicate: two identical calls record twice. Related, and
26
+ newly documented: with `maxUsd` set, a model missing from the pricing table denies
27
+ every request rather than passing it — and the table matches by exact string, so
28
+ `"openai/gpt-4o"` does not match `"gpt-4o"`.
29
+ - **The three `memory-*` adapters advertised an env-var fallback they do not read**,
30
+ and their peer dependencies are required rather than optional. Their behavioural
31
+ differences are now stated where they break the "interchangeable adapter"
32
+ assumption — honcho ignores `k` and always throws on `delete`; mem0 recalls across
33
+ sessions by design; supermemory ignores `sessionId` entirely.
34
+ - **`sdk-memory`'s `truncated` flag was documented as its own inverse**, and its
35
+ dreaming sweep claimed a mutex it never takes against the writer it names.
36
+ - **`sdk-tools`** corrected `run_vitest`'s unreachable `no_vitest` code, `truncation`'s
37
+ replacement-character claim, and two return shapes missing a live error code.
38
+ - **`acp`/`cli`** corrected sixteen statements including a named error class that is
39
+ not the one raised, a handler documented as calling `fork()` that refuses
40
+ unconditionally, handlers described as pure that mint ids and mutate a store, a
41
+ config loader credited to Zod in a package that does not import it, and a `--force`
42
+ scaffold described as atomic that deletes the destination before the rename.
43
+
44
+ Undocumented public symbols were documented across every package, with each claim
45
+ checked against the implementation rather than inferred from the name.
46
+
47
+ - e368fc1: Every published declaration file now compiles without `skipLibCheck` (#345). The
48
+ DTS rollup emitted symbols as a re-export from a chunk while omitting them from
49
+ that chunk's `import`, and dropped type-only imports from external packages —
50
+ leaving 51 unresolved references across ten of the twelve packages. Nothing broke
51
+ at runtime, and `tsc` stayed green for anyone with `skipLibCheck` on, but a
52
+ consumer running type-aware lint saw every type reached through one degrade to
53
+ `error`.
54
+
55
+ The declarations are repaired at build time from the compiler's own diagnostics.
56
+ No source or API change.
57
+
58
+ - 434b25f: `edit_file` wrote its backup to `<path>.bak` with `copyFile`, and the project-root guard that
59
+ validates `path` never looked at that second path. An attacker who could place a file inside the
60
+ workspace ahead of the edit — which is the ordinary situation for an agent working on a repository
61
+ it did not write — could plant a symlink there and have the backup written through it, anywhere on
62
+ the filesystem the process could reach.
63
+
64
+ Reproduced before the fix: a symlink at `<path>.bak` pointing outside the project root received the
65
+ file's contents, and `edit_file` reported success.
66
+
67
+ The backup is now opened with `O_NOFOLLOW`, so the kernel refuses a symlink outright rather than a
68
+ check deciding it is safe and the write happening a moment later. An existing **regular** `.bak` is
69
+ still overwritten, so the documented behaviour is unchanged for every legitimate edit.
70
+
71
+ When the path is refused, the tool returns `{ ok: false, error: "unsafe_backup_path" }` and **does
72
+ not perform the edit**. An edit that silently proceeded without the backup the caller asked for
73
+ would be the quieter bug.
74
+
75
+ - 9e1b0f7: `git_status` with an injected sandbox now asks the backend whether there is a repository, instead of
76
+ probing the host.
77
+
78
+ A session whose checkout lives inside the backend got `{ ok: false, error: "not_a_repo" }` for a
79
+ repository that was perfectly present where the command would have run — while `git_diff`,
80
+ configured identically in the same session, worked. `not_a_repo` exists so the model cannot read "no
81
+ repository" as "nothing changed", so being wrong about it is worse than being unavailable.
82
+
83
+ The path-scope check also moves ahead of the probe: a traversal attempt in a confined session used
84
+ to be answered `not_a_repo` rather than `path_traversal`.
85
+
86
+ - aadc9dd: Seventeen more negative-case tests now identify which failure they caught, and a registry test suite
87
+ stops sleeping to make timestamps differ.
88
+
89
+ Most of those assertions turned out to be under-asserting rather than untestable: twelve of them sat
90
+ on errors that were **already typed**, and simply checked that something threw. They now name the
91
+ class, the stable code and a message fragment — which means a change that swaps one failure for
92
+ another is caught, where before any error at all satisfied the test.
93
+
94
+ Four remain matched on a message fragment because the underlying error genuinely has no type yet, and
95
+ one of those is filed separately: a public entry point throwing a plain error gives callers nothing to
96
+ branch on but a string that changes whenever someone improves the wording.
97
+
98
+ Four more were reclassified out of scope after reading the source rather than the name: they raise
99
+ errors owned by Node, by the schema library, or by a database driver, and pinning a third-party class
100
+ buys little.
101
+
102
+ Separately, the live-agent-registry tests slept thirteen times — some to force last-used timestamps
103
+ apart so eviction ordering could be asserted, others to let fire-and-forget cleanup finish. Both are
104
+ now driven by the test clock, a mechanism this same file already used elsewhere and which needed no
105
+ production change. The file runs in a fraction of the time and no longer depends on how busy the
106
+ machine is.
107
+
108
+ - 8226bc6: Fourteen negative-case tests now identify which guard fired, and a scheduled job keeps test-order
109
+ independence honest.
110
+
111
+ Assertions that only checked "something threw" now assert the error class, its stable code and a
112
+ message substring — for concurrency validation, retry configuration, path traversal, filename
113
+ validation and credential loading. Each conversion was verified by mutating the production error's
114
+ code and watching the corresponding test fail, so the assertions are pinned to the real constants
115
+ rather than to a copy of them.
116
+
117
+ Forty-five remaining sites are deliberately left alone and grouped with reasons: ten raise validation
118
+ errors owned by a third-party schema library, thirteen surface Node's own errors, and twenty-two are
119
+ plain untyped errors in our code where there is no class or code to assert yet.
120
+
121
+ Separately, the suite runs one file at a time, and a comment in the configuration said that was
122
+ covering up a leak. Measured: with file-level parallelism restored the suite is fully green, twice
123
+ over — the two leaks that comment named have since been fixed. Restoring _within-file_ concurrency
124
+ plus randomised order does still fail, reproducibly, in one file that shares a mutable counter
125
+ between its cases; that is filed on its own and is not fixed here.
126
+
127
+ The default gate is unchanged. A separate weekly job runs the suite in shuffled order so the
128
+ remaining coupling keeps surfacing instead of staying suppressed by the serial default.
129
+
130
+ Also documented for contributors: what makes a wait trustworthy, and why a premise that justifies
131
+ deleting something needs checking in a way that a premise justifying keeping something does not.
132
+
133
+ - e699569: **The repository moved to the official `usetheokit` organization.** Every `repository`, `bugs` and `homepage` field now points there, along with the README, `CONTRIBUTING.md`, `SECURITY.md` and the issue templates. Existing clones and any URL already published keep working — GitHub redirects a transferred repository permanently — so this is a correctness fix for the metadata npm renders, not a break.
134
+
135
+ **The Apache-2.0 text every package ships was replaced with the official one.** The copy distributed until now had paragraph 4(d) truncated: it read "except as required for describing the origin of the Work and reproducing the content of the NOTICE file", dropping "reasonable and customary use" from the licensed clause. §4(d) governs what a redistributor must do with attribution notices, and the omission narrowed it.
136
+
137
+ That matters more than a typo would. The manifests declare the SPDX identifier `Apache-2.0`, which is an assertion that the terms are _the_ Apache-2.0 terms — a licence scanner resolves the identifier and never reads the file. A consumer's compliance review, which does read the file, would find a body that no longer matches the identifier and has no name of its own. Every `LICENSE` in this repository is now byte-identical to the canonical text, with the appendix filled in.
138
+
139
+ Nothing else about the terms changed: the licence is the same licence it has always been meant to be, and no package changes what it grants.
140
+
141
+ - f7311b0: When a tool's output exceeds its budget, the full untruncated output is written to an overflow
142
+ file. That file's name was `overflow-<timestamp>-<8 hex characters>.txt`: eight characters is 32
143
+ bits, and the rest of the name is a clock reading anyone can predict. The write itself followed
144
+ symlinks, so a guessed name planted ahead of time would have received the content — and by
145
+ construction that content is the largest thing the tool produced.
146
+
147
+ The name now carries a full UUID, and the file is created exclusively (`O_CREAT|O_EXCL`), which
148
+ POSIX requires to fail when the path already exists, symlinks included. There is no longer a window
149
+ between deciding a path is safe and writing to it.
150
+
151
+ No behaviour changes for any caller: the overflow path is still returned in the truncation trailer,
152
+ and it is still unique per truncation.
153
+
154
+ - f692988: The reference docs no longer ship inside the package. `node_modules/@theokit/sdk/docs/` is gone, along with the `harness-capability-map.md` and `error-codes.md` files it carried — the `docs` entry was removed from the published `files` list and the build step that generated it was removed with it.
155
+
156
+ The exported TypeScript types are now the only reference surface, and they remain the canonical contract: every public primitive carries its import path, signature and JSDoc example, surfaced by your editor. Nothing about the runtime API changed.
157
+
158
+ The scaffolded agent context still ships, unchanged, under `claude-template/`.
159
+
160
+ - 131b497: `run_vitest` now reports `no_vitest` when vitest is not installed, as its contract always said.
161
+
162
+ `npx --no-install` starts, complains on stderr and exits non-zero with nothing on stdout, so the
163
+ case reached `unparseable_output` — which reads as "vitest ran and printed something I could not
164
+ parse", sending a reader after a reporter or parser problem instead of a missing dependency.
165
+ `no_vitest` was reachable only when the `npx` binary itself could not be spawned.
166
+
167
+ The detection is text matching on npm's complaint, and npm's wording is not a contract: if it ever
168
+ changes, the case falls back to the old `unparseable_output` with the real reason in the payload
169
+ rather than to a wrong answer.
170
+
171
+ - 3ecc956: The barrel-export smoke test now asserts behaviour, not just that a name is a function.
172
+
173
+ It was flagged as redundant with four build gates that supposedly enforce the same public surface.
174
+ Measured, the four do not reach this package at all: two are filtered to a different package by name,
175
+ one hardcodes a path under that package, and the fourth's configuration declares only two workspaces,
176
+ neither of them this one. Proved by renaming an exported factory and running all four — every one
177
+ stayed green, and only this test noticed.
178
+
179
+ So deleting it would have removed the only coverage of that surface. It keeps its export checks and
180
+ gains a case that invokes a factory and asserts the tool descriptor it returns — name, description,
181
+ input schema and handler — which is the behavioural assertion no build gate can make.
182
+
183
+ - c7385d2: Test runs no longer claim every core on the host.
184
+
185
+ None of the package configs capped `maxWorkers`, so vitest's default applied: `os.availableParallelism()`,
186
+ one fork per core, each booting a full test environment. The repo's `test` script is
187
+ `turbo run test --filter='./packages/*'`, so that default is paid once per package _concurrently_ —
188
+ nproc forks times turbo's concurrency, on nproc cores. Measured on a 12-thread machine during an
189
+ unrelated investigation, two vitest pools alone were enough to reach load average 33.89 with the
190
+ desktop unusable; a full fan-out is several times that.
191
+
192
+ `@theokit/sdk` is the interesting case. B-104 recorded on 2026-08-19 that the `poolOptions.forks.*`
193
+ block was 100% dead in Vitest 4, deleted it, and noted that `fileParallelism: false` was forcing
194
+ `maxWorkers` to 1 unconditionally, so a fork-count knob could not act. B-059 then flipped
195
+ `fileParallelism` to `true` on 2026-08-20, which made the knob able to act again — and nothing
196
+ reintroduced one, so the package silently went back to the uncapped default. That comment has been
197
+ corrected along with the config; it claimed no knob existed, which is no longer true.
198
+
199
+ The cap leaves 4 cores free (`Math.max(2, cpus().length - 4)`), scaling with the runner rather than
200
+ hard-coding one machine's core count. It costs no wall-clock: measured in `theokit-ui`, the full
201
+ suite ran 73.96s at 4 workers against 74.36s at 12, so the parallelism above the cap was already
202
+ noise. Verified as resolved config rather than as file contents — `createVitest` reports
203
+ `maxWorkers: 8` on a 12-thread host, which is the formula, not the default.
204
+
205
+ This changes no published behaviour; it is test tooling only. Refs usetheokit/theokit-ui#51.
206
+
207
+ - e1fe586: `view_image` checked a file's size with one path lookup and read its bytes with another. Between
208
+ the two, the path could resolve to something else — so the size cap, which is the only thing
209
+ keeping an unbudgeted image out of an LLM's context, described a file that was not the one
210
+ returned.
211
+
212
+ It now opens the file once and both sizes and reads through that descriptor. A descriptor cannot
213
+ be swapped, so the two operations describe the same file by construction rather than by a check
214
+ that can be outrun.
215
+
216
+ The descriptor is closed on every path, including the early return when the image is over the cap.
217
+
3
218
  ## 0.27.0
4
219
 
5
220
  ### Minor Changes
package/LICENSE CHANGED
@@ -137,8 +137,8 @@
137
137
 
138
138
  6. Trademarks. This License does not grant permission to use the trade
139
139
  names, trademarks, service marks, or product names of the Licensor,
140
- except as required for describing the origin of the Work and
141
- reproducing the content of the NOTICE file.
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
142
 
143
143
  7. Disclaimer of Warranty. Unless required by applicable law or
144
144
  agreed to in writing, Licensor provides the Work (and each
package/README.md CHANGED
@@ -4,7 +4,7 @@ Built-in tools for `@theokit/sdk` agents. File system, git, subprocess, search-t
4
4
 
5
5
  Extracted from `@theokit/sdk@1.7.0` as part of the SDK 2.0 package split.
6
6
 
7
- See the [**Theo Harness Capability Map**](../../wiki/reference/harness-capability-map.md) for every tool + guard primitive (`buildRepoMap`, `isBlockedIp`, `screenedFetch`, `catastrophicShellReason`, ...) with import paths and examples.
7
+ The exported TypeScript types cover every tool + guard primitive (`buildRepoMap`, `isBlockedIp`, `screenedFetch`, `catastrophicShellReason`, ...) with import paths and examples.
8
8
 
9
9
  ## Install
10
10
 
@@ -57,7 +57,7 @@ Sub-shell tools (`subprocess`, `git-diff`, `run-vitest`) use timeout + AbortCont
57
57
 
58
58
  ## How it fits with `@theokit/sdk`
59
59
 
60
- - **Foundation:** `defineTool` and `CustomTool` types come from `@theokit/sdk`.
60
+ - **Foundation:** `Tool.create` and the `CustomTool` type come from `@theokit/sdk`.
61
61
  - **No kernel coupling:** sdk-tools never imports from `@theokit/sdk/internal/runtime` or the agent loop.
62
62
  - **Path-guard inline:** the small `isForbiddenPath` helper is inlined here (rather than importing from `@theokit/sdk/internal/security`) so sdk-tools is self-contained for the security-critical check.
63
63
 
@@ -77,6 +77,19 @@ import { createReadFileTool } from "@theokit/sdk-tools";
77
77
 
78
78
  See the monorepo `CHANGELOG.md` for the 1.x → 2.0 package-split migration notes.
79
79
 
80
+ ## API reference
81
+
82
+ Every symbol this package exports, with the exact specifier to import it from, is in the generated
83
+ capability map that ships inside `@theokit/sdk`:
84
+
85
+ ```
86
+ node_modules/@theokit/sdk/docs/harness-capability-map.md # symbol -> import specifier
87
+ node_modules/@theokit/sdk/docs/error-codes.md # every `code` an error can carry
88
+ ```
89
+
90
+ Both are generated from the built type declarations, so they describe the version you installed
91
+ rather than the version someone wrote a page about.
92
+
80
93
  ## License
81
94
 
82
95
  Apache-2.0.
package/dist/index.cjs CHANGED
@@ -6,8 +6,8 @@ var sdk = require('@theokit/sdk');
6
6
  var zod = require('zod');
7
7
  var pathSafety = require('@theokit/sdk/path-safety');
8
8
  var persistence = require('@theokit/sdk/persistence');
9
- var filesystem = require('@theokit/sdk/filesystem');
10
9
  var fs = require('fs');
10
+ var filesystem = require('@theokit/sdk/filesystem');
11
11
  var sandbox = require('@theokit/sdk/sandbox');
12
12
  var child_process = require('child_process');
13
13
  var interactive = require('@theokit/sdk/interactive');
@@ -591,7 +591,18 @@ async function editViaLocal(projectRoot, path, old_string, new_string) {
591
591
  }
592
592
  const outcome = computeEdit(content, old_string, new_string);
593
593
  if (!outcome.ok) return JSON.stringify({ ok: false, error: "no_match", path });
594
- await promises.copyFile(absolutePath, `${absolutePath}.bak`);
594
+ try {
595
+ await promises.writeFile(`${absolutePath}.bak`, content, {
596
+ encoding: "utf-8",
597
+ flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW
598
+ });
599
+ } catch (err) {
600
+ const code = err.code;
601
+ if (code === "ELOOP" || code === "EEXIST") {
602
+ return JSON.stringify({ ok: false, error: "unsafe_backup_path", path });
603
+ }
604
+ throw err;
605
+ }
595
606
  await promises.writeFile(absolutePath, outcome.result, "utf-8");
596
607
  return JSON.stringify({ ok: true, replacements: 1 });
597
608
  }
@@ -861,15 +872,15 @@ function createGitStatusTool(opts) {
861
872
  path: zod.z.string().optional().describe("Optional project-relative path to scope the status report.")
862
873
  }),
863
874
  handler: async ({ path: path$1 }, ctx) => {
864
- if (!fs.existsSync(path.join(projectRoot, ".git"))) {
865
- return JSON.stringify({ ok: false, error: "not_a_repo" });
866
- }
867
875
  const scopeCheck = checkPathScope(path$1, projectRoot);
868
876
  if (scopeCheck !== null) return scopeCheck;
869
877
  const args = buildArgs(path$1, opts.includeBranch !== false);
870
878
  if (opts.sandbox !== void 0) {
871
879
  return statusViaSandbox(opts.sandbox, ctx, args, timeoutMs);
872
880
  }
881
+ if (!fs.existsSync(path.join(projectRoot, ".git"))) {
882
+ return JSON.stringify({ ok: false, error: "not_a_repo" });
883
+ }
873
884
  const result = await runGitProcess(projectRoot, args, timeoutMs, maxStdoutBytes);
874
885
  return formatGitResult(result, timeoutMs);
875
886
  }
@@ -2039,6 +2050,12 @@ function validateVitestScope(path, projectRoot) {
2039
2050
  }
2040
2051
  return checkPathScope(path, projectRoot);
2041
2052
  }
2053
+ var VITEST_MISSING = [
2054
+ /npx canceled due to missing packages/i,
2055
+ /could not determine executable to run/i,
2056
+ /vitest: (?:command )?not found/i,
2057
+ /command not found: vitest/i
2058
+ ];
2042
2059
  function formatVitestResult(result, timeoutMs) {
2043
2060
  if (result.kind === "timeout") {
2044
2061
  return JSON.stringify({ ok: false, error: "timeout", timeoutMs });
@@ -2048,6 +2065,9 @@ function formatVitestResult(result, timeoutMs) {
2048
2065
  }
2049
2066
  const summary = extractTrailingJson(result.stdout);
2050
2067
  if (summary === null) {
2068
+ if (VITEST_MISSING.some((pattern) => pattern.test(result.stderr))) {
2069
+ return JSON.stringify({ ok: false, error: "no_vitest", detail: result.stderr.slice(0, 500) });
2070
+ }
2051
2071
  return JSON.stringify({
2052
2072
  ok: false,
2053
2073
  error: "unparseable_output",
@@ -2577,9 +2597,9 @@ function truncateOutput(output, opts) {
2577
2597
  return { content: output, truncated: false, originalBytes };
2578
2598
  }
2579
2599
  fs.mkdirSync(outputDir, { recursive: true });
2580
- const filename = `overflow-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.txt`;
2600
+ const filename = `overflow-${Date.now()}-${crypto.randomUUID()}.txt`;
2581
2601
  const overflowPath = path.join(outputDir, filename);
2582
- fs.writeFileSync(overflowPath, output, "utf-8");
2602
+ fs.writeFileSync(overflowPath, output, { encoding: "utf-8", flag: "wx" });
2583
2603
  const buf = Buffer.from(output, "utf-8");
2584
2604
  const trailer = `
2585
2605
 
@@ -2661,27 +2681,14 @@ function createViewImageTool(options) {
2661
2681
  });
2662
2682
  }
2663
2683
  const absolute = pathSafety.safePathJoin(projectRoot, input.path);
2664
- let bytes;
2665
- try {
2666
- bytes = fs.statSync(absolute).size;
2667
- } catch {
2668
- return json({ ok: false, error: "not_found", path: input.path });
2669
- }
2670
- if (bytes > maxBytes) {
2671
- return json({
2672
- ok: false,
2673
- error: "image_too_large",
2674
- path: input.path,
2675
- bytes,
2676
- limit_bytes: maxBytes
2677
- });
2678
- }
2684
+ const outcome = readWithinBudget(absolute, maxBytes);
2685
+ if (!outcome.ok) return json(failureFor(outcome, input.path, maxBytes));
2679
2686
  return json({
2680
2687
  ok: true,
2681
2688
  path: input.path,
2682
2689
  media_type: mediaType,
2683
- bytes,
2684
- data: fs.readFileSync(absolute).toString("base64")
2690
+ bytes: outcome.bytes,
2691
+ data: outcome.data
2685
2692
  });
2686
2693
  },
2687
2694
  /**
@@ -2707,6 +2714,31 @@ function createViewImageTool(options) {
2707
2714
  }
2708
2715
  });
2709
2716
  }
2717
+ function readWithinBudget(absolute, maxBytes) {
2718
+ let fd;
2719
+ try {
2720
+ fd = fs.openSync(absolute, "r");
2721
+ } catch {
2722
+ return { ok: false, error: "not_found" };
2723
+ }
2724
+ try {
2725
+ const bytes = fs.fstatSync(fd).size;
2726
+ if (bytes > maxBytes) return { ok: false, error: "too_large", bytes };
2727
+ return { ok: true, bytes, data: fs.readFileSync(fd).toString("base64") };
2728
+ } finally {
2729
+ fs.closeSync(fd);
2730
+ }
2731
+ }
2732
+ function failureFor(outcome, path, maxBytes) {
2733
+ if (outcome.error === "not_found") return { ok: false, error: "not_found", path };
2734
+ return {
2735
+ ok: false,
2736
+ error: "image_too_large",
2737
+ path,
2738
+ bytes: outcome.bytes,
2739
+ limit_bytes: maxBytes
2740
+ };
2741
+ }
2710
2742
  var DEFAULT_TIMEOUT_MS4 = 3e4;
2711
2743
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
2712
2744
  function createWebFetchTool(opts) {