terret-tools-std 0.1.0

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.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module ToolsStd
5
+ # `TodoWrite` (docs/subagents.md §7) — Claude Code's name and Claude Code's
6
+ # parameter shape, verbatim, per the M7 rule.
7
+ #
8
+ # This is the smallest illustration in the codebase of what "model-visible
9
+ # means logged" actually buys. The handler validates the statuses, renders
10
+ # the list back as the tool result, and holds NO state at all — that echo
11
+ # is its only storage. The list is durable because the tool result is
12
+ # durable; `derive_messages` projects it into the next request the same way
13
+ # it projects every other result; a restart replays it for free; and
14
+ # `resume_turn` re-derives it with no special case, because there is no
15
+ # special case. A todo SERVICE would have been a second source of truth to
16
+ # reconcile with the log after every crash, and it would disagree with it
17
+ # eventually.
18
+ #
19
+ # One honest limit: compaction can erase it. A `session/compacted` boundary
20
+ # that swallows the last TodoWrite result replaces it with a summary, and
21
+ # whether the plan survives depends on whether the summarizer kept it. The
22
+ # model writes a new list; nothing is corrupted.
23
+ #
24
+ # `concurrency: :serial` because the semantics are order-dependent — two
25
+ # writes in one message mean the last one wins, and "last" should be a
26
+ # property of the message rather than of which fiber returned first.
27
+ class Todo < Hames::Service
28
+ service_key :tools_std_todo
29
+ inject :tools
30
+ config_schema({}) # the TodoWrite tool takes no config
31
+
32
+ # The rendering, and the whole of this tool's vocabulary. An unknown
33
+ # status is refused against exactly this set rather than coerced to
34
+ # something plausible: a coerced status makes the list say a thing the
35
+ # model did not say, in the one place the model keeps its plan.
36
+ BOXES = { "pending" => "[ ]", "in_progress" => "[~]", "completed" => "[x]" }.freeze
37
+
38
+ FIELDS = %w[content status activeForm].freeze
39
+
40
+ DESCRIPTION = "Write the task list for this session. Send the WHOLE list every time: " \
41
+ "what this call does not mention is gone, because the result is the only " \
42
+ "place the list is kept. Mark exactly one item in_progress while you work " \
43
+ "on it, and complete it before starting the next."
44
+
45
+ def start(ctx)
46
+ @ctx = ctx
47
+ register_todo_write
48
+ end
49
+
50
+ # Nothing is captured from config; there is no knob on this row, and
51
+ # there is no state for one to govern.
52
+ def reconfigure(_config); end
53
+
54
+ private
55
+
56
+ # `ctx:` is passed explicitly, like the rest of the roster: the registry
57
+ # would otherwise record the frame on the context it was started in (the
58
+ # root), so a roster mounted into a forked agent scope would leave a
59
+ # registration behind that outlives the fork.
60
+ def register_todo_write
61
+ item = {
62
+ type: "object",
63
+ properties: {
64
+ content: { type: "string", description: "The task, as an imperative: \"Run the tests\"" },
65
+ status: { type: "string", enum: BOXES.keys,
66
+ description: "pending, in_progress, or completed" },
67
+ activeForm: { type: "string",
68
+ description: "The present continuous form shown while the task is " \
69
+ "in progress: \"Running the tests\"" }
70
+ },
71
+ required: FIELDS
72
+ }
73
+ params = {
74
+ type: "object",
75
+ properties: { todos: { type: "array", items: item,
76
+ description: "The complete list, every item, every time" } },
77
+ required: %w[todos]
78
+ }
79
+ # `todos` is required in the schema and defaulted here: an omitted
80
+ # keyword would cost a whole turn to an ArgumentError, where a
81
+ # defaulted one costs a result the model can read and correct.
82
+ @ctx[:tools].register(name: "TodoWrite", description: DESCRIPTION, params: params,
83
+ mutating: false, approval: :never, concurrency: :serial,
84
+ ctx: @ctx) do |todos: nil|
85
+ render(items!(todos))
86
+ end
87
+ end
88
+
89
+ def items!(todos)
90
+ unless todos.is_a?(Array)
91
+ raise Terret::Tools::Failure,
92
+ "TodoWrite needs `todos`: the whole list, as an array of " \
93
+ "{content, status, activeForm} objects"
94
+ end
95
+
96
+ todos.each_with_index.map { |item, index| item!(item, index + 1) }
97
+ end
98
+
99
+ # A model writes JSON, and which of the two key shapes an item arrives in
100
+ # depends on the adapter that parsed it. Normalizing once here is cheaper
101
+ # than a validation error nobody can act on for a list that was written
102
+ # correctly.
103
+ #
104
+ # Every refusal names the item's POSITION, because in a list of todos
105
+ # nothing else tells them apart — and a hole in the list has no content
106
+ # to be named by at all. `nil` is the case that needs saying twice: it
107
+ # answers `respond_to?(:to_h)` and `nil.to_h` is `{}`, so a missing item
108
+ # used to be reported back as an empty object the model never wrote.
109
+ def item!(item, position)
110
+ unless !item.nil? && item.respond_to?(:to_h) && !item.is_a?(Array)
111
+ raise Terret::Tools::Failure,
112
+ "every todo must be an object with content, status and activeForm; " \
113
+ "item #{position} is #{item.inspect}"
114
+ end
115
+
116
+ item = item.to_h.transform_keys(&:to_s)
117
+
118
+ # `status` is deliberately not checked for being text here. A status
119
+ # that arrived as something else is a WRONG status rather than an
120
+ # absent one, and the branch below is the one that can say so while
121
+ # naming what the model actually wrote.
122
+ missing = %w[content activeForm].reject { |f| item[f].is_a?(String) }
123
+ unless missing.empty?
124
+ raise Terret::Tools::Failure,
125
+ "every todo needs #{FIELDS.join(', ')}; item #{position} is missing " \
126
+ "#{missing.join(', ')}: #{item.inspect}"
127
+ end
128
+
129
+ unless BOXES.key?(item["status"])
130
+ raise Terret::Tools::Failure,
131
+ "#{item['status'].inspect} is not a todo status (item #{position}); use one " \
132
+ "of #{BOXES.keys.join(', ')}"
133
+ end
134
+
135
+ item
136
+ end
137
+
138
+ def render(items)
139
+ return "(the todo list is empty)" if items.empty?
140
+
141
+ items.map { |item| "- #{BOXES.fetch(item['status'])} #{line(item)}" }.join("\n")
142
+ end
143
+
144
+ # One item, one line. This is the forgeability register the LEDGER
145
+ # literal keeps in tools_std/jobs.rb, and it is the one place in the
146
+ # roster where forging matters rather than merely misleading: the
147
+ # rendered list IS the state, so a content string carrying a newline
148
+ # could write a checkbox line of its own and hand the model back a task
149
+ # it never completed as done. Escaped rather than dropped — what the
150
+ # model wrote stays legible, it just cannot be a line.
151
+ def line(item) = label(item).gsub(/[\r\n]/) { |c| c == "\n" ? "\\n" : "\\r" }
152
+
153
+ # The running item is shown in its active form, which is the one thing
154
+ # activeForm is for; everything else is shown as what it is.
155
+ def label(item) = item["status"] == "in_progress" ? item["activeForm"] : item["content"]
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "resolv"
6
+ require "ipaddr"
7
+
8
+ module Terret
9
+ module ToolsStd
10
+ # `WebFetch` (docs/exec.md §5) — Claude Code's name over an ordinary GET,
11
+ # behind §13's "web_fetch gets an allow and deny domain policy row". The
12
+ # row is this service's own config: `{ allow: [globs], deny: [globs] }`.
13
+ #
14
+ # Three things here are more than a GET.
15
+ #
16
+ # The policy is DENY-BY-DEFAULT. An unconfigured row fetches nothing at
17
+ # all, because the failure mode of the other default is a model reading an
18
+ # attacker's page on its first turn, and there is no configuration mistake
19
+ # that produces that from an empty allow list.
20
+ #
21
+ # Every REDIRECT HOP is re-checked against that policy. A policy applied
22
+ # only to the URL a model typed is a policy any allowed host can launder a
23
+ # fetch through by answering `301 Location: https://anywhere/`, so the
24
+ # check lives on the loop rather than in front of it — and the default
25
+ # transport deliberately does not follow redirects itself, because a
26
+ # transport that followed them would do it without ever consulting the
27
+ # policy.
28
+ #
29
+ # And the policy matches a HOSTNAME STRING, not an IP. An IP literal is
30
+ # matched as its own text (`10.0.0.1` matches the glob `10.0.0.1`, and
31
+ # nothing under an empty allow list), and a name on the allow list is
32
+ # admitted no matter where it resolves.
33
+ #
34
+ # That last part is why WebFetch does NOT lean on the sandbox for network
35
+ # safety the way the rest of §6.6 does: this GET runs HOST-side through
36
+ # Net::HTTP, so `network: none` on the sandbox row constrains the container
37
+ # and not this tool. An allowlisted name resolving to 127.0.0.1 or to the
38
+ # 169.254.169.254 cloud-metadata endpoint would reach host-local services
39
+ # and instance credentials with nothing in the way. So the tool carries its
40
+ # own SSRF floor: #check_address! resolves the host and refuses loopback and
41
+ # link-local targets before any connection, on the model's URL and on every
42
+ # redirect hop. It is a FLOOR, not full SSRF control — private ranges
43
+ # (10/8, 172.16/12, 192.168/16) stay reachable by default because a
44
+ # deployment may legitimately fetch internal services, and blocking them is
45
+ # a documented M8 config knob. It is also not DNS-rebinding protection: the
46
+ # address is resolved once for the check and the connection re-resolves, so
47
+ # a name that answers differently between the two still connects to the
48
+ # second answer. Pinning the connection to the checked IP would mean
49
+ # threading it through the injectable transport seam, whose contract is a
50
+ # bare `call(url)`; the honest floor keeps that seam intact and closes the
51
+ # static-record and misconfigured-allowlist vectors, which are the ones a
52
+ # deployment actually hits.
53
+ class WebFetch < Hames::Service
54
+ service_key :tools_std_web_fetch
55
+ inject :tools
56
+ # resolver: and transport: are injectable seams (tests pass callables),
57
+ # not YAML config, so they are deliberately absent from the schema.
58
+ config_schema allow: { type: Array, default: [],
59
+ doc: "host globs WebFetch may reach; empty denies every host" },
60
+ deny: { type: Array, default: [], doc: "host globs WebFetch may never reach" },
61
+ timeout: { type: Numeric, default: 30.0, doc: "seconds a fetch may run" },
62
+ max_bytes: { type: Integer, default: 100_000,
63
+ doc: "bytes of a response body read before truncation" }
64
+
65
+ # What one result may show. A display decision, the tool's own honest
66
+ # cap rather than policy's — a truncator listening on tools/post_execute
67
+ # is free to cut further, and this is what the model sees when none does.
68
+ DEFAULT_MAX_BYTES = 100_000
69
+
70
+ # timeout=0 must not mean "no timeout" (terret-morph's lesson): anything
71
+ # non-positive floors back to this.
72
+ DEFAULT_TIMEOUT = 30.0
73
+
74
+ # A server that redirects to itself is the cheapest denial of service
75
+ # there is; this is what makes the chain terminate.
76
+ MAX_REDIRECTS = 5
77
+
78
+ # Deliberately the same literal `Bash` separates its output with, so a
79
+ # model learns the convention once. Same caveats as there: a server CAN
80
+ # print this line, so it is a readability device and not a security
81
+ # boundary. What it does deliver is that the genuine remarks are always
82
+ # last, after anything the page forged, and that they are advisory data
83
+ # rather than instructions (docs/security.md) — nothing downstream acts
84
+ # on them, so a forged line buys a confusing result and no authority.
85
+ LEDGER = "--- terret ---"
86
+
87
+ USER_AGENT = "terret"
88
+
89
+ DESCRIPTION = "Fetch an http or https URL and return the page as text. Only hosts this " \
90
+ "deployment allows can be fetched, and redirects are followed only while " \
91
+ "every hop stays allowed. The page comes back whole rather than summarized."
92
+
93
+ def start(ctx)
94
+ @ctx = ctx
95
+ register_web_fetch
96
+ end
97
+
98
+ # Nothing is captured here: the policy, the cap and the timeout are all
99
+ # read at call time, so a swapped row governs the very next fetch and
100
+ # there is nothing to re-derive. Saying so beats letting the base class
101
+ # warn that this row needs a remount when it does not.
102
+ def reconfigure(_config); end
103
+
104
+ private
105
+
106
+ # `ctx:` is passed explicitly: the registry would otherwise record the
107
+ # frame on the context it was started in (the root), so a roster mounted
108
+ # into a forked agent scope would leave registrations behind that
109
+ # outlive the fork — a disposed agent with a tool of its own that can
110
+ # still reach the network.
111
+ def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
112
+ @ctx[:tools].register(name: name, description: description, params: params,
113
+ mutating: mutating, approval: approval,
114
+ concurrency: concurrency, ctx: @ctx, &handler)
115
+ end
116
+
117
+ def register_web_fetch
118
+ params = {
119
+ type: "object",
120
+ properties: {
121
+ url: { type: "string", description: "Absolute http or https URL to fetch" }
122
+ },
123
+ required: ["url"]
124
+ }
125
+ # `prompt:` is accepted and ignored. Claude Code's WebFetch takes one
126
+ # and runs a small model over the page; this tool hands the page back
127
+ # whole instead, so there is nothing for a prompt to do — but a model
128
+ # that has written that call before writes one anyway, and an unknown
129
+ # keyword would cost a whole turn to an ArgumentError. It stays out of
130
+ # the schema so nothing advertises a knob that does not exist.
131
+ tool("WebFetch", DESCRIPTION, params, mutating: false, approval: :policy,
132
+ concurrency: :serial) do |url:, prompt: nil|
133
+ fetch_url(url)
134
+ end
135
+ end
136
+
137
+ def fetch_url(url)
138
+ uri = admit!(parse!(url))
139
+ hops = 0
140
+ loop do
141
+ status, headers, body = answer!(transport.call(uri.to_s))
142
+ location = header(headers, "location")
143
+ # A 3xx with nothing to follow is not a redirect, it is a status,
144
+ # and gets reported like any other rather than looping.
145
+ return render(status, body, hops, uri) unless redirect?(status) && location
146
+
147
+ if hops >= MAX_REDIRECTS
148
+ raise Terret::Tools::Failure,
149
+ "gave up after more than #{MAX_REDIRECTS} redirects, last at #{uri}"
150
+ end
151
+
152
+ hops += 1
153
+ uri = admit!(resolve!(uri, location))
154
+ end
155
+ end
156
+
157
+ # -- what a URL may be ---------------------------------------------------
158
+
159
+ # The offending string is never echoed back. A malformed URL may still
160
+ # carry a password, and the message goes straight into the durable
161
+ # session log; what the model wrote is already in its own tool call,
162
+ # where redaction can see it, so repeating it here would only add a
163
+ # second copy in a place nothing scrubs.
164
+ def parse!(url)
165
+ unless url.is_a?(String)
166
+ raise Terret::Tools::Failure, "url must be a string; got #{url.class}"
167
+ end
168
+
169
+ URI(url)
170
+ rescue URI::Error, ArgumentError
171
+ raise Terret::Tools::Failure, "url could not be parsed as a URL; nothing was fetched"
172
+ end
173
+
174
+ def resolve!(base, location)
175
+ base.merge(location.to_s)
176
+ rescue URI::Error, ArgumentError
177
+ raise Terret::Tools::Failure,
178
+ "#{base.host} redirected to a location that could not be parsed as a URL"
179
+ end
180
+
181
+ # Every check a URL has to pass before it may be handed to the transport,
182
+ # applied to the model's own URL and to every redirect target alike.
183
+ def admit!(uri)
184
+ scheme = uri.scheme&.downcase
185
+ unless %w[http https].include?(scheme)
186
+ raise Terret::Tools::Failure,
187
+ "WebFetch speaks http and https only, not #{scheme.inspect}; nothing was fetched"
188
+ end
189
+ # Refused rather than stripped: stripping would fetch something the
190
+ # caller did not ask for. A credential belongs in a header a
191
+ # deployment controls, not in an argument a model writes.
192
+ if uri.userinfo
193
+ raise Terret::Tools::Failure,
194
+ "refusing a URL that carries credentials in its userinfo; nothing was fetched"
195
+ end
196
+
197
+ # `File.fnmatch("*", "")` is true, so a hostless URL would sail
198
+ # straight through a permissive allow list into a transport with
199
+ # nothing to connect to.
200
+ host = uri.host.to_s.downcase
201
+ raise Terret::Tools::Failure, "url names no host; nothing was fetched" if host.empty?
202
+
203
+ check_policy!(host)
204
+ # The SSRF floor resolves the bracket-STRIPPED hostname. `uri.host` keeps
205
+ # the brackets on an IPv6 literal ("[::1]"), which no resolver recognizes
206
+ # — the floor would see no address and connect straight through to the
207
+ # loopback the brackets name. `uri.hostname` strips them ("::1"), which is
208
+ # what resolves and what IPAddr classifies as loopback/link-local. (IPv4
209
+ # `127.0.0.1` has no brackets, so it was caught either way; this closes
210
+ # the IPv6 hole the bracket form opened.)
211
+ check_address!(uri.hostname.to_s.downcase)
212
+ uri
213
+ end
214
+
215
+ # -- the SSRF floor ------------------------------------------------------
216
+
217
+ # The address check the domain policy cannot make: a name the allow list
218
+ # admits is still refused if it resolves to a loopback or link-local
219
+ # address, so an allowlisted host cannot launder a fetch to 127.0.0.1 or
220
+ # to 169.254.169.254 (see the class comment for why this lives in the tool
221
+ # rather than the sandbox, and for the DNS-rebinding boundary it accepts).
222
+ # An unresolvable name answers no addresses and passes here — there is no
223
+ # internal target to refuse, and the connection fails on its own.
224
+ def check_address!(host)
225
+ addresses(host).each do |ip|
226
+ next unless forbidden_address?(ip)
227
+
228
+ # The IP is named so a model reading the refusal can see WHY, but it
229
+ # is the resolved address, never a secret the caller wrote.
230
+ raise Terret::Tools::Failure,
231
+ "#{host} resolves to #{ip}, a loopback or link-local address WebFetch " \
232
+ "refuses; nothing was fetched"
233
+ end
234
+ end
235
+
236
+ # The resolution seam, injectable like the transport so this gem's unit
237
+ # tests need no DNS: a callable taking a host and answering an array of
238
+ # address strings. Resolv.getaddresses answers [] rather than raising on a
239
+ # name that does not resolve, which is exactly the fail-open-safe shape
240
+ # here — nothing to refuse.
241
+ def resolver = config[:resolver] || Resolv.method(:getaddresses)
242
+
243
+ def addresses(host) = Array(resolver.call(host))
244
+
245
+ # Loopback (127.0.0.0/8, ::1) and link-local (169.254.0.0/16, fe80::/10),
246
+ # exactly the two ranges IPAddr's own predicates name. A string that does
247
+ # not parse as an IP is treated as forbidden: a resolver answer this tool
248
+ # cannot verify fails closed rather than being connected to blind.
249
+ def forbidden_address?(ip)
250
+ addr = IPAddr.new(ip.to_s)
251
+ addr.loopback? || addr.link_local?
252
+ rescue IPAddr::InvalidAddressError
253
+ true
254
+ end
255
+
256
+ # -- the domain policy ---------------------------------------------------
257
+
258
+ # Globs are `File.fnmatch` patterns, the same dialect and the same flags
259
+ # the tool AllowList uses (Terret::Tools::AllowList): case-sensitive, and
260
+ # `*` does not match a leading dot. Both fail closed.
261
+ #
262
+ # The HOST is lowered first, and that is not cosmetic. DNS hostnames are
263
+ # case-insensitive while `fnmatch` is not, so matching the raw host
264
+ # string would let `EVIL.EXAMPLE` walk past a deny rule spelled in
265
+ # lowercase — a fail-OPEN hole, the one direction this policy may not
266
+ # fail in. Patterns themselves are left exactly as written, so a pattern
267
+ # spelled with capitals simply never matches, which fails closed.
268
+ #
269
+ # A port is not part of what is matched. This is a hostname policy:
270
+ # `allow: ["internal.example.com"]` permits that host on any port. One
271
+ # matching rule is worth more than a second one that only half the
272
+ # patterns would be written against, and port-level egress control is
273
+ # the sandbox row's business rather than this glob's.
274
+ def check_policy!(host)
275
+ if deny_patterns.any? { |p| File.fnmatch(p, host) }
276
+ raise Terret::Tools::Failure,
277
+ "#{host} is denied by the WebFetch domain policy; nothing was fetched"
278
+ end
279
+ return if allow_patterns.any? { |p| File.fnmatch(p, host) }
280
+
281
+ # Deny-by-default: an empty allow list is not "unconfigured, so
282
+ # permit", it is "nobody has said this deployment may reach the web".
283
+ raise Terret::Tools::Failure,
284
+ "#{host} is not on the WebFetch domain allow list; nothing was fetched"
285
+ end
286
+
287
+ def allow_patterns = Array(config[:allow]).map(&:to_s)
288
+ def deny_patterns = Array(config[:deny]).map(&:to_s)
289
+
290
+ # -- the transport -------------------------------------------------------
291
+
292
+ # The seam every fetch reaches the wire through, injectable so this
293
+ # gem's unit tests need no network (terret-morph's pattern): a callable
294
+ # taking the URL and answering `[status, headers, body]` — a status that
295
+ # responds to #to_i, a Hash of response headers in whatever spelling the
296
+ # server used (#header folds the case), and the body as a String.
297
+ #
298
+ # Three fields rather than the two the plan sketched, because the
299
+ # per-hop policy check needs the Location header out here. A transport
300
+ # that answered a body alone would leave this tool either not following
301
+ # redirects at all, or following them down inside the transport — which
302
+ # is exactly where the domain policy cannot see them.
303
+ def transport = config[:transport] || method(:http_get)
304
+
305
+ # A transport is a deployment's own wiring, so a wrong shape is a
306
+ # configuration bug rather than a tool outcome. It keeps its class name
307
+ # (Registry#execute renders a Failure message-only, and here the class
308
+ # IS the diagnosis) and states the contract, instead of destructuring
309
+ # into a silent "(no content)" on every single fetch.
310
+ def answer!(answer)
311
+ return answer if answer.is_a?(Array) && answer.length == 3
312
+
313
+ raise TypeError,
314
+ "the WebFetch transport must answer [status, headers, body]; got #{answer.class}"
315
+ end
316
+
317
+ def redirect?(status) = (300..399).cover?(status.to_i)
318
+
319
+ # Header names are case-insensitive on the wire and a transport hands
320
+ # back whatever spelling it saw, so the lookup does the folding rather
321
+ # than the contract. An Array value is tolerated because that is the
322
+ # shape `Net::HTTPResponse#to_hash` produces, and a transport built from
323
+ # one should not have to remember which of the two accessors to use.
324
+ def header(headers, name)
325
+ return nil unless headers.respond_to?(:each_pair)
326
+
327
+ _, value = headers.each_pair.find { |k, _| k.to_s.downcase == name }
328
+ value.is_a?(Array) ? value.first : value
329
+ end
330
+
331
+ # The default transport: one GET, no redirect following. Following them
332
+ # here would be following them without the policy, which is the whole
333
+ # thing #fetch_url's loop exists to prevent.
334
+ def http_get(url)
335
+ uri = URI(url)
336
+ request = Net::HTTP::Get.new(uri.request_uri, "User-Agent" => USER_AGENT)
337
+ connection(uri).start do |http|
338
+ # Captured rather than returned: `Net::HTTP#request` hands back the
339
+ # RESPONSE, not its block's value, so returning the tuple from the
340
+ # inner block would send a Net::HTTPOK where a status was expected.
341
+ # Nothing driving a stub transport can see that — the live lane is
342
+ # what this line was written against.
343
+ answer = nil
344
+ http.request(request) do |response|
345
+ answer = [response.code.to_i, response.each_header.to_h, read_bounded(response)]
346
+ end
347
+ answer
348
+ end
349
+ end
350
+
351
+ # A server answering with a terabyte must cost this process max_bytes and
352
+ # not a terabyte, so chunks past the cap are dropped as they arrive.
353
+ #
354
+ # Dropped, and NOT `break`-ed out of: abandoning Net::HTTP's read_body
355
+ # mid-body leaves the socket positioned inside the response, and the
356
+ # `self.body` in Net::HTTPResponse#reading_body's ensure then reads the
357
+ # remaining payload as a fresh chunk-size line — "Net::HTTPBadResponse:
358
+ # wrong chunk size line" on roughly every other chunked, gzipped page,
359
+ # depending on whether the leftover bytes happened to parse as hex. The
360
+ # stream is therefore drained to its end; what is bounded here is memory,
361
+ # and duration stays the read timeout's job.
362
+ def read_bounded(response)
363
+ limit = max_bytes
364
+ # A binary buffer: chunks arrive as bytes, and appending them to a
365
+ # UTF-8 string raises the moment one is not ASCII. #scrub is the layer
366
+ # that turns them into text.
367
+ body = +"".b
368
+ # `<=`, so a body exactly the size of the cap still takes on the chunk
369
+ # that makes the truncation visible. `<` would truncate silently at
370
+ # the boundary.
371
+ response.read_body { |chunk| body << chunk if body.bytesize <= limit }
372
+ body
373
+ end
374
+
375
+ def connection(uri)
376
+ http = Net::HTTP.new(uri.host, uri.port)
377
+ http.use_ssl = uri.scheme == "https"
378
+ http.open_timeout = timeout
379
+ http.read_timeout = timeout
380
+ http.write_timeout = timeout
381
+ http
382
+ end
383
+
384
+ def timeout
385
+ configured = config[:timeout].to_f
386
+ configured.positive? ? configured : DEFAULT_TIMEOUT
387
+ end
388
+
389
+ # -- rendering -----------------------------------------------------------
390
+
391
+ # Clamped rather than trusted: a row carrying a negative cap would
392
+ # otherwise byteslice its way to nil and raise on every single call,
393
+ # turning one bad config value into a tool that never works. Zero is
394
+ # then an honest answer — the result says it kept nothing and how much
395
+ # it dropped, which is visible in the very next tool result instead of
396
+ # in a crash a turn later.
397
+ def max_bytes = [config[:max_bytes] || DEFAULT_MAX_BYTES, 0].max
398
+
399
+ def render(status, body, hops, uri)
400
+ text, dropped = cap(scrub(body))
401
+ remarks = remarks_for(status, text, dropped, hops, uri)
402
+ return text.empty? ? "(no content)" : text if remarks.empty?
403
+
404
+ # The page's own bytes are never rewritten: the newline below only
405
+ # puts the separator on a line of its own, and a body that already
406
+ # ended in one simply gets a blank line before the ledger.
407
+ "#{text.empty? ? '' : "#{text}\n"}#{LEDGER}\n#{remarks.join("\n")}"
408
+ end
409
+
410
+ def remarks_for(status, text, dropped, hops, uri)
411
+ remarks = []
412
+ # 2xx is the silent case — announcing success on every call would be
413
+ # noise in every result a model reads. Everything else is reported: a
414
+ # 404 is an answer the server gave, not a crash, and a model that is
415
+ # shown the error page without the status will read it as content.
416
+ remarks << "HTTP #{status}" unless (200..299).cover?(status.to_i)
417
+ # The model asked for one URL and is looking at another's bytes. It
418
+ # has to be told which, or every relative link on the page resolves
419
+ # against the wrong base.
420
+ remarks << "followed #{hops} redirect#{'s' unless hops == 1} to #{uri}" if hops.positive?
421
+ # Both counts measure the RENDERED text — what a model would have been
422
+ # shown — rather than what the server sent. Two things separate them:
423
+ # scrubbing has already replaced anything that was not valid UTF-8 (a
424
+ # replacement character is three bytes where the original may have
425
+ # been one), and the default transport stops KEEPING bytes past the
426
+ # cap, so a page much larger than max_bytes reports the bytes it
427
+ # actually held on to. "of rendered text" is what keeps this line from
428
+ # claiming it measured the page.
429
+ if dropped.positive?
430
+ remarks << "content truncated at max_bytes: kept the first #{text.bytesize} bytes " \
431
+ "of rendered text and dropped #{dropped} more"
432
+ end
433
+ remarks
434
+ end
435
+
436
+ # A server's body is whatever bytes it felt like sending, and the
437
+ # session log refuses invalid UTF-8 at the durable append boundary, so
438
+ # this is the layer where they have to become storable — replacing what
439
+ # was never valid rather than dropping the whole answer on the floor.
440
+ #
441
+ # No transcoding by declared charset: a page labelled iso-8859-1 comes
442
+ # back with its non-ASCII bytes replaced rather than converted. Guessing
443
+ # an encoding from a header the server may have got wrong is a second
444
+ # way to corrupt text, and the honest note beats the half-measure.
445
+ def scrub(body)
446
+ text = body.to_s
447
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
448
+ text.scrub
449
+ end
450
+
451
+ def cap(text)
452
+ limit = max_bytes
453
+ return [text, 0] if text.bytesize <= limit
454
+
455
+ kept = whole_characters(text.byteslice(0, limit))
456
+ [kept, text.bytesize - kept.bytesize]
457
+ end
458
+
459
+ # Cutting at a byte offset can split a character in half, and those
460
+ # halves are bytes this file manufactured — the server never sent them,
461
+ # and a durable append JSON-encodes the payload, so a manufactured half
462
+ # raises a layer away from the code that broke it. At most three bytes
463
+ # come back off, the longest tail a split UTF-8 character can leave.
464
+ # Belt and braces after #scrub, and kept anyway: the same rule Bash
465
+ # holds itself to, for the same reason.
466
+ def whole_characters(text)
467
+ text = text.byteslice(0, text.bytesize - 1) until text.valid_encoding?
468
+ text
469
+ end
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "terret"
5
+ rescue LoadError
6
+ require_relative "../../../terret-core/lib/terret" # monorepo path source
7
+ end
8
+
9
+ require_relative "tools_std/files"
10
+ require_relative "tools_std/bash"
11
+ require_relative "tools_std/terminals"
12
+ require_relative "tools_std/web_fetch"
13
+ require_relative "tools_std/task"
14
+ require_relative "tools_std/jobs"
15
+ require_relative "tools_std/todo"