/** * Every operation that changes something outside this process, declared as code. * * The point is not to have a list. A list is a document, and this repository has already * demonstrated what a document is worth: BUG-005 was a "derived type" that derived to * `string`, BUG-002 was a `bind` that printed success without writing, BUG-006 was a * process-scoped veto that outlived its process. Four defects, one shape -- a protection that * exists in the author's intent and not as an executable constraint. * * So the registry has to be the thing the compiler and the tests read, not a companion to * them. Concretely: * * - `SIDE_EFFECTS` is `as const`, so `SideEffectId` is the real union of ids. Deriving from * a widened array yields `string`, which looks exactly like a constraint and constrains * nothing. That mistake is already recorded here once. * - A mutation whose spec says `requiresCapability` cannot be performed without a * `Capability`, and a `Capability` cannot be constructed outside this module -- the * branded field is not satisfiable by an object literal, so the type checker rejects it. * - `assertCapability` re-checks at runtime, because the extension is loaded by jiti, which * transpiles without checking. A cast would defeat the type; it does not defeat this. * - `tools/sideeffects-test.mjs` scans the source for each declared primitive and fails if * one appears in a file the spec did not allow. A new bypass cannot be added quietly. * * ChatGPT's ruling on this is worth keeping verbatim, because it is the failure mode to avoid: * "不要把 SideEffect Registry 变成一张没人强制执行的新表;若执行 primitive 仍可直接被业务 * 代码调用,A6 只是另一条空防线." * * And its other correction is honoured in the shape below: level is a coarse label, not an * authorisation model. Creating a conversation and deleting one are both Level 2 and share * almost none of their retry, consent or ambiguity semantics. The metadata is the constraint. */ /** Coarse classification. The per-operation metadata is what actually decides anything. */ export type EffectLevel = /** Reads only. Cannot change anything anywhere. */ | 0 /** Local and reversible, and cannot change the user's account state. */ | 1 /** External, account-impacting, or not reliably reversible. */ | 2; /** How long a value written by this operation is expected to live. */ export type Lifetime = /** Discarded when the call returns. */ | "call" /** Lives for this process only; must never be persisted. */ | "process" /** Tied to a pi session. */ | "session" /** Tied to a workstream across sessions. */ | "workflow" /** Survives everything; belongs to the user. */ | "durable"; export interface SideEffectSpec { id: string; level: EffectLevel; lifetime: Lifetime; /** True when it changes something outside this machine's process. */ external: boolean; /** True when it can change the user's account, quota or standing with a provider. */ accountImpacting: boolean; /** True when it can be undone by this bridge without asking anyone. */ reversible: boolean; /** True when a person must have asked for it, each time. */ requiresIntent: boolean; /** True when a one-shot authorisation must be spent. */ requiresGrant: boolean; /** * Whether this operation's outcome can be genuinely unknown. * * A 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 turns one authorised action into several, * so it is a property of the operation rather than a policy -- `retry` is the policy. */ ambiguousOutcome: boolean; /** * What may be done after an attempt. * * `never` -- do not attempt again under any circumstance. * `idempotent` -- attempting again is the same as having attempted once. * `reconcile-before-retry` -- a second attempt is allowed only once it has been proven that * the first did not happen. The proof is the caller's, but the * permission is this field's, and `retryDecision` is what reads * it. There is deliberately no second copy of this decision * anywhere else: a ladder that retries on its own judgement is a * quieter copy of the policy, and quieter means it drifts. */ retry: "never" | "idempotent" | "reconcile-before-retry"; /** * Whether this changes what the bridge is *allowed* to do, as opposed to what it has done. * * A separate axis from `level` on purpose. `consent.issue` writes a local, reversible file -- * which is Level 1 by every measure of side effect -- and it is nevertheless the most * consequential write in the codebase, because it is what lets a conversation be created at * all. Folding that into `level` would either overstate the file write or understate the * authority change, and the external ruling called the distinction by name: this is a * confused-deputy boundary, not a plain local write. * * `revoke` exists so that withdrawing authority does not need a second boolean later. */ authorityEffect: "none" | "grant" | "revoke"; /** False when this must not happen while running under test tooling. */ allowedInTests: boolean; /** Function or method names that perform it. Drives the authority-path scan. */ primitives: readonly string[]; /** * Why `primitives` is empty, when it is. * * An empty list means the scan covers nothing for this operation, which is indistinguishable * from an oversight unless it is said out loud. So it has to be said: either there is no * named function to scan for, or the operation is composed of others that are already * covered. The test refuses an empty list without this. */ noPrimitivesBecause?: string; /** * Files permitted to call those primitives. A new file fails the scan. * * An entry may name a directory, in which case every file under it is allowed. A directory * entry that matched nothing would be the same defect as an empty `primitives` list, so it is * worth being explicit about which it is. */ allowedIn: readonly string[]; note: string; } export const SIDE_EFFECTS = [ { id: "conversation.create", level: 2, lifetime: "durable", external: true, accountImpacting: true, reversible: false, requiresIntent: true, requiresGrant: true, ambiguousOutcome: true, retry: "reconcile-before-retry", authorityEffect: "none", allowedInTests: false, primitives: ["ChatGptPage.open"], allowedIn: ["src/hosts/pi/index.ts"], note: "新建 ChatGPT 会话。外部、不可逆、影响账号;一次授权最多对应一次创建。" + "请求超时属于三种结果里的第三种(不知道是否发生),不得重试。", }, { id: "message.send", level: 2, lifetime: "durable", external: true, accountImpacting: true, reversible: false, requiresIntent: false, requiresGrant: false, ambiguousOutcome: true, retry: "reconcile-before-retry", authorityEffect: "none", allowedInTests: true, // `page.ask`, not `ask`: the bare name matches every `.ask(` in the tree, including the // checkers that mention it, and a scan that fires on its own documentation is one people // learn to ignore. primitives: ["page.ask"], allowedIn: ["src/browser/chatgpt.ts", "src/hosts/pi/index.ts", "tests", "scripts"], note: "把一条消息发进会话。外部、不可逆、账号可见。投递校验让重发可判定:" + "只有证明没送达才允许重发,否则重发会把一次写入变成两次。", }, { id: "conversation.rotate", level: 2, lifetime: "workflow", external: true, accountImpacting: true, reversible: false, requiresIntent: true, requiresGrant: true, ambiguousOutcome: true, retry: "reconcile-before-retry", authorityEffect: "none", allowedInTests: false, primitives: [], allowedIn: [], noPrimitivesBecause: "它由 conversation.create 与本地提交组成,两者各自已被扫描覆盖;轮换本身没有独立的执行原语。", note: "轮换:创建新会话并把工作流切过去。它由 conversation.create 与本地提交组成," + "因此没有自己的 primitive;登记在此是为了让「轮换」这件事在清单上可见," + "而不是藏在两次调用的间隙里。", }, { id: "browser.launch", level: 1, lifetime: "process", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: true, retry: "reconcile-before-retry", authorityEffect: "none", allowedInTests: true, primitives: ["ensureBrowser", "openTab"], allowedIn: ["src/hosts/pi/index.ts", "src/browser/launcher.ts", "src/browser/cdp.ts", "src/browser/chatgpt.ts", "tests", "scripts"], note: "启动自动化浏览器或开一个标签页。本地、可逆(关掉即可),但会在用户眼前出现窗口。" + "启动请求超时属于结果未知,因此不是盲目重试 —— 启动器会先查端口,那就是这里的 reconcile。", }, { id: "browser.kill", level: 1, lifetime: "process", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "never", authorityEffect: "none", allowedInTests: true, primitives: ["killBrowser"], allowedIn: ["src/hosts/pi/index.ts", "src/browser/launcher.ts", "tests", "scripts"], note: "关掉桥启动的浏览器。可逆(重新 launch),但会中断用户正在看的窗口。", }, { id: "page.reload", level: 1, lifetime: "process", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["reload"], allowedIn: ["src/browser/chatgpt.ts", "src/hosts/pi/index.ts", "tests", "scripts"], note: "重载标签页。会话在服务端,所以重载无损;但它会中断用户眼前正在看的东西。" + "导航到别的会话走的是开标签页那条路(browser.launch 的 openTab),不在这里。", }, { id: "clipboard.write", level: 1, lifetime: "call", external: false, accountImpacting: false, reversible: false, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "never", authorityEffect: "none", allowedInTests: true, primitives: ["clickCopySource"], allowedIn: ["src/browser/chatgpt.ts", "tests", "scripts"], note: "点击复制按钮会写进系统剪贴板,覆盖用户原有内容。不可逆,且是全局共享资源 ——" + "这也是并发时需要短时互斥的原因。", }, { id: "binding.write", level: 1, lifetime: "workflow", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["bindingStore.bind"], allowedIn: ["src/hosts/pi/index.ts", "tests", "scripts"], note: "写绑定注册表。它是路由权威,因此写入必须可读回 —— BUG-002 就是打印成功而没写。", }, { id: "consent.issue", level: 1, lifetime: "session", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "never", authorityEffect: "grant", allowedInTests: true, primitives: ["issueIntent"], allowedIn: ["src/hosts/pi/index.ts", "tests", "scripts"], note: "发出一次性新建授权。本地,但它是**权限**的变更,因此唯一写入者必须是人的显式命令;" + "恢复代码、重试、探测、测试都不得触达。", }, { id: "consent.consume", level: 1, lifetime: "session", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "never", authorityEffect: "revoke", allowedInTests: true, primitives: ["consentStore.consume"], allowedIn: ["src/hosts/pi/index.ts"], note: "消费一次性授权。在**外部创建点**消费;VERIFY 失败不归还。", }, { id: "config.write", level: 1, lifetime: "durable", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["saveConfig"], allowedIn: ["src/hosts/pi/index.ts", "tests", "scripts"], note: "写用户持久配置。**进程级的值不得经由这里落盘** —— BUG-006 就是一个只作用于单次运行的" + "安全否决被 saveConfig 写进文件,此后永久生效。", }, { id: "orphan.record", level: 1, lifetime: "workflow", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["orphanStore.record"], allowedIn: ["src/hosts/pi/index.ts"], note: "登记失败轮换留下的会话。只登记,永不删除 —— 删除是第二个外部副作用。", }, { id: "state.write", level: 1, lifetime: "workflow", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["store.save"], allowedIn: ["src/hosts/pi/index.ts", "src/runtime/store.ts", "src/runtime/guards.ts", "tests", "scripts"], note: "写 per-binding 运行状态与速率守卫状态。", }, { id: "lock.acquire", level: 1, lifetime: "call", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: ["conversationLock.acquire"], allowedIn: ["src/hosts/pi/index.ts", "tests", "scripts"], note: "取得驱动权。本地且短命,但崩溃的持有者会留下陈旧锁 —— 因此有 TTL 与 pid 存活判定。" + "浏览器互斥(BrowserMutex.run)走同一个文件,未单列:扫描按函数名,而 run 太通用," + "列上它会让扫描对每个 .run( 报警。", }, { id: "diagnostic.read", level: 0, lifetime: "call", external: false, accountImpacting: false, reversible: true, requiresIntent: false, requiresGrant: false, ambiguousOutcome: false, retry: "idempotent", authorityEffect: "none", allowedInTests: true, primitives: [], allowedIn: [], noPrimitivesBecause: "只读,没有 mutation 原语可扫。但它的诊断动作并非无副作用(见 note),那些经由已有的原语覆盖。", note: "status / doctor / sources / conversations 等只读路径。**注意**:它们并非天然无副作用 ——" + "doctor 会 hover 轮次以逼出动作条,bringToFront 会改变焦点,DOM 采样本身给渲染进程加压。", }, ] as const; /** * Derived from the registry, which is `as const`, so this is the real union. * * The first version of this pattern in this codebase derived from a widened array and the * union collapsed to `string` -- a constraint that constrained nothing while looking exactly * like one. The `as const` above is what makes this line mean something. */ export type SideEffectId = (typeof SIDE_EFFECTS)[number]["id"]; /** The spec for an operation, or undefined when it was never declared. */ export function sideEffectSpec(id: string): SideEffectSpec | undefined { return SIDE_EFFECTS.find((entry) => entry.id === id) as SideEffectSpec | undefined; } /** Every declared id, for enumerating the registry from a test. */ export function sideEffectIds(): string[] { return SIDE_EFFECTS.map((entry) => entry.id); } /* ------------------------------------------------------------------ */ /* Capability */ /* ------------------------------------------------------------------ */ declare const CAPABILITY_BRAND: unique symbol; /** * Proof that policy was consulted for one operation. * * The branded field cannot be produced by an object literal, so a caller cannot invent one: * `const cap = { operation: "conversation.create" }` is a type error, and that is the whole * mechanism. `requestCapability` is the only thing that constructs one. */ export interface Capability { readonly operation: SideEffectId; readonly issuedAt: number; /** Who asked and why, for the audit trail. Not interpreted. */ readonly context: string; readonly [CAPABILITY_BRAND]: true; } export interface CapabilityRequest { operation: SideEffectId; context: string; /** * Evidence that a person asked, when the spec requires intent or a grant. * * Passed as ids rather than booleans: "someone said yes" is not checkable, and this * repository has spent a lot of effort removing unverifiable claims. */ evidence?: { intentId?: string; grantId?: string }; now?: number; } export type CapabilityDecision = | { ok: true; capability: Capability } | { ok: false; reason: string; level: EffectLevel; blockedBy: "unknown" | "test-policy" | "evidence" }; /** * What the environment permits, read fresh each time. * * `PI_WEBGPT_TEST_MODE` is set by the harnesses. Level 2 operations are denied under it * unless named in `PI_WEBGPT_ALLOW_LEVEL2`, so a test that wants to create a conversation has * to say so explicitly -- the default is that test tooling has no external write authority at * all, rather than having it and remembering to turn it off. */ export function environmentPolicy(env: NodeJS.ProcessEnv = process.env): { testMode: boolean; allowedLevel2: Set; } { const allowed = new Set(); const list = env.PI_WEBGPT_ALLOW_LEVEL2; if (list) { for (const id of list.split(",")) { const trimmed = id.trim(); if (trimmed) allowed.add(trimmed); } } // The older, narrower switch, kept because it is what the existing harnesses set and two // mechanisms for one thing is how they drift apart. if (env.PI_WEBGPT_ALLOW_NEW_CONVERSATION === "1") allowed.add("conversation.create"); return { testMode: env.PI_WEBGPT_TEST_MODE === "1", allowedLevel2: allowed }; } /** * Ask whether an operation may be performed, and receive the proof if it may. * * Level 0 and 1 are issued freely: they are reads or local reversible writes, and requiring * ceremony for those would train everyone to wave the ceremony through. Level 2 is where the * checks live, and where an undeclared operation is refused rather than allowed by default. */ export function requestCapability( request: CapabilityRequest, env: NodeJS.ProcessEnv = process.env, ): CapabilityDecision { const spec = sideEffectSpec(request.operation); if (!spec) { return { ok: false, reason: `未登记的外部副作用:${request.operation}。` + "新增一个能改变外部状态的操必须先写进 sideEffects.ts —— 未登记默认拒绝,而不是默认允许。", level: 2, blockedBy: "unknown", }; } const policy = environmentPolicy(env); if (spec.level === 2) { // `allowedInTests` is consulted, not merely recorded. A flag that nothing reads is the // shape this whole registry exists to remove -- and sending a message is a Level 2 // operation that the harnesses genuinely perform, while creating a conversation is one // they must never perform by accident. The difference has to live somewhere the policy // reads, or it is documentation. const permittedInTestMode = spec.allowedInTests || policy.allowedLevel2.has(spec.id); if (policy.testMode && !permittedInTestMode) { return { ok: false, reason: `${spec.id} 是 Level 2,而当前处于测试模式且未被显式放行。` + "测试工具默认没有任何外部写权限;要放行请设 PI_WEBGPT_ALLOW_LEVEL2=" + spec.id + "。", level: spec.level, blockedBy: "test-policy", }; } if (spec.requiresIntent && !request.evidence?.intentId) { return { ok: false, reason: `${spec.id} 需要人的显式意图,但没有提供 intentId。`, level: spec.level, blockedBy: "evidence", }; } if (spec.requiresGrant && !request.evidence?.grantId) { return { ok: false, reason: `${spec.id} 需要一次性授权,但没有提供 grantId。`, level: spec.level, blockedBy: "evidence", }; } } return { ok: true, capability: { operation: spec.id, issuedAt: request.now ?? Date.now(), context: request.context, } as Capability, }; } /** * Runtime check, because the type is not enforced at load time. * * The extension is loaded through jiti, which transpiles without type checking, so the brand * on `Capability` stops a mistake at `npm test` and stops nothing at runtime. This stops the * cast. A guard that only exists in the type system would be exactly the shape this registry * was written to eliminate. */ export function assertCapability(capability: unknown, operation: SideEffectId): void { const candidate = capability as Partial | null | undefined; if (!candidate || typeof candidate !== "object") { throw new Error(`执行 ${operation} 需要 capability,但拿到的是 ${typeof capability}。`); } if (candidate.operation !== operation) { throw new Error( `capability 与操作不匹配:持有 ${String(candidate.operation)},要执行 ${operation}。` + "一张针对 A 的授权不能被用来做 B。", ); } if (typeof candidate.issuedAt !== "number" || typeof candidate.context !== "string") { throw new Error(`${operation} 的 capability 形状不对,可能是伪造的。`); } } /* ------------------------------------------------------------------ */ /* Retry policy */ /* ------------------------------------------------------------------ */ /** * What may be done after a failed attempt, according to the operation's own registry entry. * * This exists so the policy has exactly one home. A ladder, a delivery check or a recovery * branch that decided for itself would be a second copy -- and a second copy that nobody * reads as policy is precisely the defect class this registry was written for. * * `reconciled` is the caller's claim that the previous attempt provably did not happen. It is a * claim, not a proof, and it is the only thing that unlocks a retry of an operation whose * outcome can be unknown: without it, `reconcile-before-retry` refuses, because an unknown * outcome is not permission to repeat. */ export interface RetryDecision { ok: boolean; mode: "idempotent" | "reconcile-before-retry" | null; reason: string; } export function retryDecision( operation: SideEffectId, options: { reconciled?: boolean } = {}, ): RetryDecision { const spec = sideEffectSpec(operation); if (!spec) { return { ok: false, mode: null, reason: `未登记的副作用 ${operation}:未登记不得重试(默认拒绝,不是默认允许)。`, }; } if (spec.retry === "never") { return { ok: false, mode: null, reason: `${spec.id} 的策略是 never:不得重试。` }; } if (spec.retry === "idempotent") { return { ok: true, mode: "idempotent", reason: `${spec.id} 是幂等的,重试等价于只做一次。` }; } // reconcile-before-retry if (!options.reconciled) { return { ok: false, mode: null, reason: `${spec.id} 需要先证明上一次**没有发生**才允许重试,而本次没有该证明。` + (spec.ambiguousOutcome ? "该操作的结果可能是未知的,而未知不等于可以再做一次。" : ""), }; } return { ok: true, mode: "reconcile-before-retry", reason: `${spec.id} 已对上一次做出「没有发生」的判定,允许重试。`, }; } /* ------------------------------------------------------------------ */ /* Policy-field liveness */ /* ------------------------------------------------------------------ */ /** * Which registry fields are actually read, and by what. * * Every field must be either consumed -- with a named consumer and a test that would notice if * the consumption stopped -- or explicitly marked informational. There is deliberately no third * state, because the third state is the defect: a field that looks like enforcement, is read as * enforcement in review, and changes nothing. * * That is BUG-005 in one sentence (a derived type that derived to `string`), and the external * ruling asked for this audit by name before A6 could be called closed. */ export const POLICY_FIELD_CONSUMERS: Record< keyof SideEffectSpec, { consumedBy: string | null; test: string | null } > = { id: { consumedBy: "sideEffectSpec / sideEffectIds", test: "sideeffects-test" }, level: { consumedBy: "requestCapability", test: "sideeffects-test" }, lifetime: { consumedBy: null, test: null }, external: { consumedBy: "requestCapability (through requiresGrant)", test: "sideeffects-test" }, accountImpacting: { consumedBy: null, test: null }, reversible: { consumedBy: null, test: null }, requiresIntent: { consumedBy: "requestCapability", test: "sideeffects-test" }, requiresGrant: { consumedBy: "requestCapability", test: "sideeffects-test" }, ambiguousOutcome: { consumedBy: "retryDecision", test: "sideeffects-test" }, retry: { consumedBy: "retryDecision → cycle.planStallAction", test: "sideeffects-test" }, authorityEffect: { consumedBy: "consent.mintHumanIntent", test: "consent-test" }, allowedInTests: { consumedBy: "requestCapability", test: "sideeffects-test" }, primitives: { consumedBy: "tests/sideeffects-test.mjs authority-path scan", test: "sideeffects-test" }, allowedIn: { consumedBy: "tests/sideeffects-test.mjs authority-path scan", test: "sideeffects-test" }, noPrimitivesBecause: { consumedBy: "tests/sideeffects-test.mjs scan-coverage check", test: "sideeffects-test" }, note: { consumedBy: null, test: null }, }; /** Fields that are documentation and say so, rather than looking like policy. */ export function informationalFields(): string[] { return Object.entries(POLICY_FIELD_CONSUMERS) .filter(([, entry]) => entry.consumedBy === null) .map(([field]) => field) .sort(); } /* ------------------------------------------------------------------ */ /* Readable report */ /* ------------------------------------------------------------------ */ /** * A human-readable rendering of the registry. * * Generated from the registry rather than maintained beside it. The ruling was explicit that * a hand-synced document and a hand-synced table are how a registry ends up disagreeing with * itself: "否则半年以后很可能重演 BUG-005:两边都长得对,但一个早已失真." */ export function describeSideEffects(): string { const lines: string[] = []; for (const level of [2, 1, 0] as const) { const entries = SIDE_EFFECTS.filter((entry) => entry.level === level); if (entries.length === 0) continue; lines.push(`Level ${level}(${level === 2 ? "外部/不可逆/影响账号" : level === 1 ? "本地可逆" : "只读"}):`); for (const entry of entries) { const flags = [ entry.external ? "external" : null, entry.accountImpacting ? "account" : null, entry.reversible ? "reversible" : "irreversible", entry.requiresIntent ? "needs-intent" : null, entry.requiresGrant ? "needs-grant" : null, entry.allowedInTests ? "test-ok" : "test-denied", `lifetime=${entry.lifetime}`, `retry=${entry.retry}`, `ambiguous=${entry.ambiguousOutcome ? "yes" : "no"}`, ].filter(Boolean); lines.push(` ${entry.id} [${flags.join(" ")}]`); } } return lines.join("\n"); }