Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
75 lines
4.2 KiB
JavaScript
75 lines
4.2 KiB
JavaScript
// The broker-side web service (REQ-HARN-2): what answers a role's web.search and web.fetch.
|
|
// The fetch is packages/discord/src/web.mjs's, with all of row 23's fences: https only, public
|
|
// addresses only (checked once, then the connection goes to the vetted address), at most 3
|
|
// redirects, a capped body, a time limit, text types only. The search goes to a "search-source"
|
|
// plug-in (packages/plugins): any object with `search(query)` that resolves
|
|
// { query, total, results: [{ title, url, snippet }] } or throws a WebRefusal.
|
|
//
|
|
// A fence refusal is a normal result with one fixed reason, as in the Discord connector: it is
|
|
// `{ refused: reason }` for the session and outcome "refused" in the broker's evidence. A bug or an
|
|
// unexpected error throws, and the broker answers web-failed. The broker (not this file) records
|
|
// every call and labels every reply untrusted (broker.mjs, requestWeb).
|
|
|
|
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
|
|
import { currentProcessing } from '../../processing-control/src/effects.mjs';
|
|
|
|
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
|
|
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
|
|
export const WEB_DEADLINE_MS = 25_000;
|
|
|
|
// webService({ search, maxFetchBytes, deadlineMs, deps })
|
|
// search a search-source plug-in instance, or null when none is configured
|
|
// maxFetchBytes the cap on one fetched body (web.mjs's default when left out)
|
|
// deps web.mjs's network seams (lookup, httpsRequest), for tests
|
|
// Returns { timeout, handle(verb, args) -> { result, summary } }.
|
|
export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetchBytes, deadlineMs = WEB_DEADLINE_MS, deps } = {}) {
|
|
const config = Object.freeze({ maxFetchBytes, timeoutMs: Math.min(WEB_DEFAULTS.timeoutMs, deadlineMs) });
|
|
const refused = (reason, extra = {}) => ({ result: { refused: reason }, summary: { outcome: "refused", reason, ...extra } });
|
|
// `start(signal)` begins the call with an AbortSignal that fires at the deadline (vikunja:19/307),
|
|
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
|
|
// search-source module gets the signal as the `signal` of its last argument.
|
|
const within = (start) => {
|
|
const processing = currentProcessing(); processing?.guard();
|
|
const controller = new AbortController();
|
|
let timer;
|
|
const late = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
controller.abort();
|
|
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
|
|
}, deadlineMs);
|
|
});
|
|
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
|
|
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
|
|
};
|
|
async function run(verb, args) {
|
|
if (verb === "web.fetch") {
|
|
const page = await within((signal) => webFetch({ ...config, signal }, { url: args.url }, deps));
|
|
return {
|
|
result: page,
|
|
summary: { outcome: "ok", status: page.status, bytes: page.bytes, redirects: page.redirects, finalUrl: page.finalUrl, truncated: page.truncated || page.textTruncated },
|
|
};
|
|
}
|
|
if (verb === "web.search") {
|
|
if (!search) return refused(WEB_SEARCH_NOT_CONFIGURED);
|
|
// The one query limit, web.mjs's, applied before any backend is touched: the same refusal
|
|
// and the same reason as a module's own check.
|
|
const q = args.query;
|
|
if (q.trim().length === 0 || q.length > SEARCH_MAX_QUERY_CHARS || q.includes("\0")) return refused(WEB_REFUSAL.BAD_QUERY);
|
|
const found = await within((signal) => search.search(q, { signal }));
|
|
return { result: found, summary: { outcome: "ok", total: found.total, returned: found.results.length } };
|
|
}
|
|
throw new Error(`unknown web verb ${verb}`);
|
|
}
|
|
return {
|
|
timeout: deadlineMs + 5000,
|
|
async handle(verb, args) {
|
|
try {
|
|
return await run(verb, args);
|
|
} catch (error) {
|
|
if (error instanceof WebRefusal) return refused(error.reason, error.status ? { status: error.status } : {});
|
|
throw error;
|
|
}
|
|
},
|
|
};
|
|
}
|