Key-presence refusal at every gate: a pinned model is not enough

Owner-diagnosed: the turn gate checked only model coordinates, so a
pinned slot whose provider key was deleted passed the gate and died
mid-stream masked to 'An error occurred.' The key is now gated where
the model is — by what would RUN (env-first keyValue truth, so an
env-shadowed key passes).

- core/settings.ts: the provider_key_missing envelope family (code,
  provider, slots, human line naming the provider from AUTH_PROVIDER_KEYS
  labels) + ProviderKeyMissingError + isProviderKeyRefusal matcher.
- chat-handler turn gate: refuses 503 before beginTurn when the resolved
  conversation's provider key is gone (slots: [strong]).
- Job runner: same check via an injected dep (agent layer cannot read
  the key store) — a keyless engine refuses the spawn through the
  existing failure-shape convention, no phantom run.
- Extraction pipeline: refuseIfStagesUnready refuses BEFORE any batch
  (first offender: extractor before auditor); the trigger skips info-
  level, /api/graph/import surfaces the 503 envelope per file.
- Tests: 8 new pins (turn gate both ways, runner refusal, pipeline
  first-offender + pass-through, envelope factory, matcher); the three
  turn-driving suites seed OPENROUTER_API_KEY like any real setup.
- Doc fix: stale EXTRACTION.chunkTokens comment in the manual-extract
  route (left over from eed4b4e).

Known residual (pre-existing, flagged for master): the manual extract
route logs BOTH config refusals as console.error (fire-and-forget path);
the coverage ledger keeps everything uncovered either way.
This commit is contained in:
amosblomqvist
2026-09-24 22:51:12 +03:00
parent 085fdc1a98
commit e4049e4bf8
13 changed files with 411 additions and 36 deletions
+43 -11
View File
@@ -4,7 +4,14 @@ import type { Usage } from "@earendil-works/pi-ai";
import { z } from "zod";
import { lhTool, toPiTools } from "../lh-tool.ts";
import type { ModelRole } from "../../../config.ts";
import { FACTORY_SETTINGS, ModelsNotConfiguredError, ROLE_SLOT, type ModelSlotName } from "../../core/settings.ts";
import {
FACTORY_SETTINGS,
ModelsNotConfiguredError,
ProviderKeyMissingError,
ROLE_SLOT,
type ModelSlotName,
type ProviderKeyMissing,
} from "../../core/settings.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
import type {
@@ -566,6 +573,14 @@ export interface ExtractionModels {
* slot → NOTHING) may end EMPTY → undefined; the front doors refuse
* with ModelsNotConfiguredError before any stage runs. */
engineFor?: (role: ModelRole) => PiEngine | undefined;
/** Key-presence gate (provider-key interlude, 2026-09-25): given a
* stage engine's provider + the role's slot tier, the injected check
* returns the refusal envelope when the provider's key is missing
* (null = run). Same injection discipline as the job runner's seam
* (the agent layer may not read the key store); absent in tests that
* inject engines directly (test engines never owe a key). A keyless
* stage refuses with ProviderKeyMissingError BEFORE any batch runs. */
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
}
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
@@ -623,15 +638,30 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const stageEngine = (role: ModelRole, override?: PiEngine): PiEngine | undefined =>
override ?? deps.engineFor?.(role);
/** The per-need refusal guard: which slot tiers the pipeline's stages
* found unset (test values count as configured — they ARE engines). */
function refuseIfStagesUnconfigured(): void {
* found unset (test values count as configured — they ARE engines),
* and whether a configured stage's provider key is gone (provider-key
* interlude, 2026-09-25) — both refuse BEFORE any batch runs. */
function refuseIfStagesUnready(): void {
const stages: readonly [ModelRole, PiEngine | undefined][] = [
["extractor", stageEngine("extractor", deps.extractor)],
["auditor", stageEngine("auditor", deps.auditor)],
];
const missing = new Set<ModelSlotName>();
if (!stageEngine("extractor", deps.extractor)) missing.add(ROLE_SLOT.extractor);
if (!stageEngine("auditor", deps.auditor)) missing.add(ROLE_SLOT.auditor);
for (const [role, engine] of stages) if (!engine) missing.add(ROLE_SLOT[role]);
if (missing.size > 0) {
console.info(`[extraction] refused — no model configured for: ${[...missing].join(", ")}`);
throw new ModelsNotConfiguredError([...missing]);
}
// Key-presence parity: first offender surfaces (extractor before
// auditor); fixing that key re-gates and the next run surfaces the
// next offender, if any.
for (const [role, engine] of stages) {
const refusal = deps.providerKeyMissing?.(engine!.model.provider, ROLE_SLOT[role]);
if (refusal) {
console.info(`[extraction] refused — ${JSON.stringify(refusal)}`);
throw new ProviderKeyMissingError(refusal.provider, refusal.slots);
}
}
}
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
// instance (route entries in dev, tests) sees the same runs, the same
@@ -758,10 +788,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
try {
const record = chatStore.getChat(chatId);
if (!record) throw new Error(`Chat not found: ${chatId}`);
// BYOK per-need gate (owner, 2026-09-22): the write channel needs
// extractor + auditor engines; an empty chain refuses BEFORE any
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): the write channel needs extractor + auditor
// engines AND their providers' keys; a refusal lands BEFORE any
// stage runs (typed — the trigger skips, routes surface 503).
refuseIfStagesUnconfigured();
refuseIfStagesUnready();
const covered = coverageOf(record.messages as UIMessage[], graphStore.readCoverage(), chatId);
if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
@@ -921,9 +952,10 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
inFlight.add(id);
const t0 = performance.now();
try {
// BYOK per-need gate (owner, 2026-09-22): same refusal as the chat
// front door — no extractor/auditor engines, no run.
refuseIfStagesUnconfigured();
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): same refusal as the chat front door — no
// extractor/auditor engines, or a stage key gone, no run.
refuseIfStagesUnready();
console.info(`[extraction ${id}] notes pipeline start (${input.filePath})`);
setPhase(id, "extracting", "notes", input.filePath);
const findings = await notesExtract(input);
+33 -13
View File
@@ -21,7 +21,7 @@ import {
} from "../../core/contracts.ts";
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import type { ModelRole } from "../../../config.ts";
import { ROLE_SLOT, modelsNotConfigured } from "../../core/settings.ts";
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts";
import { runPdfReaderAgent } from "./pdf-reader.ts";
@@ -84,6 +84,12 @@ export interface JobRunnerDeps {
* drop (jobTools' searchAvailable) uses the same signal — jobs already
* in flight when a key disappears still fail honestly, never crash. */
searchProvider?: () => SearchProvider | null;
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
* resolved engine's provider + the kind's slot tier, return the
* refusal envelope when the provider's key is missing (null = run).
* Injected because the agent layer may not read the key store; absent
* in tests that inject engines directly (test engines never owe a key). */
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
}
export interface JobRunner {
@@ -189,18 +195,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return;
}
const engine = pickEngine(job.kind);
if (!engine) {
// BYOK per-need refusal (owner, 2026-09-22): this kind's tier has
// no model. Refuse through the SAME convention as every other
// failed job — record the honest error (no status flip:
// finalizeJob owns the terminal flip after delivery), then steer
// the kind's failure shape through finishJob so the wake
// pipeline resumes the lesson and the teacher reads the refusal
// instead of waiting forever on a job it already dispatched.
const tier = ROLE_SLOT[KIND_ROLE[job.kind]];
const envelope = modelsNotConfigured([tier]);
const reason = `${envelope.error} (missing: ${tier})`;
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
// The per-need refusal guards (BYOK 2026-09-22 / provider-key
// interlude 2026-09-25): no engine for the kind's tier, or the
// resolved engine's provider key gone — both refuse through the SAME
// convention as every other failed job: record the honest error (no
// status flip: finalizeJob owns the terminal flip after delivery),
// then steer the kind's failure shape through finishJob so the wake
// pipeline resumes the lesson and the teacher reads the refusal
// instead of waiting forever on a job it already dispatched.
const refuse = async (reason: string): Promise<void> => {
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
const resolved = resolveInput(job, input) as
| ResearchToolInput
@@ -229,6 +232,23 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
pagesConsulted: [],
};
await finishJob(jobId, failure);
};
if (!engine) {
const tier = ROLE_SLOT[KIND_ROLE[job.kind]];
const envelope = modelsNotConfigured([tier]);
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
await refuse(`${envelope.error} (missing: ${tier})`);
return;
}
// Key presence (2026-09-25): the engine exists, but its provider's
// key may be gone — refuse the spawn BEFORE the loop, same
// convention (the injected check reads the key store; absent in
// engine-injecting tests).
const keyRefusal =
deps.providerKeyMissing?.(engine.model.provider, ROLE_SLOT[KIND_ROLE[job.kind]]) ?? null;
if (keyRefusal) {
console.info(`[job ${jobId}] refused — ${JSON.stringify(keyRefusal)}`);
await refuse(keyRefusal.error);
return;
}
try {
+51
View File
@@ -28,6 +28,7 @@
*/
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts";
@@ -133,6 +134,56 @@ export class ModelsNotConfiguredError extends Error {
}
}
/** The key-presence refusal (provider-key interlude, owner, 2026-09-25):
* a PINNED model is not enough — its provider's key must be present, or
* the action dies mid-stream masked to a generic error. Same envelope
* family as modelsNotConfigured: HTTP callers get 503 + this JSON;
* in-band consumers (the job record's error field) carry the human
* `error` line. `provider` names whose key is gone; `slots` names the
* slot tier(s) the refused action resolves to that provider. */
export const PROVIDER_KEY_MISSING_CODE = "provider_key_missing" as const;
export interface ProviderKeyMissing {
/** Human-readable — safe to render as-is. */
error: string;
/** The machine discriminator the client switches on. */
code: typeof PROVIDER_KEY_MISSING_CODE;
/** The provider whose key is gone, e.g. "openrouter". */
provider: string;
/** Which slot tiers the refused action resolves to that provider. */
slots: ModelSlotName[];
}
/** The refusal wording's invariant tail — the matcher below keys on it
* (the label prefix varies by provider). */
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
return {
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
code: PROVIDER_KEY_MISSING_CODE,
provider,
slots,
};
}
/** The refusal error text (same discipline as isModelsRefusal): UI
* consumers detect key-refused work by the wording (info tone, not
* scary-error) — the envelope's home module owns the string so the
* writer and the matcher can never drift. */
export function isProviderKeyRefusal(error: string | undefined): boolean {
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
}
/** The typed throw for in-process seams (extraction pipeline) — the
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
* it to skip honestly (trigger) or surface the 503 envelope (routes). */
export class ProviderKeyMissingError extends Error {
readonly envelope: ProviderKeyMissing;
constructor(provider: string, slots: ModelSlotName[]) {
const envelope = providerKeyMissing(provider, slots);
super(envelope.error);
this.name = "ProviderKeyMissingError";
this.envelope = envelope;
}
}
/** One slot's stored state. BYOK (owner, 2026-09-22): the WRITE side
* requires model (a level alone can never run — no factory floor); the
* sanitizer keeps whatever is on disk, but RESOLUTION treats a slot
+28 -1
View File
@@ -10,13 +10,14 @@ import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import { providerKeyPresent } from "./keys.ts";
import {
extractionEnabled,
graphToolsEnabled,
resolveConversationSpec,
resolveRoleSpec,
} from "../core/settings-resolution.ts";
import { modelsNotConfigured } from "../core/settings.ts";
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.ts";
@@ -50,6 +51,12 @@ const jobRunner = createJobRunner({
// undefined and the runner refuses the spawn honestly.
engineFor: (role) =>
resolveEngine(role),
// Key-presence gate (provider-key interlude, 2026-09-25): the runner
// reads the resolved engine's provider and asks THIS check (the agent
// layer may not read the key store); a missing key refuses the spawn
// with the 503-envelope family instead of dying mid-run.
providerKeyMissing: (provider, tier) =>
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
// Search port wave (2026-09-20): the researcher's web_search runs on the
// injected port — resolved FRESH per job (key rotations land immediately).
// null = honestly unavailable; the dispatch-level drop (below) uses the
@@ -67,6 +74,11 @@ export const extraction = createExtractionPipeline({
// with the typed ModelsNotConfiguredError. Direct test values still win.
engineFor: (role) =>
resolveEngine(role),
// Key-presence gate (provider-key interlude, 2026-09-25): same check
// the job runner gets — a stage engine whose provider key is gone
// refuses BEFORE any batch runs (the typed throw), never mid-run.
providerKeyMissing: (provider, tier) =>
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
@@ -151,6 +163,21 @@ export async function handleChatRequest(
return Response.json(envelope, { status: 503 });
}
// THE KEY-PRESENCE GATE (provider-key interlude, owner, 2026-09-25): a
// pinned model is not enough — its PROVIDER's key must be present, or
// the turn dies mid-stream masked to a generic error. Truth is env-first
// (providerKeyPresent reads what pi-ai would read at request time), so
// an env-shadowed key passes. Same placement discipline as the models
// gate: BEFORE beginTurn (no liveness effects) and the write-ahead
// persist (nothing durable changes); wake-pipeline turns ride the same
// path. The affected tier is strong — the conversation chain's own tier
// (a chat.model override still resolves through it).
if (!providerKeyPresent(conversation.provider)) {
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
return Response.json(envelope, { status: 503 });
}
// One turn per chat, ever. A second request while a turn runs means a
// stale/remounted client raced a live one — its message list is outdated,
// and letting it through would double-run the teacher and clobber the
+11
View File
@@ -193,6 +193,17 @@ export function keyValue(id: string): string | undefined {
return process.env[id] ?? authStore.authVars()[id];
}
/** The key-presence truth for one model provider (provider-key interlude,
* 2026-09-25): would this provider's key be present at request time?
* Reads through keyValue — the SAME env-first lookup pi-ai performs
* natively at every request — so an env-shadowed key counts as present
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
}
/**
* Save one key (PUT). Shape gate first (owner decision: bad pastes are
* 400s, never stored), then the file write, then the env reflection. A
+9 -4
View File
@@ -20,7 +20,7 @@ import { withPlanBlocks } from "../core/plan-blocks.ts";
import { coverageOf } from "../core/coverage.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError } from "../core/settings.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/**
* The canonical state write: the client owns the message list between turns
@@ -101,13 +101,18 @@ export function triggerExtraction(
const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
if (covered.uncoveredTokens < chunkTokens) return;
void extraction.runExtraction(chatId).catch((err) => {
// BYOK (owner, 2026-09-22): an unconfigured write channel SKIPS
// (the coverage ledger keeps the messages uncovered — the next
// trigger retries once slots are set); only real failures log.
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
// 2026-09-25): an unconfigured OR keyless write channel SKIPS (the
// coverage ledger keeps the messages uncovered — the next trigger
// retries once slots/keys are set); only real failures log.
if (err instanceof ModelsNotConfiguredError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (missing: ${err.envelope.missing.join(", ")})`);
return;
}
if (err instanceof ProviderKeyMissingError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (provider: ${err.envelope.provider})`);
return;
}
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
});
} catch (err) {