/** * Auto-bind-on-unbound policy. * * This module only decides whether the existing PREPARE -> CREATE -> HANDSHAKE -> * COMMIT pipeline may be entered. It never opens a browser, writes a registry, or * manufactures a capability. Keeping the trigger and refusal vocabulary pure makes * the onboarding boundary testable without a ChatGPT account. */ export type AutoCreateOnUnbound = "ask" | "on" | "off"; import { classifyConversationRoute, conversationIdOf, type ConversationRouteKind, } from "../core/identity.ts"; import { ownershipConflictMessage, type Binding } from "../core/binding.ts"; export type BoundOperation = "ask" | "plan" | "read" | "copy" | "sources" | "start"; export const AUTO_CREATE_OPERATIONS: readonly BoundOperation[] = [ "ask", "plan", "read", "copy", "sources", "start", ]; export const AUTO_CREATE_OUTCOMES = { ALLOWED: "AUTO_CREATE_ALLOWED", NO_BINDING: "NO_BINDING", DISABLED_BY_SETTING: "AUTO_CREATE_DISABLED_BY_SETTING", DISABLED_BY_LIMIT: "AUTO_CREATE_DISABLED_BY_LIMIT", BLOCKED_PENDING_ORPHAN: "AUTO_CREATE_BLOCKED_PENDING_ORPHAN", BLOCKED_IN_FLIGHT: "AUTO_CREATE_BLOCKED_IN_FLIGHT", EXPLICIT_URL: "AUTO_CREATE_SKIPPED_EXPLICIT_URL", NOT_BOUND_OPERATION: "AUTO_CREATE_NOT_REQUIRED", } as const; export type AutoCreateOutcome = (typeof AUTO_CREATE_OUTCOMES)[keyof typeof AUTO_CREATE_OUTCOMES]; export interface AutoCreateDecision { ok: boolean; outcome: AutoCreateOutcome; reason: string; } export type AutoCreatePhase = "PREPARE" | "CREATE" | "HANDSHAKE" | "COMMIT"; /** The only target identity an auto-created candidate may use. */ export interface TargetLineageObservation { targetId: string; url: string; /** Target ids observed before the external create request. */ targetsBefore: readonly string[]; /** Whether the target still exists in the latest target listing. */ present: boolean; } export interface ProvisionalTargetObservation { targetId: string; url: string; present: boolean; /** The durable owner, if the observed id is already registered. */ ownerSessionId?: string | null; /** Optional owner identity details for the frozen conflict guidance. */ ownerLabel?: string | null; ownerBindingId?: string | null; /** Result of the current account/workspace affinity check. */ affinityValid?: boolean; } export type ProvisionalReconciliation = | { ok: true; kind: "STILL_PROVISIONAL"; route: ConversationRouteKind; draftRoute?: string } | { ok: true; kind: "PROMOTE"; route: "DURABLE"; conversationId: string } | { ok: false; code: "TARGET_DRIFT" | "TARGET_GONE" | "INVALID_TARGET" | "OWNERSHIP_CONFLICT" | "AFFINITY_MISMATCH"; reason: string }; export type TargetLineageFailure = | "INVALID_TARGET" | "TARGET_DRIFT" | "TARGET_GONE"; export type TargetLineageResult = | { ok: true; targetId: string; conversationId: null } | { ok: false; code: TargetLineageFailure; reason: string; conversationId?: string }; function shortTargetId(id: string): string { return id.length > 12 ? `${id.slice(0, 12)}…` : id; } /** * Validate a freshly-created target without ever selecting a replacement target. * This is deliberately pure so the create/attach/commit gate has one testable rule. */ export function validateCreatedTarget(observation: TargetLineageObservation): TargetLineageResult { const conversationId = conversationIdOf(observation.url); if (conversationId) { return { ok: false, code: "INVALID_TARGET", conversationId, reason: `INVALID_TARGET:新创建的页面落到了已有会话 ${shortTargetId(conversationId)}。`, }; } if (observation.targetsBefore.includes(observation.targetId)) { return { ok: false, code: "INVALID_TARGET", reason: `INVALID_TARGET:创建目标 ${shortTargetId(observation.targetId)} 在创建前已存在;拒绝串绑。`, }; } if (!observation.targetId || !observation.present) { return { ok: false, code: "TARGET_GONE", reason: "TARGET_GONE:自动创建的页面已消失;不绑定、不回退到其他标签页,也不自动重试。", }; } return { ok: true, targetId: observation.targetId, conversationId: null }; } /** * Gate 0→5 for an existing provisional binding. This pure classifier is used by * the host after it has read the pinned target's live URL; it never treats WEB: * as an identity and never manufactures a draft→durable mapping. */ export function reconcileProvisionalTarget( binding: Pick, observation: ProvisionalTargetObservation, localSessionId: string, ): ProvisionalReconciliation { const ownershipConflict = (): string => { const owner = { label: observation.ownerLabel ?? "", localSessionId: observation.ownerSessionId ?? "?", bindingId: observation.ownerBindingId ?? "?", }; return `OWNERSHIP_CONFLICT:${ownershipConflictMessage(owner)}`; }; if (binding.state !== "PROVISIONAL" || !binding.targetId) { return { ok: false, code: "INVALID_TARGET", reason: "INVALID_TARGET:只有带 pinned target 的 PROVISIONAL 绑定可以回填。" }; } if (observation.targetId !== binding.targetId) { return { ok: false, code: "TARGET_DRIFT", reason: "TARGET_DRIFT:回填观察来自不同 target,拒绝替代绑定目标。" }; } if (!observation.present) { return { ok: false, code: "TARGET_GONE", reason: "TARGET_GONE:PROVISIONAL 绑定的 pinned target 已消失;不回退到其他标签页。" }; } const route = classifyConversationRoute(observation.url); // Non-durable locators wait for the durable id instead of being adopted. TRANSIENT is // the `local-chatgpt:` spelling of the same thing (BUG-060): a client-side handle, never // an identity, so it must not reach the ownership/lineage rules below. if (route === "BARE" || route === "DRAFT_WEB" || route === "TRANSIENT") { return { ok: true, kind: "STILL_PROVISIONAL", route, draftRoute: route === "BARE" ? undefined : observation.url, }; } if (route !== "DURABLE") { return { ok: false, code: "INVALID_TARGET", reason: "INVALID_TARGET:pinned target 的路由不是可识别的 ChatGPT 会话路由。" }; } const durable = conversationIdOf(observation.url); if (!durable) { return { ok: false, code: "INVALID_TARGET", reason: "INVALID_TARGET:DURABLE 路由没有可提取的会话 id。" }; } // Gate 5: a durable id without an explicit SEND lineage is not ours to adopt. if (!binding.materializationPending || !binding.materializationOperationId || !binding.submittedAt) { if (observation.ownerSessionId && observation.ownerSessionId !== localSessionId) { return { ok: false, code: "OWNERSHIP_CONFLICT", reason: ownershipConflict(), }; } return { ok: false, code: "INVALID_TARGET", reason: "INVALID_TARGET:DURABLE id 没有本次 pinned target 的 SEND materialization lineage。" }; } if (observation.ownerSessionId && observation.ownerSessionId !== localSessionId) { return { ok: false, code: "OWNERSHIP_CONFLICT", reason: ownershipConflict(), }; } if (observation.affinityValid === false) { return { ok: false, code: "AFFINITY_MISMATCH", reason: "身份 AFFINITY_MISMATCH:pinned target 的账号/workspace 不匹配。" }; } return { ok: true, kind: "PROMOTE", route, conversationId: durable }; } /** Handshake/commit may observe only the target pinned by CREATE. */ export function validatePinnedTarget(expectedTargetId: string, observedTargetId: string | null | undefined): | { ok: true } | { ok: false; code: "TARGET_DRIFT"; reason: string } { if (expectedTargetId && observedTargetId === expectedTargetId) return { ok: true }; return { ok: false, code: "TARGET_DRIFT", reason: "TARGET_DRIFT:创建目标与后续握手目标不同,拒绝替代或绑定。" }; } export function isDeterministicAutoCreateFailure(reason: string): boolean { return /\b(?:INVALID_TARGET|TARGET_DRIFT|TARGET_GONE|OWNERSHIP_CONFLICT|KNOWN_FOREIGN_TARGET)\b/.test(reason); } export const PROVISIONAL_READ_MESSAGE = "新会话已准备,但尚未产生会话内容。首次发送后会完成会话绑定。"; export type AutoCreateStageResult = | { ok: true } | { ok: false; reason: string }; export interface AutoCreateTransactionResult { phases: AutoCreatePhase[]; committed: boolean; source: "auto" | "start"; candidate?: T; recoverable?: boolean; reason?: string; } /** * The production PREPARE -> CREATE -> HANDSHAKE -> COMMIT coordinator. * * The coordinator deliberately knows nothing about CDP or message transport. A * newly opened page is already a candidate: its target id and the freshly observed * account principal are enough for the local handshake. Sending HELLO here would * turn onboarding into an unsolicited ChatGPT message and would also bypass the * frozen send barrier, so the caller supplies a side-effect-free handshake step. * * Each phase is a callback so the host owns the actual capability, browser and * registry operations. The same function is used by the host and by the offline * matrix; tests therefore exercise the production phase ordering rather than a * second hand-written transaction state machine. */ export async function runAutoCreateTransaction(options: { source?: "auto" | "start"; prepare: () => T | Promise; create: (prepared: T) => T | Promise; handshake: (candidate: T) => AutoCreateStageResult | Promise; commit: (candidate: T) => AutoCreateStageResult | Promise; onPhase?: (phase: AutoCreatePhase) => void; onFailure?: (failure: { phase: AutoCreatePhase; candidate?: T; reason: string }) => void | Promise; }): Promise> { const phases: AutoCreatePhase[] = []; const source = options.source ?? "auto"; const enter = (phase: AutoCreatePhase) => { phases.push(phase); options.onPhase?.(phase); }; let candidate: T | undefined; const fail = async ( phase: AutoCreatePhase, reason: string, ): Promise> => { await options.onFailure?.({ phase, candidate, reason }); return { phases, committed: false, source, candidate, recoverable: phase === "COMMIT" || phase === "HANDSHAKE", reason, }; }; enter("PREPARE"); try { candidate = await options.prepare(); } catch (error) { return fail("PREPARE", errorMessage(error)); } enter("CREATE"); try { candidate = await options.create(candidate); } catch (error) { return fail("CREATE", errorMessage(error)); } enter("HANDSHAKE"); let handshake: AutoCreateStageResult; try { handshake = await options.handshake(candidate); } catch (error) { return fail("HANDSHAKE", errorMessage(error)); } if (!handshake.ok) return fail("HANDSHAKE", handshake.reason); enter("COMMIT"); let committed: AutoCreateStageResult; try { committed = await options.commit(candidate); } catch (error) { return fail("COMMIT", errorMessage(error)); } if (!committed.ok) return fail("COMMIT", committed.reason); return { phases, committed: true, source, candidate }; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export interface AutoCreateInput { bound: boolean; explicitUrl?: string | null; setting: AutoCreateOnUnbound; onboardingAuto: boolean; operation: string; pendingOrphan: boolean; inFlight: boolean; maxNewConversationsPerDay: number; usedInWindow: number; } export type CandidateIdentityResult = | { ok: true; principal: string } | { ok: false; outcome: "IDENTITY_UNKNOWN" | "AFFINITY_MISMATCH"; reason: string }; /** Identity gate for a freshly-created candidate: no principal or a changed principal fails closed. */ export function validateCandidateIdentity(expected: string | null, current: string | null): CandidateIdentityResult { if (!current) { return { ok: false, outcome: "IDENTITY_UNKNOWN", reason: "身份 UNKNOWN:无法验证当前登录账号。" }; } if (expected !== null && expected !== current) { return { ok: false, outcome: "AFFINITY_MISMATCH", reason: `身份 AFFINITY_MISMATCH:principal ${expected} → ${current}。`, }; } return { ok: true, principal: current }; } /** Normalize a legacy/invalid config value using the existing warning style. */ export function normalizeAutoCreateOnUnbound(value: unknown): { value: AutoCreateOnUnbound; warning: string | null; } { if (value === undefined) return { value: "ask", warning: null }; if (value === "ask" || value === "on" || value === "off") { return { value, warning: null }; } return { value: "ask", warning: `autoCreateOnUnbound 的取值 ${describeValue(value)} 不合法(合法值 ask | on | off):` + "已按 fallback=ask 生效。", }; } /** * Decide whether an unbound, bound-conversation operation may enter auto-create. * * The order is intentional: explicit URLs and already-bound/read-only operations never * reach the creation gates; orphan and single-flight guards fail closed before budget. */ export function decideAutoCreate(input: AutoCreateInput): AutoCreateDecision { if (input.explicitUrl && input.explicitUrl.trim()) { return refusal(AUTO_CREATE_OUTCOMES.EXPLICIT_URL, "用户明确指定了会话 URL,失败时不回退到自动新建。"); } if (input.bound) return refusal(AUTO_CREATE_OUTCOMES.NO_BINDING, "本线程已有绑定,复用现有会话。"); if (!(AUTO_CREATE_OPERATIONS as readonly string[]).includes(input.operation)) { return refusal(AUTO_CREATE_OUTCOMES.NOT_BOUND_OPERATION, "当前操作不需要绑定会话,不触发自动新建。"); } if (input.setting === "off") { return refusal(AUTO_CREATE_OUTCOMES.DISABLED_BY_SETTING, "autoCreateOnUnbound=off:只停止未来自动新建,不解除现有绑定。"); } // BRB_ONBOARDING_AUTO=0 only suppresses the first-time prompt. An explicit persisted // `on` answer remains effective, while `ask` stays the old NO_BINDING behaviour. if (input.setting === "ask" && !input.onboardingAuto) { return refusal(AUTO_CREATE_OUTCOMES.NO_BINDING, "BRB_ONBOARDING_AUTO=0 且设置仍为 ask:不提示、不自动新建。"); } if (input.pendingOrphan) { return refusal( AUTO_CREATE_OUTCOMES.BLOCKED_PENDING_ORPHAN, "AUTO_CREATE_BLOCKED_PENDING_ORPHAN:本线程有未解决的创建残留;不自动重试。", ); } if (input.inFlight) { return refusal( AUTO_CREATE_OUTCOMES.BLOCKED_IN_FLIGHT, "AUTO_CREATE_BLOCKED_IN_FLIGHT:创建已在进行中;不再启动第二次创建。", ); } if (input.maxNewConversationsPerDay <= 0 || input.usedInWindow + 1 > input.maxNewConversationsPerDay) { return refusal( AUTO_CREATE_OUTCOMES.DISABLED_BY_LIMIT, "AUTO_CREATE_DISABLED_BY_LIMIT:自动新建额度已关闭或已用尽;不创建、不重试。", ); } return { ok: true, outcome: AUTO_CREATE_OUTCOMES.ALLOWED, reason: "自动新建已获允许,可进入既有创建管线。" }; } function refusal(outcome: AutoCreateOutcome, reason: string): AutoCreateDecision { return { ok: false, outcome, reason }; } function describeValue(value: unknown): string { if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value); if (value === null) return "null"; if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value); return typeof value; }