UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

482 lines (481 loc) • 23.2 kB
import { userInfo } from "node:os"; import { canonicalizeTarget, isAbsoluteForFamily, joinForFamily, normalizeAbsPathLexically, pathFamilyOf } from "./safety.js"; import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, withGateTimeout } from "../../core/gate-bounded-await.js"; import { thrownText } from "../../core/thrown-value.js"; import { agentConfigDirTail } from "../../core/agent-config-dir.js"; import { validatePath } from "../../core/effective-path-target.js"; import { isRootedReadDenyPattern, readDenyPatternRun, readDenyRunHead } from "./read-deny.js"; export const AUXILIARY_HOME_PROBE_MS = 2_000; const said = new Set(); function sayOnce(kind, line) { if (said.has(kind)) return; said.add(kind); try { console.warn(line); } catch { } } function isNoAccountRow(e) { try { const info = typeof e === "object" && e !== null ? e.info : undefined; return typeof info === "object" && info !== null && info.code === "ENOENT"; } catch { return false; } } function valueText(v) { try { return JSON.stringify(v) ?? String(v); } catch { return typeof v; } } function accountHomeOf() { try { const home = userInfo().homedir; const valid = validatePath(home, { intent: "validate-coordinate" }); if (valid !== undefined) return { state: "home", home: valid }; sayOnce("bad_value", `read-deny homes: the account home lookup answered ${valueText(home)}, not an absolute path — home-anchored read-deny rows are placed under the environment's declared home only.`); return { state: "none" }; } catch (e) { if (isNoAccountRow(e)) return { state: "none" }; sayOnce("unanswered", `read-deny homes: the account home could not be read (${thrownText(e)}) — home-anchored read-deny rows hold their run at every location until the lookup answers.`); return { state: "unanswered" }; } } function declaredHomeOf(env) { return validatePath(env.homeDir, { intent: "validate-coordinate" }); } function declaredReadDenyHomesAnswered(env) { const home = declaredHomeOf(env); if (home === undefined) return { homes: [], whole: true }; const account = accountHomeOf(); if (account.state === "unanswered") return { homes: [], whole: false }; if (account.state === "none" || normalizeAbsPathLexically(account.home) === normalizeAbsPathLexically(home)) return { homes: [home], whole: true }; return { homes: [home, account.home], whole: true }; } export function declaredReadDenyHomes(env) { return declaredReadDenyHomesAnswered(env).homes; } function isDeclared(declared, home) { return declared !== undefined && normalizeAbsPathLexically(home) === normalizeAbsPathLexically(declared); } async function homeSpellingsOf(env, home, signal) { try { const canonical = await env.canonicalPath(home, signal); if (canonical.ok) return { spellings: canonical.value !== home ? [home, canonical.value] : [home], canonical: canonical.value, answered: true }; return { spellings: [home], answered: canonical.error.code === "not_found" || canonical.error.code === "not_directory" }; } catch { return { spellings: [home], answered: false }; } } export async function readDenyHomesOf(env, signal) { const listed = declaredReadDenyHomesAnswered(env); const [declared, ...auxiliary] = listed.homes; if (!listed.whole || declared === undefined) return []; const [own, ...others] = await Promise.all([ homeSpellingsOf(env, declared, signal), ...auxiliary.map((home) => until(homeSpellingsOf(env, home, signal), Date.now() + AUXILIARY_HOME_PROBE_MS, signal).then((r) => r ?? { spellings: [home], answered: false })), ]); if (!own.answered) return []; if (others.some((r) => !r.answered)) sayAuxiliary("its path probe did not answer"); return [...new Set([...own.spellings, ...others.flatMap((r) => r.spellings)])]; } async function until(work, deadline, signal) { return await withGateTimeout(work, Math.max(0, deadline - Date.now()), signal, "auxiliaryHome").catch(() => undefined); } function sayAuxiliary(what, subject = "auxiliary") { if (subject === "config_root") { sayOnce("config_root", `read-deny homes: the declared agent configuration root did not answer its probes within ${AUXILIARY_HOME_PROBE_MS} ms (${what}) — the rows that name the configuration directory sit at its spelling only, and a path through an alias of it is judged as spelled, until the next placement.`); return; } sayOnce("auxiliary", `read-deny homes: a home beside the environment's declared home did not answer its probes within ${AUXILIARY_HOME_PROBE_MS} ms (${what}) — its home-anchored read-deny rows sit at its spelling only, and a path through an alias of it is judged as spelled, until the next placement.`); } export async function readDenyLinksOf(env, entries, anchor, signal, deadline) { return await linkedLocationsOf(env, anchoredRowsOf(entries, anchor), signal, deadline); } const ROOT = "/"; function literalHeadOf(run) { return run.split("/").filter(Boolean).slice(0, readDenyRunHead(run)).join("/"); } function anchoredRowsOf(entries, anchor) { const places = anchor.at === "home" ? anchor.homes : anchor.at === "root" ? [ROOT] : anchor.roots; const rows = []; for (const { pattern, caseSensitive } of entries) { const head = rowHeadOf(pattern, caseSensitive === true, anchor); if (head === undefined) continue; const spelled = places.map((place) => normalizeAbsPathLexically(head.length === 0 ? place : joinForFamily(pathFamilyOf({ root: place }), place, head))); rows.push({ pattern, caseSensitive: caseSensitive === true, spelled, anchored: anchor.at === "unanchored" ? [] : spelled }); } return rows; } function rowHeadOf(pattern, caseSensitive, anchor) { if (anchor.at === "root") { const head = isRootedReadDenyPattern(pattern) ? literalHeadOf(pattern) : ""; return head.length > 0 ? head : undefined; } const { homeAnchored, run } = readDenyPatternRun(pattern); if (!homeAnchored) return undefined; if (anchor.at === "unanchored") { const tail = agentConfigDirTail(run, caseSensitive); return tail === undefined ? undefined : literalHeadOf(tail); } const head = literalHeadOf(run); return head.length > 0 ? head : undefined; } function configRootSpelledLinks(entries, roots) { const out = new Map(); for (const { pattern, spelled } of anchoredRowsOf(entries, { at: "unanchored", roots })) out.set(pattern, [...new Set(spelled)]); return out; } async function linkedLocationsOf(env, rows, signal, deadline) { const familyOf = new Map(); for (const { spelled } of rows) for (const at of spelled) familyOf.set(at, pathFamilyOf({ root: at })); const leadOf = async (at, family) => (await whereItLeads(env, at, family, signal)) ?? ((await holdsNothingBelow(env, at, signal)) ? at : undefined); const resolved = new Map(await Promise.all([...familyOf].map(async ([at, family]) => [at, deadline === undefined ? await leadOf(at, family) : await until(leadOf(at, family), deadline, signal)]))); const out = new Map(); const whole = [...resolved.values()].every((location) => location !== undefined); for (const { pattern, caseSensitive, spelled, anchored } of rows) { const fold = (p) => (caseSensitive ? p : p.replace(/[A-Z]/g, (c) => c.toLowerCase())); const own = new Set(anchored.map(fold)); const locations = []; for (const at of spelled) { for (const location of [at, resolved.get(at)]) { if (location !== undefined && !own.has(fold(location)) && !locations.includes(location)) locations.push(location); } } if (locations.length > 0) out.set(pattern, locations); } return { linked: out, whole }; } async function holdsNothingBelow(env, at, signal) { const probe = await withGateTimeout(Promise.resolve().then(() => env.canonicalPath(at, signal)), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "readDenyHead").catch(() => undefined); return probe !== undefined && !probe.ok && probe.error.code === "not_directory"; } export async function whereItLeads(env, at, family = pathFamilyOf({ root: at }), signal) { try { const key = await canonicalizeTarget(env, at, signal, family); return key.ok ? normalizeAbsPathLexically(key.key) : undefined; } catch { return undefined; } } export async function whereItsAnsweredPrefixLeads(env, at, family = pathFamilyOf({ root: at }), signal) { const walk = async () => { const rest = []; for (let here = normalizeAbsPathLexically(at);;) { const led = await whereItLeads(env, here, family, signal); if (led !== undefined) return rest.reduce((p, name) => joinForFamily(family, p, name), led); const parent = normalizeAbsPathLexically(`${here}/..`); if (parent === here) return undefined; rest.unshift(here.slice(parent.length).replace(/^[\\/]+/, "")); here = parent; } }; return await withGateTimeout(walk(), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "ruleRowHead").catch(() => undefined); } const NO_CHAIN = { ancestors: [], whole: false, unread: [] }; const unreadChain = (at) => ({ ancestors: [], whole: false, unread: [at] }); const identityUnread = (id) => id === undefined || (!id.ok && id.error.code !== "not_found" && id.error.code !== "not_directory" && id.error.code !== "not_supported"); async function chainAbove(env, identity, canonical, signal, deadline) { let own; const ancestors = []; const unread = []; let whole = true; for (let dir = normalizeAbsPathLexically(canonical), first = true;; first = false) { const id = deadline === undefined ? await identityOf(env, identity, dir, signal) : await until(identityOf(env, identity, dir, signal), deadline, signal); if (identityUnread(id)) unread.push(dir); if (id === undefined && deadline !== undefined && Date.now() >= deadline) { whole = false; break; } if (id?.ok !== true) whole = false; else if (first) own = { dir, id: id.value }; else ancestors.push({ dir, id: id.value }); const parent = normalizeAbsPathLexically(`${dir}/..`); if (parent === dir) break; dir = parent; } return { ...(own !== undefined ? { own } : {}), ancestors, whole, unread }; } async function identityOf(env, identity, path, signal) { try { return await identity.call(env, path, signal); } catch { return undefined; } } async function readAuxiliaryHome(env, home, opts, signal) { const deadline = Date.now() + AUXILIARY_HOME_PROBE_MS; const spelled = await until(homeSpellingsOf(env, home, signal), deadline, signal); let reading; if (spelled?.canonical === undefined) { const answered = spelled?.answered === true; const linked = opts.configRoot === true && opts.entries !== undefined ? configRootSpelledLinks(opts.entries, [home]) : new Map(); reading = { place: [home], chain: answered ? { ancestors: [], whole: true, unread: [] } : unreadChain(home), linked, answered }; } else { const place = spelled.spellings; const identity = env.fileIdentity; const [chain, heads] = await Promise.all([ typeof identity === "function" ? chainAbove(env, identity, spelled.canonical, signal, deadline) : Promise.resolve({ ancestors: [], whole: true, unread: [] }), opts.entries === undefined ? Promise.resolve({ linked: new Map(), whole: true }) : readDenyLinksOf(env, opts.entries, opts.configRoot === true ? { at: "unanchored", roots: place } : { at: "home", homes: place }, signal, deadline), ]); reading = { place, chain, linked: heads.linked, answered: chain.whole && heads.whole }; } if (!reading.answered) sayAuxiliary("a probe did not answer", opts.configRoot === true ? "config_root" : "auxiliary"); return reading; } export function homeViewUnreadSentence(subject, unread, face = "read") { return `"${subject}" could not be judged against the ${face} deny list: "${unread}" cannot be identified against the home directories the home-anchored rows are placed under, so whether such a row covers it is unknown; refused — a deny judgment that did not finish is not a pass. Retry once the environment answers identity lookups.`; } export class HomeViewUnreadError extends Error { constructor(subject, unread) { super(homeViewUnreadSentence(subject, unread)); this.name = "HomeViewUnreadError"; } } function homeViewSourceOver(env, auxiliary) { const declared = declaredHomeOf(env); let declaredChain; const readDeclared = (at) => { const identity = at.fileIdentity; if (declared === undefined || typeof identity !== "function") return Promise.resolve(NO_CHAIN); if (declaredChain === undefined) { const reading = withGateTimeout((async () => { const spelled = await homeSpellingsOf(at, declared); return spelled.canonical === undefined ? (spelled.answered ? NO_CHAIN : unreadChain(declared)) : await chainAbove(at, identity, spelled.canonical); })(), DEFAULT_GATE_AWAIT_TIMEOUT_MS, undefined, "homeChain").catch(() => unreadChain(declared)); declaredChain = reading; void reading.then((c) => { if (!c.whole && declaredChain === reading) declaredChain = undefined; }); } return declaredChain; }; return { views(signal, judgmentEnv) { const at = judgmentEnv ?? env; const identity = at.fileIdentity; let index; const indexOf = () => (index ??= (async () => { const declaredChain = await readDeclared(at); const chains = [declaredChain, ...(await Promise.all(auxiliary)).map((a) => a.chain)]; const unread = declaredChain.unread[0]; const out = new Map(); for (const { own } of chains) if (own !== undefined && !out.has(own.id)) out.set(own.id, own.dir); for (const { ancestors } of chains) for (const { dir, id } of ancestors) if (!out.has(id)) out.set(id, dir); return { ...(out.size > 0 ? { ids: out } : {}), ...(unread !== undefined ? { unread } : {}) }; })()); const views = new Map(); const viewOf = (path, leafIsFile) => { const key = `${leafIsFile ? "f" : "d"}:${path}`; let v = views.get(key); if (v === undefined) { v = (async () => { const index = await indexOf(); const chain = index.unread !== undefined ? { unread: index.unread } : {}; if (index.ids === undefined || typeof identity !== "function") return chain; if (!leafIsFile) { const id = await identityOf(at, identity, path, signal); if (identityUnread(id)) return { unread: path }; if (id?.ok === true) { const dir = index.ids.get(id.value); if (dir !== undefined) return { ...chain, view: dir }; } else if (id?.error.code === "not_supported") return chain; } const parent = normalizeAbsPathLexically(`${path}/..`); if (parent === path) return chain; const up = await viewOf(parent, false); return up.view === undefined ? up : { ...up, view: joinForFamily(pathFamilyOf({ root: path }), up.view, path.slice(parent.length).replace(/^[\\/]+/, "")) }; })(); views.set(key, v); } return v; }; return { async of(path, opts) { if (typeof identity !== "function" || (declared === undefined && auxiliary.length === 0) || !isAbsoluteForFamily("own", path)) return {}; const spelled = normalizeAbsPathLexically(path); const { view, unread } = await viewOf(spelled, opts?.leaf === "file"); return { ...(view !== undefined && view !== spelled ? { view } : {}), ...(unread !== undefined ? { unread } : {}) }; }, }; }, }; } const PLACEMENT_VIEWS = new WeakMap(); function placementOf(matcher, homes, linked, whole) { const list = Object.freeze([...homes]); PLACEMENT_VIEWS.set(list, { views: matcher.homeViews }); return { matcher, homes: list, linked, whole }; } export function placementViewsOf(homes) { return PLACEMENT_VIEWS.get(homes)?.views; } export function carriedReadFaceOf(homes) { return PLACEMENT_VIEWS.get(homes)?.face; } export function carryReadFace(homes, face) { const list = Object.freeze([...homes]); PLACEMENT_VIEWS.set(list, { views: face.readDeny.homeViews, face }); return list; } export function readDenyOver(current, homeViews) { return { get entries() { return current().entries; }, get ruleRows() { return current().ruleRows; }, matchPath: (path, opts) => current().matchPath(path, opts), matchTarget: (canonicalKey, lexicalView, opts) => current().matchTarget(canonicalKey, lexicalView, opts), get rgExclusionGlobs() { return current().rgExclusionGlobs; }, get rgProbeGlobs() { return current().rgProbeGlobs; }, unexcludableUnder: (scope) => current().unexcludableUnder?.(scope) ?? false, rowUnderRoot: (scope) => current().rowUnderRoot?.(scope) ?? null, rowAtOrBelow: (path) => current().rowAtOrBelow?.(path) ?? null, homeViews: homeViews ?? { views: (signal) => current().homeViews.views(signal) }, }; } export function sayPlacementLost() { try { console.warn("read-deny homes: this tool band was handed a home list that is not the placement's own (an engine defect) — its home-anchored read-deny rows hold their run at every location, and only the declared home's views are read."); } catch { } } function readAuxiliaryHomes(env, homes, opts, signal) { const declared = declaredHomeOf(env); return [...new Set(homes.filter((h) => isAbsoluteForFamily("own", h) && !isDeclared(declared, h)))].map((home) => readAuxiliaryHome(env, home, opts, signal)); } export function homeViewsAt(env, homes) { return homeViewSourceOver(env, readAuxiliaryHomes(env, homes, {})); } export function homeViewsOf(env, judge, signal) { return (judge?.homeViews ?? homeViewsAt(env, [])).views(signal, env); } export function carryHomeViews(judge, source) { return { ...judge, homeViews: source }; } export async function placeReadDenyAtHome(env, compile, homes, signal, configRoot) { const entries = compile([]).entries; const declared = declaredHomeOf(env); const relocated = configRoot === undefined ? undefined : readAuxiliaryHome(env, configRoot, { entries, configRoot: true }, signal); const rooted = readDenyLinksOf(env, entries, { at: "root" }, signal); const heldAnywhere = async (whole) => { const linked = new Map(); for (const map of [(await relocated)?.linked, (await rooted).linked]) for (const [pattern, locations] of map ?? []) linked.set(pattern, locations); return placementOf(carryHomeViews(linked.size === 0 ? compile([]) : compile([], linked), homeViewSourceOver(env, relocated === undefined ? [] : [relocated])), [], linked, whole); }; const listed = homes === undefined ? declaredReadDenyHomesAnswered(env) : { homes, whole: true }; if (!listed.whole) return await heldAnywhere(false); const own = listed.homes.filter((h) => isDeclared(declared, h)); let declaredAt = []; if (own.length > 0) { const ownSpellings = await withGateTimeout(homeSpellingsOf(env, own[0], signal), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "readDenyHomes"); if (!ownSpellings.answered) return await heldAnywhere(false); declaredAt = [...new Set([...own, ...ownSpellings.spellings])]; } const auxiliary = readAuxiliaryHomes(env, listed.homes, { entries }, signal); const [heads, readings, root, rootedHeads] = await Promise.all([readDenyLinksOf(env, entries, { at: "home", homes: declaredAt }, signal), Promise.all(auxiliary), relocated, rooted]); const at = [...new Set([...declaredAt, ...readings.flatMap((r) => r.place)])]; const linked = new Map(); for (const map of [heads.linked, ...readings.map((r) => r.linked), ...(root === undefined ? [] : [root.linked]), rootedHeads.linked]) { for (const [pattern, locations] of map) linked.set(pattern, [...new Set([...(linked.get(pattern) ?? []), ...locations])]); } const matcher = linked.size === 0 ? compile(at) : compile(at, linked); return placementOf(carryHomeViews(matcher, homeViewSourceOver(env, relocated === undefined ? auxiliary : [...auxiliary, relocated])), at, linked, heads.whole && rootedHeads.whole); } export function readDenyPlacedLater(unplaced, place) { let current = unplaced; let whole = false; let pending; const again = () => (pending ??= place() .then((placed) => { current = placed.matcher; whole = placed.whole; }, () => undefined) .finally(() => { pending = undefined; })); void again(); const ready = async () => { if (pending !== undefined) await pending; if (!whole) await again(); }; const settled = async () => { if (pending !== undefined) await pending; return current; }; const homeViews = { views(signal, judgmentEnv) { let views; return { of: async (path, opts) => (await (views ??= settled().then((m) => m.homeViews.views(signal, judgmentEnv)))).of(path, opts), }; }, }; return { matcher: readDenyOver(() => current, homeViews), ready }; }