/** * The send-boundary freshness barrier (DECISION-009). * * BUG-012: a long-lived process holds a stale durable-state snapshot. Observed live: one * session's status displayed the predecessor conversation while the config file already * named the current one. Had that session sent, message.send would have gone to the old * conversation -- capability genuine, send machinery genuine, target false. * * The ruling froze the shape: before a real message.send, re-resolve the destination from * authoritative durable routing state, bypassing any cached snapshot, and let the send use * only that fresh resolution. Fail closed on missing, revoked, ambiguous, or unreadable * state. mtime/content comparison is diagnostics, never the ungate mechanism. * * Scope, as ruled: only state that determines WHERE the message goes. No config lifecycle * redesign, no global coherence work -- that stays with A5+A16, and BUG-012 stays partial. * * One registered dependency the ruling anticipated: for a session with NO registry binding, * the legacy config field `conversationUrl` is what participates in destination identity. * The barrier therefore re-reads the config FILE at the send boundary for that fallback -- * a fresh read of one field at one boundary, not a config-cache change. * * BUG-025 narrowed that fallback (2026-09-21, acceptance patch): the exception was written * for the pre-registry world where the legacy field WAS the unregistered session's own * conversation. It is no longer that. Observed live: `config.conversationUrl` named * 6ab0db7b, which the registry had bound to a DIFFERENT pi thread (01a0adc9); an unregistered * thread falling back to it would have sent into another thread's conversation. The fallback * therefore survives only while no binding owns that conversation -- verdict #31's rule * (a shared config must never route a send to a conversation a binding owns) applied here. */ import { existsSync, readFileSync } from "node:fs"; import { conversationIdOf } from "../browser/chatgpt.ts"; /** The narrow slice of the registry the barrier needs. BindingStore.load() reads disk per call. */ export interface FreshRegistry { findBySession(localSessionId: string): { conversationUrl: string; conversationId: string; targetId?: string } | null; /** Who owns a conversation, if anyone. Used to refuse a legacy fallback another thread claimed (BUG-025). */ findByConversation( conversationUrl: string, conversationId?: string, ): { localSessionId: string; conversationUrl: string } | null; } export interface FreshTarget { conversationUrl: string; conversationId: string; targetId?: string; source: "binding" | "legacy-config"; } export type FreshResolution = | { ok: true; target: FreshTarget } | { ok: false; reason: string }; const REFUSE_NO_SESSION = "拿不到本地 pi session id,无法做发送前的新鲜解析。拒绝发送,不猜测目标。"; /** * The conversation id of a URL is `conversationIdOf` from chatgpt.ts -- the same one * index.ts uses. binding.ts keeps a near-identical copy of its own; that duplication is * recorded under BUG-012's remaining work rather than silently grown to three copies here. */ /** * Resolve where a send would go, right now, from durable state. * * `readLegacyConversationUrl` must read the config FILE, not the caller's in-memory * config object -- the whole point is that the in-memory one can be stale. */ export function resolveFreshSendTarget(deps: { sessionId: string | null; registry: FreshRegistry; readLegacyConversationUrl: () => string | null; /** Only the pinned provisional target may be sent its first real message. */ allowProvisionalTarget?: boolean; }): FreshResolution { if (!deps.sessionId) return { ok: false, reason: REFUSE_NO_SESSION }; const binding = deps.registry.findBySession(deps.sessionId); if (binding) { const id = binding.conversationId || conversationIdOf(binding.conversationUrl); if (!id) { if (deps.allowProvisionalTarget && binding.targetId) { return { ok: true, target: { conversationUrl: binding.conversationUrl, conversationId: "", targetId: binding.targetId, source: "binding", }, }; } return { ok: false, reason: "绑定存在但还没有会话 id —— 一个没有 id 的 URL 不是身份(创建中的候选不可投递)。" + "拒绝发送,不猜测目标。", }; } return { ok: true, target: { conversationUrl: binding.conversationUrl, conversationId: id, targetId: binding.targetId, source: "binding", }, }; } // Unregistered session: the legacy field is the only routing there is, so for this // path it participates in destination identity and is re-read from disk here. const legacyUrl = deps.readLegacyConversationUrl(); if (!legacyUrl) { return { ok: false, reason: "本线程没有注册绑定,遗留 config 也没有会话 URL —— 没有可证明的投递目标。" + "拒绝发送,不猜测「当前标签页」。", }; } const id = conversationIdOf(legacyUrl); if (!id) { return { ok: false, reason: `遗留 config 的 URL 不含会话 id(${legacyUrl})——不是身份。拒绝发送。`, }; } // BUG-025: the legacy field may no longer stand in for a binding when a binding already // owns that conversation. Showing it as a destination (badge) or accepting it as one // (here) both mean "this thread drives that conversation" — which is exactly the // cross-thread misroute the registry exists to prevent (BUG-019's class). const owner = deps.registry.findByConversation(legacyUrl, id); if (owner && owner.localSessionId !== deps.sessionId) { return { ok: false, reason: `遗留 config 的会话 ${id.slice(0, 8)}… 已经被线程 ${owner.localSessionId} 绑定,` + "而本线程没有注册绑定。拒绝发送到别的线程的会话(一条会话只能有一个 owning 线程)。" + "先在本线程执行 /brb bind <会话URL> 明确指定目标。", }; } return { ok: true, target: { conversationUrl: legacyUrl, conversationId: id, source: "legacy-config" } }; } /** * The smallest send-boundary validation against the TOCTOU window the ruling named: * after the page is attached and immediately before the send, re-resolve and require * the destination to be what it was. Registry writes are atomic (temp + rename), so a * disagreement here means a rotation landed in between -- and the send must not proceed * to the conversation that was current a moment ago. */ export function validateStillCurrent( deps: Parameters[0], target: FreshTarget, ): { ok: true } | { ok: false; reason: string } { const again = resolveFreshSendTarget(deps); if (!again.ok) return { ok: false, reason: `发送前复检失败:${again.ok === false ? again.reason : ""}` }; if (again.target.conversationId !== target.conversationId) { return { ok: false, reason: `发送窗口内路由变了:解析时是 ${target.conversationId.slice(0, 8)},` + `复检时是 ${again.target.conversationId.slice(0, 8)}。拒绝发送到旧目标。`, }; } return { ok: true }; } /** Read the legacy conversation URL from the config FILE on disk, bypassing any cached object. */ export function readLegacyConversationUrlFromFile(configPath: string): string | null { try { if (!existsSync(configPath)) return null; const parsed = JSON.parse(readFileSync(configPath, "utf8")) as { conversationUrl?: unknown }; return typeof parsed.conversationUrl === "string" && parsed.conversationUrl.trim() ? parsed.conversationUrl : null; } catch { // An unreadable config is not a target; fail closed at the caller. return null; } } /** * The only config reader permitted at the explicit legacy-migration boundary. * Runtime routing must use its isolated config path and therefore cannot call this helper. * Project one legacy field instead of returning the parsed object: modern runtime state * (auto-create, consent, quota, onboarding and session intents) must not leak into a sandbox. */ export function explicitLegacyMigrationRead(configPath: string): { conversationUrl: string | null } { return { conversationUrl: readLegacyConversationUrlFromFile(configPath) }; }