diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ced2ac82..a0127819 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,21 +1,33 @@ -name: Build & Release (Manual) +name: Build Artifacts on: + push: + branches: [master, main] + tags: ["v*"] + paths-ignore: + - "**/*.md" + - "docs/**" workflow_dispatch: inputs: platform: description: "Target platform" - required: false - default: "all" + required: true type: choice options: [all, mac, windows, linux] +permissions: + contents: read + +concurrency: + group: build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: "24" jobs: build-mac: - if: inputs.platform == 'all' || inputs.platform == 'mac' + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'mac' }} runs-on: macos-latest strategy: fail-fast: false @@ -40,7 +52,7 @@ jobs: path: out/*.dmg build-windows: - if: inputs.platform == 'all' || inputs.platform == 'windows' + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'windows' }} runs-on: windows-latest steps: - uses: actions/checkout@v6 @@ -65,7 +77,7 @@ jobs: path: out/*.zip build-linux: - if: inputs.platform == 'all' || inputs.platform == 'linux' + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'linux' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -81,7 +93,7 @@ jobs: npm ci - run: | sudo apt-get update - sudo apt-get install -y rpm fakeroot dpkg 7zip + sudo apt-get install -y rpm fakeroot dpkg 7zip unzip - run: node scripts/sync-upstream.js --force --skip-win - run: node scripts/patch-all.js mac-${{ matrix.arch }} - run: npm run build:linux-${{ matrix.arch }} diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 375134b5..eb62724f 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -8,16 +8,23 @@ on: force: description: "Force sync even without version change" type: boolean - default: false env: NODE_VERSION: "24" +permissions: + contents: read + +concurrency: + group: sync-upstream-${{ github.ref }} + cancel-in-progress: false + jobs: check: runs-on: ubuntu-latest outputs: has_update: ${{ steps.detect.outputs.has_update }} + latest_version: ${{ steps.detect.outputs.latest_version }} steps: - name: Checkout uses: actions/checkout@v6 @@ -34,18 +41,39 @@ jobs: - name: Check upstream versions id: detect run: | - set +e - node scripts/check-update.js --json --force 2>&1 - exit_code=$? - set -e - - if [ "${{ inputs.force }}" = "true" ]; then + RESULT="$(node scripts/check-update.js --json --force)" + echo "$RESULT" > /tmp/codex-version-check.json + + LATEST_VERSION="$(node -e " + const fs = require('fs'); + const result = JSON.parse(fs.readFileSync('/tmp/codex-version-check.json', 'utf8')); + const platforms = Object.values(result.platforms || {}); + const first = platforms.find((p) => p && p.version); + process.stdout.write(first?.version || ''); + ")" + + HAS_UPDATE="$(node -e " + const fs = require('fs'); + const result = JSON.parse(fs.readFileSync('/tmp/codex-version-check.json', 'utf8')); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const normalize = (version) => String(version || '').replace(/\\.0$/, ''); + const current = normalize(pkg.version); + const platforms = Object.values(result.platforms || {}); + const versions = platforms.map((p) => normalize(p.version)).filter(Boolean); + process.stdout.write(versions.some((v) => v !== current) ? 'true' : 'false'); + ")" + + echo "latest_version=$LATEST_VERSION" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event_name == 'workflow_dispatch' && inputs.force }}" = "true" ]; then echo "has_update=true" >> "$GITHUB_OUTPUT" - elif [ $exit_code -eq 0 ]; then + echo "Manual force sync requested." + elif [ "$HAS_UPDATE" = "true" ]; then echo "has_update=true" >> "$GITHUB_OUTPUT" + echo "Upstream update detected: ${LATEST_VERSION}" else echo "has_update=false" >> "$GITHUB_OUTPUT" - echo "No upstream update detected." + echo "No upstream update detected. Current package.json version already matches upstream." fi # ────────────────────────────────────────────── @@ -158,7 +186,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y rpm fakeroot dpkg 7zip + sudo apt-get install -y rpm fakeroot dpkg 7zip unzip - name: Install dependencies run: | @@ -187,8 +215,13 @@ jobs: # Bump version + tag + release # ────────────────────────────────────────────── release: - needs: [build-mac, build-windows, build-linux] - if: always() && needs.check.result == 'success' + needs: [check, build-mac, build-windows, build-linux] + if: >- + always() && + needs.check.result == 'success' && + needs.check.outputs.has_update == 'true' && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') runs-on: ubuntu-latest permissions: contents: write @@ -266,6 +299,6 @@ jobs: artifacts/**/*.deb artifacts/**/*.rpm artifacts/**/*.exe - draft: true + draft: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 2bdaf679..dce6848a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ npm run dev ## CI/CD GitHub Actions automatically builds on: -- Push to `master` -- Tag `v*` → Creates draft release +- Push to `master` / `main` and tag `v*` → build artifacts +- Daily upstream sync workflow → downloads upstream, reapplies patches, builds, and creates a draft release when a newer upstream version is detected ## Credits diff --git a/forge.config.js b/forge.config.js index 6bce3376..7af34178 100644 --- a/forge.config.js +++ b/forge.config.js @@ -2,6 +2,21 @@ const { FuseV1Options, FuseVersion } = require("@electron/fuses"); const path = require("path"); const fs = require("fs"); +const githubRepository = process.env.GITHUB_REPOSITORY || ""; +const githubRefName = process.env.GITHUB_REF_NAME || "master"; +const repositoryUrl = + process.env.CODEX_REPOSITORY_URL || + (githubRepository ? `https://github.com/${githubRepository}` : undefined); +const remoteIconUrl = + process.env.CODEX_ICON_URL || + (githubRepository + ? `https://raw.githubusercontent.com/${githubRepository}/${githubRefName}/resources/electron.ico` + : undefined); + +function withRepositoryHomepage(options) { + return repositoryUrl ? { ...options, homepage: repositoryUrl } : options; +} + module.exports = { packagerConfig: { name: "Codex", @@ -64,17 +79,17 @@ module.exports = { authors: "OpenAI, Cometix Space", description: "Codex Desktop App", setupIcon: "./resources/electron.ico", - iconUrl: "https://raw.githubusercontent.com/Haleclipse/CodexDesktop-Rebuild/master/resources/electron.ico", + ...(remoteIconUrl ? { iconUrl: remoteIconUrl } : {}), }, }, { name: "@electron-forge/maker-zip", platforms: ["win32"] }, { name: "@electron-forge/maker-deb", - config: { options: { name: "codex", productName: "Codex", genericName: "AI Coding Assistant", categories: ["Development", "Utility"], bin: "Codex", maintainer: "Cometix Space", homepage: "https://github.com/Haleclipse/CodexDesktop-Rebuild", icon: "./resources/electron.png" } }, + config: { options: withRepositoryHomepage({ name: "codex", productName: "Codex", genericName: "AI Coding Assistant", categories: ["Development", "Utility"], bin: "Codex", maintainer: "Cometix Space", icon: "./resources/electron.png" }) }, }, { name: "@electron-forge/maker-rpm", - config: { options: { name: "codex", productName: "Codex", genericName: "AI Coding Assistant", categories: ["Development", "Utility"], bin: "Codex", license: "Apache-2.0", homepage: "https://github.com/Haleclipse/CodexDesktop-Rebuild", icon: "./resources/electron.png" } }, + config: { options: withRepositoryHomepage({ name: "codex", productName: "Codex", genericName: "AI Coding Assistant", categories: ["Development", "Utility"], bin: "Codex", license: "Apache-2.0", icon: "./resources/electron.png" }) }, }, { name: "@electron-forge/maker-zip", platforms: ["linux"] }, ], @@ -129,13 +144,29 @@ module.exports = { for (const d of MACOS_ONLY_DIRS) skip.add(d); } let copied = 0; + let skippedForeignArch = 0; + + // @oai/sky ships Linux binaries for multiple CPU architectures. + // rpm's brp-strip runs target-arch strip over every executable in the package; + // leaving the foreign one inside the RPM makes linux-x64 fail on sky_linux_arm64 + // (and vice versa for arm64). + const shouldSkipForeignLinuxBinary = (name) => { + if (!isLinux) return false; + if (arch === "x64" && name === "sky_linux_arm64") return true; + if (arch === "arm64" && name === "sky_linux_x64") return true; + return false; + }; const copyDir = (s, d) => { fs.mkdirSync(d, { recursive: true }); for (const e of fs.readdirSync(s, { withFileTypes: true })) { const sp = path.join(s, e.name), dp = path.join(d, e.name); if (e.isDirectory()) copyDir(sp, dp); - else if (!e.isSymbolicLink()) { fs.copyFileSync(sp, dp); copied++; } + else if (!e.isSymbolicLink()) { + if (shouldSkipForeignLinuxBinary(e.name)) { skippedForeignArch++; continue; } + fs.copyFileSync(sp, dp); + copied++; + } } }; @@ -149,6 +180,7 @@ module.exports = { if (entry.isDirectory()) { copyDir(srcPath, destPath); } else if (!entry.isSymbolicLink()) { + if (shouldSkipForeignLinuxBinary(entry.name)) { skippedForeignArch++; continue; } fs.copyFileSync(srcPath, destPath); try { fs.chmodSync(destPath, 0o755); } catch {} copied++; @@ -156,6 +188,7 @@ module.exports = { } console.log(` [ok] ${copied} files (app.asar + unpacked + resources)`); + if (skippedForeignArch) console.log(` [ok] skipped ${skippedForeignArch} foreign-arch Linux binaries`); }, }, }; diff --git a/package-lock.json b/package-lock.json index 42e98022..e5619a6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "codex-rebuild", - "version": "26.506.31421", + "version": "26.623.81905", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-rebuild", - "version": "26.506.31421", + "version": "26.623.81905", "dependencies": { "@sentry/electron": "^7.5.0", "@sentry/node": "10.29.0", - "better-sqlite3": "^12.4.6", + "better-sqlite3": "^12.11.1", "electron-context-menu": "^4.1.1", "electron-squirrel-startup": "^1.0.1", "encoding": "^0.1.13", @@ -3071,7 +3071,9 @@ } }, "node_modules/better-sqlite3": { - "version": "12.9.0", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3079,7 +3081,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { diff --git a/package.json b/package.json index 6ecfee82..62a33371 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-rebuild", "productName": "Codex", - "version": "26.623.81905", + "version": "26.623.101652", "description": "Codex Electron App - Cross-platform Build", "main": "src/.vite/build/bootstrap.js", "scripts": { @@ -12,7 +12,7 @@ "patch:win": "node scripts/patch-all.js win", "forge:package": "electron-forge package", "forge:make": "electron-forge make", - "build": "npm run build:mac-arm64", + "build": "node scripts/build-current.js", "build:mac-arm64": "node scripts/build-from-upstream.js --platform mac-arm64", "build:mac-x64": "node scripts/build-from-upstream.js --platform mac-x64", "build:mac": "npm run build:mac-arm64 && npm run build:mac-x64", @@ -46,7 +46,7 @@ "dependencies": { "@sentry/electron": "^7.5.0", "@sentry/node": "10.29.0", - "better-sqlite3": "^12.4.6", + "better-sqlite3": "^12.11.1", "electron-context-menu": "^4.1.1", "electron-squirrel-startup": "^1.0.1", "encoding": "^0.1.13", diff --git a/scripts/analyze-minidump.js b/scripts/analyze-minidump.js new file mode 100644 index 00000000..89ba7d6a --- /dev/null +++ b/scripts/analyze-minidump.js @@ -0,0 +1,244 @@ +// Minidump 解析:异常、崩溃线程、RIP 模块归属、伪栈回溯、内存分布统计 +const fs = require("fs"); + +const file = process.argv[2]; +const buf = fs.readFileSync(file); + +const streamCount = buf.readUInt32LE(8); +const streamDirRva = buf.readUInt32LE(12); + +const streams = {}; +for (let i = 0; i < streamCount; i++) { + const off = streamDirRva + i * 12; + const type = buf.readUInt32LE(off); + const size = buf.readUInt32LE(off + 4); + const rva = buf.readUInt32LE(off + 8); + streams[type] = { size, rva }; +} +console.log( + "streams: " + + Object.keys(streams) + .map((t) => t + "(" + streams[t].size + "B)") + .join(", "), +); + +// ---- ModuleListStream (4) ---- +const mods = []; +if (streams[4]) { + const { rva } = streams[4]; + const n = buf.readUInt32LE(rva); + for (let i = 0; i < n; i++) { + const off = rva + 4 + i * 108; + const base = buf.readBigUInt64LE(off); + const size = buf.readUInt32LE(off + 8); + const nameRva = buf.readUInt32LE(off + 20); + const nameLen = buf.readUInt32LE(nameRva); + const name = buf + .slice(nameRva + 4, nameRva + 4 + nameLen) + .toString("utf16le"); + mods.push({ base, size, name }); + } +} +function findMod(addr) { + for (const m of mods) { + if (addr >= m.base && addr < m.base + BigInt(m.size)) return m; + } + return null; +} +function shortName(p) { + return p.split("\\").pop(); +} + +// ---- SystemInfoStream (7) ---- +if (streams[7]) { + const r = streams[7].rva; + console.log( + "cpu_arch=" + + buf.readUInt16LE(r) + + " ncpus=" + + buf.readUInt8(r + 6) + + " winver=" + + buf.readUInt32LE(r + 8) + + "." + + buf.readUInt32LE(r + 12) + + " build=" + + buf.readUInt32LE(r + 16), + ); +} + +// ---- MiscInfoStream (15): 进程 ID/时间 ---- +if (streams[15]) { + const r = streams[15].rva; + const flags = buf.readUInt32LE(r + 4); + if (flags & 1) console.log("process_id=" + buf.readUInt32LE(r + 8)); + if (flags & 2) { + const createT = buf.readUInt32LE(r + 12); + console.log( + "process_create_time=" + new Date(createT * 1000).toISOString(), + ); + } +} + +// ---- ExceptionStream (6) ---- +let crashTid = null; +let ctxRva = null, + ctxSize = null; +if (streams[6]) { + const r = streams[6].rva; + crashTid = buf.readUInt32LE(r); + const code = buf.readUInt32LE(r + 8); + const addr = buf.readBigUInt64LE(r + 24); + const nParams = buf.readUInt32LE(r + 32); + const params = []; + for (let i = 0; i < Math.min(nParams, 4); i++) { + params.push(buf.readBigUInt64LE(r + 40 + i * 8)); + } + ctxSize = buf.readUInt32LE(r + 160); + ctxRva = buf.readUInt32LE(r + 164); + console.log( + "\nEXCEPTION tid=" + + crashTid + + " code=0x" + + code.toString(16) + + " addr=0x" + + addr.toString(16) + + " params=" + + params.map((p) => "0x" + p.toString(16)).join(","), + ); + // params[0]: 0=read 1=write 8=dep; params[1]=访问的地址 +} + +// ---- ThreadListStream (3): 找崩溃线程的栈与 context ---- +let rip = null, + rsp = null, + stackStart = null, + stackMem = null; +if (streams[3] && crashTid !== null) { + const r = streams[3].rva; + const n = buf.readUInt32LE(r); + for (let i = 0; i < n; i++) { + const off = r + 4 + i * 48; + const tid = buf.readUInt32LE(off); + if (tid !== crashTid) continue; + stackStart = buf.readBigUInt64LE(off + 24); + const stackSize = buf.readUInt32LE(off + 32); + const stackRva = buf.readUInt32LE(off + 36); + stackMem = buf.slice(stackRva, stackRva + stackSize); + const tCtxSize = buf.readUInt32LE(off + 40); + const tCtxRva = buf.readUInt32LE(off + 44); + if (!ctxRva) { + ctxRva = tCtxRva; + ctxSize = tCtxSize; + } + } +} + +// x64 CONTEXT: Rip@0xf8, Rsp@0x98 +if (ctxRva) { + rip = buf.readBigUInt64LE(ctxRva + 0xf8); + rsp = buf.readBigUInt64LE(ctxRva + 0x98); + const ripMod = findMod(rip); + console.log( + "RIP=0x" + + rip.toString(16) + + (ripMod + ? " -> " + + shortName(ripMod.name) + + "+0x" + + (rip - ripMod.base).toString(16) + : " -> "), + ); + console.log("RSP=0x" + rsp.toString(16)); +} + +// ---- 伪栈回溯:扫描栈内存中落在模块内的地址 ---- +if (stackMem && stackStart !== null && rsp !== null) { + console.log("\npseudo-stack (top 40 module hits from RSP):"); + const startOff = Number(rsp - stackStart); + let hits = 0; + for ( + let o = Math.max(0, startOff); + o + 8 <= stackMem.length && hits < 40; + o += 8 + ) { + const v = stackMem.readBigUInt64LE(o); + const m = findMod(v); + if (m) { + console.log( + " rsp+0x" + + (o - startOff).toString(16).padStart(5, "0") + + " 0x" + + v.toString(16) + + " " + + shortName(m.name) + + "+0x" + + (v - m.base).toString(16), + ); + hits++; + } + } +} + +// ---- MemoryInfoListStream (16): 虚拟内存分布统计 ---- +if (streams[16]) { + const r = streams[16].rva; + const hdrSize = buf.readUInt32LE(r); + const entrySize = buf.readUInt32LE(r + 4); + const n = Number(buf.readBigUInt64LE(r + 8)); + let commitPrivate = 0n, + commitImage = 0n, + commitMapped = 0n, + reserve = 0n; + const bigPrivate = []; + for (let i = 0; i < n; i++) { + const off = r + hdrSize + i * entrySize; + const base = buf.readBigUInt64LE(off); + const size = buf.readBigUInt64LE(off + 24); + const state = buf.readUInt32LE(off + 32); + const type = buf.readUInt32LE(off + 40); + if (state === 0x1000) { + // MEM_COMMIT + if (type === 0x20000) commitPrivate += size; + else if (type === 0x1000000) commitImage += size; + else if (type === 0x40000) commitMapped += size; + if (type === 0x20000 && size >= 0x1000000n) { + bigPrivate.push({ base, size }); + } + } else if (state === 0x2000) { + reserve += size; + } + } + const MB = (v) => (Number(v) / 1048576).toFixed(0); + console.log( + "\nmemory: commit_private=" + + MB(commitPrivate) + + "MB commit_image=" + + MB(commitImage) + + "MB commit_mapped=" + + MB(commitMapped) + + "MB reserved=" + + MB(reserve) + + "MB regions=" + + n, + ); + bigPrivate.sort((a, b) => (b.size > a.size ? 1 : -1)); + console.log("largest private commits (>=16MB):"); + for (const bp of bigPrivate.slice(0, 15)) { + console.log(" 0x" + bp.base.toString(16) + " " + MB(bp.size) + "MB"); + } +} + +// ---- 可疑模块清单 ---- +console.log("\nnon-system modules:"); +for (const m of mods) { + if (/\\Windows\\/i.test(m.name)) continue; + console.log( + " " + + shortName(m.name) + + " @0x" + + m.base.toString(16) + + " (" + + (m.size / 1048576).toFixed(1) + + "MB)", + ); +} diff --git a/scripts/build-current.js b/scripts/build-current.js new file mode 100644 index 00000000..be59b1b1 --- /dev/null +++ b/scripts/build-current.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * 根据当前操作系统和 CPU 架构分发到对应平台构建脚本。 + */ +const { spawnSync } = require("child_process"); + +const TARGET_SCRIPT = (() => { + if (process.platform === "win32") return "build:win-x64"; + if (process.platform === "darwin") { + return process.arch === "arm64" ? "build:mac-arm64" : "build:mac-x64"; + } + if (process.platform === "linux") { + return process.arch === "arm64" ? "build:linux-arm64" : "build:linux-x64"; + } + return null; +})(); + +if (!TARGET_SCRIPT) { + console.error(`[x] Unsupported platform: ${process.platform}-${process.arch}`); + process.exit(1); +} + +console.log(`[build] current platform ${process.platform}-${process.arch} -> npm run ${TARGET_SCRIPT}`); + +const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; +const result = spawnSync(npmBin, ["run", TARGET_SCRIPT], { + stdio: "inherit", + shell: false, +}); + +if (result.error) { + console.error(`[x] Failed to run npm: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/build-from-upstream.js b/scripts/build-from-upstream.js index 5e8dfa1a..dedb4c19 100644 --- a/scripts/build-from-upstream.js +++ b/scripts/build-from-upstream.js @@ -3,7 +3,9 @@ * build-from-upstream.js — Patch upstream Codex and repackage * * For macOS and Windows: no forge needed. - * Takes the upstream app, patches ASAR in-place, replaces codex CLI, outputs distributable. + * Takes the upstream app, patches ASAR in-place, and outputs distributable. + * Windows keeps the upstream codex.exe so the app-server protocol stays aligned + * with the bundled cua_node/node_repl binaries from the same MSIX. * * Usage: * node scripts/build-from-upstream.js --platform mac-arm64 @@ -12,11 +14,14 @@ */ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); const PROJECT_ROOT = path.resolve(__dirname, ".."); const SRC_DIR = path.join(PROJECT_ROOT, "src"); -const OUT_DIR = path.join(PROJECT_ROOT, "out"); +// CODEX_OUT_DIR 用于输出目录被其他进程锁定时(如 IDE 缓存 asar 句柄)换新目录构建 +const OUT_DIR = process.env.CODEX_OUT_DIR + ? path.resolve(PROJECT_ROOT, process.env.CODEX_OUT_DIR) + : path.join(PROJECT_ROOT, "out"); const TARGET_TRIPLE_MAP = { "mac-arm64": "aarch64-apple-darwin", @@ -49,6 +54,23 @@ function copyRecursive(src, dest) { return count; } +function clearExistingAsarUnpacked(asarPath) { + const unpackedPath = `${asarPath}.unpacked`; + if (fs.existsSync(unpackedPath)) { + fs.rmSync(unpackedPath, { recursive: true, force: true }); + } +} + +function asarCliPath() { + return path.join(PROJECT_ROOT, "node_modules", "@electron", "asar", "bin", "asar.mjs"); +} + +function packAsar(asarDir, asarPath, extraArgs = []) { + execFileSync(process.execPath, [asarCliPath(), "pack", asarDir, asarPath, ...extraArgs], { + stdio: "pipe", + }); +} + function resolveCodexVendor(platform) { const triple = TARGET_TRIPLE_MAP[platform]; if (!triple) return null; @@ -148,7 +170,11 @@ function buildMac(platform) { // 3. Repack patched ASAR const asarPath = path.join(resourcesDir, "app.asar"); console.log(" [asar pack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarDir}" "${asarPath}"`); + clearExistingAsarUnpacked(asarPath); + packAsar(asarDir, asarPath, [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty}", + "--unpack", "{**/*.node,**/node-pty/build/Release/*.exe}", + ]); // 4. Update ASAR integrity hash in Info.plist const infoPlist = path.join(outApp, "Contents", "Info.plist"); @@ -220,7 +246,10 @@ function buildWin(platform) { // Repack patched ASAR console.log(" [asar pack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarDir}" "${asarPath}"`); + clearExistingAsarUnpacked(asarPath); + packAsar(asarDir, asarPath, [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty,node_modules/@worklouder}", + ]); // Compute new hash and patch exe const newHash = computeAsarHeaderHash(asarPath); @@ -236,15 +265,18 @@ function buildWin(platform) { } } - // Replace codex CLI - replaceCodex(platform, resourcesDir, "codex.exe"); + // Keep the upstream Windows codex.exe. The Desktop app-server and + // cua_node/node_repl exchange Codex-specific MCP metadata; replacing only the + // CLI with @cometix/codex can make that protocol drift and break browser + // tools (for example: sandboxCwd must use the file URI scheme). + keepUpstreamCodex(platform, resourcesDir, "codex.exe"); // Create ZIP const version = getVersion(asarDir); const zipName = `Codex-win-x64-${version}.zip`; const zipPath = path.join(OUT_DIR, zipName); console.log(` [zip] ${zipName}`); - execSync(`7zz a -tzip -mx=5 "${zipPath}" .`, { cwd: outApp }); + createZip(zipPath, outApp); const sizeMB = (fs.statSync(zipPath).size / 1048576).toFixed(1); console.log(` [ok] ${zipPath} (${sizeMB} MB)`); @@ -301,6 +333,62 @@ function replaceCodex(platform, resourcesDir, binName) { } } +function keepUpstreamCodex(platform, resourcesDir, binName) { + const codexPath = path.join(resourcesDir, binName); + if (!fs.existsSync(codexPath)) { + console.log(` [!] upstream ${binName} not found for ${platform}`); + return; + } + console.log(` [codex] keeping upstream ${binName}`); +} + +function createZip(zipPath, cwd) { + const errors = []; + + for (const bin of ["7zz", "7z"]) { + try { + execFileSync(bin, ["a", "-tzip", "-mx=5", zipPath, "."], { cwd, stdio: "pipe" }); + return; + } catch (e) { + errors.push(`${bin}: ${e.message}`); + } + } + + if (process.platform === "win32") { + try { + execFileSync("powershell", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "& { param($src, $zip) " + + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + "if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }; " + + "[System.IO.Compression.ZipFile]::CreateFromDirectory($src, $zip, " + + "[System.IO.Compression.CompressionLevel]::Optimal, $false) }", + cwd, + zipPath, + ], { stdio: "pipe" }); + return; + } catch (e) { + errors.push(`powershell: ${e.message}`); + } + } + + for (const [bin, args] of [ + ["zip", ["-r", zipPath, "."]], + ["tar", ["-a", "-cf", zipPath, "."]], + ]) { + try { + execFileSync(bin, args, { cwd, stdio: "pipe" }); + return; + } catch (e) { + errors.push(`${bin}: ${e.message}`); + } + } + + throw new Error(`Failed to create ZIP: ${errors.join("; ")}`); +} + function getVersion(asarDir) { try { const pkg = JSON.parse(fs.readFileSync(path.join(asarDir, "package.json"), "utf-8")); diff --git a/scripts/bump-version.js b/scripts/bump-version.js index b0f3ec93..ada2f563 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -20,9 +20,12 @@ const ROOT_PKG = path.join(__dirname, "..", "package.json"); const SRC_DIR = path.join(__dirname, "..", "src"); function findUpstreamPkg() { - for (const plat of ["unix", "win"]) { - const p = path.join(SRC_DIR, plat, "package.json"); - if (fs.existsSync(p)) return p; + const platforms = ["mac-arm64", "mac-x64", "win", "linux-x64", "linux-arm64", "unix"]; + for (const plat of platforms) { + for (const rel of [path.join(plat, "_asar", "package.json"), path.join(plat, "package.json")]) { + const p = path.join(SRC_DIR, rel); + if (fs.existsSync(p)) return p; + } } // Legacy fallback const legacy = path.join(SRC_DIR, "package.json"); @@ -35,7 +38,7 @@ function main() { const upstreamPath = findUpstreamPkg(); if (!upstreamPath) { - console.error("[x] No upstream package.json found in src/{unix,win}/"); + console.error("[x] No upstream package.json found in src/{platform}/_asar/ or src/"); process.exit(1); } diff --git a/scripts/launch-codex-forensics.cmd b/scripts/launch-codex-forensics.cmd new file mode 100644 index 00000000..1b526399 --- /dev/null +++ b/scripts/launch-codex-forensics.cmd @@ -0,0 +1,18 @@ +@echo off +rem Launch Codex with full Chromium logging enabled. +rem ELECTRON_ENABLE_LOGGING/ELECTRON_LOG_FILE work for the browser process +rem itself, unlike appendSwitch which only affects child processes. + +if not exist "%LOCALAPPDATA%\CodexForensics" mkdir "%LOCALAPPDATA%\CodexForensics" + +set ELECTRON_ENABLE_LOGGING=file +set ELECTRON_LOG_FILE=%LOCALAPPDATA%\CodexForensics\chrome-debug.log + +rem 优先使用 out-fix(out/ 被 IDE 锁定时的替代输出目录) +set CODEX_EXE=%~dp0..\out-fix\win\Codex-win32-x64\Codex.exe +if not exist "%CODEX_EXE%" set CODEX_EXE=%~dp0..\out\win\Codex-win32-x64\Codex.exe + +start "" "%CODEX_EXE%" +echo Codex launched: %CODEX_EXE% +echo Chromium logging to: +echo %LOCALAPPDATA%\CodexForensics\chrome-debug.log diff --git a/scripts/patch-all.js b/scripts/patch-all.js index 4a837283..e6ec15c5 100644 --- a/scripts/patch-all.js +++ b/scripts/patch-all.js @@ -3,12 +3,13 @@ * Run all patch scripts in sequence. * * Usage: - * node scripts/patch-all.js # Patch both platforms - * node scripts/patch-all.js unix # Patch unix only + * node scripts/patch-all.js # Patch all generated platforms + * node scripts/patch-all.js unix # Patch mac-arm64 + mac-x64 only * node scripts/patch-all.js win # Patch win only * node scripts/patch-all.js --check # Dry-run all */ const { execFileSync } = require("child_process"); +const fs = require("fs"); const path = require("path"); const PATCHES = [ @@ -17,32 +18,57 @@ const PATCHES = [ "patch-devtools.js", "patch-fast-mode.js", "patch-plugin-auth.js", + "patch-composer-workspace-root.js", "patch-updater.js", "patch-archive-delete.js", + "patch-crash-forensics.js", + "patch-worker-forensics.js", + "patch-worker-limits.js", + "patch-diff-limits.js", + "patch-git-output-cap.js", + "patch-sentry-scope.js", + "patch-cdp-screenshot.js", ]; function main() { const args = process.argv.slice(2); const platform = args.find((a) => ["mac-arm64", "mac-x64", "win", "unix"].includes(a)); const extra = args.filter((a) => a.startsWith("--")); - const passArgs = [...(platform ? [platform] : []), ...extra]; + const targetPlatforms = platform === "unix" + ? ["mac-arm64", "mac-x64"].filter((p) => + fs.existsSync(path.join(__dirname, "..", "src", p, "_asar")), + ) + : platform + ? [platform] + : [null]; + + if (platform === "unix" && targetPlatforms.length === 0) { + console.log("[skip] No generated unix/mac platform sources found"); + return; + } let failed = 0; - for (const script of PATCHES) { - const scriptPath = path.join(__dirname, script); - const label = script.replace(".js", ""); - console.log(`\n== ${label} ==`); + for (const targetPlatform of targetPlatforms) { + const passArgs = [...(targetPlatform ? [targetPlatform] : []), ...extra]; + const scope = targetPlatform ? ` (${targetPlatform})` : ""; + + for (const script of PATCHES) { + const scriptPath = path.join(__dirname, script); + const label = script.replace(".js", ""); + console.log(`\n== ${label}${scope} ==`); - try { - execFileSync("node", [scriptPath, ...passArgs], { stdio: "inherit" }); - } catch (e) { - console.error(`[x] ${label} failed (exit ${e.status})`); - failed++; + try { + execFileSync("node", [scriptPath, ...passArgs], { stdio: "inherit" }); + } catch (e) { + console.error(`[x] ${label}${scope} failed (exit ${e.status})`); + failed++; + } } } - console.log(`\n== Summary: ${PATCHES.length - failed}/${PATCHES.length} succeeded ==`); + const total = PATCHES.length * targetPlatforms.length; + console.log(`\n== Summary: ${total - failed}/${total} succeeded ==`); if (failed > 0) process.exit(1); } diff --git a/scripts/patch-archive-delete.js b/scripts/patch-archive-delete.js index fa9f8aa5..fed5fec6 100644 --- a/scripts/patch-archive-delete.js +++ b/scripts/patch-archive-delete.js @@ -17,7 +17,7 @@ const { locateBundles, relPath } = require("./patch-util"); // ─── Layer 1: app-main route injection ────────────────────────── -function patchAppMain(bundles) { +function patchAppMain(bundles, isCheck) { let patched = 0; for (const bundle of bundles) { const code = fs.readFileSync(bundle.path, "utf-8"); @@ -47,6 +47,15 @@ function patchAppMain(bundles) { const inject = `,${q}delete-conversation${q}:${wrapperFn}(async(${mgrVar},{conversationId:${cidVar}})=>{await ${mgrVar}.sendRequest(${q}thread/delete${q},{threadId:${cidVar}})})`; const newCode = code.slice(0, anchorEnd) + inject + code.slice(anchorEnd); + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would inject delete-conversation route (wrapper=${wrapperFn})`, + ); + patched++; + continue; + } + fs.writeFileSync(bundle.path, newCode); console.log(` [ok] ${relPath(bundle.path)}: injected delete-conversation route (wrapper=${wrapperFn})`); patched++; @@ -56,7 +65,7 @@ function patchAppMain(bundles) { // ─── Layer 2: data-controls delete button injection ───────────── -function patchDataControls(bundles) { +function patchDataControls(bundles, isCheck) { let patched = 0; for (const bundle of bundles) { const code = fs.readFileSync(bundle.path, "utf-8"); @@ -256,6 +265,15 @@ function patchDataControls(bundles) { const newArray = `[${contentVar},${deleteBtn},${unarchiveBtnVar}]`; const newCode = code.slice(0, childrenArrayStart) + newArray + code.slice(childrenArrayEnd); + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would inject delete button` + + ` (thread=${threadVar} host=${hostIdVar} qc=${queryClientVar} btn=${btnComponent})`, + ); + patched++; + continue; + } + fs.writeFileSync(bundle.path, newCode); console.log( ` [ok] ${relPath(bundle.path)}: injected delete button` + @@ -270,6 +288,7 @@ function patchDataControls(bundles) { function main() { const args = process.argv.slice(2); + const isCheck = args.includes("--check"); const platform = args.find((a) => ["mac-arm64", "mac-x64", "win"].includes(a), ); @@ -280,7 +299,7 @@ function main() { pattern: /^app-main-.*\.js$/, ...(platform ? { platform } : {}), }); - const routePatched = patchAppMain(appMainBundles); + const routePatched = patchAppMain(appMainBundles, isCheck); console.log(" [layer 2] data-controls: delete button"); const dataControlsBundles = locateBundles({ @@ -288,7 +307,7 @@ function main() { pattern: /^data-controls-.*\.js$/, ...(platform ? { platform } : {}), }); - const btnPatched = patchDataControls(dataControlsBundles); + const btnPatched = patchDataControls(dataControlsBundles, isCheck); console.log(` [done] routes: ${routePatched}, buttons: ${btnPatched}`); } diff --git a/scripts/patch-cdp-screenshot.js b/scripts/patch-cdp-screenshot.js new file mode 100644 index 00000000..cd29b4be --- /dev/null +++ b/scripts/patch-cdp-screenshot.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * patch-cdp-screenshot.js — 驯服 browser-use 的 CDP 全页截图,防主进程内存风暴 + * + * 背景:崩溃取证(minidump + 内存采样)证实主进程崩溃源于 native 内存压力: + * RSS 周期性冲到 3GB+,崩溃瞬间 commit 2.6GB、最大单块 634MB,chrome.dll 内部 + * 分配失败后近空指针读写(两次 dump 位置漂移:读 0x2 / 写 0x0)。 + * + * 元凶是 agent browser-use 的 CDP 截图管线:`Page.captureScreenshot` 带 + * `captureBeyondViewport:true` + 整页 clip + PNG——整页光栅化位图 → PNG 编码 + * → base64 经 CDP JSON 回传主进程 → 解码副本,单次几百 MB,agent 每步操作 + * 都截一张。 + * + * 修复:在主进程 CDP 统一转发点(sendDebuggerCommand → debugger.sendCommand) + * 注入参数守卫,仅拦截 captureBeyondViewport===true 的整页截图请求: + * 1. captureBeyondViewport → false,删除整页 clip(只截可视区, + * OpenAI/Anthropic 官方 computer-use 均为视口截图,agent 可滚动后再截) + * 2. format 未指定或为 png 时改为 jpeg quality=60(buffer 与 base64 体积 + * 缩一个数量级) + * 视口/小区域截图(annotation、comment、剪贴板等)不受任何影响。 + * + * 上游 waitForCaptureSurface 仍按原始参数等待整页 surface,等不到时 1 秒 + * (TJ=1e3) 超时后照常继续,功能无损,最坏多 1 秒延迟。 + * + * 写入前用 acorn 对整份文件做语法校验,解析失败则中止不写。 + * + * Usage: + * node scripts/patch-cdp-screenshot.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-cdp-screenshot.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const MARKER = "__codexCdpGuard"; + +// 守卫函数:挂在 globalThis 上,避免与压缩后的模块作用域变量冲突。 +// 只动整页截图请求,其余参数原样透传;任何异常都回退为原参数。 +const GUARD_DEF = + ";globalThis." + + MARKER + + "=function(m,p){try{if(m===`Page.captureScreenshot`&&p&&typeof p==`object`&&p.captureBeyondViewport===!0){var q={};for(var k in p)q[k]=p[k];q.captureBeyondViewport=!1;delete q.clip;(q.format==null||q.format===`png`)&&(q.format=`jpeg`,q.quality=60);return q}}catch(e){}return p};\n"; + +// CDP 统一转发点(sendDebuggerCommand 内部)——main bundle 中唯一 +const ANCHOR = + "return await hY(e.webContents.debugger.sendCommand(t,n,i),this.cdpCommandTimeoutMs,"; +const REPLACEMENT = + "return await hY(e.webContents.debugger.sendCommand(t,globalThis." + + MARKER + + "(t,n),i),this.cdpCommandTimeoutMs,"; + +function parseOk(code) { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + return true; + } catch { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + return true; + } catch { + return false; + } + } +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const bundles = locateBundles({ + dir: "build", + pattern: /^main-.*\.js$/, + platform, + }); + + if (bundles.length === 0) { + console.log(" [skip] main bundle not found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + const code = fs.readFileSync(bundle.path, "utf-8"); + + if (code.includes(MARKER)) { + console.log(` [ok] ${relPath(bundle.path)}: already patched`); + continue; + } + + // 锚点可能因不同平台的压缩变量名不同而变化,用正则放宽标识符 + let next = null; + if (code.includes(ANCHOR)) { + next = GUARD_DEF + code.replace(ANCHOR, REPLACEMENT); + } else { + const re = + /return await ([\w$]+)\((\w+)\.webContents\.debugger\.sendCommand\((\w+),(\w+),(\w+)\),this\.cdpCommandTimeoutMs,/; + const m = code.match(re); + if (m) { + const rep = `return await ${m[1]}(${m[2]}.webContents.debugger.sendCommand(${m[3]},globalThis.${MARKER}(${m[3]},${m[4]}),${m[5]}),this.cdpCommandTimeoutMs,`; + next = GUARD_DEF + code.replace(re, rep); + } + } + + if (next == null) { + console.log( + ` [!] ${relPath(bundle.path)}: CDP forward anchor not found, skipping`, + ); + continue; + } + + if (!parseOk(next)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-inject parse failed, aborting`, + ); + continue; + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would patch CDP screenshot guard`, + ); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: CDP fullpage screenshot -> viewport jpeg q60`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-composer-workspace-root.js b/scripts/patch-composer-workspace-root.js new file mode 100644 index 00000000..aba50621 --- /dev/null +++ b/scripts/patch-composer-workspace-root.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Post-build patch: make new local conversations honor the active workspace root. + * + * Upstream bundle behavior: + * a?.workspaceRoots ?? n.workspaceRoots ?? [`~`] + * + * If the composer context misses workspaceRoots, `[`~`]` is treated as a + * projectless conversation and the app creates/uses ~/Documents/Codex. The + * desktop project picker already updates the active-workspace-roots query, so + * this patch uses that query as the local-only fallback before falling back to + * projectless. + * + * Usage: + * node scripts/patch-composer-workspace-root.js [platform] # Apply patch + * node scripts/patch-composer-workspace-root.js --check # Dry-run + */ +const fs = require("fs"); +const path = require("path"); +const { SRC_DIR, relPath } = require("./patch-util"); + +const PATCHED_MARKER = "c===`local`?(e.get(Oi)?.data?.roots??[]).filter"; + +const OLD_SNIPPET = + "E=async(n,r,i,a)=>{let o=a?.workspaceRoots??n.workspaceRoots??[`~`],s=Ks(o),c=a?.hostId??v,u=C(a),{context:f,goal:h}=await T(n,c),g=MF(f,c),E=!1,D=Dn(f.imageAttachments),O=e.get(Ok),k=er(f);try{let n=await FF({hostId:c,prompt:k,projectlessPrewarmReservation:_,workspaceRoots:o}),a=n.cwd??r,l=await AF({activeCollaborationMode:t,context:f,hostId:c,scope:e,serviceTier:y}),v=await Cg({context:f,prompt:k,workspaceRoots:n.workspaceRoots,cwd:a,hostId:c,agentMode:u.agentMode,permissionProfileId:u.permissionProfileId,serviceTier:l.serviceTier,collaborationMode:l.collaborationMode,memoryPreferences:O??void 0,workspaceKind:s?`projectless`:`project`,projectlessOutputDirectory:n.projectlessOutputDirectory,projectAssignment:n.projectAssignment})"; + +const NEW_SNIPPET = + "E=async(n,r,i,a)=>{let c=a?.hostId??v,o=a?.workspaceRoots??n.workspaceRoots??(c===`local`?(e.get(Oi)?.data?.roots??[]).filter(e=>e!=null&&e!==`~`):[]);o.length===0&&(o=[`~`]);let s=Ks(o),u=C(a),{context:f,goal:h}=await T(n,c),g=MF(f,c),E=!1,D=Dn(f.imageAttachments),O=e.get(Ok),k=er(f);try{let n=await FF({hostId:c,prompt:k,projectlessPrewarmReservation:_,workspaceRoots:o}),a=n.cwd??r,l=await AF({activeCollaborationMode:t,context:f,hostId:c,scope:e,serviceTier:y}),v=await Cg({context:f,prompt:k,workspaceRoots:n.workspaceRoots,cwd:a,hostId:c,agentMode:u.agentMode,permissionProfileId:u.permissionProfileId,serviceTier:l.serviceTier,collaborationMode:l.collaborationMode,memoryPreferences:O??void 0,workspaceKind:s?`projectless`:`project`,projectlessOutputDirectory:n.projectlessOutputDirectory,projectAssignment:n.projectAssignment})"; + +function getPlatforms(platform) { + if (platform) return [platform]; + return ["mac-arm64", "mac-x64", "win"].filter((p) => + fs.existsSync(path.join(SRC_DIR, p, "_asar", "webview", "assets")), + ); +} + +function findComposerBundles(platform) { + const targets = []; + for (const plat of getPlatforms(platform)) { + const assetsDir = path.join(SRC_DIR, plat, "_asar", "webview", "assets"); + if (!fs.existsSync(assetsDir)) continue; + for (const file of fs.readdirSync(assetsDir)) { + if (!/^composer-.*\.js$/.test(file)) continue; + const filePath = path.join(assetsDir, file); + const source = fs.readFileSync(filePath, "utf-8"); + if ( + source.includes("projectlessPrewarmReservation") && + source.includes("workspaceRoots") && + (source.includes(OLD_SNIPPET) || source.includes(PATCHED_MARKER)) + ) { + targets.push({ platform: plat, path: filePath, source }); + } + } + } + return targets; +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const targets = findComposerBundles(platform); + if (targets.length === 0) { + console.log(" [skip] No matching composer bundle found"); + return; + } + + let patched = 0; + for (const target of targets) { + const label = relPath(target.path); + if (target.source.includes(PATCHED_MARKER)) { + console.log(` [ok] ${label}: already patched`); + continue; + } + + const count = target.source.split(OLD_SNIPPET).length - 1; + if (count !== 1) { + console.log(` [!] ${label}: expected 1 patch site, found ${count}`); + continue; + } + + if (isCheck) { + console.log(` [?] ${label}: would patch local workspace fallback`); + patched++; + continue; + } + + const next = target.source.replace(OLD_SNIPPET, NEW_SNIPPET); + fs.writeFileSync(target.path, next, "utf-8"); + console.log(` [ok] ${label}: local workspace fallback patched`); + patched++; + } + + if (isCheck) { + console.log(` [check] ${patched} composer bundle(s) would be patched`); + } else { + console.log(` [done] ${patched} composer bundle(s) patched`); + } +} + +main(); diff --git a/scripts/patch-crash-forensics.js b/scripts/patch-crash-forensics.js new file mode 100644 index 00000000..de733afc --- /dev/null +++ b/scripts/patch-crash-forensics.js @@ -0,0 +1,624 @@ +#!/usr/bin/env node +/** + * patch-crash-forensics.js — 主进程崩溃取证注入 + * + * 背景:已从 minidump 定位到崩溃发生在“主进程(browser/UI 线程)”,类型为 + * ACCESS_VIOLATION 读地址 0x2,RIP 落在 chrome.dll。现有 crashpad dump 为精简版 + * (仅崩溃线程、无符号),无法进一步定位。本补丁向主进程入口 bootstrap.js 最前端 + * 注入一段“只观测、不改行为”的取证代码,用于在下一次崩溃前后落盘可分析的证据: + * + * 1. Chromium 原生日志落盘 (--enable-logging=file / --log-file),可捕获崩溃前 + * chrome.dll 的 CHECK/DCHECK/GPU 等错误。 + * 2. child-process-gone / render-process-gone / GPU 崩溃 事件结构化落盘。 + * 3. 主进程 uncaughtException / unhandledRejection 落盘,并尽量保留默认退出语义。 + * 4. 每 30s 采样 process.memoryUsage() + app.getAppMetrics(),判断是否内存泄漏/OOM。 + * 5. 记录启动进程信息与已加载的可疑原生模块 (computer-use / device-kit / pty / sqlite)。 + * + * 全部逻辑包在 try/catch 内,任何失败都不会影响应用本身。 + * + * 注入方式:把一个“真实 JS 函数”序列化成 IIFE 预置到 bootstrap.js 顶部, + * 写入前用 acorn 对整份文件做语法校验,解析失败则中止不写。 + * + * Usage: + * node scripts/patch-crash-forensics.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-crash-forensics.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const LEGACY_MARKERS = [ + "__CODEX_CRASH_FORENSICS__", + "__CODEX_CRASH_FORENSICS_V2__", +]; +const MARKER = "__CODEX_CRASH_FORENSICS_V3__"; + +// ────────────────────────────────────────────── +// 注入体:以真实函数形式书写,保证语法正确。 +// 运行在 bootstrap.js 的 CommonJS 作用域内 (可用 require)。 +// 注意:不得依赖任何压缩后的外部变量名,全部自包含。 +// ────────────────────────────────────────────── +function __codexForensics() { + try { + var electron = require("electron"); + var app = electron.app; + var fs = require("node:fs"); + var path = require("node:path"); + var os = require("node:os"); + + // 取证输出目录:优先 LOCALAPPDATA/APPDATA,最后回退临时目录。 + // 该目录在 app ready 之前即可确定,便于给 Chromium 的 --log-file 使用。 + var baseDir = + process.env.LOCALAPPDATA || process.env.APPDATA || os.tmpdir(); + var earlyDir = path.join(baseDir, "CodexForensics"); + try { + fs.mkdirSync(earlyDir, { recursive: true }); + } catch (e) {} + + var logFile = path.join( + earlyDir, + "forensics-" + new Date().toISOString().slice(0, 10) + ".log", + ); + var chromeLog = path.join(earlyDir, "chrome-debug.log"); + + function write(line) { + try { + fs.appendFileSync( + logFile, + "[" + new Date().toISOString() + "] " + line + "\n", + ); + } catch (e) {} + } + + // 截图活动观测:只统计不干预,供高水位现场快照读取。 + // capturePage 走 Electron 原生路径;cdp 走 Page.captureScreenshot。 + var capStats = { capturePage: 0, cdpShot: 0, recent: [] }; + function noteCapture(kind, w, h, extra) { + try { + if (kind === "cdp") capStats.cdpShot++; + else capStats.capturePage++; + var rec = { t: new Date().toISOString().slice(11, 23), kind: kind }; + if (typeof w === "number" && w > 0) rec.w = Math.round(w); + if (typeof h === "number" && h > 0) rec.h = Math.round(h); + if (extra) rec.x = extra; + capStats.recent.push(rec); + if (capStats.recent.length > 40) capStats.recent.shift(); + } catch (e) {} + } + + // 首次拿到 webContents 时包装原型上的 capturePage 与 debugger.sendCommand, + // 全部原样透传参数与返回值,仅打点计数。带幂等标记防重复包装。 + function tryHookCaptures(wc) { + try { + var proto = Object.getPrototypeOf(wc); + if ( + proto && + typeof proto.capturePage === "function" && + !proto.capturePage.__codexObserved + ) { + var origCap = proto.capturePage; + proto.capturePage = function () { + try { + var r = arguments && arguments[0]; + if (r && typeof r === "object") + noteCapture("page", r.width, r.height, "rect"); + else noteCapture("page", 0, 0, "fullview"); + } catch (e) {} + return origCap.apply(this, arguments); + }; + proto.capturePage.__codexObserved = true; + write("hooked WebContents.capturePage"); + } + } catch (e) {} + try { + var dbg = wc.debugger; + if (dbg) { + var dproto = Object.getPrototypeOf(dbg); + if ( + dproto && + typeof dproto.sendCommand === "function" && + !dproto.sendCommand.__codexObserved + ) { + var origSend = dproto.sendCommand; + dproto.sendCommand = function (method, params) { + try { + if (method === "Page.captureScreenshot") { + var clip = params && params.clip; + var beyond = params && params.captureBeyondViewport === true; + noteCapture( + "cdp", + clip && clip.width, + clip && clip.height, + beyond ? "beyond" : "viewport", + ); + } + } catch (e) {} + return origSend.apply(this, arguments); + }; + dproto.sendCommand.__codexObserved = true; + write("hooked WebContents.debugger.sendCommand"); + } + } + } catch (e) {} + } + + // 高水位现场快照:把"谁在占内存"落盘——每个 webContents 的 + // 类型/URL/所属系统进程/是否挂了 CDP debugger,配合各子进程内存明细, + // 再带上最近的截图活动,用于崩溃前定位真正的内存来源。 + function snapshot(reason, mu) { + try { + var wcInfo = []; + try { + var all = electron.webContents.getAllWebContents(); + for (var i = 0; i < all.length; i++) { + var w = all[i]; + var info = {}; + try { + info.id = w.id; + } catch (e) {} + try { + info.type = w.getType(); + } catch (e) {} + try { + info.osPid = w.getOSProcessId(); + } catch (e) {} + try { + var u = w.getURL(); + info.url = u ? u.slice(0, 140) : ""; + } catch (e) {} + try { + info.dbg = + w.debugger && w.debugger.isAttached && w.debugger.isAttached() + ? 1 + : 0; + } catch (e) {} + wcInfo.push(info); + } + } catch (e) {} + var metrics = []; + try { + metrics = app.getAppMetrics().map(function (m) { + return { + pid: m.pid, + type: m.type, + wsMB: Math.round( + ((m.memory && m.memory.workingSetSize) || 0) / 1024, + ), + }; + }); + } catch (e) {} + write( + "SNAPSHOT[" + + reason + + "] main_rssMB=" + + Math.round(mu.rss / 1048576) + + " heapMB=" + + Math.round(mu.heapUsed / 1048576) + + " extMB=" + + Math.round((mu.external || 0) / 1048576) + + " capturePageTotal=" + + capStats.capturePage + + " cdpShotTotal=" + + capStats.cdpShot + + " recentCaptures=" + + JSON.stringify(capStats.recent.slice(-12)) + + " webContents=" + + JSON.stringify(wcInfo) + + " procs=" + + JSON.stringify(metrics), + ); + } catch (e) { + write("snapshot error: " + (e && e.message)); + } + } + + // 1) 打开 Chromium 原生日志到文件 (必须在 app ready 之前设置) + try { + app.commandLine.appendSwitch("enable-logging", "file"); + app.commandLine.appendSwitch("log-file", chromeLog); + app.commandLine.appendSwitch("log-level", "1"); + } catch (e) { + write("appendSwitch failed: " + (e && e.message)); + } + + write( + "=== forensics boot pid=" + + process.pid + + " ppid=" + + process.ppid + + " node=" + + process.versions.node + + " electron=" + + process.versions.electron + + " chrome=" + + process.versions.chrome + + " platform=" + + process.platform + + " arch=" + + process.arch + + " argv=" + + JSON.stringify(process.argv.slice(1)) + + " ===", + ); + + // 2) 主进程未捕获异常 / 未处理 Promise 拒绝。 + // uncaughtExceptionMonitor 只观察,不改变 Node 默认退出行为。 + process.on("uncaughtExceptionMonitor", function (err) { + write( + "MAIN uncaughtExceptionMonitor: " + + (err && err.stack ? err.stack : String(err)), + ); + }); + + // unhandledRejection 没有 monitor 事件;如果没有其他业务监听器,记录后 + // 重新抛出,让 Node/Electron 维持“未处理拒绝为致命错误”的默认行为。 + // 如果业务代码已有监听器,默认行为本来已经被业务代码接管,这里只记录。 + process.on("unhandledRejection", function (reason) { + write( + "MAIN unhandledRejection: " + + (reason && reason.stack ? reason.stack : String(reason)), + ); + try { + if (process.listenerCount("unhandledRejection") === 1) { + setImmediate(function () { + throw reason instanceof Error + ? reason + : new Error("Unhandled rejection: " + String(reason)); + }); + } + } catch (e) {} + }); + + // 记录已加载的可疑原生模块 (.node) + function dumpNativeModules(tag) { + try { + var loaded = Object.keys(require.cache || {}).filter(function (k) { + return /\.node$/i.test(k); + }); + var hot = loaded.filter(function (k) { + return /computer-use|device-kit|wl-device|node-hid|serialport|node-pty|conpty|better.?sqlite|canvas|sharp|tesseract/i.test( + k, + ); + }); + write( + tag + + " native_modules total=" + + loaded.length + + " suspects=" + + JSON.stringify(hot), + ); + } catch (e) {} + } + + function onReady() { + try { + write( + "app ready userData=" + + (function () { + try { + return app.getPath("userData"); + } catch (e) { + return "?"; + } + })() + + " version=" + + (function () { + try { + return app.getVersion(); + } catch (e) { + return "?"; + } + })(), + ); + dumpNativeModules("at-ready"); + + // 3) 子进程 / 渲染进程 / GPU 崩溃落盘 + app.on("child-process-gone", function (_e, details) { + write("child-process-gone " + JSON.stringify(details)); + try { + snapshot("child-process-gone", process.memoryUsage()); + } catch (e) {} + }); + app.on("render-process-gone", function (_e, _wc, details) { + write("render-process-gone " + JSON.stringify(details)); + }); + try { + app.on("gpu-process-crashed", function (_e, killed) { + write("gpu-process-crashed killed=" + killed); + }); + } catch (e) {} + + // 4) 观测截图活动:为已存在与后续新建的 webContents 挂钩子 + try { + var existing = electron.webContents.getAllWebContents(); + for (var i = 0; i < existing.length; i++) tryHookCaptures(existing[i]); + } catch (e) {} + try { + app.on("web-contents-created", function (_e, wc) { + tryHookCaptures(wc); + }); + } catch (e) {} + + // 启动时先落一张基线快照,确认观测链路通 + try { + snapshot("startup", process.memoryUsage()); + } catch (e) {} + } catch (e) { + write("onReady failed: " + (e && e.message)); + } + } + try { + if (app.isReady && app.isReady()) onReady(); + else app.once("ready", onReady); + } catch (e) {} + + // 内存采样 + 分级水位取证。 + // - 常规每 30s 采一次 mem。 + // - HIGH 水位 (>=2.2GB):落 SNAPSHOT 现场快照(哪个 webContents/进程占内存 + + // 最近截图活动),15s 限频。这是抓"空闲也崩"真凶的关键——崩溃前必先越过此线。 + // - CRITICAL 水位 (>=2.8GB):SNAPSHOT + 清 session 缓存止血,60s 限频。 + // 越过 HIGH 后把采样间隔临时收紧到 5s,尽量抓到临界前最后一帧。 + var HIGH_BYTES = Math.round(2.2 * 1024 * 1024 * 1024); + var CRIT_BYTES = Math.round(2.8 * 1024 * 1024 * 1024); + var SNAP_COOLDOWN_MS = 15 * 1000; + var PURGE_COOLDOWN_MS = 60 * 1000; + var NORMAL_INTERVAL = 30000; + var FAST_INTERVAL = 5000; + var lastSnap = 0; + var lastPurge = 0; + var sample = 0; + var curInterval = NORMAL_INTERVAL; + var timer = null; + + function tick() { + try { + var mu = process.memoryUsage(); + var procs = []; + try { + procs = app.getAppMetrics().map(function (m) { + return { + pid: m.pid, + type: m.type, + wsMB: Math.round( + ((m.memory && m.memory.workingSetSize) || 0) / 1024, + ), + }; + }); + } catch (e) {} + write( + "mem#" + + ++sample + + " main_rssMB=" + + Math.round(mu.rss / 1048576) + + " heapUsedMB=" + + Math.round(mu.heapUsed / 1048576) + + " externalMB=" + + Math.round((mu.external || 0) / 1048576) + + " capPage=" + + capStats.capturePage + + " cdpShot=" + + capStats.cdpShot + + " procs=" + + JSON.stringify(procs), + ); + + var now = Date.now(); + var overHigh = mu.rss >= HIGH_BYTES; + + // 越过 HIGH:现场快照(限频) + if (overHigh && now - lastSnap > SNAP_COOLDOWN_MS) { + lastSnap = now; + snapshot(mu.rss >= CRIT_BYTES ? "critical" : "high", mu); + } + + // 越过 CRITICAL:清缓存止血(限频) + if (mu.rss >= CRIT_BYTES && now - lastPurge > PURGE_COOLDOWN_MS) { + lastPurge = now; + write( + "WATERLINE main_rssMB=" + + Math.round(mu.rss / 1048576) + + " >= 2800MB, purging session caches", + ); + try { + var ses = electron.session && electron.session.defaultSession; + if (ses) { + ses.clearCache().then( + function () { + write("WATERLINE clearCache done"); + }, + function (e) { + write("WATERLINE clearCache failed: " + (e && e.message)); + }, + ); + if (ses.clearCodeCaches) { + ses.clearCodeCaches({}).catch(function () {}); + } + } + } catch (e) { + write("WATERLINE purge error: " + (e && e.message)); + } + } + + // 动态调整采样频率:高水位时收紧到 5s,回落后恢复 30s + var want = overHigh ? FAST_INTERVAL : NORMAL_INTERVAL; + if (want !== curInterval) { + curInterval = want; + try { + clearInterval(timer); + } catch (e) {} + timer = setInterval(tick, curInterval); + try { + timer.unref && timer.unref(); + } catch (e) {} + } + } catch (e) {} + } + + timer = setInterval(tick, curInterval); + try { + timer.unref && timer.unref(); + } catch (e) {} + } catch (err) { + // 取证代码自身绝不能拖垮应用 + try { + require("node:fs").appendFileSync( + require("node:path").join( + require("node:os").tmpdir(), + "codex-forensics-fatal.log", + ), + String((err && err.stack) || err) + "\n", + ); + } catch (e) {} + } +} + +const INJECT = + "/*" + MARKER + "*/;(" + __codexForensics.toString() + ")();\n"; + +function startsWithForensicsInjection(code) { + const trimmed = code.trimStart(); + if (trimmed.startsWith("/*" + MARKER + "*/;(")) return true; + for (const legacy of LEGACY_MARKERS) { + if (trimmed.startsWith("/*" + legacy + "*/;(")) return true; + } + return trimmed.startsWith("(function __codexForensics()"); +} + +function stripLeadingForensicsInjection(code) { + if (!startsWithForensicsInjection(code)) { + return { code, stripped: false }; + } + + try { + const ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + const first = ast.body && ast.body.find((node) => node.type !== "EmptyStatement"); + if (!first || first.end == null) return { code, stripped: false }; + return { + code: code.slice(first.end).replace(/^\s*\n?/, ""), + stripped: true, + }; + } catch { + return { code, stripped: false }; + } +} + +function stripKnownInjections(code) { + let next = code; + let stripped = false; + + while (true) { + const before = next; + const result = stripLeadingForensicsInjection(next); + next = result.code; + stripped = stripped || result.stripped; + + if (next === before) break; + } + + return { code: next, stripped }; +} + +function isElectronBootstrap(code) { + let ast; + try { + ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + } catch { + return false; + } + let found = false; + (function walk(node) { + if (!node || typeof node !== "object" || found) return; + if ( + node.type === "CallExpression" && + node.callee && + node.callee.type === "Identifier" && + node.callee.name === "require" + ) { + const arg = node.arguments && node.arguments[0]; + const val = + arg && arg.type === "Literal" + ? arg.value + : arg && + arg.type === "TemplateLiteral" && + arg.quasis.length === 1 && + arg.expressions.length === 0 + ? arg.quasis[0].value.cooked + : null; + if (val === "electron") found = true; + } + for (const key of Object.keys(node)) { + if (key === "type" || key === "start" || key === "end") continue; + const v = node[key]; + if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object" && v.type) walk(v); + } + })(ast); + return found; +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const bundles = locateBundles({ + dir: "build", + pattern: /^bootstrap\.js$/, + platform, + }); + + if (bundles.length === 0) { + console.log(" [skip] bootstrap.js not found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + const code = fs.readFileSync(bundle.path, "utf-8"); + + if (code.includes(MARKER)) { + const afterCurrent = stripLeadingForensicsInjection(code).code; + if (!startsWithForensicsInjection(afterCurrent)) { + console.log(` [ok] ${relPath(bundle.path)}: already patched`); + continue; + } + } + + const { code: baseCode, stripped } = stripKnownInjections(code); + if (!isElectronBootstrap(baseCode)) { + console.log( + ` [!] ${relPath(bundle.path)}: not an electron bootstrap, skipping`, + ); + continue; + } + + const next = INJECT + baseCode; + + // 写入前对最终结果做语法校验,解析失败则中止不写 + try { + acorn.parse(next, { ecmaVersion: 2022, sourceType: "script" }); + } catch (e) { + console.log( + ` [x] ${relPath(bundle.path)}: post-inject parse failed, aborting (${e.message})`, + ); + continue; + } + + if (isCheck) { + console.log(` [?] ${relPath(bundle.path)}: would inject forensics (+${INJECT.length} bytes)`); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: ${stripped ? "upgraded" : "injected"} crash forensics`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-diff-limits.js b/scripts/patch-diff-limits.js new file mode 100644 index 00000000..ac27942a --- /dev/null +++ b/scripts/patch-diff-limits.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * patch-diff-limits.js — 收紧 git diff 单命令输出上限 32MB -> 8MB(治本缓解) + * + * 背景:2026-07-04 22:22 崩溃取证实锤 worker.js 在执行 diff 类任务时 + * V8 堆暴涨到 2.8GB(large_object_space 破 1GB)。代码审查发现: + * - git 执行器 $ 支持 maxOutputBytes,超限即 kill 并报 outputLimitExceeded, + * 上游统一映射为 diff-too-large 错误(业务已有该错误的处理与 UI 文案); + * - diff 封装 b2 的兜底上限 A1=32MB,且最多 8 路并发(F1=8)拉不同文件 + * 的 diff,每路结果同时持有 Uint8Array buffer + 解码后 string 双副本, + * 再叠加 queryClient 的短期缓存 —— 32MB 上限下瞬时驻留可达数百 MB, + * GC 追不上时滚雪球,最终 native OOM 整崩。 + * + * 修复:A1 32MB -> 8MB。正常代码 review 不会看 8MB 以上的单文件 diff, + * 超限文件会走 diff-too-large 分支被跳过/提示,不再全量入内存。 + * 不改 I1 的 64MB 总量上限(j1)、cat-file 的 5MB(k1)、turn-diff 的 1MB。 + * + * 锚点:`k1=5*1024*1024,A1=32*1024*1024,j1=64*1024*1024`(bundle 内唯一)。 + * 幂等:已是 A1=8*1024*1024 则跳过。写入前 acorn 校验。 + * + * Usage: + * node scripts/patch-diff-limits.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-diff-limits.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const ANCHOR_OLD = "k1=5*1024*1024,A1=32*1024*1024,j1=64*1024*1024"; +const ANCHOR_NEW = "k1=5*1024*1024,A1=8*1024*1024,j1=64*1024*1024"; + +function parseOk(code) { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + return true; + } catch { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + return true; + } catch { + return false; + } + } +} + +function count(haystack, needle) { + let c = 0; + let i = 0; + while ((i = haystack.indexOf(needle, i)) !== -1) { + c++; + i += needle.length; + } + return c; +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const bundles = locateBundles({ + dir: "build", + pattern: /^worker\.js$/, + platform, + }); + + if (bundles.length === 0) { + console.log(" [skip] worker.js not found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + const code = fs.readFileSync(bundle.path, "utf-8"); + + if (code.includes(ANCHOR_NEW)) { + console.log(` [ok] ${relPath(bundle.path)}: already patched`); + continue; + } + + const n = count(code, ANCHOR_OLD); + if (n !== 1) { + console.log( + ` [!] ${relPath(bundle.path)}: expected exactly 1 anchor, found ${n}, skipping`, + ); + continue; + } + + const next = code.replace(ANCHOR_OLD, ANCHOR_NEW); + + if (!parseOk(next)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-patch parse failed, aborting`, + ); + continue; + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would tighten diff output limit 32MB -> 8MB`, + ); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: diff output limit tightened 32MB -> 8MB`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-fast-mode.js b/scripts/patch-fast-mode.js index 9229139e..92434843 100644 --- a/scripts/patch-fast-mode.js +++ b/scripts/patch-fast-mode.js @@ -2,37 +2,66 @@ /** * Post-build patch: Force-enable Fast mode (speed selector) * - * The speed selector is gated by authMethod === "chatgpt" checks. - * API-key users never see it because their authMethod differs. + * The speed selector and request-time service_tier plumbing are gated by + * authMethod === "chatgpt" checks. API-key users never see/use it because + * their authMethod differs. * - * This patch locates BinaryExpression nodes matching: + * This patch locates BinaryExpression nodes matching the old gate: * X.authMethod !== "chatgpt" * inside functions that also reference "fast_mode", and replaces * the comparison with !1 (always false), removing the auth gate. * - * Target: permissions-mode-helpers-*.js (or any chunk with the pattern) + * It also handles the newer gate shape: + * X.authMethod === "chatgpt" + * authMethod === "chatgpt" + * inside fast_mode functions, and expands it to also allow "apikey". + * + * Target: chunks containing "fast_mode" + "chatgpt". */ const fs = require("fs"); const path = require("path"); const { parse } = require("acorn"); const { locateBundles, relPath, SRC_DIR } = require("./patch-util"); -function walk(node, visitor) { +function walk(node, visitor, parent = null) { if (!node || typeof node !== "object") return; - if (node.type) visitor(node); + if (node.type) visitor(node, parent); for (const key of Object.keys(node)) { if (key === "type" || key === "start" || key === "end") continue; const child = node[key]; if (Array.isArray(child)) { for (const item of child) { - if (item && typeof item === "object" && item.type) walk(item, visitor); + if (item && typeof item === "object" && item.type) + walk(item, visitor, node); } } else if (child && typeof child === "object" && child.type) { - walk(child, visitor); + walk(child, visitor, node); } } } +function isChatGptLiteral(node) { + return ( + (node.type === "Literal" && node.value === "chatgpt") || + (node.type === "TemplateLiteral" && + node.expressions.length === 0 && + node.quasis.length === 1 && + node.quasis[0].value.cooked === "chatgpt") + ); +} + +function expressionSourceForApiKeySide(binary, source) { + if (isChatGptLiteral(binary.right)) return source.slice(binary.left.start, binary.left.end); + if (isChatGptLiteral(binary.left)) return source.slice(binary.right.start, binary.right.end); + return null; +} + +function isAlreadyExpandedToApiKey(parent, source) { + if (!parent || parent.type !== "LogicalExpression" || parent.operator !== "||") + return false; + return source.slice(parent.start, parent.end).includes("apikey"); +} + function collectPatches(ast, source) { const patches = []; @@ -45,28 +74,51 @@ function collectPatches(ast, source) { if (!isFn) return; const fnSrc = source.slice(node.start, node.end); - if (!fnSrc.includes("authMethod") || !fnSrc.includes("fast_mode")) return; + if (!fnSrc.includes("fast_mode") || !fnSrc.includes("chatgpt")) return; - // Inside this function, find: X.authMethod !== `chatgpt` - walk(node, (child) => { - if (child.type !== "BinaryExpression" || child.operator !== "!==") return; + walk(node, (child, parent) => { + if (child.type !== "BinaryExpression") return; const childSrc = source.slice(child.start, child.end); - if (!childSrc.includes("authMethod") || !childSrc.includes("chatgpt")) - return; - if (childSrc === "!1") return; + // Old shape: X.authMethod !== "chatgpt" gates the fast-mode selector. + if (child.operator === "!==") { + if (!childSrc.includes("authMethod") || !childSrc.includes("chatgpt")) + return; - // Avoid duplicate patches at same offset - if (patches.some((p) => p.start === child.start)) return; + if (childSrc === "!1") return; - patches.push({ - id: "fast_mode_auth_gate", - start: child.start, - end: child.end, - replacement: "!1", - original: childSrc, - }); + // Avoid duplicate patches at same offset + if (patches.some((p) => p.start === child.start)) return; + + patches.push({ + id: "fast_mode_auth_gate", + start: child.start, + end: child.end, + replacement: "!1", + original: childSrc, + }); + return; + } + + // New shape: authMethod === "chatgpt" or authKind === "chatgpt". + // Expand it to allow API-key auth as well. + if (child.operator === "===") { + const apiKeySide = expressionSourceForApiKeySide(child, source); + if (apiKeySide == null) return; + if (isAlreadyExpandedToApiKey(parent, source)) return; + + // Avoid duplicate patches at same offset + if (patches.some((p) => p.start === child.start)) return; + + patches.push({ + id: "fast_mode_api_auth_gate", + start: child.start, + end: child.end, + replacement: `${childSrc}||${apiKeySide}===\`apikey\``, + original: childSrc, + }); + } }); }); @@ -94,7 +146,7 @@ function main() { if (!f.endsWith(".js")) continue; const fp = path.join(assetsDir, f); const src = fs.readFileSync(fp, "utf-8"); - if (src.includes("authMethod") && src.includes("fast_mode")) { + if (src.includes("chatgpt") && src.includes("fast_mode")) { targets.push({ platform: plat, path: fp }); } } @@ -106,6 +158,7 @@ function main() { } let totalPatched = 0; + let totalFound = 0; for (const bundle of targets) { const source = fs.readFileSync(bundle.path, "utf-8"); @@ -121,6 +174,7 @@ function main() { const patches = collectPatches(ast, source); if (patches.length === 0) continue; + totalFound += patches.length; console.log( ` [${bundle.platform}] ${relPath(bundle.path)} (parse ${Date.now() - t0}ms)`, @@ -147,6 +201,8 @@ function main() { if (totalPatched > 0) { console.log(` [ok] ${totalPatched} auth gate(s) removed`); + } else if (isCheck && totalFound > 0) { + console.log(` [check] ${totalFound} auth gate(s) would be patched`); } else { console.log(" [ok] fast_mode auth gates already patched or absent"); } diff --git a/scripts/patch-git-output-cap.js b/scripts/patch-git-output-cap.js new file mode 100644 index 00000000..d8f1b87c --- /dev/null +++ b/scripts/patch-git-output-cap.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * patch-git-output-cap.js — git 执行器输出兜底上限 32MB(治本) + * + * 背景:2026-07-04 23:07 第三次崩溃,V3 任务归因直接点名 review-summary: + * 两个并行任务 60 秒把 worker 堆从 13MB 推到 718MB(old_space 359MB + + * large_object_space 348MB),随后 V8 共享指针压缩 cage 连续段分配失败, + * 进程主动 OOM crash(commit 仅 1.7GB,系统内存充足)。 + * 触发场景:用户授权 Codex 扫描 C 盘后跑 review。 + * + * 根因:worker.js 的 git 执行器 $ 支持 maxOutputBytes(超限 kill 并报 + * outputLimitExceeded),但大量调用点没传上限,其中 review-summary 路径的 + * `git ls-files --others`(枚举全部未跟踪文件)对超大目录会产出几百 MB + * 的单条 stdout 巨串,再 split 出百万级路径数组——堆瞬间爆炸。 + * + * 修复:给 $ 的 maxOutputBytes 解构加默认值 32MB: + * maxOutputBytes:l -> maxOutputBytes:l=33554432 + * 一处改动覆盖所有未传上限的调用点。显式传值的调用(diff 8MB、 + * cat-file 5MB、turn-diff 1MB)不受影响。正常仓库 ls-files 输出仅几 MB, + * 完全无感;病态场景(扫盘)命令被截停,走现成的 success:false 错误路径, + * 任务报错但应用不死。 + * + * 锚点:`maxOutputBytes:l,collectOutput:u=!0`(每个 bundle 内唯一)。 + * 同一 git 执行器还以副本形式打进了 src-*.js(其他进程用的共享库), + * 一并覆盖;无锚点的 src-*.js 自动跳过。 + * 幂等:已含默认值即跳过。写入前 acorn 校验。 + * + * Usage: + * node scripts/patch-git-output-cap.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-git-output-cap.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const path = require("path"); +const acorn = require("acorn"); +const { relPath, SRC_DIR } = require("./patch-util"); + +const ANCHOR_OLD = "maxOutputBytes:l,collectOutput:u=!0"; +const ANCHOR_NEW = "maxOutputBytes:l=33554432,collectOutput:u=!0"; + +function parseOk(code) { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + return true; + } catch { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + return true; + } catch { + return false; + } + } +} + +function count(haystack, needle) { + let c = 0; + let i = 0; + while ((i = haystack.indexOf(needle, i)) !== -1) { + c++; + i += needle.length; + } + return c; +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + // locateBundles 对同一 pattern 只返回一个文件,这里需要目录下全部 + // worker.js 与 src-*.js(git 执行器以副本形式存在于多个 bundle)。 + const PLATFORMS = platform + ? [platform] + : ["mac-arm64", "mac-x64", "win"]; + const bundles = []; + for (const plat of PLATFORMS) { + const d = path.join(SRC_DIR, plat, "_asar", ".vite", "build"); + if (!fs.existsSync(d)) continue; + for (const f of fs.readdirSync(d)) { + if (f === "worker.js" || /^src-.*\.js$/.test(f)) { + bundles.push({ platform: plat, path: path.join(d, f) }); + } + } + } + + if (bundles.length === 0) { + console.log(" [skip] no target bundles found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + const code = fs.readFileSync(bundle.path, "utf-8"); + + if (code.includes(ANCHOR_NEW)) { + console.log(` [ok] ${relPath(bundle.path)}: already patched`); + continue; + } + + const n = count(code, ANCHOR_OLD); + if (n === 0) { + console.log(` [--] ${relPath(bundle.path)}: no git executor here, skipping`); + continue; + } + if (n !== 1) { + console.log( + ` [!] ${relPath(bundle.path)}: expected exactly 1 anchor, found ${n}, skipping`, + ); + continue; + } + + const next = code.replace(ANCHOR_OLD, ANCHOR_NEW); + + if (!parseOk(next)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-patch parse failed, aborting`, + ); + continue; + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would add default git output cap 32MB`, + ); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: default git output cap 32MB added`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-sentry-scope.js b/scripts/patch-sentry-scope.js new file mode 100644 index 00000000..8325a6d8 --- /dev/null +++ b/scripts/patch-sentry-scope.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * patch-sentry-scope.js — 限制 Sentry breadcrumbs 体积,防 scope_v3.json 膨胀 + * + * 背景:实测发现 %APPDATA%/Codex/web/Codex/sentry/scope_v3.json 在长会话中 + * 膨胀到 40MB。原因是 app_state_snapshot 等 breadcrumb 的 data 字段随会话 + * 增长(单条可达数百 KB),而 @sentry/electron 的 SentryMinidump 集成会在 + * 每次 breadcrumb 更新时把整个 scope 同步序列化落盘。40MB JSON 的反复 + * stringify + 写盘发生在主进程,带来明显的内存波动与 CPU 开销,加剧了 + * 主进程的内存压力(WER 已记录到 RADAR_PRE_LEAK_64)。 + * + * 修复:向两处 Sentry.init(...) 注入: + * 1. maxBreadcrumbs: 20 —— scope 内最多保留 20 条(默认 100) + * 2. beforeBreadcrumb: —— 单条 data 序列化超过 4KB 时替换为占位符 + * + * 注入点特征(worker.js 与 workspace-root-drop-handler-*.js 各一处): + * dsn:XX,environment:... + * 替换为: + * dsn:XX,maxBreadcrumbs:20,beforeBreadcrumb:,environment:... + * + * 写入前用 acorn 对整份文件做语法校验,解析失败则中止不写。 + * + * Usage: + * node scripts/patch-sentry-scope.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-sentry-scope.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const MARKER = "__codex_bc_truncated"; + +// 内联注入的 beforeBreadcrumb:data 序列化超 4KB 就替换为占位符。 +// 任何异常(如循环引用)都吞掉并原样返回,绝不影响上报流程。 +const TRIM_FN = + "e=>{try{if(e&&e.data){var t=JSON.stringify(e.data);" + + "t.length>4096&&(e.data={" + + MARKER + + ":!0,bytes:t.length})}}catch(n){}return e}"; + +const OPTS = "maxBreadcrumbs:20,beforeBreadcrumb:" + TRIM_FN + ","; + +// dsn:<标识符或成员访问>,environment: —— 两处 init 共同的锚点 +const INIT_RE = /dsn:([\w$]+(?:\.[\w$]+)*),environment:/g; + +const TARGETS = [/^worker\.js$/, /^workspace-root-drop-handler-.*\.js$/]; + +function patchOne(bundlePath, isCheck) { + const code = fs.readFileSync(bundlePath, "utf-8"); + + if (code.includes(MARKER)) { + console.log(` [ok] ${relPath(bundlePath)}: already patched`); + return false; + } + + let count = 0; + const next = code.replace(INIT_RE, (_m, dsnExpr) => { + count++; + return `dsn:${dsnExpr},${OPTS}environment:`; + }); + + if (count === 0) { + console.log(` [!] ${relPath(bundlePath)}: no Sentry init anchor found`); + return false; + } + + try { + acorn.parse(next, { ecmaVersion: 2022, sourceType: "module" }); + } catch (e) { + try { + acorn.parse(next, { ecmaVersion: 2022, sourceType: "script" }); + } catch (e2) { + console.log( + ` [x] ${relPath(bundlePath)}: post-inject parse failed, aborting (${e2.message})`, + ); + return false; + } + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundlePath)}: would patch ${count} Sentry init site(s)`, + ); + return false; + } + + fs.writeFileSync(bundlePath, next); + console.log( + ` [ok] ${relPath(bundlePath)}: patched ${count} Sentry init site(s) (maxBreadcrumbs=20, data>4KB truncated)`, + ); + return true; +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + let patched = 0; + let found = 0; + + for (const pattern of TARGETS) { + const bundles = locateBundles({ dir: "build", pattern, platform }); + for (const bundle of bundles) { + found++; + if (patchOne(bundle.path, isCheck)) patched++; + } + } + + if (found === 0) { + console.log(" [skip] no target bundles found"); + return; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-statsig-logger.js b/scripts/patch-statsig-logger.js index fa17ac8e..2962b134 100644 --- a/scripts/patch-statsig-logger.js +++ b/scripts/patch-statsig-logger.js @@ -13,7 +13,7 @@ * Fallback: index-*.js (older builds) * * Usage: - * node scripts/patch-statsig-logger.js [platform] # Apply patch (unix/win/omit=both) + * node scripts/patch-statsig-logger.js [platform] # mac-arm64 | mac-x64 | win | omit=all * node scripts/patch-statsig-logger.js --check # Dry-run: report matches */ const fs = require("fs"); @@ -165,7 +165,9 @@ function locateTargets(platform) { function main() { const args = process.argv.slice(2); const isCheck = args.includes("--check"); - const platform = args.find((a) => a === "unix" || a === "win"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); const bundles = locateTargets(platform); diff --git a/scripts/patch-worker-forensics.js b/scripts/patch-worker-forensics.js new file mode 100644 index 00000000..1a66f236 --- /dev/null +++ b/scripts/patch-worker-forensics.js @@ -0,0 +1,437 @@ +#!/usr/bin/env node +/** + * patch-worker-forensics.js — app-server / worker 进程未捕获异常落盘取证 + * + * 背景:审查发现 worker.js(Node 子进程 / app-server)里 Sentry 的 + * OnUncaughtException 集成配置为 exitEvenIfOtherHandlersAreRegistered:false, + * 该进程一旦抛出未捕获异常且无其他 handler,就会 process.exit(1) 静默退出。 + * 这是“进程直接没了”的另一条可能路径(JS 层,区别于主进程 native 崩溃)。 + * + * 主进程的 crash-forensics 只覆盖 browser 进程;worker 退出虽会触发主进程 + * child-process-gone(已落盘),但拿不到 worker 内部的 JS 异常栈。本补丁向 + * worker.js 顶部注入一段取证钩子,在 worker 抛异常时把栈落盘到同一 + * CodexForensics 目录。 + * + * 关键:用 process.on("uncaughtExceptionMonitor", ...) 而非 "uncaughtException"。 + * 前者是 Node 专为“只观测”设计的事件——监听器执行后,原有的 uncaughtException + * 处理(含 Sentry 的 handler 与退出决策)照常进行,绝不改变行为。unhandledRejection + * 同样只记录不干预。 + * + * V2 新增:worker 线程内存自采样(纯观测)。 + * 背景:2026-07-04 15:03 崩溃取证显示主进程 RSS 冲到 2.9GB 时主线程 JS 堆 + * 仅 30-50MB、截图计数为 0,dump 中 640/415/320MB 巨型私有块位于 V8 堆保留区 + * ——矛盾指向跑在主进程里的 worker 线程(worker 的 V8 堆/ArrayBuffer 计入 + * RSS 但不计入主线程 heapUsed)。worker_threads 里 process.memoryUsage() 的 + * heapUsed/external/arrayBuffers 是本线程 isolate 的,正好让每个 worker 自报家门: + * - 每 30s 一条 wmem 采样(heapUsed/heapTotal/external/arrayBuffers/v8malloc/rss) + * - 本线程 heapUsed+external >= 500MB 视为高水位:加密到 5s 采样, + * 并限频落一份 V8 堆空间分布(old_space/large_object_space/...), + * 直接看出是普通对象堆积还是大字符串/大数组 + * + * V3 新增:任务归因(纯观测)。 + * 背景:22:22 崩溃锁定 worker#1 大字符串暴涨后,收紧 diff 上限 8MB 仍在 + * 22:49 复崩(堆 1.2GB 时 V8 共享指针压缩 cage 分配失败主动 OOM crash, + * 系统内存充足)——说明另有任务在搬大数据,需要精确到"哪个 RPC 任务"。 + * worker 的 RPC 走 parentPort 消息:入向 {type:'worker-request', + * request:{id,method}},出向 {type:'worker-response', response:{id,method}}。 + * 注入体在业务代码注册前: + * - 给 parentPort 加一个额外 message listener(EventEmitter 多 listener + * 互不影响)记录 in-flight 任务与最近任务环形缓冲; + * - 包一层 parentPort.postMessage 观测 worker-response 以清除 in-flight + * (apply 透传所有参数,不改行为)。 + * 每条 wmem 带 task=[进行中任务],高水位再补 recent=[最近完成/开始的任务]。 + * 堆暴涨瞬间即可从日志读出正在执行的任务名,一锤定音。 + * + * 全部逻辑包在 try/catch 内,任何失败都不影响 worker 本身。 + * 写入前用 acorn 校验,解析失败则中止不写。 + * + * Usage: + * node scripts/patch-worker-forensics.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-worker-forensics.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const MARKER = "__CODEX_WORKER_FORENSICS_V3__"; +const LEGACY_MARKERS = [ + "__CODEX_WORKER_FORENSICS__", + "__CODEX_WORKER_FORENSICS_V2__", +]; + +// 注入体:自包含 CJS,不依赖 electron(worker/utility 进程无 app)。 +function __codexWorkerForensics() { + try { + var fs = require("node:fs"); + var path = require("node:path"); + var os = require("node:os"); + var baseDir = + process.env.LOCALAPPDATA || process.env.APPDATA || os.tmpdir(); + var dir = path.join(baseDir, "CodexForensics"); + try { + fs.mkdirSync(dir, { recursive: true }); + } catch (e) {} + var logFile = path.join( + dir, + "forensics-" + new Date().toISOString().slice(0, 10) + ".log", + ); + + // 标注进程角色:worker_threads 主线程 / 工作线程 / 独立进程 + var role = "worker"; + try { + var wt = require("node:worker_threads"); + role = wt.isMainThread ? "worker-main" : "worker-thread#" + wt.threadId; + } catch (e) {} + + function write(line) { + try { + fs.appendFileSync( + logFile, + "[" + + new Date().toISOString() + + "] [" + + role + + " pid=" + + process.pid + + "] " + + line + + "\n", + ); + } catch (e) {} + } + + write("worker boot argv=" + JSON.stringify(process.argv.slice(1))); + + // uncaughtExceptionMonitor:只观测,不改变默认/Sentry 退出决策 + process.on("uncaughtExceptionMonitor", function (err) { + write( + "WORKER uncaughtException: " + + (err && err.stack ? err.stack : String(err)), + ); + }); + // unhandledRejection:只记录,不干预 + process.on("unhandledRejection", function (reason) { + write( + "WORKER unhandledRejection: " + + (reason && reason.stack ? reason.stack : String(reason)), + ); + }); + + // ===== V3:任务归因(纯观测,不改业务) ===== + // 记录 in-flight 的 worker-request 与最近完成的任务;堆暴涨时直接 + // 从 wmem 行读出正在执行的任务名。 + var inflight = {}; // id -> {m: method, t: startedAt} + var inflightCount = 0; + var recent = []; // 最近完成/取消的任务 ["method:1234ms", ...] + function recordRecent(entry) { + try { + recent.push(entry); + if (recent.length > 8) recent.shift(); + } catch (e) {} + } + function taskSummary() { + try { + var now = Date.now(); + var parts = []; + for (var k in inflight) { + var it = inflight[k]; + parts.push(it.m + "(" + Math.round((now - it.t) / 1000) + "s)"); + if (parts.length >= 5) break; + } + return parts.length ? parts.join(",") : "-"; + } catch (e) { + return "?"; + } + } + try { + var wt2 = require("node:worker_threads"); + var pp = wt2.parentPort; + if (pp && !wt2.isMainThread) { + // 入向:记录 worker-request / worker-request-cancel + pp.on("message", function (e) { + try { + if (!e || typeof e !== "object") return; + if (e.type === "worker-request" && e.request && e.request.id != null) { + var m = String(e.request.method || "?").slice(0, 48); + if (inflightCount < 64) { + if (!(e.request.id in inflight)) inflightCount++; + inflight[e.request.id] = { m: m, t: Date.now() }; + } + } else if (e.type === "worker-request-cancel" && e.id != null) { + var it = inflight[e.id]; + if (it) { + recordRecent(it.m + ":cancelled"); + delete inflight[e.id]; + inflightCount--; + } + } + } catch (e2) {} + }); + // 出向:worker-response 表示任务结束(apply 透传,不改行为) + var origPost = pp.postMessage.bind(pp); + pp.postMessage = function (msg, transfer) { + try { + if ( + msg && + typeof msg === "object" && + msg.type === "worker-response" && + msg.response && + msg.response.id != null + ) { + var it = inflight[msg.response.id]; + if (it) { + recordRecent(it.m + ":" + (Date.now() - it.t) + "ms"); + delete inflight[msg.response.id]; + inflightCount--; + } + } + } catch (e2) {} + return arguments.length > 1 + ? origPost(msg, transfer) + : origPost(msg); + }; + } + } catch (e) {} + + // ===== V2:worker 内存自采样(纯观测,不改业务) ===== + // heapUsed/external/arrayBuffers 是本线程 isolate 的,rss 是全进程的。 + var v8mod = null; + try { + v8mod = require("node:v8"); + } catch (e) {} + + var seq = 0; + var NORMAL_INTERVAL = 30000; + var FAST_INTERVAL = 5000; + var curInterval = NORMAL_INTERVAL; + var HIGH_BYTES = 500 * 1024 * 1024; // 本线程 heapUsed+external 高水位 + var lastSpacesAt = 0; + var timer = null; + + function sample() { + try { + seq++; + var mu = process.memoryUsage(); + var v8part = ""; + try { + if (v8mod) { + var hs = v8mod.getHeapStatistics(); + v8part = + " v8totalMB=" + + Math.round(hs.total_heap_size / 1048576) + + " v8mallocMB=" + + Math.round(hs.malloced_memory / 1048576); + } + } catch (e) {} + var hot = mu.heapUsed + mu.external >= HIGH_BYTES; + write( + (hot ? "WORKER-HIGH " : "") + + "wmem#" + + seq + + " heapUsedMB=" + + Math.round(mu.heapUsed / 1048576) + + " heapTotalMB=" + + Math.round(mu.heapTotal / 1048576) + + " extMB=" + + Math.round(mu.external / 1048576) + + " abMB=" + + Math.round((mu.arrayBuffers || 0) / 1048576) + + v8part + + " rssMB=" + + Math.round(mu.rss / 1048576) + + " task=[" + + taskSummary() + + "]", + ); + + // 高水位时限频补一份堆空间分布:old_space 涨=对象堆积, + // large_object_space 涨=大字符串/大数组;同时落最近完成的任务 + if (hot && v8mod && Date.now() - lastSpacesAt > 60000) { + lastSpacesAt = Date.now(); + try { + var sp = v8mod + .getHeapSpaceStatistics() + .filter(function (s) { + return s.space_used_size > 16 * 1048576; + }) + .map(function (s) { + return ( + s.space_name + + "=" + + Math.round(s.space_used_size / 1048576) + + "MB" + ); + }) + .join(" "); + write("WORKER-HEAP-SPACES " + (sp || "(all<16MB)")); + } catch (e) {} + try { + write("WORKER-RECENT-TASKS [" + recent.join(", ") + "]"); + } catch (e) {} + } + + // 动态采样频率:高水位 5s,回落 30s + var want = hot ? FAST_INTERVAL : NORMAL_INTERVAL; + if (want !== curInterval) { + curInterval = want; + try { + clearInterval(timer); + } catch (e) {} + timer = setInterval(sample, curInterval); + try { + timer.unref && timer.unref(); + } catch (e) {} + } + } catch (e) {} + } + + timer = setInterval(sample, curInterval); + try { + timer.unref && timer.unref(); + } catch (e) {} + try { + sample(); // 启动基线 + } catch (e) {} + } catch (e) { + try { + require("node:fs").appendFileSync( + require("node:path").join( + require("node:os").tmpdir(), + "codex-forensics-fatal.log", + ), + "worker-forensics: " + String((e && e.stack) || e) + "\n", + ); + } catch (e2) {} + } +} + +const INJECT = + "/*" + MARKER + "*/;(" + __codexWorkerForensics.toString() + ")();\n"; + +function parseOk(code) { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + return true; + } catch { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + return true; + } catch { + return false; + } + } +} + +function startsWithWorkerInjection(code) { + const trimmed = code.trimStart(); + if (trimmed.startsWith("/*" + MARKER + "*/;(")) return true; + for (const legacy of LEGACY_MARKERS) { + if (trimmed.startsWith("/*" + legacy + "*/;(")) return true; + } + return trimmed.startsWith("(function __codexWorkerForensics()"); +} + +// 去掉文件顶部的旧版注入(按 AST 第一条语句切除,避免手工数括号) +function stripLeadingWorkerInjection(code) { + if (!startsWithWorkerInjection(code)) return { code, stripped: false }; + let ast; + try { + ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + } catch { + try { + ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + } catch { + return { code, stripped: false }; + } + } + const first = + ast.body && ast.body.find((node) => node.type !== "EmptyStatement"); + if (!first || first.end == null) return { code, stripped: false }; + return { code: code.slice(first.end).replace(/^\s*\n?/, ""), stripped: true }; +} + +function stripKnownWorkerInjections(code) { + let next = code; + let stripped = false; + while (true) { + const before = next; + const result = stripLeadingWorkerInjection(next); + next = result.code; + stripped = stripped || result.stripped; + if (next === before) break; + } + return { code: next, stripped }; +} + +// 确认是运行 Sentry OnUncaughtException 的 worker(避免误注入其它同名文件) +function isSentryWorker(code) { + return ( + code.includes("worker_threads") && + code.includes("we are exiting the process now") + ); +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const bundles = locateBundles({ + dir: "build", + pattern: /^worker\.js$/, + platform, + }); + + if (bundles.length === 0) { + console.log(" [skip] worker.js not found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + const code = fs.readFileSync(bundle.path, "utf-8"); + + if (code.includes(MARKER)) { + console.log(` [ok] ${relPath(bundle.path)}: already patched (V3)`); + continue; + } + + // 去掉旧版注入后再判断/重注入,实现 V1/V2 -> V3 升级 + const { code: baseCode, stripped } = stripKnownWorkerInjections(code); + + if (!isSentryWorker(baseCode)) { + console.log( + ` [!] ${relPath(bundle.path)}: not the Sentry worker, skipping`, + ); + continue; + } + + const next = INJECT + baseCode; + + if (!parseOk(next)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-inject parse failed, aborting`, + ); + continue; + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would ${stripped ? "upgrade" : "inject"} worker forensics (+${INJECT.length} bytes)`, + ); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: ${stripped ? "upgraded" : "injected"} worker forensics`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/patch-worker-limits.js b/scripts/patch-worker-limits.js new file mode 100644 index 00000000..61b7c53c --- /dev/null +++ b/scripts/patch-worker-limits.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * patch-worker-limits.js — 给主进程 git/diff worker 线程加 V8 堆上限(保命线) + * + * 背景:2026-07-04 22:22 崩溃取证(V3 主进程采样 + V2 worker 采样)实锤: + * worker-manager 创建的 worker.js 线程在执行任务时 V8 堆从 52MB 暴涨到 + * 2.8GB(large_object_space 破 1GB,即巨型字符串/数组),把整个主进程 + * commit 顶到 3GB+,最终 chrome.dll 在 native 分配失败后写空指针整崩。 + * + * worker_threads 默认不限制堆大小,失控任务会拖死整个应用。本补丁在 + * new Worker(...) 处加 resourceLimits: + * - maxOldGenerationSizeMb: 1024 —— 老生代上限 1GB。正常观测基线为 + * 52MB、任务高峰几百 MB,1GB 足够正常任务;失控时 worker 以 + * ERR_WORKER_OUT_OF_MEMORY 终止,业务侧 worker-manager 已有 + * error/exit 监听与懒重建逻辑(ensureWorker),应用本体不受影响。 + * - maxYoungGenerationSizeMb: 128 —— 新生代宽松上限。 + * + * 2026-07-04 22:49 复崩后从 1536 降到 1024:当时 worker 堆到 1189MB + * 时进程先因 V8 共享指针压缩 cage(同进程全部 isolate 共享 4GB 保留 + * 地址空间)分配失败而主动 OOM crash,1536 的优雅上限来不及触发。 + * resourceLimits 只在 GC 检查点核对,必须显著低于 cage 崩溃线才有 + * 机会先优雅 OOM;1024 仍是正常基线(52MB)的 20 倍。 + * + * 只动 worker-manager 的 ensureWorker 构造点(锚点唯一); + * child-process-snapshot 等短命 worker 不动。 + * + * 幂等:构造点已含 resourceLimits 即跳过。写入前 acorn 校验。 + * + * Usage: + * node scripts/patch-worker-limits.js [platform] # mac-arm64 | mac-x64 | win | 省略=全部 + * node scripts/patch-worker-limits.js --check # 试运行,只报告 + */ +const fs = require("fs"); +const acorn = require("acorn"); +const { locateBundles, relPath } = require("./patch-util"); + +const LIMITS = "resourceLimits:{maxOldGenerationSizeMb:1024,maxYoungGenerationSizeMb:128}"; +// 已注入的旧上限(用于降级/升级替换) +const STALE_LIMITS = [ + "resourceLimits:{maxOldGenerationSizeMb:1536,maxYoungGenerationSizeMb:128}", +]; + +// ensureWorker(){...new X.Worker(i,{name:this.id,workerData:l})...} +const CTOR_RE = + /new ([\w$]+)\.Worker\(([\w$]+),\{name:this\.id,workerData:([\w$]+)\}\)/; + +function parseOk(code) { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "script" }); + return true; + } catch { + try { + acorn.parse(code, { ecmaVersion: 2022, sourceType: "module" }); + return true; + } catch { + return false; + } + } +} + +function main() { + const args = process.argv.slice(2); + const isCheck = args.includes("--check"); + const platform = args.find((a) => + ["mac-arm64", "mac-x64", "win"].includes(a), + ); + + const bundles = locateBundles({ + dir: "build", + pattern: /^main-.*\.js$/, + platform, + }); + + if (bundles.length === 0) { + console.log(" [skip] main bundle not found"); + return; + } + + let patched = 0; + for (const bundle of bundles) { + let code = fs.readFileSync(bundle.path, "utf-8"); + + // 旧上限值升级:直接替换常量串 + let upgraded = false; + for (const stale of STALE_LIMITS) { + if (code.includes(stale)) { + code = code.split(stale).join(LIMITS); + upgraded = true; + } + } + + // 幂等:worker 构造点已带最新 resourceLimits + if (code.includes(LIMITS)) { + if (upgraded) { + if (!parseOk(code)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-upgrade parse failed, aborting`, + ); + continue; + } + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would upgrade resourceLimits to old-gen 1024MB`, + ); + continue; + } + fs.writeFileSync(bundle.path, code); + console.log( + ` [ok] ${relPath(bundle.path)}: resourceLimits upgraded (old-gen 1024MB)`, + ); + patched++; + } else { + console.log(` [ok] ${relPath(bundle.path)}: already patched`); + } + continue; + } + + const matches = code.match(new RegExp(CTOR_RE.source, "g")) || []; + if (matches.length !== 1) { + console.log( + ` [!] ${relPath(bundle.path)}: expected exactly 1 worker ctor anchor, found ${matches.length}, skipping`, + ); + continue; + } + + const next = code.replace( + CTOR_RE, + (_, x, file, data) => + `new ${x}.Worker(${file},{name:this.id,workerData:${data},${LIMITS}})`, + ); + + if (!parseOk(next)) { + console.log( + ` [x] ${relPath(bundle.path)}: post-patch parse failed, aborting`, + ); + continue; + } + + if (isCheck) { + console.log( + ` [?] ${relPath(bundle.path)}: would add worker resourceLimits (old-gen 1024MB)`, + ); + continue; + } + + fs.writeFileSync(bundle.path, next); + console.log( + ` [ok] ${relPath(bundle.path)}: worker resourceLimits added (old-gen 1024MB)`, + ); + patched++; + } + + console.log(` [done] ${patched} file(s) patched`); +} + +main(); diff --git a/scripts/prepare-src.js b/scripts/prepare-src.js index 4bd50cb1..ab33d39e 100644 --- a/scripts/prepare-src.js +++ b/scripts/prepare-src.js @@ -1,12 +1,14 @@ #!/usr/bin/env node /** - * Pre-build: Repack patched ASAR, replace codex CLI, assemble for forge. + * Pre-build: Repack patched ASAR, replace codex CLI where needed, assemble for forge. * * Flow: * 1. Repack _asar/ -> app.asar (with patches applied) - * 2. Replace codex binary with @cometix/codex version + * 2. Replace codex binary with @cometix/codex version where needed * 3. Copy everything to src/ for forge (app.asar + unpacked + resources) * + * For Windows: keep upstream codex.exe so app-server and cua_node/node_repl + * stay protocol-aligned. * For Linux: strip macOS-only resources, add Linux codex from @cometix/codex * * Usage: @@ -15,7 +17,7 @@ */ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); const SRC = path.join(__dirname, "..", "src"); const PROJECT_ROOT = path.join(__dirname, ".."); @@ -36,6 +38,16 @@ const MACOS_STRIP = new Set([ ]); const MACOS_STRIP_DIRS = new Set(["native"]); +function asarCliPath() { + return path.join(PROJECT_ROOT, "node_modules", "@electron", "asar", "bin", "asar.mjs"); +} + +function packAsar(asarDir, asarPath, extraArgs = []) { + execFileSync(process.execPath, [asarCliPath(), "pack", asarDir, asarPath, ...extraArgs], { + stdio: "pipe", + }); +} + function copyRecursive(src, dest, skipFiles, skipDirs) { fs.mkdirSync(dest, { recursive: true }); let count = 0; @@ -163,15 +175,29 @@ function main() { // 1. Repack _asar/ -> app.asar const repackedAsar = path.join(sourceDir, "app.asar"); console.log(" [repack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarContentDir}" "${repackedAsar}"`); + const asarPackArgs = isLinux + ? [] + : platform === "win" + ? ["--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty,node_modules/@worklouder}"] + : [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty}", + "--unpack", "{**/*.node,**/node-pty/build/Release/*.exe}", + ]; + packAsar(asarContentDir, repackedAsar, asarPackArgs); const asarSize = (fs.statSync(repackedAsar).size / 1048576).toFixed(1); console.log(` [ok] app.asar: ${asarSize} MB`); - // 2. Replace codex binary with @cometix/codex + // 2. Replace codex binary with @cometix/codex where needed. + // Windows must keep the upstream codex.exe from the MSIX. The Desktop + // app-server and bundled cua_node/node_repl exchange Codex-specific MCP + // metadata; mixing a different @cometix/codex build can break browser tools + // (for example: sandboxCwd must use the file URI scheme). const isWin = platform === "win"; const codexBinName = isWin ? "codex.exe" : "codex"; - const vendorCodex = resolveCodexVendor(platform); - if (vendorCodex) { + const vendorCodex = isWin ? null : resolveCodexVendor(platform); + if (isWin) { + console.log(` [codex] keeping upstream ${codexBinName}`); + } else if (vendorCodex) { // For Linux: put codex in sourceDir (mac-x64/) so it can be found, // but also mark for later copy to forge output. const dest = path.join(sourceDir, codexBinName); diff --git a/scripts/sync-upstream.js b/scripts/sync-upstream.js index f0eaca36..d0578c05 100644 --- a/scripts/sync-upstream.js +++ b/scripts/sync-upstream.js @@ -6,7 +6,7 @@ * src/{platform}/ * _asar/ Extracted app.asar content (patch target) * app.asar.unpacked/ Native modules (kept as-is from upstream) - * codex|codex.exe CLI binary (will be replaced by @cometix/codex) + * codex|codex.exe CLI binary (Windows keeps upstream; Linux uses @cometix/codex later) * rg|rg.exe ripgrep binary (kept from upstream) * plugins/ Bundled plugins * native/ Platform native modules @@ -21,7 +21,7 @@ const tls = require("tls"); const http = require("http"); const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); // TLS certs for MS delivery CDN const certsDir = path.join(__dirname, "certs"); @@ -70,18 +70,90 @@ function extractArchive(archive, dest) { if (process.platform === "darwin" && archive.endsWith(".zip")) { // ditto preserves macOS symlinks + resource forks (required for .app) execSync(`ditto -xk "${archive}" "${dest}"`); - } else { - // 7zz for Windows MSIX and Linux (symlinks don't matter — only ASAR content used) - for (const bin of ["7zz", "7z"]) { - try { - execSync(`${bin} x -y -o"${dest}" "${archive}"`, { stdio: "pipe" }); + decodePercentNames(dest); + return; + } + + const errors = []; + const attempts = []; + + // macOS app ZIPs generated by ditto can be more reliably extracted on Linux + // with unzip than 7-Zip. Keep 7-Zip as the primary fallback for MSIX/ZIP. + if (archive.endsWith(".zip")) { + attempts.push(["unzip", ["-q", "-o", archive, "-d", dest]]); + } + attempts.push( + ["7zz", ["x", "-y", `-o${dest}`, archive]], + ["7z", ["x", "-y", `-o${dest}`, archive]], + ["bsdtar", ["-xf", archive, "-C", dest]], + ["tar", ["-xf", archive, "-C", dest]], + ); + + for (const [bin, args] of attempts) { + clearDir(dest); + try { + execFileSync(bin, args, { stdio: "pipe" }); + decodePercentNames(dest); + return; + } catch (e) { + try { decodePercentNames(dest); } catch {} + + // Some archive tools return non-zero for macOS metadata/resource-fork + // entries even after extracting the actual app payload. If app.asar is + // present, continue and let the later ASAR/unpacked validation decide. + if (findFile(dest, "app.asar")) { + console.log(` [extract] ${bin} exited non-zero, but app.asar is available; continuing`); return; - } catch { - if (fs.readdirSync(dest).length > 0) return; } + + const stderr = e.stderr?.toString?.().trim(); + const stdout = e.stdout?.toString?.().trim(); + const detail = stderr || stdout || e.message; + errors.push(`${bin}: ${detail.split(/\r?\n/).slice(0, 3).join(" | ")}`); } - throw new Error(`Failed to extract ${archive}`); } + + if (process.platform === "win32") { + clearDir(dest); + try { + execFileSync("powershell", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "& { param($archive, $dest) " + + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + "[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $dest) }", + archive, + dest, + ], { stdio: "pipe" }); + decodePercentNames(dest); + return; + } catch (e) { + errors.push(`powershell: ${e.message}`); + } + } + + throw new Error(`Failed to extract ${archive}: ${errors.join("; ")}`); +} + +function decodePercentNames(root) { + if (!fs.existsSync(root)) return; + + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const current = path.join(dir, e.name); + if (e.isDirectory()) walk(current); + + if (!/%[0-9a-fA-F]{2}/.test(e.name)) continue; + let decoded; + try { decoded = decodeURIComponent(e.name); } catch { continue; } + if (!decoded || decoded === e.name || /[\\/:*?"<>|\0]/.test(decoded)) continue; + + const target = path.join(dir, decoded); + if (!fs.existsSync(target)) fs.renameSync(current, target); + } + }; + walk(root); } function findFile(dir, name) { @@ -93,7 +165,32 @@ function findFile(dir, name) { return null; } +function findExistingPathCaseInsensitive(p) { + if (fs.existsSync(p)) return p; + + const parsed = path.parse(p); + let current = parsed.root; + const rest = path.relative(parsed.root, p); + if (!rest || rest.startsWith("..")) return p; + + for (const part of rest.split(/[\\/]+/)) { + if (!part) continue; + let entries; + try { + entries = fs.readdirSync(current); + } catch { + return p; + } + const match = entries.find((entry) => entry.toLowerCase() === part.toLowerCase()); + if (!match) return p; + current = path.join(current, match); + } + + return fs.existsSync(current) ? current : p; +} + function copyRecursive(src, dest) { + src = findExistingPathCaseInsensitive(src); fs.mkdirSync(dest, { recursive: true }); let count = 0; for (const e of fs.readdirSync(src, { withFileTypes: true })) { @@ -119,6 +216,108 @@ function countFiles(dir) { return n; } +function encodeScopedPackagePath(relPath) { + return relPath + .split(/[\\/]+/) + .map((part) => part.startsWith("@") ? `%40${part.slice(1)}` : part) + .join(path.sep); +} + +function resolveUnpackedFile(unpackedRoot, relPath) { + const direct = path.join(unpackedRoot, relPath); + if (fs.existsSync(direct)) return direct; + + const directCase = findExistingPathCaseInsensitive(direct); + if (fs.existsSync(directCase)) return directCase; + + // Windows Store MSIX extraction can percent-encode scoped package folders: + // @worklouder -> %40worklouder + // @serialport -> %40serialport + const encoded = path.join(unpackedRoot, encodeScopedPackagePath(relPath)); + if (fs.existsSync(encoded)) return encoded; + + const encodedCase = findExistingPathCaseInsensitive(encoded); + return fs.existsSync(encodedCase) ? encodedCase : null; +} + +function assertInside(baseDir, targetPath, label) { + const rel = path.relative(baseDir, targetPath); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`${label} escapes output: ${targetPath}`); + } +} + +function extractAsarForPatching(asarPath, asarDest) { + const asar = require("@electron/asar"); + const { header, headerSize } = asar.getRawHeader(asarPath); + const fd = fs.openSync(asarPath, "r"); + const unpackedRoot = `${asarPath}.unpacked`; + let packedCount = 0; + let unpackedCount = 0; + const missingUnpacked = []; + + // ASAR layout: 8-byte pickle header + header JSON + packed file payload. + const dataStart = 8 + headerSize; + + try { + const visit = (node, relPath) => { + const dest = path.join(asarDest, relPath); + assertInside(asarDest, dest, `ASAR entry ${relPath}`); + + if (node.files) { + fs.mkdirSync(dest, { recursive: true }); + for (const [name, child] of Object.entries(node.files)) { + visit(child, path.join(relPath, name)); + } + return; + } + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + + if (node.link) { + const linkTarget = path.join(asarDest, node.link); + assertInside(asarDest, linkTarget, `ASAR link ${relPath}`); + try { fs.unlinkSync(dest); } catch {} + fs.symlinkSync(path.relative(path.dirname(dest), linkTarget), dest); + return; + } + + const size = Number(node.size || 0); + if (node.unpacked) { + const unpackedFile = resolveUnpackedFile(unpackedRoot, relPath); + if (!unpackedFile) { + missingUnpacked.push(relPath); + return; + } + fs.copyFileSync(unpackedFile, dest); + unpackedCount++; + } else if (size <= 0) { + fs.writeFileSync(dest, Buffer.alloc(0)); + packedCount++; + } else { + const buf = Buffer.alloc(size); + fs.readSync(fd, buf, 0, size, dataStart + Number(node.offset || 0)); + fs.writeFileSync(dest, buf); + packedCount++; + } + + if (node.executable) { + try { fs.chmodSync(dest, 0o755); } catch {} + } + }; + + visit({ files: header.files }, ""); + } finally { + fs.closeSync(fd); + } + + console.log(` [asar extract] ${packedCount} packed files, ${unpackedCount} unpacked files`); + if (missingUnpacked.length > 0) { + const sample = missingUnpacked.slice(0, 5).join(", "); + throw new Error(`Missing ${missingUnpacked.length} unpacked ASAR file(s); refusing to build crash-prone stubs. First: ${sample}`); + } +} + // ─── Version detection ────────────────────────────────────────── async function getAppcastVersion(url) { @@ -223,7 +422,7 @@ function assembleOutput(resourcesDir, destDir, label) { // 1. Extract app.asar → _asar/ (for patching) const asarDest = path.join(destDir, "_asar"); console.log(" [asar extract] -> _asar/"); - execSync(`npx asar extract "${asarPath}" "${asarDest}"`); + extractAsarForPatching(asarPath, asarDest); // 2. Copy app.asar.unpacked/ as-is (native modules) const unpackedSrc = path.join(resourcesDir, "app.asar.unpacked");