/** * The three gates that stand between "the bridge needs a conversation" and "a * conversation now exists in someone's account". * * Lazy creation used to be one step: a thread with no binding ran `start`, and the * bridge made a conversation for it. That read as helpful, and it quietly encoded a * mistaken rule -- * * "there is no binding, therefore I may create the missing precondition" * * -- which conflates *lacking permission to proceed* with *having permission to * create*. The failure that followed was not subtle: fourteen conversations, and the * account owner paid for cleaning them up. * * So the decision is split into three questions, all of which must answer yes: * * Intent Did a person ask for this conversation or rotation? * Grant Is there a valid, unused, one-shot authorisation for this one creation? * Budget Even so, is the safety fuse intact? * * Intent and Grant cannot be manufactured by the code that wants them. Recovery, * retries, concurrent starts, probes and tests all want a conversation, and none of * them is a person, so none of them can produce either. That is the point: the * failure mode to prevent is not "a bug created one extra conversation", it is * "automatic machinery decided on its own to create external resources". * * Budget survives as a fuse rather than a policy. It cannot authorise anything now; * it only stops a bug, a loop or a race from creating many at once. ChatGPT's framing * is worth keeping verbatim: it is a fuse, not a permission system. * * Nothing here deletes. Counting is the bridge's business; disposal is not. */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import { consentStorePath } from "../runtime/paths.ts"; import { sideEffectSpec } from "./sideEffects.ts"; /* ------------------------------------------------------------------ */ /* Authority boundary */ /* ------------------------------------------------------------------ */ declare const HUMAN_INTENT_BRAND: unique symbol; /** * Proof that a person asked for this, produced only where a person demonstrably acts. * * Issuing an intent is the one write in this codebase that changes what the bridge is *allowed* * to do rather than what it has done. It is a local reversible file write, which is Level 1 by * every measure of side effect, and it is also the most consequential write here -- the external * ruling called it a confused-deputy boundary for exactly that reason. * * So it cannot be reached by a normal capability. The brand makes a proof unforgeable by an * object literal at the type level, and `mintHumanIntent` re-checks at runtime, because the * extension is loaded by jiti and a type that only exists in `npm test` is half a guard. */ export interface HumanIntentProof { /** Which writer produced it. Recorded so the audit trail says who asked. */ readonly source: string; readonly at: number; readonly [HUMAN_INTENT_BRAND]: true; } /** * The writers permitted to mint a proof. * * `control-command` is the production path: a control command the person typed, matched by a * deterministic parser. `test-fixture` exists because tests must be able to construct state, * and it is refused outside test mode -- the ruling's requirement that fixtures may build test * state but must not amount to a production bypass. */ export const AUTHORITY_WRITERS = ["control-command", "test-fixture"] as const; export type AuthorityWriter = (typeof AUTHORITY_WRITERS)[number]; /** * Mint proof that a person asked. The only constructor. * * Consults the registry rather than assuming: if `consent.issue` ever stops being an authority * grant, minting here becomes wrong, and this refuses instead of continuing to hand out proof * for something that no longer needs it. That is what makes `authorityEffect` a consumed field * rather than a fifth decoration. */ export function mintHumanIntent( source: string, options: { env?: NodeJS.ProcessEnv; now?: number } = {}, ): HumanIntentProof { const env = options.env ?? process.env; const spec = sideEffectSpec("consent.issue"); if (!spec) { throw new Error("consent.issue 未登记在副作用登记表里,无法签发意图。"); } if (spec.authorityEffect !== "grant") { throw new Error( `consent.issue 的 authorityEffect 是 ${spec.authorityEffect},不是 grant:` + "它不再是一项权限授予,因此不应再签发人类意图证明。", ); } if (!(AUTHORITY_WRITERS as readonly string[]).includes(source)) { throw new Error( `不允许的授权写入者:${source}。允许的只有 ${AUTHORITY_WRITERS.join(" / ")}。` + "签发权限的路径必须是有限的、可枚举的,否则 confused deputy 又回来了。", ); } if (source === "test-fixture" && env.PI_WEBGPT_TEST_MODE !== "1") { throw new Error( "test-fixture 只能在测试模式下签发人类意图证明;生产路径必须来自 control-command。" + "否则测试夹具就成了一条生产后门。", ); } return { source, at: options.now ?? Date.now() } as HumanIntentProof; } /** Runtime check, for the same reason `assertCapability` exists. */ export function assertHumanIntent(proof: unknown): asserts proof is HumanIntentProof { const candidate = proof as Partial | null | undefined; if (!candidate || typeof candidate !== "object") { throw new Error(`签发意图需要人类意图证明,但拿到的是 ${typeof proof}。`); } if (typeof candidate.source !== "string" || typeof candidate.at !== "number") { throw new Error("人类意图证明的形状不对,可能是伪造的。"); } if (!(AUTHORITY_WRITERS as readonly string[]).includes(candidate.source)) { throw new Error(`人类意图证明的来源不可信:${candidate.source}。`); } } export type CreationGate = "intent" | "grant" | "budget"; export interface CreationIntent { id: string; localSessionId: string; /** What the person asked for. Rotation is a different act from starting fresh. */ kind: "new-workflow" | "rotate"; /** Free text, for the audit trail. Not interpreted. */ reason: string; at: number; } export interface CreationGrant { id: string; localSessionId: string; intentId: string; at: number; expiresAt: number; /** Set when consumed, so a one-shot cannot be spent twice. */ consumedAt?: number; /** What it was spent on, recorded after the fact. */ consumedBy?: string; } export interface ConsentFile { version: 1; intents: CreationIntent[]; grants: CreationGrant[]; } export function defaultConsentPath(): string { return consentStorePath(); } /** A grant that sits unused for this long is stale, not a standing permission. */ export const GRANT_TTL_MS = 30 * 60 * 1000; export class ConsentStore { readonly path: string; constructor(options: { path?: string } = {}) { this.path = options.path ?? defaultConsentPath(); } load(): ConsentFile { try { if (!existsSync(this.path)) return { version: 1, intents: [], grants: [] }; const parsed = JSON.parse(readFileSync(this.path, "utf8")) as Partial; return { version: 1, intents: Array.isArray(parsed.intents) ? parsed.intents : [], grants: Array.isArray(parsed.grants) ? parsed.grants : [], }; } catch { // A corrupt consent file must not grant anything, so this fails closed. return { version: 1, intents: [], grants: [] }; } } private save(file: ConsentFile): void { mkdirSync(dirname(this.path), { recursive: true }); // The consent file is small, but it is written atomically anyway: a torn write // here means either a lost authorisation or, worse, a duplicated one. const temp = `${this.path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify(file, null, 2)}\n`, "utf8"); renameSync(temp, this.path); } private static id(prefix: string): string { return `${prefix}-${randomBytes(6).toString("hex")}`; } /** * Record that a person asked for a new conversation. * * Only call this from a place where a person is demonstrably acting -- a control * command they typed. If recovery code can reach this, the gate is decorative. */ issueIntent( proof: HumanIntentProof, input: { localSessionId: string; kind: CreationIntent["kind"]; reason: string; now?: number; }, ): { intent: CreationIntent; grant: CreationGrant } { // The proof is a required parameter rather than a flag, so a caller that has none cannot // reach this at all, and the runtime check covers the cast that the type cannot stop. assertHumanIntent(proof); const now = input.now ?? Date.now(); const file = this.load(); const intent: CreationIntent = { id: ConsentStore.id("intent"), localSessionId: input.localSessionId, kind: input.kind, // Recorded rather than assumed: "a person asked" is the whole claim, so the record // should say which writer produced it and when. reason: `${input.reason}|由 ${proof.source} 签发`, at: now, }; const grant: CreationGrant = { id: ConsentStore.id("grant"), localSessionId: input.localSessionId, intentId: intent.id, at: now, expiresAt: now + GRANT_TTL_MS, }; // Asking twice replaces the earlier authorisation instead of stacking it: two // taps on the same command should not amount to two conversations. file.intents = [...file.intents.filter((entry) => entry.localSessionId !== input.localSessionId), intent]; file.grants = [ ...file.grants.filter((entry) => entry.localSessionId !== input.localSessionId || entry.consumedAt), grant, ]; this.save(file); return { intent, grant }; } /** The live intent for a session, if one exists. */ intentFor(localSessionId: string): CreationIntent | null { return this.load().intents.find((entry) => entry.localSessionId === localSessionId) ?? null; } /** A grant that is still spendable. */ grantFor(localSessionId: string, now = Date.now()): CreationGrant | null { return ( this.load().grants.find( (entry) => entry.localSessionId === localSessionId && !entry.consumedAt && entry.expiresAt > now, ) ?? null ); } /** * Spend the authorisation. Returns the grant that was spent, or null if there was * nothing to spend -- which the caller must treat as "do not create". */ consume(localSessionId: string, spentOn: string, now = Date.now()): CreationGrant | null { const file = this.load(); const grant = file.grants.find( (entry) => entry.localSessionId === localSessionId && !entry.consumedAt && entry.expiresAt > now, ); if (!grant) return null; grant.consumedAt = now; grant.consumedBy = spentOn; file.intents = file.intents.filter((entry) => entry.id !== grant.intentId); this.save(file); return grant; } /** * Give back a grant that was spent on a creation which then failed. * * Spending before creating is what stops two concurrent starts from both * spending the same authorisation. The cost of that ordering is this method: * a creation that never happened must not consume what a person authorised, or * a transient failure would quietly cost them a conversation they asked for. */ restore(grantId: string): boolean { const file = this.load(); const grant = file.grants.find((entry) => entry.id === grantId); if (!grant || !grant.consumedAt) return false; grant.consumedAt = undefined; grant.consumedBy = undefined; // The intent was cleared when the grant was spent; put it back too, or the // restored grant would have no intent and fail the first gate on retry. if (!file.intents.some((entry) => entry.id === grant.intentId)) { file.intents.push({ id: grant.intentId, localSessionId: grant.localSessionId, kind: "new-workflow", reason: "(创建失败后归还的授权)", at: Date.now(), }); } this.save(file); return true; } /** Forget everything for a session, e.g. after an explicit cancel. */ revoke(localSessionId: string): boolean { const file = this.load(); const before = file.intents.length + file.grants.length; file.intents = file.intents.filter((entry) => entry.localSessionId !== localSessionId); file.grants = file.grants.filter((entry) => entry.localSessionId !== localSessionId); const after = file.intents.length + file.grants.length; if (before === after) return false; this.save(file); return true; } } /** * Whether a failed creation is known to have produced nothing. * * A create request has three outcomes, not two: it did not happen, it happened, or it is * not known whether it happened. The third is the one that gets people into trouble, * because it looks like the first. A lost reply is the classic case -- the tab may exist * while nothing came back about it -- and treating that as "not created" returns an * authorisation for a conversation that is already in the account, so the next attempt * creates a second one. * * Only connection-level failures are provable: nothing was listening, or the name did not * resolve. Those mean the request never reached anything that could have acted on it. * Everything else fails closed. * * Lives here rather than in the extension so the distinction can be tested: it is the * difference between returning and burning someone's authorisation, and it is easy to get * backwards. */ export function isProvablyNotCreated(error: unknown): boolean { const codes = new Set(["ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "ECONNRESET"]); const seen = new Set(); let cursor: unknown = error; while (cursor && typeof cursor === "object" && !seen.has(cursor)) { seen.add(cursor); const code = (cursor as { code?: string }).code; if (code && codes.has(code)) return true; cursor = (cursor as { cause?: unknown }).cause; } return false; } export interface CreationDecision { ok: boolean; /** Which gate stopped it. Null when it did not. */ gate: CreationGate | null; reason: string; } /** * The three gates, in the order a person would ask them. * * Evaluated in order so the message names the *first* missing thing. Telling someone * about the budget when they have not asked for a conversation at all answers a * question they did not ask, and hides the one they need to act on. */ export function canCreate(input: { consent: ConsentStore; localSessionId: string; /** The safety fuse. Zero switches creation off entirely. */ cap: number; /** Auto-created conversations in the window already. */ usedInWindow: number; now?: number; }): CreationDecision { const now = input.now ?? Date.now(); const intent = input.consent.intentFor(input.localSessionId); if (!intent) { return { ok: false, gate: "intent", reason: "没有新建会话的意图。桥不会因为「缺少绑定」就替你建一条 —— " + "缺少执行前提不等于被授权创造外部资源。要新建请显式执行 brb 新会话。", }; } const grant = input.consent.grantFor(input.localSessionId, now); if (!grant) { return { ok: false, gate: "grant", reason: "没有可用的新建授权(一次性,且有时效)。" + "请重新执行 brb 新会话 以产生新的授权。", }; } if (input.cap <= 0) { return { ok: false, gate: "budget", reason: `新建会话已被关闭(maxNewConversationsPerDay=${input.cap})。` + "这是保险丝,不是权限:即使有授权也不会新建。", }; } if (input.usedInWindow + 1 > input.cap) { return { ok: false, gate: "budget", reason: `保险丝已熔断:过去 24 小时自动建了 ${input.usedInWindow} 条(上限 ${input.cap})。` + "继续自动新建会让账号里堆满对话,而清理它们要你一条条来。" + "请复用已有会话,或用 brb 会话 看清现状。", }; } return { ok: true, gate: null, reason: "" }; } /** * Intent, Grant and Budget, printed together. * * The refusal is only useful if it tells someone what to do, and "which of the three * am I missing" is the first thing they need. Kept here rather than assembled at the * call site so the wording cannot drift from the gate names. */ export function describeGates(input: { consent: ConsentStore; localSessionId: string; cap: number; usedInWindow: number; now?: number; }): string { const now = input.now ?? Date.now(); const intent = input.consent.intentFor(input.localSessionId); const grant = input.consent.grantFor(input.localSessionId, now); const spendable = grant ? Math.max(0, Math.round((grant.expiresAt - now) / 60_000)) : 0; const budgetOk = input.cap > 0 && input.usedInWindow + 1 <= input.cap; return [ `Intent: ${intent ? `有(${intent.kind}|${intent.reason})` : "无 —— 需要显式 brb 新会话"}`, `Grant : ${grant ? `有(${spendable} 分钟内有效,未使用)` : "无"}`, `Budget: ${input.cap <= 0 ? "已关闭" : `${input.usedInWindow} / ${input.cap}${budgetOk ? "" : "(已熔断)"}`}`, ].join("\n"); }