8 Commits
Author SHA1 Message Date
only-cli 1103798914 release: 0.3.0 2026-08-23 09:22:16 -04:00
only-cli 5567b31d6a test: prove the redirect guard without a third party
The test for revalidating redirect hops drove httpbin.org, so httpbin being
down failed the suite. It is down now, returning 503, which fails CI on main
and would have failed the release: the publish workflow runs npm test before
it ships, so a stable version could not have reached npm while a third
party's app tier was unwell.

It was also testing less than it looked. Each transport carried its own copy
of the redirect loop, and the live test only ever exercised whichever one was
installed, so the guarantee held in one copy and was unproven in the other.
A check that matters twice is a check a change can fix once.

Both transports now share one loop that takes the request as a callback,
which is what makes the hop check provable against a transport that never
leaves the process. Three offline tests replace the live one: a hop to a
private address is refused and never asked for, a hop to a public address is
still followed (a loop that rejected everything would have passed the first
test and broken every redirect on the web), and a cycle gives up. Removing
the hop check fails the first of them, which is more than the httpbin test
could say for the transport it did not run.

Verified live afterwards on both paths: an http to https chain through
impers, a plain page, and a literal and a resolved private address both
still refused.
2026-08-23 09:22:04 -04:00
only-cli 90d5c20ef9 fix: hand over code an agent can actually run
A syntax highlighter gives every token of a command its own element, so
`s3://bucket/` reached the walk as `s3`, `:`, `//`, `bucket`, `/`, and the
rule that reassembles text fragments only glues the ones sharing a parent.
The rest were space-joined. The AWS CLI reference handed over

  aws s3 cp test . txt s3 : // amzn - s3 - demo - bucket / test2 . txt

and Node's fs docs handed over `console . log` and `fd ?. close ()`. Any
command or snippet an agent took from a docs page was wrong, and nothing in
the output said so. Highlighting is not an edge case: of 172 pre elements on
the AWS CLI reference, the Rust book, the Node API docs and the Python
library docs, 159 are split this way.

A pre or code subtree is now read as one string. That costs nothing to
follow, because not one of those 172 blocks contains a link, and it also
fixes inline code, which was inserting a space into `Byte ( u8 only)`.

Two things fall out of it.

Page furniture had to stop riding along. Node puts a language label beside a
copy button inside every code block, so the subtree's text ended in
`javascriptcopy`. A control is chrome, and so is the block-level element
holding it, which is how the label leaves with the button it sits beside.
The test stays on block-level wrappers because a highlighter's own elements
are inline, so a stray control can never take a line of code out with it.

Code blocks keep their lines. Collapsing them was survivable while the code
was already wrecked; once it reads correctly, `// read it back` in front of
the statements that followed it is worse, because the output now looks
trustworthy. Blank runs and the indentation the whole block shares carry no
meaning and still go. A cut lands on a line end for the same reason it
already lands on a sentence end, and a snippet stays one line, because that
is what find's index promises.

Measured over five real pages, the compact view moves by -15, -7, +208, -45
and 0 characters, about 35 tokens in total, all of the growth being Python's
pretty-printed output getting its indentation back.
2026-08-23 09:12:25 -04:00
only-cli 780318a780 release: 0.3.0-beta.2 2026-08-23 08:52:10 -04:00
only-cli f84074701d perf: spend one command where the tool used to need two
A tool call inside an agent session costs 23,000 to 33,000 tokens of
overhead whatever it prints, so the page-view win only reaches the
session total if answering a task takes fewer commands. Three places
were charging a command to say what the next command should be, each
found by capturing the command stream of a real agent run rather than
by reading the code.

A search result title is a link. Every engine puts it in an anchor
filling an <h2>, and the walk took the heading's text and returned,
dropping the href, so `do` on the most obvious number on a results page
printed the title back. The agent then spent a second command finding
the number that navigates. The href now rides along when the anchor is
the whole heading, which is the test documentation fails on purpose:
every heading in the Rust book and on an AWS CLI reference page carries
a permalink to its own id, and following one would refetch the page the
agent is already reading.

`find` pointed at its answer. With a single match it printed the block
and a number, and the agent's next command was always the `read` on
that number, so it now prints the region. With several matches it
showed a 200 character snippet of each even when the budget had room
for them whole, so it spends that room, on the same terms `FINISH`
already documents for a page that nearly fits.

A truncated block ended mid sentence. Asked for the first sentence of a
page, an agent was handed it complete, followed by a marker saying 302
characters were cut, and spent a command on `read` to find out whether
the sentence went on. The cut now falls on the last sentence that
finished inside the cap, and measured across five real pages it costs
nothing: four came out within three characters of before.

The package-lock name field catches up with the scoped package name,
which npm rewrites on any install.
2026-08-23 08:46:10 -04:00
only-cli 29ab00b5c6 fix: read a json resource as the resource, not as the array beside it
Three faults in the JSON renderer, all of which the npm registry's package
endpoint hits at once, where the render came out as a truncated blob titled
after the package's two maintainers.

mainArray took the longest array of objects at the top level, so `maintainers`
became the subject and the package itself was pushed into the metadata line. A
root carrying its own name is the resource, and an array hanging off it
describes that resource rather than standing in for it. Conventional container
keys are checked first, so a named collection is still read as a collection.

The metadata line capped nothing. One long scalar there, a readme in npm's
case, cost more than the rest of the page put together; a summary line has to
stay a line, so a long one becomes its own block.

An oversized markup field distilled into more blocks than the item it hangs
off had fields. Under BODY_CAP a body is still rendered in place with its links
followable, which is what the Stack Exchange withbody shape wants; over it, one
numbered line, with `oc raw` still holding the whole thing.
2026-08-23 08:23:50 -04:00
only-cli 75fc1a0da3 fix: drop a node_modules symlink committed by the 0.3.0-beta.1 release
The release commit was made from a scratch worktree whose node_modules was a
symlink to another checkout, and .gitignore listed node_modules/ with a
trailing slash, which matches a directory and not a symlink. So the link
itself went into the tree, pointing at an absolute path on one machine.

Anyone cloning main got a dangling node_modules before npm was ever run. The
npm tarball is unaffected: the files whitelist decides what ships, and npm
never packs node_modules, which the 0.3.0-beta.1 pack listing confirms.

The ignore rule loses its trailing slash so it matches either shape.
2026-08-22 15:32:02 -04:00
only-cli f7c8a5583b fix: refuse binary responses on the impers transport too
Testing the 0.3.0-beta.1 build against live URLs turned up a gap the beta
notes claimed was closed: only the native-fetch path checked the content
type, and impers is the default whenever the optional dependency installs.
So 'oc open' on a PNG rendered eight kilobytes of mojibake as a page, with
numbered blocks, an actions footer, and a straight face.

The check now lives in one exported assertReadableType that both transports
call, so a refusal cannot depend on which client happened to get the page.

While the gate was being written down it also grew a correct allow list.
The old one matched the substring html, xml, or json anywhere in the header,
which let application/vnd.ms-htmlhelp through and, worse, refused text/plain:
a robots.txt or an llms.txt is exactly the kind of small text file an agent
asks for, and the fetch path was answering that it was not a page. Readable
now means any text/* type plus the application/* types that are really text,
including the +json and +xml families a feed answers with. A missing header
stays readable, since small servers omit it and the page behind it is fine.

Tested offline against the header strings themselves rather than the network.
2026-08-22 14:40:28 -04:00
18 changed files with 661 additions and 96 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"name": "only-cli",
"source": { "source": "github", "repo": "only-cli/oc" },
"description": "Browse websites from the terminal in a few hundred tokens",
"version": "0.2.0",
"version": "0.3.0",
"homepage": "https://github.com/only-cli/oc",
"license": "MIT"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "only-cli",
"description": "Browse websites from the terminal in a few hundred tokens",
"version": "0.2.0"
"version": "0.3.0"
}
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/
node_modules
*.log
.idea/
.env
+3 -2
View File
@@ -59,7 +59,8 @@ No setup at all also works: `npx @only-cli/oc` runs without a global install, an
```
oc open <url> fetch and render a page with numbered actions
oc do <n> follow the numbered link [n], or read [n] if it is text
oc find <query> where a string appears on the page already open
oc find <query> where a string appears on the page already open, or
the region itself when only one place matches
oc read <n> full text of the region at [n], up to 2000 tokens
oc next the next budget worth of the page already open
oc raw [url] distilled markdown of the whole page
@@ -69,7 +70,7 @@ oc submit [n] submit a form (planned)
Flags: `--budget <tokens>` (default 500), `--json`, `--html` (raw as cleaned HTML), `--session <name>`, `--verbose`/`-v` (metrics on stderr, or export `OC_VERBOSE=1`).
`oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. Pages longer than the budget say what they left out; `oc find`, `oc read <n>`, and `oc next` read the rest without refetching the page. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved.
`oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. A result title on a search page is a link, so `oc do` on it opens the result rather than repeating the title. Pages longer than the budget say what they left out; `oc find`, `oc read <n>`, and `oc next` read the rest without refetching the page, and a `find` with a single match prints that region instead of the number to read it with. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved.
## Supported websites
-1
View File
@@ -1 +0,0 @@
/home/small/PycharmProjects/only-cli/node_modules
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "only-cli",
"version": "0.3.0-beta.1",
"name": "@only-cli/oc",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "only-cli",
"version": "0.3.0-beta.1",
"name": "@only-cli/oc",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"linkedom": "^0.18.12",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@only-cli/oc",
"version": "0.3.0-beta.1",
"version": "0.3.0",
"description": "Turn websites into a compact CLI so AI agents can browse without burning tokens.",
"type": "module",
"bin": {
+11 -9
View File
@@ -8,12 +8,13 @@ description: Token-efficient web browsing and web content extraction for AI agen
Renders a web page as a compact, numbered terminal view instead of raw HTML. A typical page is under 500 tokens.
```
npx --yes @only-cli/oc@0.2.0 open <url> compact view, numbered elements
npx --yes @only-cli/oc@0.2.0 do <n> follow link [n], or read it if [n] is text
npx --yes @only-cli/oc@0.2.0 find <query> lines where a string appears, with numbers
npx --yes @only-cli/oc@0.2.0 next next ~500 tokens of the page already open
npx --yes @only-cli/oc@0.2.0 read <n> full text of region [n]
npx --yes @only-cli/oc@0.2.0 raw [url] whole page as markdown (--html for cleaned HTML)
npx --yes @only-cli/oc@0.3.0 open <url> compact view, numbered elements
npx --yes @only-cli/oc@0.3.0 do <n> follow link [n], or read it if [n] is text
npx --yes @only-cli/oc@0.3.0 find <query> where a string appears, or that place itself
when only one matches
npx --yes @only-cli/oc@0.3.0 next next ~500 tokens of the page already open
npx --yes @only-cli/oc@0.3.0 read <n> full text of region [n]
npx --yes @only-cli/oc@0.3.0 raw [url] whole page as markdown (--html for cleaned HTML)
```
None of these except `open`/`do`/`raw <url>` fetch anything — they replay the page `open` already saved.
@@ -23,13 +24,14 @@ None of these except `open`/`do`/`raw <url>` fetch anything — they replay the
- Line 1 is the title, then main content (article/thread/results); nav/sidebar/footer follow after `--- rest of page ---`, still numbered.
- `--- repeated controls hidden ---`: per-item chrome (save/report/reply) dropped as repetitive; `raw` keeps it.
- `[n]` marks a link, button, input, heading, or a text block long enough to be cut.
- `... +820 chars`: block was cut there; `read <n>` prints it whole.
- Code blocks arrive as the page wrote them, lines and indentation intact, so a command in one can be run as printed.
- `... +820 chars`: block was cut there; `read <n>` prints it whole. The cut lands on the end of a sentence, or of a line in code, so what is shown is never half of one.
- `... 164 more blocks (~7,100 tokens)`: rest of page past budget — a cost estimate, not a fetch. Omitted when the page would finish only a little over budget; then it's printed whole instead.
- `actions:` footer lists valid next commands.
## Going further, cheapest first
- `find <query>` — every place a string appears, one line + number each. Matches as a phrase (case-insensitive), falling back to separate words; reports how many matches didn't fit.
- `find <query>` — every place a string appears, one line + number each. Matches as a phrase (case-insensitive), falling back to separate words; reports how many matches didn't fit. When one place matches, or when the matches all fit, it prints them in full: no `read <n>` afterwards.
- `read <n>` — one region in full: the block at `[n]` plus a little context, or the whole section for a heading.
- `next` — continues the same page from where the budget stopped.
- `raw` — everything, ~10x the cost. Use only when you need the whole page, not to hunt for a link's URL (use `do` for that).
@@ -42,7 +44,7 @@ None of these except `open`/`do`/`raw <url>` fetch anything — they replay the
- `[6-9] 4 similar links` markers still work despite the collapsed text.
- Search result links resolve to the destination, not the tracking redirect.
- `do` on an input/button reports that instead (typing/submitting not yet supported).
- `do` on a heading/text block prints the read instead of refusing, since there's nothing to follow.
- `do` on a heading/text block prints the read instead of refusing, since there's nothing to follow. A heading that is itself a link, which is what a search result title is, opens instead.
- `--session <name>` keeps separate page state, for working on two sites at once.
## Flags
+42 -7
View File
@@ -9,7 +9,7 @@
*/
import { DEFAULT_SESSION, handleFor, handleNumbers, loadSession, saveSession } from './session.js';
import { estimateTokens, formatBlock, render } from './render.js';
import { FINISH, estimateTokens, formatBlock, render } from './render.js';
export class NotImplemented extends Error {
constructor(command) {
@@ -63,7 +63,11 @@ export function activate(n, { session = DEFAULT_SESSION } = {}) {
const range = nums.length ? `1-${Math.max(...nums)}` : 'none';
throw new Error(`no [${n}] on ${state.url} (handles ${range}), run 'oc open <url>' again to renumber`);
}
if (handle.type === 'text' || handle.type === 'heading') {
// A heading can be a link: on a search results page the result title is one,
// and opening it is what `do` was asked for. Reading the title back instead
// cost a turn, and then another to find the number that does navigate, so a
// heading that has an href falls through to the link below.
if ((handle.type === 'text' || handle.type === 'heading') && !handle.href) {
// There is nothing to follow, but the agent asked to see what is at [n],
// and that is what read prints. Refusing would spend a whole turn to name
// the command that should have run, and a turn costs more than the page.
@@ -208,14 +212,41 @@ export function find(query, { session = DEFAULT_SESSION, budget = 500 } = {}) {
return `no match for "${query}"${tried} in ${blocks.length} blocks on ${state.url}, try fewer words or 'oc raw' for the full text`;
}
const lines = [`${hits.length} ${hits.length === 1 ? 'match' : 'matches'} for "${query}"${loose ? ', matching the words separately' : ''}`];
const separately = loose ? ', matching the words separately' : '';
// One match is not an index, it is the answer. Naming the number and
// stopping spends a turn to say where to look, and the agent's next command
// is always the `read` that looks, so find does the reading. Measured on an
// AWS CLI reference page, `find "Example 7"` printed a 24 token heading and
// the example it names was in the block after it.
if (hits.length === 1 && hits[0].n != null) {
const only = hits[0];
const follow = only.type === 'link' ? 'do <n> | ' : '';
return [
`1 match for "${query}"${separately}, region [${only.n}]`,
read(only.n, { session, budget: budget * FINISH }),
`actions: ${follow}read <n> | next | raw`,
].join('\n');
}
const lines = [`${hits.length} ${hits.length === 1 ? 'match' : 'matches'} for "${query}"${separately}`];
// A snippet is short because many matches have to share one screen. When
// every match would fit whole inside the allowance a page already gets for
// finishing (render's FINISH), showing them whole makes this command the
// answer instead of an index into a `read <n>` that has to run next. The
// trade is the same one FINISH documents, and it is not close: a few
// hundred tokens against a turn.
const label = (hit, text) => `[${hit.n ?? '?'}] ${text}`;
const whole = hits.reduce((n, h) => n + estimateTokens(label(h, h.text)) + 1, estimateTokens(lines[0]));
const full = whole <= budget * FINISH;
const cap = full ? budget * FINISH : budget;
let spent = estimateTokens(lines[0]);
let shown = 0;
let hasLinks = false;
for (const hit of hits) {
const line = `[${hit.n ?? '?'}] ${hit.snippet}`;
const line = label(hit, full ? hit.text : hit.snippet);
const cost = estimateTokens(line) + 1;
if (spent + cost > budget && shown) break;
if (spent + cost > cap && shown) break;
spent += cost;
shown++;
if (hit.type === 'link') hasLinks = true;
@@ -251,8 +282,12 @@ function search(blocks, terms) {
if (out.length && out.at(-1).n === n) continue;
const start = Math.max(0, Math.min(...found) - BEFORE);
const end = Math.min(block.text.length, start + SNIPPET);
const snippet = `${start > 0 ? '... ' : ''}${block.text.slice(start, end)}${end < block.text.length ? ' ...' : ''}`;
out.push({ n, type: block.type, snippet });
// One line per match is the promise the list makes, and a code block is the
// one kind of block that carries lines of its own. They survive where they
// are read rather than indexed: the whole-match mode above, and `read`.
const window = block.text.slice(start, end).replace(/\n/g, ' ');
const snippet = `${start > 0 ? '... ' : ''}${window}${end < block.text.length ? ' ...' : ''}`;
out.push({ n, type: block.type, snippet, text: block.text });
}
return out;
}
+4 -2
View File
@@ -11,7 +11,8 @@ const HELP = `only-cli: the web as a compact terminal, built for AI agents.
usage: oc <command> [args] [flags]
open <url> fetch and render a page with numbered actions
find <query> where a string appears on the page already open
find <query> where a string appears on the page already open, or
the region itself when only one place matches
next the next budget worth of the page already open
read <n> full text of the region at [n], up to 2000 tokens
raw [url] distilled markdown of the whole page
@@ -36,7 +37,8 @@ flags:
'oc open' remembers the page it printed, so 'oc do 3' follows link [3] without
you ever handling its URL, and 'oc next' or 'oc read 12' picks up what the
budget left behind without fetching it again. State lives in ~/.only-cli
budget left behind without fetching it again. 'oc do' on a search result title
opens the result, because that title is a link. State lives in ~/.only-cli
(override with OC_HOME).`;
// A rendered page has to be remembered or its [3] means nothing to the next
+170 -19
View File
@@ -7,7 +7,7 @@ import TurndownService from 'turndown';
* @property {string} text
* @property {number} [n] - action handle
* @property {number} [level] - heading level 1..6
* @property {string} [href] - links only
* @property {string} [href] - links, and a heading that is one
* @property {string} [name] - inputs only
*
* @typedef {Object} Page
@@ -32,6 +32,32 @@ const DROP = new Set([
// them, so the search below refuses to descend into them.
const FURNITURE = new Set(['nav', 'header', 'footer', 'aside']);
// Controls a page puts inside its own code samples, and the selector that
// finds one. Node's API docs give every code block a copy button, a module
// toggle and a language label, so the text of the sample ends in
// `javascriptcopy` unless the toolbar holding them is left out of it.
const CODE_CONTROLS = new Set(['button', 'input', 'select', 'textarea', 'label']);
const CONTROL = [...CODE_CONTROLS].join(', ');
// Lines in a code block are kept, where every other kind of text has its
// whitespace collapsed. Joining them would put `// comment` in front of the
// statements that followed it, so a sample an agent could run would arrive
// commented out, and the shape of the wreck is invisible on one line. Blank
// runs and the indentation the whole block shares are the parts that carry no
// meaning, so those go.
const codeText = (raw) => {
const lines = raw.replace(/\r\n?/g, '\n').replace(/[^\S\n]+$/gm, '').split('\n');
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
const indent = lines
.filter((l) => l.trim())
.reduce((least, l) => Math.min(least, l.length - l.trimStart().length), Infinity);
return lines
.map((l) => (Number.isFinite(indent) ? l.slice(indent) : l).trimEnd())
.join('\n')
.replace(/\n{3,}/g, '\n\n');
};
// Elements that end a line on the page and so must end one here. Without this
// the text of six separate posts merges into a single block, because nothing
// between them survives distillation to keep them apart.
@@ -82,6 +108,29 @@ const clean = (s) => s.replace(/\s+/g, ' ').trim();
const asHTML = (text, url = '', opts = {}) =>
jsonToHTML(text, url, opts) ?? youtubeToHTML(text) ?? transcriptToHTML(text) ?? feedToHTML(text) ?? text;
/**
* The link a heading is, if it is one. A search engine puts the result title
* in an anchor inside an <h2>, so a heading can be the most followable thing
* on the page, and taking only its text threw that away.
*
* The heading has to BE the link, not merely contain one: exactly one anchor,
* labelled with the whole heading. Documentation fails that test on purpose.
* Every heading in the Rust book and every one on an AWS CLI reference page
* carries a permalink to its own id, so following those would refetch the
* page the agent is already reading, which is worse than the reading it
* already gets. A bare fragment is never a destination.
* @param {any} node - the heading element
* @param {string} text - its cleaned text
* @returns {string|null}
*/
function headingHref(node, text) {
const anchors = node.querySelectorAll('a[href]');
if (anchors.length !== 1) return null;
const href = anchors[0].getAttribute('href') ?? '';
if (!href || href.startsWith('#')) return null;
return clean(anchors[0].textContent) === text ? href : null;
}
/**
* Reduce raw HTML to an interaction tree: readable text plus numbered
* elements, in document order. The walk is deterministic and numbering is a
@@ -105,6 +154,45 @@ export function distill(html, url = '') {
/** Subtree already emitted, skipped when the rest of the page is walked. */
let done = null;
/**
* The text of a code subtree, read as one string.
*
* A syntax highlighter gives every token its own element, so `s3://bucket/`
* reaches the walk as `s3`, `:`, `//`, `bucket`, `/`, and the rule that puts
* fragments back together only glues the ones that share a parent. The rest
* are space-joined, which turned an AWS example into
* `aws s3 cp s3 : // bucket / -- recursive`, a command an agent cannot run.
* Reading the subtree whole is what fixes that, and it discards nothing: over
* 172 pre elements on the AWS CLI reference, the Rust book, the Node API docs
* and the Python library docs, 159 are split this way and not one of them
* contains a link.
*
* textContent would be enough if pages put only code in their code blocks.
* A control inside one is chrome, and so is the block-level element holding
* it: that is how the toolbar's `javascript` label leaves with the copy
* button it sits beside. The test stays on block-level wrappers because a
* highlighter's own elements are inline, so a stray control can never take a
* line of code out with it.
* @param {any} node
* @returns {string}
*/
const verbatim = (node) => {
let out = '';
const gather = (n) => {
if (n.nodeType === 3) {
out += n.textContent ?? '';
return;
}
if (n.nodeType !== 1) return;
const tag = n.localName;
if (DROP.has(tag) || CODE_CONTROLS.has(tag) || hidden(n)) return;
if (n !== node && BLOCKY.has(tag) && n.querySelector(CONTROL)) return;
for (const child of n.childNodes) gather(child);
};
gather(node);
return out;
};
const walk = (node) => {
if (node.nodeType === 3) {
const raw = node.textContent ?? '';
@@ -126,7 +214,10 @@ export function distill(html, url = '') {
if (/^h[1-6]$/.test(tag)) {
const text = clean(node.textContent);
if (text) blocks.push({ type: 'heading', level: Number(tag[1]), text });
if (text) {
const href = headingHref(node, text);
blocks.push({ type: 'heading', level: Number(tag[1]), text, ...(href ? { href } : {}) });
}
return;
}
if (tag === 'a' && node.getAttribute('href')) {
@@ -157,6 +248,21 @@ export function distill(html, url = '') {
if (text) blocks.push({ type: 'button', text });
return;
}
if (tag === 'pre' || tag === 'code') {
const raw = verbatim(node);
const text = tag === 'pre' ? codeText(raw) : clean(raw);
// A pre is its own line, which is what BLOCKY gave it before this branch
// started claiming it first. Inline code belongs to the sentence around
// it, so it carries the parent and the edge spacing a text node would and
// merges back into that sentence the same way.
const own = tag === 'pre';
if (own) blocks.push({ type: 'break' });
if (text) {
blocks.push({ type: 'text', text, host: node.parentNode, pre: /^\s/.test(raw), post: /\s$/.test(raw) });
}
if (own) blocks.push({ type: 'break' });
return;
}
if (BLOCKY.has(tag)) {
blocks.push({ type: 'break' });
for (const child of node.childNodes) walk(child);
@@ -513,6 +619,15 @@ const TITLE_KEYS = [
'full_name', 'summary', 'question', 'message',
];
// Keys an API is likely to put its list of results under. A response using one
// of these is a list whatever else it carries, so the name settles it before
// shape does: a sideloaded `included` array can outnumber the `items` the
// request was for without being what the request was for.
const CONTAINER_KEYS = [
'items', 'data', 'results', 'hits', 'records', 'rows', 'entries',
'nodes', 'edges', 'docs', 'list', 'children', 'values',
];
// Keys holding the item's own page. A URL under any other name is still found,
// by looking at values rather than names, but these win when several qualify.
const LINK_KEYS = ['link', 'url', 'html_url', 'web_url', 'permalink', 'href'];
@@ -540,6 +655,14 @@ const NESTED_PENALTY = 0.25;
// How many names the footer lists when it says which fields it left out.
const DROPPED_LISTED = 5;
// Characters of markup a field may carry before the compact view stops
// treating it as a document and starts treating it as somewhere to go. A
// question body arrives well under this and is worth rendering in place, links
// and code and all. A package readme arrives at four figures and distils into
// more blocks than the resource it is attached to has fields, which buries the
// resource the response was fetched for. `oc raw` renders either one in full.
const BODY_CAP = 4000;
// Seconds and milliseconds since the epoch, bounded either side so an ordinary
// count (a score, a byte size) is never mistaken for a date.
const EPOCH_S = [1e9, 4e9];
@@ -572,6 +695,10 @@ const decodeEntities = (s) =>
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const isURL = (v) => typeof v === 'string' && /^https?:\/\/\S+$/.test(v);
const looksHTML = (v) => typeof v === 'string' && /<\/?(p|div|pre|code|br|ul|ol|li|h[1-6]|blockquote|table|img|a|em|strong)\b[^>]*>/i.test(v);
// Markup as one line of prose, for a body the compact view is pointing at
// rather than rendering. The distiller is what reads markup properly; this
// only has to make a line an agent can tell one body from another by.
const stripTags = (v) => clean(decodeEntities(String(v).replace(/<[^>]*>/g, ' ')));
/**
* One level of flattening, so `owner: {display_name}` becomes an
@@ -662,32 +789,40 @@ function chooseFields(rows, skip) {
}
/**
* Pick the array the response is actually about: the root when it is one,
* otherwise the longest array of objects at the top level, which is where
* `items`, `data`, `results`, and `hits` all live. Everything beside it is
* metadata about the request rather than content.
* Pick what the response is actually about: the root when it is an array or a
* single resource, otherwise the array of objects at the top level that holds
* the results. Everything beside it is metadata about the request rather than
* content.
* @param {any} data
* @returns {{items: any[], meta: Record<string, any>}}
*/
function mainArray(data) {
if (Array.isArray(data)) return { items: data, meta: {} };
let key = '';
/** @type {any[] | null} */
let items = null;
/** @type {Map<string, any[]>} */
const arrays = new Map();
for (const [k, v] of Object.entries(data)) {
if (!Array.isArray(v) || !v.length) continue;
if (!v.some(isPlain)) continue;
if (!items || v.length > items.length) {
items = v;
key = k;
arrays.set(k, v);
}
let key = CONTAINER_KEYS.find((k) => arrays.has(k)) ?? '';
// A root carrying its own name is the resource, and an array hanging off it
// describes that resource rather than being the subject in its place. Taking
// the longest array regardless titled the npm registry's package endpoint
// after its two maintainers and demoted the package to the metadata line,
// where a 9KB readme then cost more than the rest of the page put together.
const named = TITLE_KEYS.some((k) => typeof data[k] === 'string' && data[k].trim() !== '');
if (!key && !named) {
for (const [k, v] of arrays) {
if (!key || v.length > (arrays.get(key)?.length ?? 0)) key = k;
}
}
// A response with no array is a single resource, which renders as one item
// rather than as a special case.
if (!items) return { items: [data], meta: {} };
// A response with no results array of its own is a single resource, which
// renders as one item rather than as a special case.
if (!key) return { items: [data], meta: {} };
const meta = { ...data };
delete meta[key];
return { items, meta };
return { items: arrays.get(key) ?? [data], meta };
}
/**
@@ -784,9 +919,16 @@ export function jsonToHTML(text, url = '', { full = false } = {}) {
if (kept && !kept.has(key) && !looksHTML(value)) continue;
// A field carrying HTML is a page in itself: `filter=withbody` on the
// Stack Exchange API puts a whole question in one. It goes through the
// distiller like any other markup instead of into a cell.
// distiller like any other markup instead of into a cell, unless it is
// longer than the compact view can afford, in which case it becomes one
// numbered line rather than a dozen blocks that bury the item it hangs
// off. `oc read <n>` opens it at a budget that fits it, `oc raw` always.
if (looksHTML(value)) {
bodies.push(String(value));
if (full || String(value).length <= BODY_CAP) {
bodies.push(String(value));
continue;
}
long.push(`<p>${escHTML(`${key}: ${stripTags(value)}`)}</p>`);
continue;
}
const rendered = row.get(key);
@@ -824,12 +966,21 @@ export function jsonToHTML(text, url = '', { full = false } = {}) {
}
const metaBits = [];
const metaLong = [];
for (const [key, value] of Object.entries(meta)) {
if (value === null || typeof value === 'object') continue;
const rendered = renderValue(key, value);
if (rendered) metaBits.push(`${key}=${rendered}`);
if (!rendered) continue;
// A summary line has to stay a line. One long scalar at the root, a
// package readme or an endpoint description, would otherwise spend the
// whole page budget here, so it becomes a block of its own instead. The
// block is numbered, so `oc read <n>` opens it when it fits that budget
// and `oc raw` has it whatever its size.
if (rendered.length > TEXT_CAP) metaLong.push(`<p>${escHTML(`${key}: ${rendered}`)}</p>`);
else metaBits.push(`${key}=${rendered}`);
}
if (metaBits.length) parts.push(`<p>response: ${escHTML(metaBits.join(', '))}</p>`);
parts.push(...metaLong);
const count = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
return `<html><head><title>${escHTML(jsonTitle(url, count))}</title></head><body>\n${parts.join('\n')}\n</body></html>`;
+51 -34
View File
@@ -24,6 +24,25 @@ const loadImpers = () => {
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
const MAX_REDIRECTS = 20;
// What oc can turn into text: any text/* type, plus the application/* types
// that are really text (json, xml, and the +json / +xml families a feed or an
// API answers with). A PNG matches none of these, and rendering one produces
// pages of mojibake an agent then pays for, so it is refused by name instead.
const READABLE_TYPE = /^\s*(?:text\/|application\/(?:json|xml|javascript|x-ndjson|[\w.+-]*\+(?:json|xml)))/i;
/**
* Refuse a response oc cannot read as text. Both transports call this: the
* gate has to live on whichever client got the page, or the same URL renders
* as an error through fetch and as binary noise through impers.
* @param {string | null | undefined} type - the content-type header
*/
export function assertReadableType(type) {
// No header at all is not a refusal: plenty of small servers omit it, and
// the distiller handles whatever comes back.
if (!type || READABLE_TYPE.test(type)) return;
throw new Error(`not a page oc can read (${type.split(';')[0].trim()}), it renders HTML, XML feeds, JSON, and plain text`);
}
// IPv4 ranges with no business receiving a server-initiated fetch: loopback,
// link-local, the three RFC 1918 private blocks, carrier-grade NAT, the
// unspecified/broadcast addresses, and the documentation/benchmark ranges.
@@ -141,11 +160,25 @@ export async function fetchPage(url) {
return impers ? viaImpers(impers, target) : viaFetch(target);
}
async function followImpersRedirects(impers, startUrl, impersonate) {
let current = startUrl;
/**
* Follow redirects one hop at a time, validating each destination before the
* next request goes out.
*
* Both transports share this loop. They used to carry one each, which made the
* check that matters something a change could fix in one place and leave broken
* in the other, and made the guarantee testable only through a third party
* willing to 302 wherever it was told. Taking the request as a callback is what
* lets the hop check be proven against a transport that never leaves the
* process.
* @param {(url: string) => Promise<any>} get - one request, redirects not followed
* @param {string} start
* @returns {Promise<{res: any, url: string}>} the first non-redirect response
*/
export async function followRedirects(get, start) {
let current = start;
for (let i = 0; ; i++) {
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${startUrl}`);
const res = await impers.get(current, { impersonate, allowRedirects: false });
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${start}`);
const res = await get(current);
const status = res.status ?? res.statusCode ?? 0;
const location = res.headers.get('location');
if (status >= 300 && status < 400 && location) {
@@ -153,56 +186,40 @@ async function followImpersRedirects(impers, startUrl, impersonate) {
await assertSafeTarget(current);
continue;
}
return res;
return { res, url: current };
}
}
async function viaImpers(impers, target) {
// Some sites (Reddit) 403 the chrome fingerprint but accept firefox, so a
// blocked first attempt gets one cheap retry with a second identity.
const asking = (impersonate) => (url) => impers.get(url, { impersonate, allowRedirects: false });
let via = 'impers:chrome';
let res = await followImpersRedirects(impers, target, 'chrome');
let { res } = await followRedirects(asking('chrome'), target);
let status = res.status ?? res.statusCode ?? 0;
if (status >= 400) {
via = 'impers:firefox';
res = await followImpersRedirects(impers, target, 'firefox');
({ res } = await followRedirects(asking('firefox'), target));
status = res.status ?? res.statusCode ?? 0;
}
if (status >= 400) throw new Error(`fetch failed: ${status} for ${target}`);
assertReadableType(res.headers.get('content-type'));
const html = typeof res.text === 'function' ? await res.text() : String(res.text ?? res.body ?? '');
return { url: res.url ?? target, html, status, via };
}
async function viaFetch(target) {
let current = target;
let res;
for (let i = 0; ; i++) {
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${target}`);
res = await fetch(current, {
redirect: 'manual',
headers: {
'user-agent': UA,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
},
});
const location = res.headers.get('location');
if (res.status >= 300 && res.status < 400 && location) {
current = new URL(location, current).toString();
await assertSafeTarget(current);
continue;
}
break;
}
const { res, url: current } = await followRedirects((url) => fetch(url, {
redirect: 'manual',
headers: {
'user-agent': UA,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
},
}), target);
if (!res.ok) {
throw new Error(`fetch failed: ${res.status} ${res.statusText} for ${current}`);
}
// JSON is a page here too: an API answer distills into one article per item.
// The impers path never checked the type at all, so this is also what keeps
// the two transports rendering the same URL the same way.
const type = res.headers.get('content-type') ?? '';
if (type && !/html|xml|json/.test(type)) {
throw new Error(`not a page oc can read (${type.split(';')[0]}), it renders HTML, XML feeds, and JSON`);
}
assertReadableType(res.headers.get('content-type'));
return { url: res.url || current, html: await res.text(), status: res.status, via: 'fetch' };
}
+26 -3
View File
@@ -24,7 +24,7 @@ const num = (v) => v.toLocaleString('en-US');
// saving is only collected when the agent would have paged at all, while the
// overspend is paid on every page that runs a little long, including the ones
// answered by their first few lines. Four caps that overspend near 1,500 tokens.
const FINISH = 4;
export const FINISH = 4;
/**
* Budget-aware compact view of a distilled page. `from` is a position in the
@@ -162,5 +162,28 @@ export function formatBlock(b, { full = false } = {}) {
}
}
const truncate = (s) =>
s.length > TEXT_CAP ? `${s.slice(0, TEXT_CAP)} ... +${num(s.length - TEXT_CAP)} chars` : s;
// A cut inside a sentence makes the half that is shown untrustworthy. Asked
// for the first sentence of a page, an agent was given it in full, followed by
// a truncation marker, and spent a turn on `read` to find out whether the
// sentence carried on. Ending on the last sentence that finished inside the cap
// answers that in the view itself. The floor bounds what the courtesy costs: a
// block whose only sentence end is early keeps the plain cut instead of
// throwing away a third of the window.
const SENTENCE_END = /[.!?]["')\]]*(?=\s)/g;
const SENTENCE_FLOOR = 0.7;
const truncate = (s) => {
if (s.length <= TEXT_CAP) return s;
// A line is to code what a sentence is to prose, and a code block is the only
// text that keeps its newlines, so the same courtesy applies: cut where a
// line ended. A period in code ends nothing, which is why this returns
// instead of falling through to the sentence rule below.
const line = s.slice(0, TEXT_CAP).lastIndexOf('\n');
if (line >= TEXT_CAP * SENTENCE_FLOOR) return `${s.slice(0, line)} ... +${num(s.length - line)} chars`;
let cut = TEXT_CAP;
for (const m of s.slice(0, TEXT_CAP).matchAll(SENTENCE_END)) {
const end = (m.index ?? 0) + m[0].length;
if (end >= TEXT_CAP * SENTENCE_FLOOR) cut = end;
}
return `${s.slice(0, cut).trimEnd()} ... +${num(s.length - cut)} chars`;
};
+65 -3
View File
@@ -14,6 +14,7 @@ const { sessionFromPage, saveSession, loadSession, resolveHref } = await import(
const { render } = await import('../src/render.js');
const html = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8');
const searchHTML = readFileSync(new URL('./pages/search.html', import.meta.url), 'utf8');
const page = () => distill(html, 'https://example.test/news');
const open = (name = 'default', budget = 500) => {
const p = page();
@@ -77,10 +78,36 @@ test('find reports where a string is, with a number to read it by', () => {
});
test('find opens the snippet on the match, not on the start of a long block', () => {
// Several long blocks holding the same term is what puts find on its
// snippet path: too much to print whole, too many to be the one answer.
const filler = 'x'.repeat(300);
saveSession('long', {
url: 'https://example.test/long',
blocks: [1, 2, 3].map((n) => ({ n, type: 'text', text: `${filler} needle ${filler}` })),
cursor: null,
});
const out = find('needle', { session: 'long', budget: 40 });
assert.match(out, /\[1\] \.\.\. .*needle/, 'the window must open on the match');
assert.ok(!out.includes('x'.repeat(250)), `snippet was not trimmed:\n${out.slice(0, 200)}`);
});
test('find answers with the whole match when the matches fit', () => {
open();
// The point of the whole path: the text an agent would have spent a `read
// <n>` on arrives in the command that found it.
const many = find('fixture');
assert.ok(!many.includes('...'), `nothing should be elided:\n${many}`);
assert.ok(many.includes('which this sentence now safely does'), 'the block must arrive whole');
});
test('a single match is read, not pointed at', () => {
open();
// One hit means the agent has already said where it wants to look, so the
// number alone would cost a turn to resolve into the region behind it.
const out = find('lazy dog');
assert.match(out, /\[9\] \.\.\. .*lazy dog/);
assert.ok(out.length < 400, `snippet was not trimmed:\n${out}`);
assert.match(out, /1 match for "lazy dog", region \[9\]/);
assert.ok(out.includes('which this sentence now safely does'), 'the region must arrive with it');
assert.ok(out.includes('## [8] About'), 'and with the heading that gives it context');
});
test('a phrase that matches nothing falls back to the words, and says so', () => {
@@ -93,7 +120,7 @@ test('a phrase that matches nothing falls back to the words, and says so', () =>
test('find caps its own output and says how many it held back', () => {
open();
const out = find('comments', { budget: 12 });
const out = find('comments', { budget: 3 });
assert.match(out, /\.\.\. \d+ more matches/);
});
@@ -156,6 +183,22 @@ test('do on a heading or a text block reads it instead of refusing', () => {
assert.ok(read(activate(9).read).includes('safely does'), 'the read must be the full text');
});
test('do on a search result title opens it instead of reading it back', () => {
// What this costs when it goes wrong: `do` on the most obvious number on a
// results page, the title, used to print the title back, so the agent spent
// one turn learning nothing and another finding the number that navigates.
const p = distill(searchHTML, 'https://fixture.test/html/?q=s3+cp+recursive');
saveSession('search', sessionFromPage(p, null, { cursor: render(p, { budget: 500 }).stats.next }));
const target = activate(1, { session: 'search' });
assert.equal(target.read, undefined, 'a title that is a link must not be read back');
// The engine wraps its results in a click tracker whose landing page is a
// script, so the handle has to resolve to the destination itself.
assert.equal(target.url, 'https://docs.example.test/s3/cp.html');
const pilcrow = p.blocks.find((b) => b.type === 'heading' && b.text.startsWith('Options'));
assert.equal(activate(pilcrow.n, { session: 'search' }).read, pilcrow.n, 'a permalink heading still reads');
});
test('named sessions keep separate page state', () => {
open('work');
saveSession('other', { url: 'https://example.test/other', blocks: [], cursor: null });
@@ -169,3 +212,22 @@ test('history grows with each page and stays bounded', () => {
assert.equal(state.history.length, 20);
assert.equal(state.history.at(-1), 'https://example.test/p24');
});
test('a snippet stays one line even when the block it came from is code', () => {
// Code blocks keep their newlines. An index that prints one match per line
// cannot, or the header's count stops matching what is on screen. Long
// filler beside it is what keeps find on the snippet path.
const filler = 'x'.repeat(600);
saveSession('code', {
url: 'https://fixture.test/c',
blocks: [
{ n: 1, type: 'text', text: ['first();', 'needle();', 'third();'].join('\n') },
{ n: 2, type: 'text', text: `${filler} needle ${filler}` },
],
cursor: null,
});
const out = find('needle', { session: 'code', budget: 20 });
const lines = out.split('\n');
assert.match(lines[0], /^2 matches for "needle"/);
assert.equal(lines[1], '[1] first(); needle(); third();');
});
+137
View File
@@ -17,6 +17,11 @@ const results = () => distill(api, API_URL);
// Turndown escapes the underscores in field names, which is correct markdown
// and only noise to assert against.
const rawApi = () => toMarkdown(api, API_URL).replace(/\\_/g, '_');
const searchHTML = readFileSync(new URL('./pages/search.html', import.meta.url), 'utf8');
const search = () => distill(searchHTML, 'https://fixture.test/html/?q=s3+cp+recursive');
const docsHTML = readFileSync(new URL('./pages/docs.html', import.meta.url), 'utf8');
const docs = () => distill(docsHTML, 'https://docs.fixture.test/s3/cp.html');
const codeBlock = (match) => docs().blocks.find((b) => (b.text ?? '').includes(match))?.text ?? '';
test('noise never reaches the output, compact or raw', () => {
for (const out of [render(page(), { budget: 5000 }).text, toMarkdown(html), toHTML(html)]) {
@@ -322,6 +327,51 @@ test('json shapes other than a wrapped array still render', () => {
assert.ok(single.blocks.some((b) => b.text?.includes('score: 3')), 'a single resource lost its fields');
});
test('a resource with its own name is the subject, not the array hanging off it', () => {
// The npm registry shape: a named package carrying a short array of
// maintainers. Picking the longest array made the maintainers the subject
// and pushed the package into the metadata line.
const pkg = JSON.stringify({
name: 'turnstile', license: 'MIT', description: 'does a thing',
maintainers: [{ name: 'ada', email: 'ada@example.test' }, { name: 'grace', email: 'grace@example.test' }],
});
const p = distill(pkg, 'https://registry.example.test/turnstile');
assert.ok(p.title.includes('(1 item)'), `the package was not the subject:\n${p.title}`);
assert.ok(p.blocks.some((b) => b.text === 'turnstile'), 'the resource lost its name');
assert.ok(!p.blocks.some((b) => b.text?.startsWith('response:')), 'the resource was demoted to metadata');
// A conventional container key still wins over the root's own name, so a
// named collection is still read as the collection it is.
const coll = distill(JSON.stringify({ name: 'a collection', items: [{ title: 'one' }, { title: 'two' }] }), 'https://x.test/c.json');
assert.ok(coll.title.includes('(2 items)'), `a named collection lost its items:\n${coll.title}`);
});
test('one long field at the root cannot spend the whole page budget', () => {
const long = 'sentence about the package. '.repeat(400);
const body = JSON.stringify({ readme: long, total: 2, items: [{ title: 'one' }, { title: 'two' }] });
const p = distill(body, 'https://x.test/list.json');
const meta = p.blocks.find((b) => b.text?.startsWith('response:'));
assert.ok(meta, 'the request metadata went missing');
assert.ok(meta.text.includes('total=2'), 'a short metadata field was lost with the long one');
assert.ok(!meta.text.includes(long.slice(0, 200)), 'a long field stayed on the summary line');
assert.ok(meta.text.length < TEXT_CAP * 2, `the summary line is not a line:\n${meta.text.slice(0, 300)}`);
// Off the line, not out of the page: it is its own block, and raw has it.
assert.ok(p.blocks.some((b) => b.text?.startsWith('readme:')), 'the long field vanished instead of moving');
assert.ok(toMarkdown(body, 'https://x.test/list.json').includes('sentence about the package'), 'raw lost the long field');
});
test('a body too long for the compact view becomes a line, not a dozen blocks', () => {
const short = '<p>A <em>short</em> body with <a href="https://example.test/x">a link</a>.</p>';
const huge = `<p>${'A paragraph that goes on. '.repeat(400)}</p><pre><code>code()</code></pre>`;
const withShort = distill(JSON.stringify({ items: [{ title: 'q', body: short }] }), 'https://x.test/a.json');
assert.ok(withShort.blocks.some((b) => b.type === 'link' && b.text === 'a link'), 'a body that fits lost its links');
const withHuge = distill(JSON.stringify({ items: [{ title: 'q', body: huge }] }), 'https://x.test/b.json');
const line = withHuge.blocks.find((b) => b.text?.startsWith('body:'));
assert.ok(line, 'an oversized body left nothing behind');
assert.ok(!line.text.includes('<p>'), 'markup reached the line unstripped');
// raw is still the escape hatch, and still reads the markup as markup.
assert.ok(toMarkdown(JSON.stringify({ items: [{ title: 'q', body: huge }] }), 'https://x.test/b.json').includes('code()'), 'raw lost the oversized body');
});
test('only json is read as json', () => {
assert.equal(jsonToHTML(html), null, 'an html page was parsed as json');
assert.equal(jsonToHTML(feed), null, 'a feed was parsed as json');
@@ -339,3 +389,90 @@ test('long runs of short links collapse into a range marker', () => {
assert.ok(text.includes('actual content'), 'content after the run was lost');
assert.ok(!text.includes('sub9'), 'collapsed link still rendered');
});
test('a result title that is a link stays a link', () => {
const headings = search().blocks.filter((b) => b.type === 'heading');
const [first, second] = headings;
assert.equal(first.text, 'cp - Fixture CLI Command Reference');
assert.ok(first.href.includes('docs.example.test'), 'the title anchor of a search result must survive');
assert.equal(second.href, 'https://docs.example.test/s3/index.html');
});
test('a heading that merely contains a link is not one', () => {
const headings = search().blocks.filter((b) => b.type === 'heading');
const partial = headings.find((b) => b.text.startsWith('Related searches'));
assert.equal(partial.href, undefined, 'the link is part of the heading, not the whole of it');
// Documentation hangs a permalink off every heading. Following one refetches
// the page the agent is already reading, so it must stay a read.
const pilcrow = headings.find((b) => b.text.startsWith('Options'));
assert.equal(pilcrow.href, undefined);
const selfAnchor = headings.find((b) => b.text === 'See also');
assert.equal(selfAnchor.href, undefined, 'a bare fragment is not a destination');
});
test('a truncated block ends on a sentence, so what is shown can be trusted', () => {
const first = 'Welcome to The Rust Programming Language, an introductory book about Rust.';
const second = ' The Rust programming language helps you write faster, more reliable software.';
const rest = ' High-level ergonomics and low-level control are often at odds with each other, and Rust challenges that conflict.';
const line = render(
{ url: '', title: '', blocks: [{ n: 1, type: 'text', text: first + second + rest }] },
{ budget: 60 },
).text;
assert.ok(line.includes(second.trim()), 'a sentence that finished inside the cap must be shown whole');
assert.ok(!line.includes('High-level'), 'the sentence that did not finish must not be half shown');
assert.match(line, /\.\.\. \+\d+ chars/, 'and the reader must still be told there is more');
// Nothing to end on means the plain cut stands rather than most of the
// window being thrown away for the sake of a boundary.
const unbroken = render(
{ url: '', title: '', blocks: [{ n: 1, type: 'text', text: `A. ${'word '.repeat(60)}` }] },
{ budget: 60 },
).text;
assert.ok(unbroken.length > TEXT_CAP, `an early sentence end must not shrink the view:\n${unbroken}`);
});
test('a highlighted command comes out runnable', () => {
// Every token of this command is its own element on the page. Space-joining
// them gave `aws s3 cp s3 : // bucket / -- recursive`, which is not a command
// an agent can run, and the agent has no way to see that from the output.
assert.equal(codeBlock('aws s3 cp test.txt'), 'aws s3 cp test.txt s3://amzn-demo/ --recursive');
});
test('a code sample keeps its lines, so a comment cannot eat the rest', () => {
assert.equal(
codeBlock('readFileSync'),
"const fs = require('node:fs');\n// read it back\nfs.readFileSync('out.txt');",
);
// A shell continuation is only a continuation while the break is still there.
assert.equal(codeBlock('--expires'), 'aws s3 cp test.txt s3://amzn-demo/ \\\n --expires 2014-10-01T20:30:00Z');
});
test('a code block loses the indentation it all shares and keeps the rest', () => {
assert.equal(codeBlock('def load'), 'def load(path):\n with open(path) as fh:\n return json.load(fh)');
});
test('a toolbar inside a code block is not part of the sample', () => {
const block = codeBlock("import fs from 'node:fs'");
assert.equal(block, "import fs from 'node:fs';");
const out = render(docs(), { budget: 5000 }).text;
assert.ok(!out.includes('javascript'), 'the language label leaked into the page');
// The controls go with it: a copy button is not something oc can press, and
// `do` on it would be a turn spent on nothing.
assert.equal(docs().blocks.filter((b) => b.type === 'button' || b.type === 'input').length, 0);
});
test('inline code joins the sentence it sits in', () => {
assert.ok(docs().blocks.some((b) => b.text === 'Pass the --recursive flag to copy a directory.'));
assert.ok(docs().blocks.some((b) => b.text === 'A period (.) means the working directory.'));
});
test('a truncated code block ends on a line, not mid-statement', () => {
const code = ['first();', 'second();', ...Array.from({ length: 20 }, (_, i) => `line${i}('${'x'.repeat(20)}');`)].join('\n');
const page = { url: 'https://fixture.test/c', title: 'c', blocks: [{ type: 'text', n: 1, text: code }] };
const shown = render(page, { budget: 10 }).text.split('\n');
const marker = shown.findIndex((l) => l.includes('... +'));
assert.ok(marker > 0, 'nothing was truncated');
// The kept part stops where a statement did, so every line shown is whole.
assert.ok(shown[marker].startsWith('line'), `cut mid-line: ${shown[marker]}`);
assert.ok(shown[marker].includes(');'), `cut mid-statement: ${shown[marker]}`);
});
+71 -8
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
const { fetchPage } = await import('../src/fetch.js');
const { fetchPage, followRedirects } = await import('../src/fetch.js');
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
@@ -64,11 +64,74 @@ test('fetchPage blocks a hostname that merely resolves to a loopback address (DN
await assert.rejects(() => fetchPage('localtest.me'), new RegExp(BLOCKED_MESSAGE));
});
test('fetchPage re-validates every redirect hop, not just the original URL', async () => {
// httpbin.org is a public host with no reason to be blocked itself; its
// /redirect-to endpoint 302s wherever it's told, which is exactly the
// shape of an SSRF that hides the real target behind a public-looking
// first hop.
const redirector = `https://httpbin.org/redirect-to?url=${encodeURIComponent('http://127.0.0.1/admin')}`;
await assert.rejects(() => fetchPage(redirector), new RegExp(BLOCKED_MESSAGE));
// A response, as little of one as the redirect loop reads.
const replies = (...hops) => {
const asked = [];
const get = (url) => {
asked.push(url);
const hop = hops[asked.length - 1] ?? { status: 200 };
return Promise.resolve({ status: hop.status, headers: new Map(hop.location ? [['location', hop.location]] : []) });
};
return { get, asked };
};
test('every redirect hop is re-validated, not just the original URL', async () => {
// An SSRF hides the real target behind a public-looking first hop, so the
// check has to run again on what the 302 names. This used to be proven
// against httpbin.org, which meant a third party's uptime could fail the
// release, and it never covered the impers transport's own copy of the loop.
const { get, asked } = replies({ status: 302, location: 'http://127.0.0.1/admin' });
await assert.rejects(() => followRedirects(get, 'https://public.example/start'), new RegExp(BLOCKED_MESSAGE));
// Blocked before the socket, not after: the private address is never asked for.
assert.deepEqual(asked, ['https://public.example/start']);
});
test('a hop to somewhere public is followed', async () => {
// The other half of the guarantee. A loop that rejected everything would
// pass the test above and break every redirect on the web.
const { get, asked } = replies(
{ status: 301, location: 'https://elsewhere.example/moved' },
{ status: 302, location: '/relative' },
);
const { res, url } = await followRedirects(get, 'https://public.example/start');
assert.equal(res.status, 200);
assert.equal(url, 'https://elsewhere.example/relative');
assert.equal(asked.length, 3);
});
test('a redirect loop gives up instead of spinning', async () => {
const get = () => Promise.resolve({ status: 302, headers: new Map([['location', 'https://public.example/again']]) });
await assert.rejects(() => followRedirects(get, 'https://public.example/start'), /too many redirects/);
});
test('the readable-type gate accepts text and refuses binary, on either transport', async () => {
const { assertReadableType } = await import('../src/fetch.js');
// Everything oc has something to say about.
for (const type of [
'text/html; charset=utf-8',
'text/plain',
'text/markdown',
'application/json',
'application/json; charset=utf-8',
'application/xml',
'application/atom+xml',
'application/rss+xml',
'application/ld+json',
' text/html ',
]) {
assert.doesNotThrow(() => assertReadableType(type), `expected ${type} to be readable`);
}
// A missing header is not a refusal: small servers omit it and the page
// behind it is usually fine.
assert.doesNotThrow(() => assertReadableType(undefined));
assert.doesNotThrow(() => assertReadableType(''));
// Binary renders as pages of mojibake the agent pays for, so it is named
// and refused rather than distilled.
for (const type of ['image/png', 'image/jpeg', 'application/pdf', 'application/octet-stream', 'video/mp4', 'application/zip']) {
assert.throws(() => assertReadableType(type), /not a page oc can read/, `expected ${type} to be refused`);
}
assert.throws(() => assertReadableType('image/png'), /image\/png/);
});
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html><head><title>cp - Fixture CLI Command Reference</title></head>
<body>
<main>
<h1>cp</h1>
<!-- What a syntax highlighter does to a command: one element per token, so
the walk sees `s3`, `:`, `//`, `bucket` as separate fragments with
different parents. Every real highlighter emits this shape. -->
<p>Copy a file to the bucket:</p>
<pre class="highlight"><code><span class="nb">aws</span> <span class="n">s3</span> <span class="n">cp</span> <span class="n">test</span><span class="p">.</span><span class="n">txt</span> <span class="n">s3</span><span class="p">:</span><span class="p">//</span><span class="n">amzn</span><span class="p">-</span><span class="n">demo</span><span class="p">/</span> <span class="p">--</span><span class="n">recursive</span></code></pre>
<!-- A sample the page wrote across lines, with a shell continuation. Joining
these would hide the break; joining the next one would comment out the
call that follows the comment. -->
<pre><code>aws s3 cp test.txt s3://amzn-demo/ \
--expires 2014-10-01T20:30:00Z</code></pre>
<pre><code>const fs = require('node:fs');
// read it back
fs.readFileSync('out.txt');</code></pre>
<!-- Node's docs put a toolbar inside the block itself: a language label
sitting beside a copy button, plus a flavour toggle. None of it is part
of the sample. -->
<pre class="shiki"><input class="js-flavor-toggle" type="checkbox"><div class="code-toolbar"><span class="code-language">javascript</span><button class="copy-button">copy</button></div><code><span>import</span> <span>fs</span> <span>from</span> <span>'node:fs'</span><span>;</span></code></pre>
<!-- Indentation the whole block shares says nothing; indentation inside it
is the program. -->
<pre><code> def load(path):
with open(path) as fh:
return json.load(fh)</code></pre>
<!-- Inline code belongs to the sentence it sits in, and it gets split into
tokens the same way. -->
<p>Pass the <code><span class="p">--</span><span class="n">recursive</span></code> flag to copy a directory.</p>
<p>A period (<code>.</code>) means the working directory.</p>
</main>
</body></html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html><head><title>s3 cp recursive at Fixture Search</title></head>
<body>
<div id="links">
<!-- The shape every engine uses: the result title is an anchor filling an
h2, wrapped in the engine's own click tracker. Following it is the
whole point of the page. -->
<div class="result">
<h2 class="result__title"><a class="result__a" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">cp - Fixture CLI Command Reference</a></h2>
<a class="result__url" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">docs.example.test/s3/cp.html</a>
<a class="result__snippet" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">Recursively copying local files to S3 with the --recursive parameter.</a>
</div>
<div class="result">
<h2 class="result__title"><a class="result__a" href="https://docs.example.test/s3/index.html">s3 - Fixture CLI Command Reference</a></h2>
<a class="result__snippet" href="https://docs.example.test/s3/index.html">High level commands for the object store.</a>
</div>
<!-- A heading that only contains a link is not a heading that is one. -->
<div class="result">
<h2 class="result__title">Related searches for <a href="https://docs.example.test/s3/sync.html">s3 sync</a></h2>
</div>
<!-- Documentation markup, and the reason the href cannot ride along
unconditionally: both of these point back into this same page. -->
<h2 id="options">Options<a class="headerlink" href="#options">&para;</a></h2>
<h2><a class="anchor" href="#see-also">See also</a></h2>
</div>
</body></html>