/** * Authenticated pending-challenge state for the arming handshake (conductor * #415, transaction reworked by #614, storage hardened by review of #896, * targets recorded when the console took over verification in phase 1 of the * orchestrator-workflow redesign). * * `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and * files the challenge here. Verification classifies the operator's message * against these records and arms the projects the challenge named — either * mechanically in the orchestrator session, which owns the project topic the * challenge is sent to (#1061), or through the CLI step * `omp-conductor arm --reply ""` from a console host * whose DM the operator answered in. * * Nothing waits for the reply any more. The console session owns the operator * DM, and no tick extension runs there — so the in-session acknowledgement * wait `armTicks` used to hold could never be satisfied. What survives is the * durable half: the record that says which code is live, until when, and which * projects it arms. * * This module stays a leaf so both roles can reach the state without an import * cycle: `fleet.ts` imports `daemon.ts`, `daemon.ts` imports * `orchestrator-tick.ts`, so `orchestrator-tick.ts` can never import * `fleet.ts`. Everything here reads only `config.ts`. * * The handshake keeps no shared mutable state at all — every file is named by * its own key, and every deletion is id-addressed, so two processes can * neither lose each other's updates nor delete each other's proofs: * * - `arm-challenges/.json` — one pending challenge per project: * `{ project, id, hash, sentAt, expiresAt, targets?, owner? }`. Host-owned: * only the arm ceremony writes it (record before the send, settle on * consumption or send failure), always through the atomic tmp+rename write * the admission ack uses. A re-armed project overwrites its own file — * pruning only its own prior id's acknowledgement; a concurrent arm for a * different project touches a different file. * - `arm-challenge-acks/.json` — one file per acknowledgement: * `{ challengeId, acknowledgedAt }`. The reply step's acknowledgement is a * single-file create/overwrite via rename — it never reads or rewrites * another transaction's record, and `doctor` reads it to report a handshake * that was answered but never settled. Files that outlive their transaction * (a crash between the acknowledgement and the settle) are inert by * construction and removed only by age, never by membership in any * directory snapshot (see {@link gcAgedAcks}). * * `targets` is why the reply step never re-derives what to arm: the challenge * the operator answered named specific projects, and the config could have * changed between the send and the reply. Arming what the record says is the * only reading that matches what the operator was asked. It is optional purely * for backward tolerance — a record written before this field existed still * verifies (see {@link resolveArmReply}). * * Only the sha-256 of the code is ever persisted — never the code, whose * plaintext appearance in the chat is the whole proof and must not leak into a * durable report, an issue comment, or a diagnostic line the way a new * artifact could. The id is a random UUID cut with the challenge, so * acknowledgement records are challenge-specific without carrying anything * guessable. No `FLEET-` prefix is ever trusted: a reply is classified by * hashing its whitespace-separated tokens against the pending record, so no * lookalike can arm a fleet. */ import { createHash, randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { stateDir } from "./config.ts"; const ARM_CHALLENGES_DIR = "arm-challenges"; const ARM_ACKS_DIR = "arm-challenge-acks"; /** * One project a challenge arms: recorded with the challenge so the reply step * arms exactly what the operator was asked about, never a re-derivation. */ export interface ArmTarget { /** * The configured project name, absent only for a legacy unstamped * single-project fleet — the same value `resolveArmState` needs to decide * whether the pre-per-project shared marker still speaks for this project. */ project?: string; /** The arm marker path this project's heartbeat reads. */ armedFile: string; } interface PendingChallenge { /** The project key this pending belongs to, mirrored for read-back checks. */ project: string; /** Random transaction id, cut when this challenge was recorded. */ id: string; /** sha-256 hex of the challenge code — never the code itself. */ hash: string; /** Unix ms the challenge was recorded, just before the send. */ sentAt: number; /** Unix ms after which a matching reply is no longer an active proof. */ expiresAt: number; /** * The projects this challenge arms. Absent on a record written before the * field existed; callers fall back rather than crash. */ targets?: ArmTarget[]; /** * The chat the challenge was actually sent to, so the marker the reply step * writes attributes the arm to the operator who was asked — not to whatever * the paired channel happens to say minutes later. Absent on a pre-targets * record. */ owner?: string; } interface ArmAcknowledgement { /** The exact challenge id this record satisfies. */ challengeId: string; /** Unix ms the reply step classified the operator's message as this proof. */ acknowledgedAt: number; } function challengesDir(): string { return join(stateDir(), ARM_CHALLENGES_DIR); } function acksDir(): string { return join(stateDir(), ARM_ACKS_DIR); } function ackPath(challengeId: string): string { return join(acksDir(), `${challengeId}.json`); } function projectKey(project?: string): string { return project ?? ""; } /** * Filesystem-safe, injective encoding of a project key. Raw names that need * no escaping are used verbatim; everything else (including the empty key of * an unstamped config) becomes `=` plus hex bytes — `=` never appears in the * raw class, so the two namespaces cannot collide. */ function encodeProjectKey(key: string): string { if (/^[A-Za-z0-9._-]{1,64}$/.test(key) && key !== "." && key !== "..") return key; return `=${[...Buffer.from(key, "utf8")].map((b) => b.toString(16).padStart(2, "0")).join("")}`; } function pendingPath(key: string): string { return join(challengesDir(), `${encodeProjectKey(key)}.json`); } /** * Atomic durable write, exactly like the admission ack: content lands at a * unique temp path and is renamed into place, so every reader — including the * one-shot inbound adapter racing the host — sees either the old file or the * new one, never a mid-truncate parse. */ function writeFileAtomic(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`; try { // 0600 like the other conductor state an operator never shares; the // records are hashes and random ids, but there is no reason to be less // careful. writeFileSync(tmp, content, { mode: 0o600 }); renameSync(tmp, path); } catch (err) { rmSync(tmp, { force: true }); throw err; } } /** * A recorded target list, validated element by element, or undefined when the * record carries none. `invalid` is deliberately distinct from `absent`: a * torn target list must fail the whole record closed (below) rather than * silently degrade into the legacy fallback, which would arm a project the * challenge may never have named. */ function readTargets(raw: unknown): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; targets: ArmTarget[] } { if (raw === undefined) return { kind: "absent" }; if (!Array.isArray(raw) || raw.length === 0) return { kind: "invalid" }; const targets: ArmTarget[] = []; for (const entry of raw) { if (entry === null || typeof entry !== "object") return { kind: "invalid" }; const target = entry as { project?: unknown; armedFile?: unknown }; if (typeof target.armedFile !== "string" || target.armedFile.length === 0) return { kind: "invalid" }; if (target.project !== undefined && typeof target.project !== "string") return { kind: "invalid" }; targets.push( target.project === undefined ? { armedFile: target.armedFile } : { project: target.project, armedFile: target.armedFile }, ); } return { kind: "ok", targets }; } /** * This project's pending challenge parsed from its own file, with every field * validated. Anything absent, torn, garbage, pre-#614, or naming another * project reads as none — fail closed until the host's next transaction * rewrites the file wholesale. */ function readPendingFor(key: string): PendingChallenge | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(pendingPath(key), "utf8")); } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const pending = parsed as Partial; if ( pending.project !== key || typeof pending.id !== "string" || typeof pending.hash !== "string" || typeof pending.expiresAt !== "number" || (pending.owner !== undefined && typeof pending.owner !== "string") ) { return undefined; } const targets = readTargets(pending.targets); if (targets.kind === "invalid") return undefined; return { ...(pending as PendingChallenge), ...(targets.kind === "ok" ? { targets: targets.targets } : {}), }; } /** sha-256 hex of the challenge code — the persisted token, never the code. */ function challengeHash(code: string): string { return createHash("sha256").update(code).digest("hex"); } /** * The state key of a whole-fleet ceremony (#991): one challenge that arms every * configured project on a single matching reply. * * A NUL byte, so it can never collide with a project name — `encodeProjectKey` * escapes it to hex, and no configured project can produce the same file. The * empty string was unavailable: that is already the key of a legacy unstamped * config, and a fleet record must not shadow one. */ export const FLEET_ARM_KEY = "\u0000fleet"; /** * Whether a token looks like a challenge code at all. * * This is what makes a reply answerable without knowing any code: the codes are * `FLEET-` plus hex (see `makeChallengeCode`), so a token of that shape is an * arming attempt whether or not it matches, while ordinary chat is left alone. * Only the shape is inspected — the token itself is never hashed against * anything but a pending record, never logged, and never echoed. */ export function looksLikeChallengeCode(token: string): boolean { return /^FLEET-[0-9A-Fa-f]{4,32}$/.test(token); } /** * Acknowledgement files outliving their transaction — a crash between the * classification and the settle — are inert by construction: nothing ever * reads their id again, and ids are UUIDs, so no future transaction can * collide with one. They are therefore not swept by membership (a directory * listing captured before another process records is stale the moment it is * taken, and acting on it deletes on-time proofs), only by age: a file older * than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong to a live handshake under * any configured window, so its mtime alone decides removal. */ const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000; function gcAgedAcks(now: number): void { let names: string[] = []; try { names = readdirSync(acksDir()); } catch { return; /* no acknowledgements yet */ } for (const name of names) { if (!name.endsWith(".json")) continue; const path = join(acksDir(), name); try { if (statSync(path).mtimeMs >= now - ACK_ORPHAN_GC_AFTER_MS) continue; } catch { continue; /* raced away */ } rmSync(path, { force: true }); } } /** * What a challenge arms, recorded with it: the projects the operator was told * about and the chat they were asked in. */ export interface ArmChallengePlan { /** Every project a matching reply arms — one for `arm --project X`, all of them for a fleet ceremony. */ targets: readonly ArmTarget[]; /** The chat the challenge is being sent to. */ owner: string; } /** * Register a new active arming challenge for the project — overwriting the * project's own pending file — and return the transaction id. Before the * overwrite, the project's PRIOR id is read back and exactly that id's * acknowledgement is pruned: project-local cleanup with no directory * snapshot, so another project's handshake cannot lose its proof here no * matter how the calls interleave. * * `plan` is optional only so the pre-targets call shape stays valid for the * surfaces (doctor's fixtures, the CLI's own tests) that record a challenge * without meaning to arm anything through the reply step. The ceremony always * passes one. */ export function recordArmChallenge( project: string | undefined, code: string, sentAt: number, expiresAt: number, plan?: ArmChallengePlan, ): string { const key = projectKey(project); // Read the prior record BEFORE the overwrite shadows it. const prior = readPendingFor(key); const id = randomUUID(); const record: PendingChallenge = { project: key, id, hash: challengeHash(code), sentAt, expiresAt, ...(plan === undefined ? {} : { targets: [...plan.targets], owner: plan.owner }), }; writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`); if (prior !== undefined) rmSync(ackPath(prior.id), { force: true }); gcAgedAcks(sentAt); return id; } /** * What a reply was, so the console can answer it (#991, retained now that the * console — not a tick session — does the answering). * * Before this, a non-matching or expired code was simply ignored: the operator * had no idea they had been heard, and the fastest way to be sure you held the * current code was to scroll the chat. That is exactly the work * conductor-owned verification exists to end. * * - `matched` — an active proof; the acknowledgement is written. * - `expired` — a code that hashes to a pending record whose window has closed. * - `unknown` — a challenge-shaped token that matches nothing readable here. * - `none` — no challenge-shaped token at all: ordinary chat, arm nothing. */ export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none"; /** The transaction a matching reply proved, and what the challenge said it arms. */ export interface ArmReplyMatch { /** * The state key the matched record lives under — a project key (possibly the * empty key of an unstamped config) or {@link FLEET_ARM_KEY}. Passed straight * back to {@link clearArmTransaction}, so the settle is addressed to the * record that was actually consumed. */ key: string; /** The matched transaction's id; every deletion is addressed by it. */ id: string; /** * The projects the challenge recorded. Absent on a record written before * targets existed — the caller falls back rather than crashing. */ targets?: ArmTarget[]; /** The chat the challenge was sent to. Absent on a pre-targets record. */ owner?: string; } /** * Discriminated so a caller cannot reach for `match` on a verdict that has * none: only `matched` carries the transaction, and only `matched` may write a * marker. */ export type ArmReplyResolution = | { verdict: "matched"; match: ArmReplyMatch } | { verdict: Exclude; match?: undefined }; /** * Classify one operator reply against this host's own records and, on a match, * write the challenge-id-specific acknowledgement. * * Two records are consulted and no others: this project's, and the fleet-wide * one (#991). That bound is the non-disclosure property — a reply that matches * nothing here can never reveal that a *different* project has a live * challenge, because no other project's record is ever read. * * Fail-closed ordering is unchanged: the project's own active challenge wins, * then the fleet ceremony, and an expired record is never a proof. The write * touches only the matched challenge's own file, so a wrong token, a wrong * project, an expired challenge or a racing settle all fail closed without * touching anyone else's handshake. * * The acknowledgement record is no longer something a waiter polls — nothing * waits any more. It is kept because it is the only durable evidence that a * reply was seen, which is what `doctor`'s `arm-ack` finding reports when a * ceremony was answered but never settled. */ export function resolveArmReply( project: string | undefined, replyText: string, now: number, ): ArmReplyResolution { // Challenge codes contain no whitespace, so tokenising on whitespace never // splits one; empty replies simply yield no token. const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0); if (tokens.length === 0) return { verdict: "none" }; const candidates: { key: string; pending: PendingChallenge }[] = []; for (const key of [projectKey(project), FLEET_ARM_KEY]) { const pending = readPendingFor(key); if (pending !== undefined) candidates.push({ key, pending }); } let expired = false; for (const token of tokens) { const hash = challengeHash(token); for (const { key, pending } of candidates) { if (pending.hash !== hash) continue; if (now >= pending.expiresAt) { // Keep looking: a fresh record for the same code is a proof, and only // an exhausted search may conclude "expired". expired = true; continue; } // Keyed by the challenge id, so replays overwrite the one record this // transaction owns; a stale id's file can never be created here. const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now }; writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`); return { verdict: "matched", match: { key, id: pending.id, ...(pending.targets === undefined ? {} : { targets: pending.targets }), ...(pending.owner === undefined ? {} : { owner: pending.owner }), }, }; } } if (expired) return { verdict: "expired" }; // Shape only, and only to decide how the operator is answered. A token that // is not challenge-shaped is ordinary chat. return { verdict: tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none" }; } /** The acknowledgement record for one exact challenge id, or undefined. */ export function readArmAcknowledgement(challengeId: string): ArmAcknowledgement | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(ackPath(challengeId), "utf8")); } catch { return undefined; /* absent, torn or garbage — not an acknowledgement */ } if (parsed === null || typeof parsed !== "object") return undefined; const ack = parsed as { challengeId?: unknown; acknowledgedAt?: unknown }; if (ack.challengeId !== challengeId || typeof ack.acknowledgedAt !== "number") return undefined; return { challengeId, acknowledgedAt: ack.acknowledgedAt }; } /** * Settle one arm transaction — consumption, timeout, or send failure: remove * the project's pending file iff it still names this transaction, and drop * its acknowledgement. Deletion is id-addressed only, so another project's * records are untouchable by construction, and a same-project replacement * (different id) survives the stale settle. */ export function clearArmTransaction(project: string | undefined, challengeId: string): void { const key = projectKey(project); const pending = readPendingFor(key); if (pending !== undefined && pending.id === challengeId) rmSync(pendingPath(key), { force: true }); rmSync(ackPath(challengeId), { force: true }); } export interface ArmChallengeSighting { /** The pending transaction's id — random, not the challenge code. */ id: string; /** Unix ms the challenge was recorded, when the record carries it. */ sentAt?: number; /** Unix ms the challenge expires, when the record carries it. */ expiresAt?: number; /** Present once the inbound adapter acknowledged this challenge id. */ acknowledgedAt?: number; } /** * Doctor's view of the project's open transaction, or undefined when none is * recorded. An expired-but-uncleared record is reported, not hidden: it is * inert (a reply past expiry is refused) but lingers until the project's next * arm replaces it. */ export function observeArmChallenge(project: string | undefined): ArmChallengeSighting | undefined { const pending = readPendingFor(projectKey(project)); if (pending === undefined || typeof pending.id !== "string") return undefined; const ack = readArmAcknowledgement(pending.id); return { id: pending.id, ...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}), ...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}), ...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }), }; } /** One expired, unsettled ceremony, with the key the notice clears it under. */ export interface ExpiredArmChallenge { /** * The record's own state key — the project key or {@link FLEET_ARM_KEY} — * passed straight back to {@link clearArmTransaction} after the notice, so * the clear is addressed to the record that actually expired. */ key: string; /** The expired transaction's id. */ id: string; /** The sighting, acknowledgement included when a reply was seen. */ sighting: ArmChallengeSighting; } /** * Every pending challenge that is no longer a proof — the project's own record * and the fleet-wide one, in the same consultation order as * {@link resolveArmReply} — whose window has passed without being settled. * An acknowledgement may already exist (the reply was seen but the settle * never completed), which is still unsettled: the notice must name that * reason, and {@link doctor} does the same. This is what lets the fleet * session tell the operator a ceremony died instead of letting the record sit * invisible until the next arm replaces it. */ export function expiredArmChallenges( project: string | undefined, now: number, ): ExpiredArmChallenge[] { const keys = projectKey(project) === FLEET_ARM_KEY ? [FLEET_ARM_KEY] : [projectKey(project), FLEET_ARM_KEY]; const expired: ExpiredArmChallenge[] = []; for (const key of keys) { const pending = readPendingFor(key); if (pending === undefined || pending.expiresAt > now) continue; const ack = readArmAcknowledgement(pending.id); expired.push({ key, id: pending.id, sighting: { id: pending.id, ...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}), ...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}), ...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }), }, }); } return expired; }