agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
316 lines • 14.8 kB
JavaScript
;
/**
* hosting/ingressRecord — what the door decided, for the requests that never
* became a run.
*
* await standingAgent({
* agent, sessions, host,
* identity: { verify },
* admission: turnsPerHour({ limit: 60 }),
* onIngressDecision: (record) => myAuditSink.write(record),
* });
*
* ── The gap this closes, named exactly ───────────────────────────────────────
* `auditExport()` is a record of RUNS. A 401 out of `identity.verify` and a 429
* out of `admission.decide` both happen before a run exists — no agent has been
* built, no observer is attached, no typed event is emitted — so neither is in
* the bundle. Two independent review rounds wrote that down as the same finding,
* and the honest consequence is the sharp one: **an empty bundle is not evidence
* that nobody was turned away.** It is evidence that nobody ran.
*
* This is the seam where the turned-away go. One record per request the composer
* decided, handed to a sink you own, at the moment the reply reaches its
* terminal.
*
* ── What it is NOT, said before anything else ────────────────────────────────
* **It does not join the audit hash chain.** Nothing here is hashed, chained,
* sequenced against `auditExport()`'s records, or verifiable by
* `verifyAuditBundle`. It is a STREAM you chain into your own sink. Saying
* otherwise would make this fix the exact failure it exists to close — a
* mechanism that looks like evidence and is not. If you need ingress decisions
* inside a tamper-evident chain, write them into the same store your audit
* bundle lands in and chain them there, where the sequence is yours to define.
*
* ── Secrets never travel ─────────────────────────────────────────────────────
* A record carries the CLASS of what happened and the identifiers the caller
* already knew. Never the bearer token, never a header, never a claim set,
* never an error's message — the `sdkFailure` law, applied to the front door.
* `errorName` and `errorCode` are this package's own vocabulary; a message may
* carry an SDK's text, an operator's sentence or a store's detail, so no message
* is copied. The one identity field is `userId`, and it is the id the token
* PROVED — never one a request merely claimed.
*
* ── What the composer can decide, and what it never sees ─────────────────────
* Every ingress exit of `standingAgent` funnels through one reply terminal, so
* all of these land here — including the ones a consumer's own `verify` and
* `decide` never observe:
*
* - a request with no `Authorization: Bearer …` at all (`verify` is never
* called, so nothing you wrote could have logged it);
* - a verifier that could not answer (503) as distinct from a bad credential;
* - an admission refusal, and also an admission ALLOW — the census is what
* makes an absence readable;
* - a turn or a transcript naming somebody else's session (the 404 that is
* deliberately indistinguishable from "no such session");
* - a session op at a door with no verifier, a store with no owner index, a
* concurrent-run refusal, an artifact ref that did not resolve;
* - and a request that was SERVED.
*
* The HOST's own decisions land here too, though no handler ever saw them: a
* request its door guard turned away (a content type any page can send, a
* foreign Origin, a Host it was not configured for — `'cross-site-refused'`)
* and a session id over the bound (`'refused'`), at the request door and the
* conversation door alike. They arrive through the host's `onRefusal`, which
* the composer subscribes to whenever a sink is set, and they carry the same
* classes and nothing more — door `'request'` or `'conversation'`, because a
* forged request is refused before its body says which of the three doors it
* wanted.
*
* What it does not carry, stated rather than implied: a body the TRANSPORT
* refused as MALFORMED — unparseable JSON, a body over `maxBodyBytes`, or an
* `op` this host's wire grammar does not speak (`InvalidWireOpError`, answered
* 400 by `httpHost` inside its own request reader). That is a broken request
* rather than a decision about a caller, and the composer does not claim it.
* Your HTTP access log has it.
*
* Pattern: Observer at a composition boundary. Zero-cost when unset — with no
* sink configured nothing is built, nothing is wrapped, and the reply object the
* handler uses is the host's own, to the byte.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.beginIngress = exports.recordHostRefusal = void 0;
const identityVerification_js_1 = require("./identityVerification.js");
const errors_js_1 = require("./errors.js");
const artifactWire_js_1 = require("./artifactWire.js");
const sessionWire_js_1 = require("./sessionWire.js");
/**
* Classify one refusal into the record's vocabulary.
*
* The order is deliberate: the classes this package authored are matched by
* `instanceof` first, so a door refusal never reads as a generic failure, and
* anything else keeps only its class NAME. No message is read, from any error,
* ever.
*/
function classify(err) {
if (err instanceof errors_js_1.IdentityNotVerifiedError) {
return {
outcome: 'identity-refused',
errorCode: err.code,
errorName: err.name,
identityFailure: err.failure,
claimedUser: err.claimedUser,
};
}
if (err instanceof errors_js_1.VerifierUnavailableError) {
return { outcome: 'verifier-unavailable', errorCode: err.code, errorName: err.name };
}
if (err instanceof errors_js_1.AdmissionRefusedError) {
return { outcome: 'admission-refused', errorCode: err.code, errorName: err.name };
}
if (err instanceof errors_js_1.SessionNotFoundError) {
return { outcome: 'session-refused', errorCode: err.code, errorName: err.name };
}
if (err instanceof errors_js_1.UnsupportedMediaTypeError ||
err instanceof errors_js_1.OriginNotAllowedError ||
err instanceof errors_js_1.HostNotAllowedError) {
return { outcome: 'cross-site-refused', errorCode: err.code, errorName: err.name };
}
const code = err?.code;
const name = err?.name;
// A hosting refusal names itself with an `ERR_…` code. Anything else is a
// failure rather than a decision — and either way only the class travels.
const hasHostingCode = typeof code === 'string' && code.startsWith('ERR_');
return {
outcome: hasHostingCode ? 'refused' : 'failed',
...(typeof code === 'string' && { errorCode: code }),
...(typeof name === 'string' && { errorName: name }),
};
}
/** Which op name a request named, in the spelling a body uses. */
function opOf(request) {
if (request.session !== undefined) {
return request.session.op === 'list'
? sessionWire_js_1.SESSION_LIST_OP
: request.session.op === 'pending'
? sessionWire_js_1.SESSION_PENDING_OP
: sessionWire_js_1.SESSION_TRANSCRIPT_OP;
}
if (request.artifact !== undefined) {
return (0, artifactWire_js_1.artifactOpWireName)(request.artifact.op);
}
return undefined;
}
/** Which door a request knocked at — the same discriminants the handler uses. */
function doorOf(request) {
if (request.session !== undefined)
return 'session-op';
if (request.artifact !== undefined)
return 'artifact';
return 'turn';
}
/**
* Which conversation this request named. A `session-transcript` names it inside
* the op rather than beside it, and a record that missed that would be blind to
* exactly the refusal the ownership rule produces.
*/
function sessionOf(request) {
if (request.session !== undefined &&
(request.session.op === 'transcript' || request.session.op === 'pending')) {
return request.session.sessionId;
}
return request.sessionId;
}
let warnedOnce = false;
/** Report a throwing sink once. A broken record is a fault worth naming; a
* fault reported on every request is a second outage. */
function warnSink(err) {
if (warnedOnce)
return;
warnedOnce = true;
// eslint-disable-next-line no-console
console.warn(`[hosting] the onIngressDecision sink threw and the record was dropped. The request was ` +
`unaffected — a broken log must not turn a served request into a failed one — but this ` +
`door's ingress decisions are not being recorded. Reported once per process. Cause: ` +
`${err?.name ?? 'unknown'}`);
}
/**
* File the record for a request the HOST refused before the composer saw it —
* what `standingAgent` hands to the host's `onRefusal` when a sink is set.
*
* Built from the refusal's class and the fact of a credential, exactly as a
* composer refusal is: no message, no header, no session id (a session id the
* bound refused is the one thing this record must never carry), no user —
* nothing was verified, because nothing got that far.
*
* @internal — the option is public, this bookkeeping is not.
*/
function recordHostRefusal(refusal, sink) {
const detail = classify(refusal.error);
const record = {
at: Date.now(),
door: refusal.door,
outcome: detail.outcome,
...(detail.errorCode !== undefined && { errorCode: detail.errorCode }),
...(detail.errorName !== undefined && { errorName: detail.errorName }),
...(detail.identityFailure !== undefined && { identityFailure: detail.identityFailure }),
bearerPresent: refusal.bearerPresent,
};
try {
sink(record);
}
catch (sinkErr) {
warnSink(sinkErr);
}
}
exports.recordHostRefusal = recordHostRefusal;
/**
* Begin one request's ingress record.
*
* @internal — the option is public, this bookkeeping is not.
*/
function beginIngress(request, sink) {
const door = doorOf(request);
const op = opOf(request);
const sessionId = sessionOf(request);
// Presence, never the value: the whole point of the class vocabulary above is
// that an operator can act without a single character of the credential.
const bearerPresent = (0, identityVerification_js_1.bearerToken)(request.headers) !== undefined;
let userId;
let admission;
let hookFailure;
let filed = false;
function file(outcome, err) {
if (filed)
return;
filed = true;
const detail = err === undefined ? undefined : classify(err);
const record = {
at: Date.now(),
door,
...(op !== undefined && { op }),
outcome: detail?.outcome ?? outcome,
...(detail?.errorCode !== undefined && { errorCode: detail.errorCode }),
...(detail?.errorName !== undefined && { errorName: detail.errorName }),
...(detail?.identityFailure !== undefined && { identityFailure: detail.identityFailure }),
...(detail?.claimedUser !== undefined && { claimedUser: detail.claimedUser }),
...(userId !== undefined && { userId }),
...(sessionId !== undefined && { sessionId }),
bearerPresent,
...(admission !== undefined && { admission }),
...(hookFailure !== undefined && { turnArtifactsFailure: hookFailure }),
};
try {
sink(record);
}
catch (sinkErr) {
warnSink(sinkErr);
}
}
return {
watch(reply) {
// Every optional terminal is forwarded ONLY when the host has it: the
// composer feature-detects `awaiting`/`artifact`/`sessions`, so a wrapper
// that defined them unconditionally would tell it the host can describe a
// pause it cannot, and the named refusal for that case would never fire.
return {
complete(output) {
file('served');
reply.complete(output);
},
fail(error) {
file('failed', error);
reply.fail(error);
},
...(reply.awaiting && {
awaiting: (pending) => {
file('served');
reply.awaiting?.(pending);
},
}),
...(reply.artifact && {
artifact: (result) => {
file('served');
reply.artifact?.(result);
},
}),
...(reply.sessions && {
sessions: (result) => {
file('served');
reply.sessions?.(result);
},
}),
// Not a terminal: a chunk is part of an answer still being written.
...(reply.emit && { emit: (chunk) => reply.emit?.(chunk) }),
// Not a terminal either, and forwarded by presence for the same reason
// the terminals are: the composer builds a binding only for a host
// that asked, so defining it here unconditionally would build one per
// turn that nobody holds. The host's promise is returned as-is — the
// composer awaits it (bounded), and records a failure through
// `turnArtifactsFailed` below.
...(reply.turnArtifacts && {
turnArtifacts: (turn) => reply.turnArtifacts?.(turn),
}),
};
},
identified(verified) {
if (verified !== undefined)
userId = verified.userId;
},
admitted(verdict) {
admission = verdict;
},
turnArtifactsFailed(failure) {
// The first failure is the census entry; a later one of the same hand-over
// (the bound firing after an operation failed, say) is on the stream.
hookFailure ??= failure;
},
settle() {
// Only reachable if a request left the door without ending its reply,
// which the composer never does. Recorded as a failure rather than
// silently dropped: a census with holes is the thing this replaces.
file('failed');
},
};
}
exports.beginIngress = beginIngress;
//# sourceMappingURL=ingressRecord.js.map