From aec0016fb442c96df4aa3a8db6be9a3b8f3a1fa9 Mon Sep 17 00:00:00 2001 From: superche <35420957+superche@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:43:12 +0800 Subject: [PATCH] fix: cold refresh Codex Dev plugin cache --- docs/codex-plugin-dev-release-engineering.md | 20 +- package.json | 3 +- scripts/codex-dev-profile.mjs | 241 ++++++++++++++++++- scripts/codex-dev-profile.test.ts | 43 ++++ 4 files changed, 290 insertions(+), 17 deletions(-) diff --git a/docs/codex-plugin-dev-release-engineering.md b/docs/codex-plugin-dev-release-engineering.md index 19f3262..810cccb 100644 --- a/docs/codex-plugin-dev-release-engineering.md +++ b/docs/codex-plugin-dev-release-engineering.md @@ -56,19 +56,29 @@ npm run codex:dev:full-gate ``` 首次接入先执行一次 `codex:dev:profile:setup`,再在独立终端启动 `codex:dev` watcher。 -profile setup 在稳定 Dev root 完成 bootstrap,并把本地 marketplace 与 +profile setup 在稳定 Dev root 完成 bootstrap,并对 Dev plugin 执行一次冷刷新:卸载 +`pippit-video@pippit-bridge-dev`、清空该 identity 在 Dev profile 下的全部版本化 cache、 +移除并重加本地 marketplace,最后重新安装 plugin。脚本会比较新 cache 与 gateway bundle +(忽略安装期 `node_modules` 和 `*.tsbuildinfo`)的 SHA-256;不一致时拒绝继续。setup 要求 +Dev App 已停止,避免已连接的 host 继续持有旧 manifest、Skill 或 Widget。 +完成冷刷新后,本地 marketplace 与 `pippit-video@pippit-bridge-dev` 安装到持久化的独立 Codex profile。默认 `CODEX_HOME` 为 `~/.codex-profiles/dev`;macOS Desktop 的独立浏览器数据目录为 `~/Library/Application Support/Codex Dev`。脚本拒绝复用生产 `~/.codex`、生产 ChatGPT 浏览器数据目录或已安装 `pippit-video@pippit-bridge` 的 profile。路径可分别通过 `PIPPIT_CODEX_DEV_PROFILE_HOME` 和 `PIPPIT_CODEX_DEV_BROWSER_DATA_DIR` 覆盖。 -macOS 使用 `npm run codex:dev:app` 启动独立 Dev App。启动参数只包含 Dev `CODEX_HOME` 与 -`--user-data-dir`,不附加 worktree 路径,避免 Electron 把目录错误解析为应用入口。重复执行时, -若相同 Dev 浏览器目录的 ChatGPT 主进程已存在,命令只报告 PID 而不再启动第二个实例。 +macOS 使用 `npm run codex:dev:app` 执行默认冷启动:先准备新 gateway,停止且只停止使用 +Dev browser-data 的 ChatGPT 主进程,执行上述冷刷新,再启动独立 Dev App。这样每次端到端 +调试都从当前 cache snapshot 和新 Codex session 开始。只需在已经完成冷刷新的 profile 上 +补启动 App 时,可使用 `npm run codex:dev:app:launch`;它不会修改 cache。 +启动参数只包含 Dev `CODEX_HOME`、Dev browser-data 和受支持的 Node 路径,不附加 worktree +路径,避免 Electron 把目录错误解析为应用入口。脚本会把当前且符合仓库 `engines` 的 Node 作为 +`PIPPIT_NODE_PATH` 传入 Dev App;不支持的 Node(例如 `22.19.0`)会在 bootstrap 前直接失败, +避免冷刷后的 plugin 又落到 GUI App 的旧 Node 环境。 登录和主题由该 profile 自身持久化:首次在 Dev App 内登录并选择主题即可;脚本不读取、复制或提交 `auth.json`、Cookie、浏览器数据等凭据。`codex:dev:profile:status` 只报告登录状态、Dev plugin -identity/version、隔离路径与正在运行的 Dev PID。 +identity/version、cache/gateway 哈希、隔离路径与正在运行的 Dev PID。 每次 candidate 必须有 `.pippit-dev/semantic-review.json`: diff --git a/package.json b/package.json index 8d11cd9..6b96edc 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "codex:dev:bootstrap": "node scripts/codex-dev.mjs bootstrap", "codex:dev:profile:setup": "node scripts/codex-dev-profile.mjs setup", "codex:dev:profile:status": "node scripts/codex-dev-profile.mjs status", - "codex:dev:app": "node scripts/codex-dev-profile.mjs launch" + "codex:dev:app": "node scripts/codex-dev-profile.mjs restart", + "codex:dev:app:launch": "node scripts/codex-dev-profile.mjs launch" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/scripts/codex-dev-profile.mjs b/scripts/codex-dev-profile.mjs index df4b5bf..11b1638 100644 --- a/scripts/codex-dev-profile.mjs +++ b/scripts/codex-dev-profile.mjs @@ -1,13 +1,17 @@ +import { createHash } from "node:crypto" import { execFile } from "node:child_process" -import { chmod, mkdir, readFile, stat } from "node:fs/promises" +import { chmod, lstat, mkdir, readFile, readdir, rm } from "node:fs/promises" import { homedir } from "node:os" -import { resolve } from "node:path" +import { dirname, relative, resolve, sep } from "node:path" import { promisify } from "node:util" const execFileAsync = promisify(execFile) const root = resolve(import.meta.dirname, "..") +const DEV_MARKETPLACE_NAME = "pippit-bridge-dev" +const DEV_PLUGIN_NAME = "pippit-video" const DEV_PLUGIN_ID = "pippit-video@pippit-bridge-dev" const RELEASE_PLUGIN_ID = "pippit-video@pippit-bridge" +const PROCESS_STOP_TIMEOUT_MS = 15_000 export function resolveProfilePaths({ env = process.env, @@ -61,17 +65,44 @@ export function findDevProcessIds(processList, browserData) { .filter(Number.isSafeInteger) } -export function buildMacLaunchArgs({ appPath, browserData, profileHome }) { +export function resolveDevPluginCacheRoot(profileHome) { + return resolve(profileHome, "plugins/cache", DEV_MARKETPLACE_NAME) +} + +export function assertSupportedDevNode(version = process.versions.node) { + const parts = version.split(".").map(part => Number.parseInt(part, 10)) + const [major, minor, patch] = parts + const supported = ( + (major === 22 && (minor > 22 || (minor === 22 && patch >= 2))) || + (major === 24 && minor >= 15) || + major >= 26 + ) + if (!supported) throw new Error(`CODEX_DEV_UNSUPPORTED_NODE:${version}`) + return version +} + +export function buildMacLaunchArgs({ + appPath, + browserData, + nodePath = process.execPath, + profileHome, +}) { return [ "-na", appPath, "--env", `CODEX_HOME=${profileHome}`, + "--env", + `PIPPIT_NODE_PATH=${nodePath}`, "--args", `--user-data-dir=${browserData}`, ] } +function resolveDevDataRoot(env = process.env, home = homedir()) { + return resolve(env.PIPPIT_BRIDGE_DEV_HOME ?? resolve(home, ".pippit-bridge/dev-v1")) +} + async function run(command, args, { env = process.env, reject = true } = {}) { try { const result = await execFileAsync(command, args, { @@ -98,12 +129,115 @@ async function run(command, args, { env = process.env, reject = true } = {}) { async function ensureOwnedDirectory(path) { await mkdir(path, { mode: 0o700, recursive: true }) await chmod(path, 0o700) - const metadata = await stat(path) + const metadata = await lstat(path) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`DEV_PROFILE_DIRECTORY_UNSAFE:${path}`) + } if (typeof process.getuid === "function" && metadata.uid !== process.getuid()) { throw new Error(`DEV_PROFILE_OWNER_MISMATCH:${path}`) } } +async function pathMetadata(path) { + try { + return await lstat(path) + } catch (error) { + if (error?.code === "ENOENT") return undefined + throw error + } +} + +async function assertSafeDevPluginCacheRoot(profileHome) { + const cacheRoot = resolveDevPluginCacheRoot(profileHome) + const relativePath = relative(profileHome, cacheRoot) + if ( + !relativePath || + relativePath.startsWith("..") || + relativePath.includes(`..${sep}`) || + dirname(dirname(relativePath)) !== "plugins" + ) { + throw new Error(`DEV_PLUGIN_CACHE_ESCAPES_PROFILE:${cacheRoot}`) + } + const metadata = await pathMetadata(cacheRoot) + if (!metadata) return { cacheRoot, exists: false } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`DEV_PLUGIN_CACHE_UNSAFE:${cacheRoot}`) + } + if (typeof process.getuid === "function" && metadata.uid !== process.getuid()) { + throw new Error(`DEV_PLUGIN_CACHE_OWNER_MISMATCH:${cacheRoot}`) + } + return { cacheRoot, exists: true } +} + +export async function clearDevPluginCache(profileHome) { + const { cacheRoot, exists } = await assertSafeDevPluginCacheRoot(profileHome) + if (!exists) return false + await rm(cacheRoot, { force: false, maxRetries: 3, recursive: true, retryDelay: 100 }) + return true +} + +async function hashTree(path) { + const hash = createHash("sha256") + async function visit(current) { + const entries = await readdir(current, { withFileTypes: true }) + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name === "node_modules" || entry.name.endsWith(".tsbuildinfo")) continue + const target = resolve(current, entry.name) + const relativePath = relative(path, target) + if (entry.isSymbolicLink()) throw new Error(`DEV_PLUGIN_CACHE_CONTAINS_SYMLINK:${target}`) + if (entry.isDirectory()) { + hash.update(`directory:${relativePath}\0`) + await visit(target) + } else if (entry.isFile()) { + hash.update(`file:${relativePath}\0`).update(await readFile(target)) + } + } + } + await visit(path) + return hash.digest("hex") +} + +async function hashTreeIfPresent(path) { + const metadata = await pathMetadata(path) + if (!metadata) return undefined + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`DEV_PLUGIN_TREE_UNSAFE:${path}`) + } + return hashTree(path) +} + +async function inspectDevPluginCache(paths, devPlugin) { + const dataRoot = resolveDevDataRoot() + const gatewayBundle = resolve(dataRoot, "gateway-bundle") + const cachePath = resolve( + resolveDevPluginCacheRoot(paths.profileHome), + DEV_PLUGIN_NAME, + devPlugin.version, + ) + const [gatewayHash, cacheHash] = await Promise.all([ + hashTreeIfPresent(gatewayBundle), + hashTreeIfPresent(cachePath), + ]) + const registeredSource = devPlugin?.source?.path + const registeredMarketplaceSource = devPlugin?.marketplaceSource?.source + const sourceMatches = ( + typeof registeredSource === "string" && + resolve(registeredSource) === gatewayBundle && + typeof registeredMarketplaceSource === "string" && + resolve(registeredMarketplaceSource) === dataRoot + ) + return { + cacheHash, + cachePath, + fresh: Boolean(sourceMatches && gatewayHash && cacheHash && gatewayHash === cacheHash), + gatewayBundle, + gatewayHash, + registeredMarketplaceSource, + registeredSource, + sourceMatches, + } +} + async function readPluginList(profileHome) { const result = await run("codex", ["plugin", "list", "--json"], { env: { ...process.env, CODEX_HOME: profileHome }, @@ -111,6 +245,31 @@ async function readPluginList(profileHome) { return JSON.parse(result.stdout) } +async function readMarketplaceList(profileHome) { + const result = await run("codex", ["plugin", "marketplace", "list", "--json"], { + env: { ...process.env, CODEX_HOME: profileHome }, + }) + return JSON.parse(result.stdout) +} + +async function readDevProcessIds(paths) { + if (process.platform !== "darwin") return [] + const processes = await run("ps", ["-axo", "pid=,command="]) + return findDevProcessIds(processes.stdout, paths.browserData) +} + +async function stopDevApp(paths) { + let pids = await readDevProcessIds(paths) + for (const pid of pids) process.kill(pid, "SIGTERM") + const deadline = Date.now() + PROCESS_STOP_TIMEOUT_MS + while (pids.length > 0 && Date.now() < deadline) { + await new Promise(resolveDelay => setTimeout(resolveDelay, 250)) + pids = await readDevProcessIds(paths) + } + if (pids.length > 0) throw new Error(`CODEX_DEV_APP_DID_NOT_STOP:${pids.join(",")}`) + return pids.length === 0 +} + async function profileStatus(paths) { const profileEnv = { ...process.env, CODEX_HOME: paths.profileHome } const [login, plugins, processes] = await Promise.all([ @@ -121,9 +280,11 @@ async function profileStatus(paths) { : Promise.resolve({ stdout: "" }), ]) const devPlugin = validatePluginIsolation(plugins) + const cache = await inspectDevPluginCache(paths, devPlugin) return { appRunningPids: findDevProcessIds(processes.stdout, paths.browserData), browserData: paths.browserData, + cache, devPlugin: { enabled: devPlugin.enabled !== false, pluginId: devPlugin.pluginId, @@ -135,24 +296,62 @@ async function profileStatus(paths) { } } -async function setup(paths) { +async function prepareGateway(paths) { + assertSupportedDevNode() await ensureOwnedDirectory(paths.profileHome) await ensureOwnedDirectory(paths.browserData) + await run(process.execPath, [resolve(root, "scripts/codex-dev.mjs"), "bootstrap"]) + const dataRoot = resolveDevDataRoot() + await readFile(resolve(dataRoot, ".agents/plugins/marketplace.json"), "utf8") + return dataRoot +} + +async function assertNoReleasePlugin(paths) { const existing = await readPluginList(paths.profileHome) const installed = Array.isArray(existing?.installed) ? existing.installed : [] if (installed.some(plugin => plugin?.pluginId === RELEASE_PLUGIN_ID && plugin?.installed !== false)) { throw new Error(`DEV_PROFILE_CONTAINS_RELEASE_PLUGIN:${RELEASE_PLUGIN_ID}`) } + return existing +} - await run(process.execPath, [resolve(root, "scripts/codex-dev.mjs"), "bootstrap"]) - - const dataRoot = resolve(process.env.PIPPIT_BRIDGE_DEV_HOME ?? resolve(homedir(), ".pippit-bridge/dev-v1")) - await readFile(resolve(dataRoot, ".agents/plugins/marketplace.json"), "utf8") +async function installFreshDevPlugin(paths, dataRoot, existing) { const profileEnv = { ...process.env, CODEX_HOME: paths.profileHome } - await run("codex", ["plugin", "marketplace", "add", dataRoot, "--json"], { env: profileEnv }) + const installed = Array.isArray(existing?.installed) ? existing.installed : [] + const cacheBefore = await assertSafeDevPluginCacheRoot(paths.profileHome) + if (installed.some(plugin => plugin?.pluginId === DEV_PLUGIN_ID && plugin?.installed !== false)) { + await run("codex", ["plugin", "remove", DEV_PLUGIN_ID, "--json"], { env: profileEnv }) + } + await clearDevPluginCache(paths.profileHome) + const marketplaceList = await readMarketplaceList(paths.profileHome) + const marketplaces = Array.isArray(marketplaceList?.marketplaces) ? marketplaceList.marketplaces : [] + const marketplaceReset = marketplaces.some(marketplace => marketplace?.name === DEV_MARKETPLACE_NAME) + if (marketplaceReset) { + await run("codex", ["plugin", "marketplace", "remove", DEV_MARKETPLACE_NAME, "--json"], { env: profileEnv }) + } + await run("codex", ["plugin", "marketplace", "add", dataRoot, "--json"], { env: profileEnv }) await run("codex", ["plugin", "add", DEV_PLUGIN_ID, "--json"], { env: profileEnv }) + const status = await profileStatus(paths) + if (!status.cache.fresh) throw new Error("DEV_PLUGIN_CACHE_REFRESH_MISMATCH") + return { + ...status, + coldRefresh: { + cacheCleared: cacheBefore.exists, + marketplaceReset, + }, + } +} + +async function setup(paths) { + await ensureOwnedDirectory(paths.profileHome) + await ensureOwnedDirectory(paths.browserData) + const runningPids = await readDevProcessIds(paths) + if (runningPids.length > 0) throw new Error(`CODEX_DEV_APP_RUNNING_REQUIRES_RESTART:${runningPids.join(",")}`) + const existing = await assertNoReleasePlugin(paths) + const dataRoot = await prepareGateway(paths) + const status = await installFreshDevPlugin(paths, dataRoot, existing) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`) } @@ -173,13 +372,33 @@ async function launch(paths) { process.stdout.write(`${JSON.stringify({ ...current, launched: true }, null, 2)}\n`) } +async function restart(paths) { + if (process.platform !== "darwin") { + throw new Error("CODEX_DEV_DESKTOP_RESTART_REQUIRES_MACOS") + } + await ensureOwnedDirectory(paths.profileHome) + await ensureOwnedDirectory(paths.browserData) + const existing = await assertNoReleasePlugin(paths) + const dataRoot = await prepareGateway(paths) + const stoppedPids = await readDevProcessIds(paths) + await stopDevApp(paths) + const current = await installFreshDevPlugin(paths, dataRoot, existing) + await run("open", buildMacLaunchArgs(paths)) + process.stdout.write(`${JSON.stringify({ + ...current, + launched: true, + restartedPids: stoppedPids, + }, null, 2)}\n`) +} + async function main() { const paths = resolveProfilePaths() const command = process.argv[2] if (command === "setup") await setup(paths) else if (command === "status") await status(paths) else if (command === "launch") await launch(paths) - else throw new Error("Use setup, status, or launch.") + else if (command === "restart") await restart(paths) + else throw new Error("Use setup, status, launch, or restart.") } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) await main() diff --git a/scripts/codex-dev-profile.test.ts b/scripts/codex-dev-profile.test.ts index 0ba6f88..c528a31 100644 --- a/scripts/codex-dev-profile.test.ts +++ b/scripts/codex-dev-profile.test.ts @@ -1,8 +1,14 @@ +import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { resolve } from "node:path" import { describe, expect, it } from "vitest" import { + assertSupportedDevNode, buildMacLaunchArgs, + clearDevPluginCache, findDevProcessIds, + resolveDevPluginCacheRoot, resolveProfilePaths, validatePluginIsolation, } from "./codex-dev-profile.mjs" @@ -37,6 +43,14 @@ describe("Codex Dev profile", () => { }).profileHome).toBe("/Users/dev/.codex-profiles/dev") }) + it("requires the repository-supported Node ranges for cold setup", () => { + expect(assertSupportedDevNode("22.22.2")).toBe("22.22.2") + expect(assertSupportedDevNode("24.15.0")).toBe("24.15.0") + expect(assertSupportedDevNode("26.0.0")).toBe("26.0.0") + expect(() => assertSupportedDevNode("22.19.0")).toThrow("CODEX_DEV_UNSUPPORTED_NODE:22.19.0") + expect(() => assertSupportedDevNode("23.11.0")).toThrow("CODEX_DEV_UNSUPPORTED_NODE:23.11.0") + }) + it("requires the enabled development identity and rejects production", () => { const plugin = validatePluginIsolation({ installed: [{ @@ -69,16 +83,45 @@ describe("Codex Dev profile", () => { expect(findDevProcessIds(processes, browserData)).toEqual([101]) }) + it("clears only the isolated development marketplace cache", async () => { + const home = await mkdtemp(resolve(tmpdir(), "pippit-codex-dev-profile-")) + const devProfile = resolve(home, ".codex-profiles/dev") + const devCache = resolveDevPluginCacheRoot(devProfile) + const unrelatedCache = resolve(devProfile, "plugins/cache/openai-bundled/browser/1.0.0") + await mkdir(resolve(devCache, "pippit-video/0.2.17"), { recursive: true }) + await mkdir(unrelatedCache, { recursive: true }) + await writeFile(resolve(devCache, "pippit-video/0.2.17/stale.txt"), "stale") + await writeFile(resolve(unrelatedCache, "keep.txt"), "keep") + + await expect(clearDevPluginCache(devProfile)).resolves.toBe(true) + await expect(readFile(resolve(unrelatedCache, "keep.txt"), "utf8")).resolves.toBe("keep") + await expect(clearDevPluginCache(devProfile)).resolves.toBe(false) + }) + + it("refuses to follow a symlink at the development cache boundary", async () => { + const home = await mkdtemp(resolve(tmpdir(), "pippit-codex-dev-profile-")) + const devProfile = resolve(home, ".codex-profiles/dev") + const external = resolve(home, "external") + await mkdir(resolve(devProfile, "plugins/cache"), { recursive: true }) + await mkdir(external) + await symlink(external, resolveDevPluginCacheRoot(devProfile)) + + await expect(clearDevPluginCache(devProfile)).rejects.toThrow("DEV_PLUGIN_CACHE_UNSAFE") + }) + it("builds an isolated macOS launch without a workspace argument", () => { expect(buildMacLaunchArgs({ appPath: "/Applications/ChatGPT.app", browserData: "/Users/dev/Library/Application Support/Codex Dev", + nodePath: "/Users/dev/.nvm/versions/node/v24.15.0/bin/node", profileHome: "/Users/dev/.codex-profiles/dev", })).toEqual([ "-na", "/Applications/ChatGPT.app", "--env", "CODEX_HOME=/Users/dev/.codex-profiles/dev", + "--env", + "PIPPIT_NODE_PATH=/Users/dev/.nvm/versions/node/v24.15.0/bin/node", "--args", "--user-data-dir=/Users/dev/Library/Application Support/Codex Dev", ])