Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions docs/codex-plugin-dev-release-engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
241 changes: 230 additions & 11 deletions scripts/codex-dev-profile.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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, {
Expand All @@ -98,19 +129,147 @@ 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 },
})
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([
Expand 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,
Expand All @@ -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`)
}

Expand All @@ -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()
Loading