/** * The one resolver for every durable path the bridge touches. * * Until now each module computed its own path from `homedir()`, which made test isolation * impossible without monkey-patching the filesystem: a harness could set a test session id and * still write into the real registry, and it did -- `mock-session-alpha` left real residue that * had to be cleaned by hand. * * The shape is not "one HOME variable for everything". The external ruling froze three classes: * * 记录跟随 state root mutable durable records (registry, per-binding state, consent, * orphans, legacy driver state) resolve under PI_WEBGPT_HOME when set. * 配置跟随运行环境 config is a runtime input, not test-owned state. Reads come from the * real file; a write under isolation lands in an isolated copy instead * of silently modifying the real one. * 锁跟随被保护对象 locks that coordinate a REAL shared resource (a conversation, the * clipboard/focus via the browser mutex) never redirect: a test with * isolated locks driving a real resource would enter a coordination * domain invisible to production, which is the cross-wire race the * locks exist to prevent. Locks that live INSIDE a state file * (SharedStore's in-file lock record) follow their file automatically. * * So `PI_WEBGPT_HOME` owns only what a test instance may safely isolate. Setting it never * changes which conversation a lock protects or which config the browser endpoint comes from. * * Every module resolves through here rather than calling `homedir()` itself, so the classes * above are auditable in one place and a new durable file cannot quietly pick the wrong class. */ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; /** The real home of bridge state. Never moves. */ const PI_AGENT_HOME = join(homedir(), ".pi", "agent"); const CONFIG_NAME = "chatgpt-web-bridge.json"; /** The isolation root when one is set, otherwise the real home. */ export function stateRoot(): string { const override = process.env.PI_WEBGPT_HOME?.trim(); return override ? override : PI_AGENT_HOME; } /** Whether this process is running against an isolated state root. */ export function isIsolated(): boolean { return Boolean(process.env.PI_WEBGPT_HOME?.trim()); } /* ---------------- 记录类:随 state root ---------------- */ export function bindingsRegistryPath(): string { return join(stateRoot(), "chatgpt-web-bridge-bindings.json"); } export function bindingStateDir(): string { return join(stateRoot(), "chatgpt-web-bridge-state"); } export function consentStorePath(): string { return join(stateRoot(), "chatgpt-web-bridge-consent.json"); } export function orphanStorePath(): string { return join(stateRoot(), "chatgpt-web-bridge-orphans.json"); } /** T0 shadow samples (JSONL), one line per settled loop round when enabled. */ export function shadowSamplesPath(): string { return join(stateRoot(), "chatgpt-web-bridge-shadow-samples.jsonl"); } /** Pre-binding-era driver state, kept for the migration reader. */ export function legacyDriverStatePath(): string { return join(stateRoot(), "chatgpt-web-bridge-state.json"); } /* ---------------- 运行配置:跟随运行环境 ---------------- */ /** * Legacy overlay path retained for compatibility with callers that explicitly need to inspect * the owner's config after an isolated write. Runtime loaders must use runtimeConfigReadPath(). */ export function configReadPath(): string { if (isIsolated()) { const copy = join(stateRoot(), CONFIG_NAME); if (existsSync(copy)) return copy; } return join(PI_AGENT_HOME, CONFIG_NAME); } /** * Runtime config reads are hermetic in an isolated process. Keep this separate from * `configReadPath()` for the legacy overlay API: production runtime code must never make * a missing sandbox file an implicit read of the owner's real config. A missing isolated * file is intentionally returned as a non-existent path so callers fall back to defaults. */ export function runtimeConfigReadPath(): string { return isIsolated() ? join(stateRoot(), CONFIG_NAME) : join(PI_AGENT_HOME, CONFIG_NAME); } /** * Config writes under isolation land in the isolated copy. The ruling's words: a code path that * tries to save config during a self-test must write a temporary copy rather than silently * modify the real file. */ export function configWritePath(): string { return isIsolated() ? join(stateRoot(), CONFIG_NAME) : join(PI_AGENT_HOME, CONFIG_NAME); } /** The real config path, for error messages that must name it regardless of isolation. */ export function realConfigPath(): string { return join(PI_AGENT_HOME, CONFIG_NAME); } /** * Explicit source for the pre-registry migration only. No generic runtime loader may use * this path; callers must opt into the narrow legacy-field reader at the migration boundary. */ export function legacyMigrationSourcePath(): string { return realConfigPath(); } /* ---------------- 资源协调:跟随被保护对象(真实协调域) ---------------- */ /** Conversation locks protect real conversations. Isolating them would be the bug they prevent. */ export function conversationLockDir(): string { return join(PI_AGENT_HOME, "chatgpt-web-bridge-locks"); } /** The browser mutex protects the real clipboard and focus. Same reasoning. */ export function browserMutexDir(): string { return join(PI_AGENT_HOME, "chatgpt-web-bridge-mutex"); }