diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md index 53cc20e17..232fdb6ad 100644 --- a/.agents/skills/codexhost-release/SKILL.md +++ b/.agents/skills/codexhost-release/SKILL.md @@ -17,6 +17,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No - `.github/workflows/release-packages.yml` 与 `.github/workflows/ci.yml` - `packages/repository-automation/src/release.mjs` - `docs/operations/repository-maintenance.md` +- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.2-0` / `0.13.2-1`…,控制台版本同源),再读本 Skill 的上游渠道表 - 涉及更新提示时:`packages/update-manager/src/github-release.ts` 源码和工作流是执行行为的依据。发现指南与当前实现不一致时,说明差异;不要假装已有不支持的发布能力。 @@ -38,6 +39,8 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No 用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。 +在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 仍为 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 在未改成 `@karlorz` Trusted Publisher 前保持 disabled。控制台「codexhost 版本」必须与该准备版本一致。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。 + ## 2. 检查现场与验证 1. 检查工作区、当前分支、远端与已有标签;快进同步 `main`,不覆盖无关修改。 diff --git a/.agents/skills/codexhost-release/references/release-guide.md b/.agents/skills/codexhost-release/references/release-guide.md index b7f3533da..9ff9fa7bd 100644 --- a/.agents/skills/codexhost-release/references/release-guide.md +++ b/.agents/skills/codexhost-release/references/release-guide.md @@ -20,7 +20,7 @@ | 稳定 SemVer,默认发布 | `latest` | 正式 Release、latest | | 稳定 SemVer,手动发布且 `github_prerelease=true` | `latest` | Prerelease、非 latest | -静默预览发行用于“小修复让用户自行下载,但不推送更新提示”:npm 正常发布到 `latest`,GitHub 标记为 Prerelease。当前更新检查读取 GitHub 正式 latest Release,不读取 npm dist-tag;预览发行仍公开可下载。SemVer 预发布则在 npm 使用 `next`,用户需显式安装 `@codexhost/cli@next`。 +静默预览发行用于“小修复让用户自行下载,但不推送更新提示”:npm 正常发布到 `latest`,GitHub 标记为 Prerelease。当前更新检查读取 GitHub 正式 latest Release,不读取 npm dist-tag;预览发行仍公开可下载。SemVer 预发布则在 npm 使用 `next`,用户需显式安装 `@karlorz/codexhost-cli@next`。 ## 发布步骤 @@ -104,7 +104,7 @@ Composer 增加 Harness 命令按钮和模型搜索。Grok 支持自动或手动 ### npm ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` @@ -178,7 +178,7 @@ Tag 推送后,GitHub Actions 会自动: - 校验 Tag、npm 版本和 Cargo Workspace 版本一致。 - 构建 Windows x64/arm64 EXE。 - 构建 macOS x64/arm64 DMG。 -- 发布 macOS、Windows、Linux 的 x64/arm64 共六个平台 npm 包和 `@codexhost/cli` 元包。 +- 发布 macOS、Windows、Linux 的 x64/arm64 共六个平台 npm 包和 `@karlorz/codexhost-cli` 元包。 - 根据版本及手动发布输入创建公开 GitHub Release Prerelease 或正式 Release;`test` 渠道不创建 GitHub Release。 - GitHub Release 只上传四个安装包,不上传 npm tarball。 diff --git a/Cargo.lock b/Cargo.lock index 236113572..134609827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ dependencies = [ [[package]] name = "codexhost-gate-a-native" -version = "0.13.2" +version = "0.13.2-1" dependencies = [ "codexhost-platform", "codexhost-shim", @@ -92,7 +92,7 @@ dependencies = [ [[package]] name = "codexhost-launcher" -version = "0.13.2" +version = "0.13.2-1" dependencies = [ "codexhost-platform", "fs2", @@ -106,7 +106,7 @@ dependencies = [ [[package]] name = "codexhost-platform" -version = "0.13.2" +version = "0.13.2-1" dependencies = [ "libproc", "nix", @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "codexhost-shim" -version = "0.13.2" +version = "0.13.2-1" dependencies = [ "codexhost-platform", "fs2", @@ -131,7 +131,7 @@ dependencies = [ [[package]] name = "codexhost-updater" -version = "0.13.2" +version = "0.13.2-1" dependencies = [ "codexhost-platform", "serde", diff --git a/Cargo.toml b/Cargo.toml index c355d0895..985314740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ default-members = [ resolver = "3" [workspace.package] -version = "0.13.2" +version = "0.13.2-1" edition = "2024" rust-version = "1.97.1" publish = false diff --git a/README.md b/README.md index 250d0ed3b..f80ad77d9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ But **Codex** isn't the only great **Agent Harness** — **Claude Code** and **P


-

Download · Cross-Agent Collaboration · Remote · 微信交流群 · 简体中文 · 한국어

+

Download · Cross-Agent Collaboration · Remote · 微信交流群 · 简体中文 · 한국어


@@ -67,13 +67,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 **Option 1: npm** (macOS / Windows / Linux) ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` **Option 2: Installer** (macOS / Windows) -Grab the installer for your platform from [Releases](https://github.com/BytePioneer-AI/codex-host/releases). +Grab the installer for your platform from [Releases](https://github.com/karlorz/codex-host/releases). > Linux is supported on x64 and ARM64. See the [Linux guide](docs/platforms/linux/linux.md). @@ -225,7 +225,7 @@ Before you start, add the remote machine in Codex Desktop under **Settings → C 1. Install and start codexhost on the remote machine: ```bash - npm install -g @codexhost/cli + npm install -g @karlorz/codexhost-cli codexhost remote install codexhost remote start codexhost remote status @@ -290,7 +290,7 @@ Please read the [contributing guide](CONTRIBUTING.md) before opening an issue or Requirements: the official Codex Desktop, Node.js 22.19+ or 24, and Rust. ```bash -git clone https://github.com/BytePioneer-AI/codex-host +git clone https://github.com/karlorz/codex-host cd codex-host npm ci npm start @@ -322,8 +322,8 @@ Tip: point your coding Agent at the in-repo [codexhost-add-harness Skill](.agent Thanks to everyone who has contributed to CodexHost. - - CodexHost contributors + + CodexHost contributors ## Star History diff --git a/crates/platform/src/desktop_launch.rs b/crates/platform/src/desktop_launch.rs index f204d136f..1ac2c1100 100644 --- a/crates/platform/src/desktop_launch.rs +++ b/crates/platform/src/desktop_launch.rs @@ -31,8 +31,7 @@ pub type DesktopProcess = super::windows_desktop::WindowsDesktopProcess; #[cfg(not(target_os = "windows"))] pub type DesktopProcess = Child; -const CODEXHOST_RELEASES_LATEST_URL: &str = - "https://github.com/BytePioneer-AI/codex-host/releases/latest"; +const CODEXHOST_RELEASES_LATEST_URL: &str = "https://github.com/karlorz/codex-host/releases/latest"; const REMOTE_SSH_MANAGED_ENV: &str = "CODEXHOST_REMOTE_SSH_MANAGED"; const REMOTE_PROFILE_ONLY_ENVIRONMENT: [&str; 3] = [ "CODEX_INSTALL_DIR", diff --git a/crates/updater/src/install.rs b/crates/updater/src/install.rs index baec17330..daca67a44 100644 --- a/crates/updater/src/install.rs +++ b/crates/updater/src/install.rs @@ -23,7 +23,7 @@ use crate::request::{ #[cfg(target_os = "macos")] use crate::status::unix_seconds; -const NPM_PACKAGE_NAME: &str = "@codexhost/cli"; +pub(crate) const NPM_PACKAGE_NAME: &str = "@karlorz/codexhost-cli"; #[cfg(any(target_os = "windows", target_os = "macos"))] const DISTRIBUTION_FILE: &str = "codexhost-distribution.json"; diff --git a/crates/updater/src/remote.rs b/crates/updater/src/remote.rs index 61f76f4f6..1e3cc1bd0 100644 --- a/crates/updater/src/remote.rs +++ b/crates/updater/src/remote.rs @@ -201,7 +201,7 @@ fn apply_locked(request: &Request) -> Result<(), Box> { "npm uses a different global installation; update this remote manually".into(), ); } - let package = format!("@codexhost/cli@{}", request.version); + let package = format!("{}@{}", crate::install::NPM_PACKAGE_NAME, request.version); // Unpublished local builds fail here, before touching the installation or service. run(Command::new(&request.node).arg(&request.npm).args([ "view", @@ -231,16 +231,16 @@ fn apply_locked(request: &Request) -> Result<(), Box> { .and_then(|name| name.to_str()) .ok_or("Invalid platform package path")?; if ![ - "cli-darwin-arm64", - "cli-darwin-x64", - "cli-linux-arm64", - "cli-linux-x64", + "codexhost-cli-darwin-arm64", + "codexhost-cli-darwin-x64", + "codexhost-cli-linux-arm64", + "codexhost-cli-linux-x64", ] .contains(&name) { return Err("Unknown remote platform package".into()); } - command.arg(format!("@codexhost/{name}@{}", request.version)); + command.arg(format!("@karlorz/{name}@{}", request.version)); } run(&mut command).map_err(|error| format!("npm installation failed; check remote network access and write permissions: {error}"))?; } @@ -285,7 +285,7 @@ mod tests { std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); - let package_root = root.join("node_modules/@codexhost/cli-darwin-arm64"); + let package_root = root.join("node_modules/@karlorz/codexhost-cli-darwin-arm64"); fs::create_dir_all(package_root.join("app")).unwrap(); fs::write( package_root.join("app/codexhost-distribution.json"), diff --git a/crates/updater/src/ssh.rs b/crates/updater/src/ssh.rs index b8e3b4acb..675e0679c 100644 --- a/crates/updater/src/ssh.rs +++ b/crates/updater/src/ssh.rs @@ -8,6 +8,8 @@ use std::{ time::{Duration, Instant}, }; +use crate::install::NPM_PACKAGE_NAME; + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { @@ -66,7 +68,8 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr "install" => { let version = release_version(request)?; Ok(format!( - "{node}set -e; {lock}[ ! -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 43; case $(uname -s) in Darwin|Linux) ;; *) exit 44;; esac; command -v npm >/dev/null 2>&1 || exit 56; command -v codex >/dev/null 2>&1 || exit 57; npm install -g @codexhost/cli@{version} >/dev/null 2>&1 || exit 46; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'" + "{node}set -e; {lock}[ ! -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 43; case $(uname -s) in Darwin|Linux) ;; *) exit 44;; esac; command -v npm >/dev/null 2>&1 || exit 56; command -v codex >/dev/null 2>&1 || exit 57; npm install -g {package}@{version} >/dev/null 2>&1 || exit 46; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'", + package = NPM_PACKAGE_NAME, )) } // For services too old to update themselves. A newer CLI refuses to stop an installation @@ -75,7 +78,8 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr "update" => { let version = release_version(request)?; Ok(format!( - "{node}set -e; {lock}[ -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 52; command -v npm >/dev/null 2>&1 || exit 56; npm view @codexhost/cli@{version} version --fetch-retries=0 --fetch-timeout=30000 >/dev/null 2>&1 || exit 53; if command -v codexhost >/dev/null; then codexhost remote stop >/dev/null 2>&1 || true; fi; npm install -g @codexhost/cli@{version} >/dev/null 2>&1 || {{ codexhost remote start >/dev/null 2>&1 || true; exit 46; }}; command -v codexhost >/dev/null || exit 54; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote stop >/dev/null 2>&1 || {stock} || exit 50; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'" + "{node}set -e; {lock}[ -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 52; command -v npm >/dev/null 2>&1 || exit 56; npm view {package}@{version} version --fetch-retries=0 --fetch-timeout=30000 >/dev/null 2>&1 || exit 53; if command -v codexhost >/dev/null; then codexhost remote stop >/dev/null 2>&1 || true; fi; npm install -g {package}@{version} >/dev/null 2>&1 || {{ codexhost remote start >/dev/null 2>&1 || true; exit 46; }}; command -v codexhost >/dev/null || exit 54; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote stop >/dev/null 2>&1 || {stock} || exit 50; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'", + package = NPM_PACKAGE_NAME, )) } // A damaged installation is exactly what the CLI refuses to stop, so the first stop is @@ -85,18 +89,24 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr )), "uninstall" => { let package_check = if request.uninstall_package { - r#"command -v npm >/dev/null 2>&1 || exit 56; + format!( + r#"command -v npm >/dev/null 2>&1 || exit 56; NPM_ROOT=$(npm root --global 2>/dev/null) || exit 59; CLI=$(command -v codexhost) || exit 58; -node -e 'const fs = require("node:fs"), path = require("node:path"); try { const expected = path.join(process.argv[1], "@codexhost/cli/bin/codexhost.js"); process.exit(fs.realpathSync(expected) === fs.realpathSync(process.argv[2]) ? 0 : 1); } catch { process.exit(1); }' "$NPM_ROOT" "$CLI" >/dev/null 2>&1 || exit 59; -"# +node -e 'const fs = require("node:fs"), path = require("node:path"); try {{ const expected = path.join(process.argv[1], "{package}/bin/codexhost.js"); process.exit(fs.realpathSync(expected) === fs.realpathSync(process.argv[2]) ? 0 : 1); }} catch {{ process.exit(1); }}' "$NPM_ROOT" "$CLI" >/dev/null 2>&1 || exit 59; +"#, + package = NPM_PACKAGE_NAME, + ) } else { - "" + String::new() }; let package_remove = if request.uninstall_package { - "npm uninstall --global @codexhost/cli >/dev/null 2>&1 || exit 60; [ ! -e \"$NPM_ROOT/@codexhost/cli/package.json\" ] || exit 60;" + format!( + "npm uninstall --global {package} >/dev/null 2>&1 || exit 60; [ ! -e \"$NPM_ROOT/{package}/package.json\" ] || exit 60;", + package = NPM_PACKAGE_NAME, + ) } else { - "" + String::new() }; // Keep the npm package until its CLI has stopped the service and removed the // managed entrypoint/profile (and the macOS broker). No user data is deleted. diff --git a/crates/updater/src/ssh/tests.rs b/crates/updater/src/ssh/tests.rs index 118342dc8..d9e83d9a4 100644 --- a/crates/updater/src/ssh/tests.rs +++ b/crates/updater/src/ssh/tests.rs @@ -165,7 +165,7 @@ node(){ case "$2" in *realpathSync*) return "${MATCH_CODE:-0}";; *) return 0;; e npm(){ case "$1" in root) printf '%s\n' "$HOME/npm";; - uninstall) printf '%s\n' 'npm uninstall' >> "$HOME/calls"; [ "${FAIL:-}" != npm ] || return 1; rm "$HOME/npm/@codexhost/cli/package.json";; + uninstall) printf '%s\n' 'npm uninstall' >> "$HOME/calls"; [ "${FAIL:-}" != npm ] || return 1; rm "$HOME/npm/@karlorz/codexhost-cli/package.json";; *) return 99;; esac } @@ -224,7 +224,7 @@ codexhost(){ ] { let home = root.join(name); let remote = home.join(".codexhost/remote"); - let package = home.join("npm/@codexhost/cli"); + let package = home.join("npm/@karlorz/codexhost-cli"); std::fs::create_dir_all(remote.join("data")).unwrap(); std::fs::create_dir_all(&package).unwrap(); std::fs::write(remote.join("manifest.json"), "{}").unwrap(); @@ -370,7 +370,7 @@ fn install_refuses_to_replace_existing_remote_services() { r.version = Some("1.2.3".into()); let value = script(&r).unwrap(); assert!(value.contains("exit 43")); - assert!(value.contains("@codexhost/cli@1.2.3")); + assert!(value.contains("@karlorz/codexhost-cli@1.2.3")); assert!(!value.contains("remote stop")); } #[cfg(unix)] @@ -399,7 +399,7 @@ fn executes_initial_setup_and_refuses_an_existing_installation() { let calls = std::fs::read_to_string(root.join("calls")).unwrap(); assert_eq!( calls, - "install -g @codexhost/cli@1.2.3\nremote install\nremote start\n" + "install -g @karlorz/codexhost-cli@1.2.3\nremote install\nremote start\n" ); std::fs::create_dir_all(root.join(".codexhost/remote")).unwrap(); std::fs::write(root.join(".codexhost/remote/manifest.json"), "{}").unwrap(); @@ -421,7 +421,7 @@ fn executes_initial_setup_and_refuses_an_existing_installation() { .unwrap(); assert!(updated.status.success()); let calls = format!( - "{calls}view @codexhost/cli@1.2.4 version --fetch-retries=0 --fetch-timeout=30000\nremote stop\ninstall -g @codexhost/cli@1.2.4\nremote install\nremote stop\nremote start\n" + "{calls}view @karlorz/codexhost-cli@1.2.4 version --fetch-retries=0 --fetch-timeout=30000\nremote stop\ninstall -g @karlorz/codexhost-cli@1.2.4\nremote install\nremote stop\nremote start\n" ); assert_eq!(std::fs::read_to_string(root.join("calls")).unwrap(), calls); assert!(!root.join(".codexhost/ssh-setup.lock").exists()); diff --git a/docs/NOTICE.md b/docs/NOTICE.md index 7c1a77097..42035611e 100644 --- a/docs/NOTICE.md +++ b/docs/NOTICE.md @@ -12,4 +12,4 @@ type: warning - 用户需要采取的临时措施。 - 修复进度或预计恢复时间。 -[查看项目发布记录](https://github.com/BytePioneer-AI/codex-host/releases) +[查看项目发布记录](https://github.com/karlorz/codex-host/releases) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 2091b696f..f5abd35c3 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -30,4 +30,4 @@ After stopping CodexHost, you can delete its applicable local data. Native Harne ## Contact -For non-sensitive privacy questions, open an issue at [BytePioneer-AI/codex-host](https://github.com/BytePioneer-AI/codex-host/issues). Do not include credentials, private code, or unredacted logs in public reports. +For non-sensitive privacy questions, open an issue at [karlorz/codex-host](https://github.com/karlorz/codex-host/issues). Do not include credentials, private code, or unredacted logs in public reports. diff --git a/docs/architecture/harness-executable-discovery.md b/docs/architecture/harness-executable-discovery.md index 185b1af5b..05c43cbf9 100644 --- a/docs/architecture/harness-executable-discovery.md +++ b/docs/architecture/harness-executable-discovery.md @@ -28,7 +28,7 @@ packages/harness-discovery/ } ``` -该包参与 TypeScript 构建,但不会作为独立 npm 包发布。正式 npm 发布物仍是 `@codexhost/cli` 及对应的平台包;发现代码随 Host Runtime 和 Adapter 的发布产物一起交付。 +该包参与 TypeScript 构建,但不会作为独立 npm 包发布。正式 npm 发布物仍是 `@karlorz/codexhost-cli` 及对应的平台包;发现代码随 Host Runtime 和 Adapter 的发布产物一起交付。 ## 公共能力 diff --git a/docs/index.md b/docs/index.md index 5f51cb962..fb1c17f50 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,6 +106,7 @@ | [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 | | [`architecture/renderer-settings-styling.md`](architecture/renderer-settings-styling.md) | 设置页 Tailwind CSS 使用边界、构建方式与编写规则;新增或改版设置页界面时阅读。 | | [`operations/codex-desktop-upgrade-diagnosis-playbook.md`](operations/codex-desktop-upgrade-diagnosis-playbook.md) | Desktop 更新后 Renderer、Bridge、Agent 和 Model 异常,以及输入性能的诊断流程;兼容性与交互性能回归时阅读。 | +| [`operations/cpa-live-voice-desktop-hook.md`](operations/cpa-live-voice-desktop-hook.md) | Live-voice `clientOwnsCall` override (default on, Desktop 26.1007.30912 only); Settings → General switch, `CODEXHOST_CPA_LIVE_VOICE=0`, and `config.toml` realtime base URLs. | ## 平台、进程与远程运行 @@ -129,6 +130,7 @@ | [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 | | [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 | | [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 | +| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`…;npm 发 `@karlorz/codexhost-cli`;in-app Update 读 fork GitHub latest;不向 upstream 推送、不发布 `@codexhost/*`。 | | [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 | | [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 | diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md index 0417e4d4c..0f41fffb0 100644 --- a/docs/operations/codexhost-console.md +++ b/docs/operations/codexhost-console.md @@ -2,11 +2,11 @@ 控制台是一个本地网页,不依赖 Codex Desktop 启动成功。左侧导航分为: -- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。 +- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如 upstream 仍为 `0.13.2` 时显示 `0.13.2-0`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。 - **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。 - **其他**:关于。 -外观(思考文本显示、空闲释放)由 Codex 界面保存,只能在 Codex 设置页修改。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。 +外观(思考文本显示、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。本 fork 的改写实时语音默认开启;`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。 ## 项目公告 @@ -21,7 +21,7 @@ title: 版本兼容性提醒 type: warning --- -这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/BytePioneer-AI/codex-host)。 +这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/karlorz/codex-host)。 ``` - `enabled`:仅 `true` 显示,`false` 隐藏;正文为空也隐藏。 diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md new file mode 100644 index 000000000..19e797509 --- /dev/null +++ b/docs/operations/cpa-live-voice-desktop-hook.md @@ -0,0 +1,46 @@ +# CPA live-voice Desktop hook + +Gated CodexHost Renderer hook that forces durable-only `clientOwnsCall` on official Codex Desktop **26.1007.30912**, so CPA can create the realtime call (`POST /v1/realtime/calls`) without rewriting `app.asar` or replacing `/Applications/ChatGPT.app`. + +## Default + +**On** in this fork. The Desktop Controller injects the hook unless `CODEXHOST_CPA_LIVE_VOICE=0`. That environment variable is **not live**: changing it requires relaunching Codex. Codex Settings → General has an **Override live voice** switch, also on by default (`localStorage` missing means on; `"0"` means off). The switch applies on the next voice overlay without relaunch. If the hook was not injected (`CODEXHOST_CPA_LIVE_VOICE=0`), the page shows a restart warning and the switch cannot take effect until relaunch. The unaudited-Desktop asset gate still fail-closes. The Web console does not duplicate this switch. + +The hook only changes Desktop call ownership. Point Codex at the realtime host in `~/.codex/config.toml` as well, then relaunch: + +```toml +experimental_realtime_ws_base_url = "https:///v1" +experimental_realtime_webrtc_call_base_url = "https:///v1" +``` + +## What it changes + +Desktop’s voice-start path sets `clientOwnsCall` when the `hostId` argument is `durable` **or** both of these hold: the host's app-server version supports the `threadRealtimeExistingCall` capability (a version comparison, not a stored flag), and Statsig gate `206132468` passes. That second branch POSTs to ChatGPT `/wham/realtime/calls` and then starts `thread/realtime/start` as `ExistingCall`. CPA never sees creation, so the sideband websocket 404s. + +The hook forces Statsig `checkGate("206132468")` to false on every client registered on `__STATSIG__`. In the audited build that gate has no other reader, so the result is durable-only ownership: a local host gets `clientOwnsCall=false` and `transport=webrtc`, and CPA can `POST /v1/realtime/calls`. Other gates pass through unchanged. Official app-server traffic is not rewritten. Host still passes official `thread/realtime/*` through. + +## How it is installed + +1. Unless `CODEXHOST_CPA_LIVE_VOICE=0`, the Controller prefixes the production Renderer source with a frozen `globalThis.__codexhostCpaLiveVoiceHookV1` record (audited Desktop version and `app-initial` asset name only). +2. `Page.addScriptToEvaluateOnNewDocument` plus immediate `Runtime.evaluate` inject the Host Renderer into the primary `app://-/index.html` page. +3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client. +4. The Renderer hook no-ops unless the exact audited `app://-/assets/app-initial-6350d04672d0.js` asset is present (performance entries, script src, or link href, including relative URLs). Unknown assets fail closed; matching text in a query string or source-map filename is insufficient. +5. On a matching page it polls for up to 60 seconds until a Statsig client is registered, then wraps that client's `checkGate`. The gate is read when voice starts, so the wrap only has to land before the first voice click. + +Verify before a trial: on the overlay, `__STATSIG__.firstInstance.checkGate("206132468")` returns `false`. + +Overlay discovery, connection, and injection share one timeout. Overlay attempts run concurrently, and a stalled or disappearing overlay does not prevent the primary Host session from becoming ready after that deadline. + +The hook does not copy OpenAI frontend JavaScript into git. Tests cover the asset gate and Statsig override with synthetic clients. + +## Version gate + +Audited only for Desktop **26.1007.30912** (primary bundle `app-initial-6350d04672d0.js`). Re-audit before enabling on a newer build. Fail closed: no wrap, no ASAR patch, keep `~/Applications/ChatGPT-Voice.app` if that copy is still the working path. + +## Invariants + +- Do not overwrite `/Applications/ChatGPT.app` (Developer ID `2DC432GLL2`). +- Do not reuse `install_voice_patch.py`. +- Do not quit or replace the working Voice copy until the CodexHost path proves `POST /v1/realtime/calls` 201. +- Do not put call IDs, SDP, or auth material in docs, git, or command output. +- Do not `git push` this branch or open an upstream PR unless explicitly requested. diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md new file mode 100644 index 000000000..40aff1c0d --- /dev/null +++ b/docs/operations/fork-versioning.md @@ -0,0 +1,91 @@ +# Fork versioning (`karlorz/codex-host`) + +This checkout tracks [BytePioneer-AI/codex-host](https://github.com/BytePioneer-AI/codex-host) as `upstream` and publishes only to the fork [karlorz/codex-host](https://github.com/karlorz/codex-host). It does not define upstream BytePioneer release policy. + +Package identity follows the same fork-scope rule as other karlorz forks: a permanent `@karlorz/` rename, not a publish-time override, and not `@codexhost/*`. + +## Tag rule + +Fork versions stay on the **upstream version that is currently held**, plus a numeric fork counter. + +| Upstream holds | First fork tag | Next fork tags | +| --- | --- | --- | +| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, … | +| later `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, … | + +- Read upstream from `upstream/main` `package.json` and the upstream GitHub latest release. If they disagree, stop and report; do not guess. +- `n` is the next unused non-negative integer for that upstream version on the fork (`0` if none exist). +- Do **not** invent a newer minor or patch than upstream (for example `0.13.3-cpa.1` while upstream still holds `0.13.2`). +- `v0.13.2-0` is valid SemVer (prerelease `0` of `0.13.2`). Prepare it with `npm run release:prepare -- 0.13.2-0`. +- Numeric prerelease `0.13.2-0` compares **below** upstream `0.13.2`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead. + +The four prepared files (`package.json`, `package-lock.json`, `Cargo.toml`, `Cargo.lock`) must equal that version. Packaged `app/codexhost-distribution.json` is written later by `release:package` / `release:npm`, not by `release:prepare`. The Web console overview **codexhost 版本** reads that distribution file (installer/npm) or workspace `package.json` (source). After prepare, rebuild or recopy the installed `codexhost-distribution.json` so the live console matches the tag. + +## npm and in-app updates + +Fork operators install and update with: + +```bash +npm install -g @karlorz/codexhost-cli@latest +``` + +That is the Settings → Updates manual command. Settings auto-checks `karlorz/codex-host` `/releases/latest` when Updates is opened. A current version that already matches that latest tag is up to date. A higher fork counter is offered only after that version is published as the fork's non-draft, non-prerelease GitHub latest Release. **Update and restart** on an npm install then runs `@karlorz/codexhost-cli` at that GitHub latest version (via the Updater). Installation requires an explicit user action. + +| Channel | Fork identity | Do not use | +| --- | --- | --- | +| npm meta package | `@karlorz/codexhost-cli` | `@codexhost/cli` | +| npm platform packages | `@karlorz/codexhost-cli-darwin-arm64` (and the other five) | `@codexhost/cli-*` | +| GitHub latest / notes / assets | `karlorz/codex-host` | `BytePioneer-AI/codex-host` | + +A fork tag is not shipped until both of these exist for that version: + +1. **npm:** attended `npm publish` of the packed meta package and matching platform tarballs under the `@karlorz` scope, dist-tag `latest` (or `next` only if the tag is an npm prerelease channel). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`. +2. **GitHub:** a **latest** (non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build. + +The standard `Release packages` workflow classifies hyphenated SemVer tags as prereleases and publishes them with `--prerelease --latest=false`. An attended fork counter release must explicitly use non-prerelease GitHub Release status and become latest to appear in Updates; the numeric tag alone does not make it discoverable. + +Keep GitHub Actions workflow `Release packages` **disabled** until it is wired to publish `@karlorz/*` with a Trusted Publisher on `karlorz/codex-host`. Do not enable it while it would still publish `@codexhost/*`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry. + +## GitHub Actions secrets + +CI and `Release packages` already use the job `GITHUB_TOKEN` (`contents` / `id-token: write`). Do not add `secrets.NPM_TOKEN` or `NODE_AUTH_TOKEN` to those workflows: npm publish is Trusted Publisher (`id-token: write` + `--provenance`), same as upstream. + +Store these repo secrets on `karlorz/codex-host` for attended and future fork-sync work, matching the other karlorz forks: + +| Secret | Same as | Purpose | +| --- | --- | --- | +| `NPM_TOKEN` | `karlorz/llm-wiki` | Operator/local `npm publish` of `@karlorz/*` when OIDC is not in play. Not read by current CI YAML. | +| `FORK_SYNC_TOKEN` | `karlorz/orca` | Classic PAT with `repo` + `workflow`. Needed later for `merge-upstream` / pushing workflow-file updates; `GITHUB_TOKEN` cannot do that. `karlorz/cliproxyapi` names the same kind of PAT `UPSTREAM_SYNC_PUSH_TOKEN`. | + +```bash +# npmjs auth token from ~/.npmrc, value never printed +python3 -c 'from pathlib import Path +for line in Path.home().joinpath(".npmrc").read_text().splitlines(): + if line.startswith("//registry.npmjs.org/:_authToken="): + print(line.split("=",1)[1], end="") + break +' | gh secret set NPM_TOKEN -R karlorz/codex-host + +# classic PAT (repo + workflow), not a gh oauth gho_ token +gh secret set FORK_SYNC_TOKEN -R karlorz/codex-host +``` + +Keep `Release packages` disabled until npm Trusted Publisher on `@karlorz/codexhost-cli` (and the six platform packages) is the karlorz GitHub repo. + +## Release channel on this fork + +- Never `git push` to `upstream`. +- Never publish `@codexhost/*` from this checkout. +- Do not move, delete, or re-push an existing tag unless the operator explicitly requested that rewrite. +- Create GitHub Releases with `-R karlorz/codex-host`. `gh release create --notes-from-tag` does not work with `--repo`; write the tag annotation to a notes file instead. A `gh` invocation without `-R` may resolve to BytePioneer and must abort. + +## Next unused counter + +```bash +git fetch upstream --tags --prune +git fetch origin --tags --prune +# example when upstream holds 0.13.2: +git ls-remote --tags origin 'refs/tags/v0.13.2-*' +``` + +Pick the smallest `n` that is not already a fork tag. Then `npm run release:prepare -- 0.13.2-` on a branch, with the usual release-skill checks before any tag push. diff --git a/docs/platforms/linux/linux.md b/docs/platforms/linux/linux.md index 279023de3..ed6febfbd 100644 --- a/docs/platforms/linux/linux.md +++ b/docs/platforms/linux/linux.md @@ -3,7 +3,7 @@ codexhost supports x64 and ARM64 Linux through the npm package. Install the official ChatGPT App matching the current architecture first, then install codexhost: ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` diff --git a/docs/platforms/linux/linux.zh-CN.md b/docs/platforms/linux/linux.zh-CN.md index 01b4d5856..d3dc1ca2c 100644 --- a/docs/platforms/linux/linux.zh-CN.md +++ b/docs/platforms/linux/linux.zh-CN.md @@ -3,7 +3,7 @@ codexhost 通过 npm 包支持 x64 和 ARM64 Linux。请先安装与当前架构匹配的官方 ChatGPT App,再安装 codexhost: ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` diff --git a/docs/platforms/remote/remote-ssh-host.md b/docs/platforms/remote/remote-ssh-host.md index 6cfdcf724..7937a213e 100644 --- a/docs/platforms/remote/remote-ssh-host.md +++ b/docs/platforms/remote/remote-ssh-host.md @@ -90,7 +90,7 @@ Older remote installations cannot report their version; the page shows **Update ```bash codexhost remote stop -npm install -g @codexhost/cli@latest +npm install -g @karlorz/codexhost-cli@latest codexhost remote install codexhost remote start ``` @@ -139,7 +139,7 @@ This applies to external Harness conversations created in SSH projects. Other lo Choose **Uninstall remote service** on the connection card. After confirmation, auto-connect is disabled for this connection, the remote service stops and its connection setup is removed. On Mac, the associated Harness background service is also uninstalled. The SSH connection entry, project files and chat data are kept, so the service can be installed again later. Active remote sessions are interrupted. -The codexhost package is kept by default. Select **Also uninstall the codexhost package** to remove the remote computer's global npm `@codexhost/cli` after service removal succeeds. Codex CLI, Node.js and other Harnesses are kept. Only select this if the remote Desktop no longer needs that package to launch. A mismatch between the active CLI and npm's global installation rejects package removal before stopping the service; choose service-only removal or uninstall the package manually on the remote computer. If package removal fails after service removal, the page explains the partial result and allows retrying. +The codexhost package is kept by default. Select **Also uninstall the codexhost package** to remove the remote computer's global npm `@karlorz/codexhost-cli` after service removal succeeds. Codex CLI, Node.js and other Harnesses are kept. Only select this if the remote Desktop no longer needs that package to launch. A mismatch between the active CLI and npm's global installation rejects package removal before stopping the service; choose service-only removal or uninstall the package manually on the remote computer. If package removal fails after service removal, the page explains the partial result and allows retrying. When you no longer need the remote installation, run these commands on the remote computer: diff --git a/docs/platforms/remote/remote-ssh-host.zh-CN.md b/docs/platforms/remote/remote-ssh-host.zh-CN.md index e4bb2e6c2..19331ea15 100644 --- a/docs/platforms/remote/remote-ssh-host.zh-CN.md +++ b/docs/platforms/remote/remote-ssh-host.zh-CN.md @@ -90,7 +90,7 @@ Web 控制台的「远程连接」(`#remote-connections`)也提供同一套 ```bash codexhost remote stop -npm install -g @codexhost/cli@latest +npm install -g @karlorz/codexhost-cli@latest codexhost remote install codexhost remote start ``` @@ -139,7 +139,7 @@ npm start 0.12.0 在连接卡片中点击「卸载远程服务」。确认后,会关闭此连接的自动连接、停止远程服务并移除远程接入配置;Mac 上还会卸载配套的 Harness 后台服务。SSH 连接记录、项目文件和会话数据保留,之后可重新安装服务。卸载会中断正在进行的远程会话。 -默认保留 codexhost 软件包。勾选「同时卸载 codexhost 软件包」会在服务卸载成功后,再卸载远程电脑的 npm 全局 `@codexhost/cli`,不会卸载 Codex CLI、Node.js 或其他 Harness。勾选前请确认远程桌面端不再需要通过该软件包启动。安装位置与当前 npm 不匹配时,软件包卸载会在停止服务前被拒绝;可改为仅卸载服务,或在远程电脑上手动卸载软件包。软件包卸载失败时,页面会明确提示远程服务已卸载,并允许重试。 +默认保留 codexhost 软件包。勾选「同时卸载 codexhost 软件包」会在服务卸载成功后,再卸载远程电脑的 npm 全局 `@karlorz/codexhost-cli`,不会卸载 Codex CLI、Node.js 或其他 Harness。勾选前请确认远程桌面端不再需要通过该软件包启动。安装位置与当前 npm 不匹配时,软件包卸载会在停止服务前被拒绝;可改为仅卸载服务,或在远程电脑上手动卸载软件包。软件包卸载失败时,页面会明确提示远程服务已卸载,并允许重试。 不再使用远程端时,在远程电脑的终端执行: diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md index 9d6ca4fe9..0617c9c03 100644 --- a/docs/project/README.ko.md +++ b/docs/project/README.ko.md @@ -30,7 +30,7 @@


-

다운로드 · Agent 간 협업 · 원격 연결 · 微信交流群 · 简体中文 · English

+

다운로드 · Agent 간 협업 · 원격 연결 · 微信交流群 · 简体中文 · English


@@ -66,13 +66,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 **방법 1: npm** (macOS / Windows / Linux) ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` **방법 2: 설치 프로그램** (macOS / Windows) -[Releases](https://github.com/BytePioneer-AI/codex-host/releases)에서 플랫폼에 맞는 설치 프로그램을 다운로드하세요. +[Releases](https://github.com/karlorz/codex-host/releases)에서 플랫폼에 맞는 설치 프로그램을 다운로드하세요. > Linux는 x64 / ARM64를 지원합니다. 자세한 내용은 [Linux 안내](../platforms/linux/linux.md)를 참고하세요. @@ -222,7 +222,7 @@ CodexHost는 대상 Harness를 위한 별도의 Native Session을 만듭니다. 1. 제어 대상 컴퓨터에서 설치하고 시작합니다: ```bash - npm install -g @codexhost/cli + npm install -g @karlorz/codexhost-cli codexhost remote install codexhost remote start codexhost remote status @@ -287,7 +287,7 @@ Issue나 PR을 제출하기 전에 [기여 안내](../../CONTRIBUTING.md)를 읽 환경 요구 사항: 공식 Codex Desktop, Node.js 22.19+ 또는 24, Rust. ```bash -git clone https://github.com/BytePioneer-AI/codex-host +git clone https://github.com/karlorz/codex-host cd codex-host npm ci npm start @@ -315,8 +315,8 @@ Harness를 추가할 때는 코딩 Agent가 저장소의 [codexhost-add-harness CodexHost에 기여해 주신 모든 분께 감사드립니다. - - CodexHost 기여자 + + CodexHost 기여자 ## Star History diff --git a/docs/project/README.zh-CN.md b/docs/project/README.zh-CN.md index 65a7d8deb..718ea28f8 100644 --- a/docs/project/README.zh-CN.md +++ b/docs/project/README.zh-CN.md @@ -30,7 +30,7 @@


-

下载 · 跨 Agent 协作 · 远程连接 · 微信交流群 · English · 한국어

+

下载 · 跨 Agent 协作 · 远程连接 · 微信交流群 · English · 한국어


@@ -66,13 +66,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 **方式一:npm**(macOS / Windows / Linux) ```bash -npm install -g @codexhost/cli +npm install -g @karlorz/codexhost-cli codexhost ``` **方式二:安装包**(macOS / Windows) -从 [Releases](https://github.com/BytePioneer-AI/codex-host/releases) 下载对应平台的安装包。 +从 [Releases](https://github.com/karlorz/codex-host/releases) 下载对应平台的安装包。 > Linux 支持 x64 / ARM64,详见 [Linux 说明](../platforms/linux/linux.zh-CN.md)。 @@ -225,7 +225,7 @@ CodexHost 会为目标 Harness 创建独立的 Native Session。委派会话将 1. 在被控机器上安装并启动: ```bash - npm install -g @codexhost/cli + npm install -g @karlorz/codexhost-cli codexhost remote install codexhost remote start codexhost remote status @@ -290,7 +290,7 @@ CodexHost 的做法不同: 环境要求:官方 Codex Desktop、Node.js 22.19+ 或 24、Rust。 ```bash -git clone https://github.com/BytePioneer-AI/codex-host +git clone https://github.com/karlorz/codex-host cd codex-host npm ci npm start @@ -321,8 +321,8 @@ npm start 感谢所有为 CodexHost 做出贡献的开发者。 - - CodexHost 贡献者 + + CodexHost 贡献者 ## Star History diff --git a/openspec/specs/github-release-background-update/spec.md b/openspec/specs/github-release-background-update/spec.md index 480becd89..00f651054 100644 --- a/openspec/specs/github-release-background-update/spec.md +++ b/openspec/specs/github-release-background-update/spec.md @@ -4,7 +4,7 @@ TBD - created by archiving change implement-github-release-update-flow. Update Purpose after archive. ## Requirements ### Requirement: Host discovers one stable GitHub Release -The update capability SHALL discover the latest stable codexhost version only from `https://api.github.com/repos/BytePioneer-AI/codex-host/releases/latest`, SHALL compare its `v`-prefixed tag with the packaged current SemVer, and SHALL keep check failure non-blocking for normal application launch. It MUST NOT require a custom manifest, checksum sidecar, codexhost server, login, or embedded GitHub credential. +The update capability SHALL discover the latest stable codexhost version only from `https://api.github.com/repos/karlorz/codex-host/releases/latest`, SHALL compare its `v`-prefixed tag with the packaged current SemVer, and SHALL keep check failure non-blocking for normal application launch. It MUST NOT require a custom manifest, checksum sidecar, codexhost server, login, or embedded GitHub credential. #### Scenario: New stable Release exists - **WHEN** GitHub returns a non-draft, non-prerelease Release with a valid newer `v` tag diff --git a/package-lock.json b/package-lock.json index f754edd8e..f4b7766f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexhost", - "version": "0.13.2", + "version": "0.13.2-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexhost", - "version": "0.13.2", + "version": "0.13.2-1", "license": "LGPL-3.0-only", "workspaces": [ "packages/*", diff --git a/package.json b/package.json index 5dd761af7..6d9be7eb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexhost", - "version": "0.13.2", + "version": "0.13.2-1", "private": true, "license": "LGPL-3.0-only", "type": "module", diff --git a/packages/console-server/src/announcement.ts b/packages/console-server/src/announcement.ts index 4a8df2b4c..141631dd2 100644 --- a/packages/console-server/src/announcement.ts +++ b/packages/console-server/src/announcement.ts @@ -1,7 +1,6 @@ import { consoleAnnouncementSchema, type ConsoleAnnouncement } from "@codexhost/shared-contracts"; -const ANNOUNCEMENT_URL = - "https://raw.githubusercontent.com/BytePioneer-AI/codex-host/main/docs/NOTICE.md"; +const ANNOUNCEMENT_URL = "https://raw.githubusercontent.com/karlorz/codex-host/main/docs/NOTICE.md"; const MAX_DOCUMENT_BYTES = 16 * 1024; const TIMEOUT_MS = 5_000; diff --git a/packages/console-server/src/diagnostic-report.ts b/packages/console-server/src/diagnostic-report.ts index a77e914d0..bdd227d86 100644 --- a/packages/console-server/src/diagnostic-report.ts +++ b/packages/console-server/src/diagnostic-report.ts @@ -8,7 +8,7 @@ import type { } from "./diagnostics.js"; import type { InspectDocument } from "./installation.js"; -export const ISSUE_REPOSITORY_URL = "https://github.com/BytePioneer-AI/codex-host"; +export const ISSUE_REPOSITORY_URL = "https://github.com/karlorz/codex-host"; const ISSUE_ERROR_CHARS = 800; const ISSUE_URL_MAX_LENGTH = 7_000; export const REPORT_LOG_TAIL_BYTES = 32 * 1024; diff --git a/packages/console-server/test/announcement.test.ts b/packages/console-server/test/announcement.test.ts new file mode 100644 index 000000000..9cc2d60ff --- /dev/null +++ b/packages/console-server/test/announcement.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readAnnouncement } from "../src/announcement.js"; + +describe("console announcement source", () => { + it("reads the fork NOTICE.md through the announcement fetch path", async () => { + const fetcher = vi.fn( + async () => + new Response("---\nenabled: true\ntitle: Fork update\ntype: info\n---\nFork release notes"), + ); + + await expect(readAnnouncement(fetcher)).resolves.toEqual({ + title: "Fork update", + type: "info", + body: "Fork release notes", + }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith( + "https://raw.githubusercontent.com/karlorz/codex-host/main/docs/NOTICE.md", + expect.objectContaining({ redirect: "error", cache: "no-store" }), + ); + }); + + it("hides an unavailable fork announcement without retrying", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 404 })); + + await expect(readAnnouncement(fetcher)).resolves.toBeNull(); + expect(fetcher).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/console-server/test/server.test.ts b/packages/console-server/test/server.test.ts index abddd4c75..b8c7682d7 100644 --- a/packages/console-server/test/server.test.ts +++ b/packages/console-server/test/server.test.ts @@ -167,7 +167,7 @@ describe("console server", () => { expect(overview.summary.state).toBe("stopped"); expect(overview.inspect.desktop?.version).toBe("26.924.20706"); expect(overview.issueUrl).toMatch( - /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/issues\/new\?/u, + /^https:\/\/github\.com\/karlorz\/codex-host\/issues\/new\?/u, ); expect(decodeURIComponent(overview.issueUrl)).toContain("26.924.20706"); }); diff --git a/packages/console-server/test/updates.test.ts b/packages/console-server/test/updates.test.ts index 2bfd1773c..18ca16797 100644 --- a/packages/console-server/test/updates.test.ts +++ b/packages/console-server/test/updates.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { EventEmitter } from "node:events"; @@ -18,14 +18,24 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); -const release = { - version: "1.1.0", - releaseNotes: "Fixes", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.1.0", - assets: [], -}; +function releaseFor(version: string) { + return { + version, + releaseNotes: "Fixes", + releaseNotesUrl: `https://github.com/karlorz/codex-host/releases/tag/v${version}`, + assets: [], + }; +} -async function npmLayout(platform: NodeJS.Platform = process.platform) { +const release = releaseFor("1.1.0"); + +async function writeWaitingForExit(statusPath: string): Promise { + const status = JSON.parse(await readFile(statusPath, "utf8")); + await writeFile(`${statusPath}.tmp`, JSON.stringify({ ...status, phase: "waiting-for-exit" })); + await rename(`${statusPath}.tmp`, statusPath); +} + +async function npmLayout(platform: NodeJS.Platform = process.platform, version = "1.0.0") { const packageRoot = path.join(root, "package"); const appDirectory = path.join(packageRoot, "app"); await mkdir(path.join(packageRoot, "libexec"), { recursive: true }); @@ -41,7 +51,7 @@ async function npmLayout(platform: NodeJS.Platform = process.platform) { await writeFile(npmCli, ""); await writeFile(wrapper, ""); const target: ConsoleUpdateTarget = { - distribution: { schemaVersion: 1, version: "1.0.0", distribution: "npm", target: "linux-x64" }, + distribution: { schemaVersion: 1, version, distribution: "npm", target: "linux-x64" }, appDirectory, runtimeDescriptorPath: path.join(root, "runtime", "desktop-runtime-v1.json"), codexhostRunning: false, @@ -142,8 +152,29 @@ describe("console updates", () => { await expect(createConsoleUpdates(options).status()).resolves.toEqual({ status: null }); }); - it("hands off to the Updater before returning", async () => { - const { target, environment } = await npmLayout("linux"); + it("reports an equal fork counter as up to date without starting the Updater", async () => { + const { target, environment } = await npmLayout("linux", "0.13.2-1"); + const updates = createConsoleUpdates({ + onHandedOff: vi.fn(), + environment, + stateDirectory: path.join(root, "state"), + fetchLatest: async () => releaseFor("0.13.2-1"), + }); + + await expect(updates.check(target)).resolves.toMatchObject({ + currentVersion: "0.13.2-1", + latestVersion: "0.13.2-1", + updateAvailable: false, + installationAvailable: false, + error: null, + }); + }); + + it.each([ + ["1.0.0", "1.1.0"], + ["0.13.2-1", "0.13.2-2"], + ])("hands off %s → %s to the Updater before returning", async (currentVersion, latestVersion) => { + const { target, environment } = await npmLayout("linux", currentVersion); const spawnUpdater = vi.fn<(executable: string, requestPath: string) => never>( () => Object.assign(new EventEmitter(), { pid: 4321, kill: vi.fn() }) as never, ); @@ -156,30 +187,34 @@ describe("console updates", () => { processId: 777, processExecutable: process.execPath, stateDirectory: path.join(root, "state"), - fetchLatest: async () => release, + fetchLatest: async () => releaseFor(latestVersion), manager: createBackgroundUpdateManager({ platform: "linux", spawnUpdater }), }); - await updates.check(target); + await expect(updates.check(target)).resolves.toMatchObject({ + currentVersion, + latestVersion, + updateAvailable: true, + installationAvailable: true, + }); + expect(spawnUpdater).not.toHaveBeenCalled(); + expect(onHandedOff).not.toHaveBeenCalled(); const starting = updates.start(target); await vi.waitFor(() => expect(spawnUpdater).toHaveBeenCalledOnce()); expect(onHandedOff).not.toHaveBeenCalled(); const call = spawnUpdater.mock.calls[0]; if (!call) throw new Error("Updater was not spawned"); const spawnedRequest = JSON.parse(await readFile(call[1], "utf8")); - const status = JSON.parse(await readFile(spawnedRequest.status_path, "utf8")); - await writeFile( - spawnedRequest.status_path, - JSON.stringify({ ...status, phase: "waiting-for-exit" }), - ); + await writeWaitingForExit(spawnedRequest.status_path); const result = await starting; expect(result.status.phase).toBe("waiting-for-exit"); - expect(result.status).toMatchObject({ version: "1.1.0", installation: "npm" }); + expect(result.status).toMatchObject({ version: latestVersion, installation: "npm" }); expect(onHandedOff).toHaveBeenCalledOnce(); const requestPath = (spawnUpdater.mock.calls[0] as unknown as [string, string])[1]; const request = JSON.parse(await readFile(requestPath, "utf8")) as Record; expect(request).toMatchObject({ + version: latestVersion, wait_pid: 777, wait_executable: process.execPath, runtime_descriptor_path: target.runtimeDescriptorPath, @@ -214,8 +249,7 @@ describe("console updates", () => { const call = spawnUpdater.mock.calls[0]; if (!call) throw new Error("Updater was not spawned"); const request = JSON.parse(await readFile(call[1], "utf8")); - const status = JSON.parse(await readFile(request.status_path, "utf8")); - await writeFile(request.status_path, JSON.stringify({ ...status, phase: "waiting-for-exit" })); + await writeWaitingForExit(request.status_path); await expect(retry).resolves.toMatchObject({ status: { phase: "waiting-for-exit" } }); await expect(updates.status()).resolves.toMatchObject({ status: { phase: "waiting-for-exit", error: null }, diff --git a/packages/desktop-control/src/cpa-live-voice-hook.ts b/packages/desktop-control/src/cpa-live-voice-hook.ts new file mode 100644 index 000000000..727f7bfd3 --- /dev/null +++ b/packages/desktop-control/src/cpa-live-voice-hook.ts @@ -0,0 +1,17 @@ +export const CPA_LIVE_VOICE_ENV = "CODEXHOST_CPA_LIVE_VOICE"; +export const CPA_LIVE_VOICE_HOOK_GLOBAL = "__codexhostCpaLiveVoiceHookV1"; +export const AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION = "26.1007.30912"; +export const AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET = "app-initial-6350d04672d0.js"; + +export function cpaLiveVoiceHookEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { + return environment[CPA_LIVE_VOICE_ENV] !== "0"; +} + +export function cpaLiveVoiceHookBootstrap(environment: NodeJS.ProcessEnv = process.env): string { + if (!cpaLiveVoiceHookEnabled(environment)) return ""; + return `globalThis.${CPA_LIVE_VOICE_HOOK_GLOBAL} = Object.freeze(${JSON.stringify({ + enabled: true, + auditedDesktopVersion: AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION, + auditedAppInitialAsset: AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, + })});\n`; +} diff --git a/packages/desktop-control/src/index.ts b/packages/desktop-control/src/index.ts index cb842a2a6..57fa52b46 100644 --- a/packages/desktop-control/src/index.ts +++ b/packages/desktop-control/src/index.ts @@ -30,6 +30,7 @@ export { inspectRendererDom, validateRendererDomInspection } from "./renderer-do export { createRendererCdpControlSession, installRendererCdpControlSession, + selectAvatarOverlayRendererTargets, selectPrimaryRendererTarget, } from "./renderer-cdp-control-session.js"; export type { diff --git a/packages/desktop-control/src/production-controller.ts b/packages/desktop-control/src/production-controller.ts index 0dbf76ca7..953f0f14b 100644 --- a/packages/desktop-control/src/production-controller.ts +++ b/packages/desktop-control/src/production-controller.ts @@ -15,6 +15,7 @@ import { createRendererStatusReporter, type DesktopControllerStatusDocument, } from "./controller-status.js"; +import { cpaLiveVoiceHookBootstrap } from "./cpa-live-voice-hook.js"; import { installRendererCdpControlSession, type RendererCdpControlSession, @@ -38,6 +39,7 @@ export interface DesktopControllerDependencies { install(options: { rendererCdpEndpoint: string; rendererSource: string; + overlayHookSource?: string; enabledAgents?: readonly string[]; timeoutMs: number; signal?: AbortSignal; @@ -241,13 +243,25 @@ export async function runDesktopController( const createSession = async (): Promise => { status.installing(); startupTrace("reading Renderer bundle"); - const rendererSource = await dependencies.readRenderer(options.rendererPath); + const rendererRead = dependencies.readRenderer(options.rendererPath); + const bootstrap = cpaLiveVoiceHookBootstrap(); + const overlayPath = bootstrap + ? path.join(path.dirname(options.rendererPath), "cpa-live-voice-hook.js") + : undefined; + const [rendererSource, overlayBundle] = overlayPath + ? await Promise.all([rendererRead, dependencies.readRenderer(overlayPath)]) + : [await rendererRead, undefined]; if (rendererSource.trim().length === 0) throw new Error("production Renderer Bundle is empty"); + const overlayHookSource = + bootstrap && overlayBundle && overlayBundle.trim().length > 0 + ? `${bootstrap}${overlayBundle}` + : undefined; startupTrace("installing Renderer Session"); const installed = await installProductionSession( { rendererCdpEndpoint: options.rendererCdpEndpoint, - rendererSource: `${RENDERER_CSP_BOOTSTRAP}\n${rendererSource}`, + rendererSource: `${RENDERER_CSP_BOOTSTRAP}\n${bootstrap}${rendererSource}`, + ...(overlayHookSource ? { overlayHookSource } : {}), // External Agents are discovered from each target Host after installation. timeoutMs: PRODUCTION_INSTALL_TIMEOUT_MS, signal, diff --git a/packages/desktop-control/src/renderer-cdp-control-session.ts b/packages/desktop-control/src/renderer-cdp-control-session.ts index 85e76de67..a99b8de98 100644 --- a/packages/desktop-control/src/renderer-cdp-control-session.ts +++ b/packages/desktop-control/src/renderer-cdp-control-session.ts @@ -6,6 +6,7 @@ import { type CdpTarget, cdpExceptionMessage, } from "./cdp-client.js"; +import { CPA_LIVE_VOICE_HOOK_GLOBAL } from "./cpa-live-voice-hook.js"; import { installRendererDraftPrewarmPolicyDirect, type RendererDraftPrewarmPolicyStatus, @@ -49,6 +50,7 @@ export interface RendererCdpControlSession { export interface InstallRendererCdpControlOptions { rendererCdpEndpoint: string; rendererSource: string; + overlayHookSource?: string; enabledAgents?: readonly string[]; pollIntervalMs?: number; timeoutMs?: number; @@ -124,6 +126,19 @@ function isPrimaryRendererUrl(value: string): boolean { } } +function isAvatarOverlayRendererUrl(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol !== "app:" || url.hostname !== "-") return false; + if (url.pathname === "/avatar-overlay.html") return true; + return ( + url.pathname === "/index.html" && url.searchParams.get("initialRoute") === "/avatar-overlay" + ); + } catch { + return false; + } +} + export function selectPrimaryRendererTarget( targets: readonly CdpTarget[], preferredTargetId?: string, @@ -134,6 +149,10 @@ export function selectPrimaryRendererTarget( return candidates.find((target) => target.id === preferredTargetId) ?? candidates.at(0) ?? null; } +export function selectAvatarOverlayRendererTargets(targets: readonly CdpTarget[]): CdpTarget[] { + return targets.filter((item) => item.type === "page" && isAvatarOverlayRendererUrl(item.url)); +} + class RendererAdapterReadinessError extends Error { constructor( readonly state: string, @@ -249,6 +268,71 @@ async function waitForBinding( throw new Error(`Production Renderer binding did not become ready${detail}`); } +async function overlayHookAlreadyEnabled(renderer: RendererCdpClient): Promise { + try { + const value = await renderer.evaluate( + `globalThis.${CPA_LIVE_VOICE_HOOK_GLOBAL}?.enabled === true`, + ); + return value === true; + } catch { + return false; + } +} + +async function installOverlayHook( + target: CdpTarget, + overlayHookSource: string, + operations: RendererCdpControlOperations, + signal: AbortSignal, +): Promise { + const run = (operation: () => Promise): Promise => { + signal.throwIfAborted(); + return awaitWithSignal(operation(), signal, "Overlay hook installation timed out"); + }; + const renderer = await run(async () => { + const connected = await operations.connect(target.webSocketDebuggerUrl); + if (signal.aborted) { + connected.close(); + signal.throwIfAborted(); + } + return connected; + }); + try { + await run(() => renderer.command("Runtime.enable")); + await run(() => renderer.command("Page.enable")); + if (await run(() => overlayHookAlreadyEnabled(renderer))) return; + await run(() => + renderer.command("Page.addScriptToEvaluateOnNewDocument", { source: overlayHookSource }), + ); + await run(() => evaluateSource(renderer, overlayHookSource)); + } finally { + renderer.close(); + } +} + +async function installOverlayHooks( + endpoint: string, + overlayHookSource: string | undefined, + operations: RendererCdpControlOperations, + signal: AbortSignal, +): Promise { + if (!overlayHookSource) return; + try { + const overlays = selectAvatarOverlayRendererTargets( + await awaitWithSignal( + operations.listTargets(endpoint, signal), + signal, + "Overlay CDP target discovery timed out", + ), + ); + await Promise.allSettled( + overlays.map((overlay) => installOverlayHook(overlay, overlayHookSource, operations, signal)), + ); + } catch { + // Overlay discovery and injection are best-effort and share one deadline. + } +} + async function installTarget( target: CdpTarget, rendererSource: string, @@ -279,6 +363,7 @@ class InstalledRendererCdpControlSession implements RendererCdpControlSession { private renderer: RendererCdpClient, private readonly rendererCdpEndpoint: string, private readonly rendererSource: string, + private readonly overlayHookSource: string | undefined, private readonly enabledAgents: readonly string[] | undefined, private readonly timeoutMs: number, private readonly pollIntervalMs: number, @@ -311,37 +396,41 @@ class InstalledRendererCdpControlSession implements RendererCdpControlSession { this.renderer.close(); this.renderer = replacement.renderer; this.currentSnapshot = replacement.snapshot; - return this.currentSnapshot; - } - - try { - const existing = await readBinding(this.renderer); - if (existing === null) await evaluateSource(this.renderer, this.rendererSource); - // A Host switch can temporarily leave the Adapter installing. Reconcile - // native owners before validating readiness; that is not a lost CDP page. - const draftPrewarmPolicy = await this.operations.installDraftPrewarmPolicy(this.renderer); - const binding = await waitForBinding( - this.renderer, - this.enabledAgents, - this.timeoutMs, - this.pollIntervalMs, - ); - this.currentSnapshot = { target, draftPrewarmPolicy, binding }; - return this.currentSnapshot; - } catch { - const replacement = await installTarget( - target, - this.rendererSource, - this.enabledAgents, - this.timeoutMs, - this.pollIntervalMs, - this.operations, - ); - this.renderer.close(); - this.renderer = replacement.renderer; - this.currentSnapshot = replacement.snapshot; - return this.currentSnapshot; + } else { + try { + const existing = await readBinding(this.renderer); + if (existing === null) await evaluateSource(this.renderer, this.rendererSource); + // A Host switch can temporarily leave the Adapter installing. Reconcile + // native owners before validating readiness; that is not a lost CDP page. + const draftPrewarmPolicy = await this.operations.installDraftPrewarmPolicy(this.renderer); + const binding = await waitForBinding( + this.renderer, + this.enabledAgents, + this.timeoutMs, + this.pollIntervalMs, + ); + this.currentSnapshot = { target, draftPrewarmPolicy, binding }; + } catch { + const replacement = await installTarget( + target, + this.rendererSource, + this.enabledAgents, + this.timeoutMs, + this.pollIntervalMs, + this.operations, + ); + this.renderer.close(); + this.renderer = replacement.renderer; + this.currentSnapshot = replacement.snapshot; + } } + await installOverlayHooks( + this.rendererCdpEndpoint, + this.overlayHookSource, + this.operations, + discoveryTimeoutSignal(this.timeoutMs), + ); + return this.currentSnapshot; } async activateDesktop(): Promise { @@ -391,10 +480,17 @@ export async function createRendererCdpControlSession( pollIntervalMs, operations, ); + await installOverlayHooks( + options.rendererCdpEndpoint, + options.overlayHookSource, + operations, + discoveryTimeoutSignal(timeoutMs, options.signal), + ); return new InstalledRendererCdpControlSession( installed.renderer, options.rendererCdpEndpoint, options.rendererSource, + options.overlayHookSource, enabledAgents, timeoutMs, pollIntervalMs, diff --git a/packages/desktop-control/test/cpa-live-voice-hook.test.ts b/packages/desktop-control/test/cpa-live-voice-hook.test.ts new file mode 100644 index 000000000..cd3ff373a --- /dev/null +++ b/packages/desktop-control/test/cpa-live-voice-hook.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { + AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, + AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION, + CPA_LIVE_VOICE_ENV, + cpaLiveVoiceHookBootstrap, + cpaLiveVoiceHookEnabled, +} from "../src/cpa-live-voice-hook.js"; + +describe("CPA live-voice controller bootstrap", () => { + it("defaults on and opts out only with CODEXHOST_CPA_LIVE_VOICE=0", () => { + expect(cpaLiveVoiceHookEnabled({})).toBe(true); + expect(cpaLiveVoiceHookEnabled({ [CPA_LIVE_VOICE_ENV]: "1" })).toBe(true); + expect(cpaLiveVoiceHookEnabled({ [CPA_LIVE_VOICE_ENV]: "0" })).toBe(false); + expect(cpaLiveVoiceHookBootstrap({ [CPA_LIVE_VOICE_ENV]: "0" })).toBe(""); + }); + + it("emits a frozen enable record by default", () => { + const source = cpaLiveVoiceHookBootstrap({}); + expect(source.startsWith("globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze(")).toBe( + true, + ); + expect(source).toContain(`"auditedDesktopVersion":"${AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION}"`); + expect(source).toContain( + `"auditedAppInitialAsset":"${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}"`, + ); + expect(source).toContain('"enabled":true'); + expect(source).not.toContain("clientOwnsCall"); + expect(source).not.toContain("function pa"); + }); +}); diff --git a/packages/desktop-control/test/production-controller.test.ts b/packages/desktop-control/test/production-controller.test.ts index a55a275ff..68f4633ee 100644 --- a/packages/desktop-control/test/production-controller.test.ts +++ b/packages/desktop-control/test/production-controller.test.ts @@ -113,7 +113,7 @@ describe("production Desktop Controller", () => { close, }; const ready = vi.fn(); - const install = vi.fn(async () => session); + const install = vi.fn(async () => session); const server = attachmentServer(); let attach: (() => Promise) | undefined; const startAttachmentServer = vi.fn(async (options) => { @@ -131,13 +131,20 @@ describe("production Desktop Controller", () => { await runDesktopController(controllerOptions(), abort.signal, dependencies); - expect(install).toHaveBeenCalledWith({ - rendererCdpEndpoint: "http://127.0.0.1:43123", - rendererSource: - "globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;\nproduction renderer", - timeoutMs: 90_000, - signal: abort.signal, - }); + expect(install).toHaveBeenCalledWith( + expect.objectContaining({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + timeoutMs: 90_000, + signal: abort.signal, + }), + ); + const source = install.mock.calls[0]?.[0].rendererSource; + expect(source).toContain( + "globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;", + ); + expect(source).toContain("production renderer"); + expect(source).toContain("__codexhostCpaLiveVoiceHookV1"); + expect(install.mock.calls[0]?.[0].overlayHookSource).toContain("__codexhostCpaLiveVoiceHookV1"); expect(startAttachmentServer).toHaveBeenCalledWith({ port: 43124, nonce: attachmentNonce, @@ -156,6 +163,83 @@ describe("production Desktop Controller", () => { expect(attach).toEqual(expect.any(Function)); }); + it("prefixes the CPA live-voice hook by default", async () => { + const previous = process.env.CODEXHOST_CPA_LIVE_VOICE; + delete process.env.CODEXHOST_CPA_LIVE_VOICE; + const abort = new AbortController(); + const snapshot = controllerSnapshot(); + const session: RendererCdpControlSession = { + snapshot, + ensureInstalled: vi.fn(async () => { + abort.abort(); + return snapshot; + }), + activateDesktop: vi.fn(async () => 1), + executeRenderer: vi.fn(), + close: vi.fn(), + }; + const install = vi.fn(async () => session); + try { + await runDesktopController(controllerOptions(), abort.signal, { + readRenderer: vi.fn(async (filePath: string) => + filePath.endsWith("cpa-live-voice-hook.js") ? "overlay hook" : "production renderer", + ), + install, + startAttachmentServer: vi.fn(async () => attachmentServer()), + ready: vi.fn(), + sleep: vi.fn(async () => {}), + monitorIntervalMs: 1, + }); + const source = install.mock.calls[0]?.[0].rendererSource; + expect(source).toContain("globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze("); + expect(source).toContain("26.1007.30912"); + expect(source?.endsWith("production renderer")).toBe(true); + expect(install.mock.calls[0]?.[0].overlayHookSource).toContain("overlay hook"); + expect(install.mock.calls[0]?.[0].overlayHookSource).toContain( + "globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze(", + ); + } finally { + if (previous === undefined) delete process.env.CODEXHOST_CPA_LIVE_VOICE; + else process.env.CODEXHOST_CPA_LIVE_VOICE = previous; + } + }); + + it("omits the CPA live-voice hook when CODEXHOST_CPA_LIVE_VOICE=0", async () => { + const previous = process.env.CODEXHOST_CPA_LIVE_VOICE; + process.env.CODEXHOST_CPA_LIVE_VOICE = "0"; + const abort = new AbortController(); + const snapshot = controllerSnapshot(); + const session: RendererCdpControlSession = { + snapshot, + ensureInstalled: vi.fn(async () => { + abort.abort(); + return snapshot; + }), + activateDesktop: vi.fn(async () => 1), + executeRenderer: vi.fn(), + close: vi.fn(), + }; + const install = vi.fn(async () => session); + try { + await runDesktopController(controllerOptions(), abort.signal, { + readRenderer: vi.fn(async (filePath: string) => + filePath.endsWith("cpa-live-voice-hook.js") ? "overlay hook" : "production renderer", + ), + install, + startAttachmentServer: vi.fn(async () => attachmentServer()), + ready: vi.fn(), + sleep: vi.fn(async () => {}), + monitorIntervalMs: 1, + }); + const source = install.mock.calls[0]?.[0].rendererSource; + expect(source).not.toContain("globalThis.__codexhostCpaLiveVoiceHookV1"); + expect(install.mock.calls[0]?.[0].overlayHookSource).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.CODEXHOST_CPA_LIVE_VOICE; + else process.env.CODEXHOST_CPA_LIVE_VOICE = previous; + } + }); + it("publishes readiness before a pending Renderer install finishes", async () => { const abort = new AbortController(); const close = vi.fn(); diff --git a/packages/desktop-control/test/renderer-cdp-control-session.test.ts b/packages/desktop-control/test/renderer-cdp-control-session.test.ts index daab931c8..49c5b59ca 100644 --- a/packages/desktop-control/test/renderer-cdp-control-session.test.ts +++ b/packages/desktop-control/test/renderer-cdp-control-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createRendererCdpControlSession, + selectAvatarOverlayRendererTargets, selectPrimaryRendererTarget, } from "../src/renderer-cdp-control-session.js"; import type { CdpTarget } from "../src/cdp-client.js"; @@ -62,6 +63,14 @@ describe("Renderer CDP Control Session", () => { expect( selectPrimaryRendererTarget([target("overlay", "app://-/avatar-overlay.html")]), ).toBeNull(); + expect( + selectAvatarOverlayRendererTargets([ + target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"), + target("legacy", "app://-/avatar-overlay.html"), + first, + target("query", "app://-/index.html?window=second"), + ]).map((item) => item.id), + ).toEqual(["overlay", "legacy"]); }); it("registers future-document injection before evaluating the current document", async () => { @@ -95,6 +104,189 @@ describe("Renderer CDP Control Session", () => { session.close(); }); + it("injects the hook-only source into the avatar overlay page", async () => { + const primary = rendererClient(); + const overlay = rendererClient(); + overlay.evaluateSpy.mockResolvedValue(false); + const session = await createRendererCdpControlSession({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + rendererSource: "production renderer", + overlayHookSource: "overlay hook only", + pollIntervalMs: 1, + timeoutMs: 100, + operations: { + listTargets: vi.fn(async () => [ + target("page-1"), + target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"), + ]), + connect: vi.fn(async (webSocketDebuggerUrl: string) => + webSocketDebuggerUrl.includes("overlay") ? overlay : primary, + ), + installDraftPrewarmPolicy: vi.fn(async () => ({ + state: "ready" as const, + reason: "owned-request-bridge" as const, + })), + }, + }); + + expect(primary.commands).toEqual( + expect.arrayContaining([ + { + method: "Page.addScriptToEvaluateOnNewDocument", + params: { source: "production renderer" }, + }, + ]), + ); + expect(overlay.commands).toEqual([ + { method: "Runtime.enable" }, + { method: "Page.enable" }, + { method: "Page.addScriptToEvaluateOnNewDocument", params: { source: "overlay hook only" } }, + { + method: "Runtime.evaluate", + params: { expression: "overlay hook only", awaitPromise: true }, + }, + ]); + session.close(); + }); + + it("keeps the primary session when overlay CDP connect fails", async () => { + const primary = rendererClient(); + const session = await createRendererCdpControlSession({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + rendererSource: "production renderer", + overlayHookSource: "overlay hook only", + pollIntervalMs: 1, + timeoutMs: 100, + operations: { + listTargets: vi.fn(async () => [ + target("page-1"), + target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"), + ]), + connect: vi.fn(async (webSocketDebuggerUrl: string) => { + if (webSocketDebuggerUrl.includes("overlay")) { + throw new Error("overlay closed between list and connect"); + } + return primary; + }), + installDraftPrewarmPolicy: vi.fn(async () => ({ + state: "ready" as const, + reason: "owned-request-bridge" as const, + })), + }, + }); + + expect(session.snapshot.binding).toEqual(readyBinding()); + expect(primary.close).not.toHaveBeenCalled(); + session.close(); + }); + + it("re-injects the hook-only overlay bundle when a reload drops the hook", async () => { + const primary = rendererClient(); + const overlay = rendererClient(); + overlay.evaluateSpy.mockResolvedValue(false); + const session = await createRendererCdpControlSession({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + rendererSource: "production renderer", + overlayHookSource: "overlay hook only", + pollIntervalMs: 1, + timeoutMs: 100, + operations: { + listTargets: vi.fn(async () => [ + target("page-1"), + target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"), + ]), + connect: vi.fn(async (webSocketDebuggerUrl: string) => + webSocketDebuggerUrl.includes("overlay") ? overlay : primary, + ), + installDraftPrewarmPolicy: vi.fn(async () => ({ + state: "ready" as const, + reason: "owned-request-bridge" as const, + })), + }, + }); + + overlay.commands.length = 0; + await session.ensureInstalled(); + + expect(overlay.commands).toEqual([ + { method: "Runtime.enable" }, + { method: "Page.enable" }, + { method: "Page.addScriptToEvaluateOnNewDocument", params: { source: "overlay hook only" } }, + { + method: "Runtime.evaluate", + params: { expression: "overlay hook only", awaitPromise: true }, + }, + ]); + expect( + overlay.commands.some( + ({ params }) => + params?.source === "production renderer" || params?.expression === "production renderer", + ), + ).toBe(false); + session.close(); + }); + + it.each(["connect", "command", "evaluate"] as const)( + "bounds stalled overlay %s operations without blocking other overlays", + async (stage) => { + const primary = rendererClient(); + const stalled = rendererClient(); + const healthy = rendererClient(); + healthy.evaluateSpy.mockResolvedValue(false); + let finishConnect: ((client: typeof stalled) => void) | undefined; + const pendingConnect = new Promise((resolve) => { + finishConnect = resolve; + }); + if (stage === "command") { + stalled.command.mockImplementation(() => new Promise(() => {})); + } + if (stage === "evaluate") { + stalled.evaluateSpy.mockImplementation(() => new Promise(() => {})); + } + const session = await createRendererCdpControlSession({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + rendererSource: "production renderer", + overlayHookSource: "overlay hook only", + timeoutMs: 40, + operations: { + listTargets: async () => [ + target("page-1"), + target("stalled", "app://-/avatar-overlay.html"), + target("healthy", "app://-/avatar-overlay.html"), + ], + connect: async (url) => { + if (url.endsWith("/stalled")) return stage === "connect" ? pendingConnect : stalled; + return url.endsWith("/healthy") ? healthy : primary; + }, + installDraftPrewarmPolicy: async () => ({ + state: "ready", + reason: "owned-request-bridge", + }), + }, + }); + try { + expect(session.snapshot.binding).toEqual(readyBinding()); + expect(primary.close).not.toHaveBeenCalled(); + expect(healthy.commands).toContainEqual({ + method: "Runtime.evaluate", + params: { expression: "overlay hook only", awaitPromise: true }, + }); + expect(healthy.close).toHaveBeenCalledOnce(); + if (stage === "connect") { + expect(stalled.close).not.toHaveBeenCalled(); + finishConnect?.(stalled); + await vi.waitFor(() => expect(stalled.close).toHaveBeenCalledOnce()); + expect(stalled.command).not.toHaveBeenCalled(); + } else { + expect(stalled.close).toHaveBeenCalledOnce(); + } + await expect(session.ensureInstalled()).resolves.toMatchObject({ binding: readyBinding() }); + } finally { + session.close(); + } + }, + ); + it("reconciles a changed Host before judging the previous Adapter readiness", async () => { const binding = readyBinding(); const client = rendererClient(binding); @@ -216,6 +408,44 @@ describe("Renderer CDP Control Session", () => { expect(client.close).not.toHaveBeenCalled(); }); + it("keeps the primary session when overlay CDP discovery hangs past its timeout", async () => { + const primary = rendererClient(); + let calls = 0; + const session = await createRendererCdpControlSession({ + rendererCdpEndpoint: "http://127.0.0.1:43123", + rendererSource: "production renderer", + overlayHookSource: "overlay hook only", + pollIntervalMs: 10, + timeoutMs: 40, + operations: { + listTargets: vi.fn((_endpoint, signal) => { + calls += 1; + if (calls % 2 === 1) return Promise.resolve([target("page-1")]); + return new Promise((_resolve, reject) => { + const fail = (): void => reject(new Error("Overlay CDP target discovery timed out")); + if (signal?.aborted) { + fail(); + return; + } + signal?.addEventListener("abort", fail, { once: true }); + }); + }), + connect: vi.fn(async () => primary), + installDraftPrewarmPolicy: vi.fn(async () => ({ + state: "ready" as const, + reason: "owned-request-bridge" as const, + })), + }, + }); + + expect(session.snapshot.binding).toEqual(readyBinding()); + await expect(session.ensureInstalled()).resolves.toMatchObject({ + binding: readyBinding(), + }); + expect(primary.close).not.toHaveBeenCalled(); + session.close(); + }); + it("fails closed when the injected Adapter is unsupported", async () => { const client = rendererClient({ version: 2, diff --git a/packages/host-runtime/test/app-server-host.projection-1.test.ts b/packages/host-runtime/test/app-server-host.projection-1.test.ts index a8fed42c8..e3092097b 100644 --- a/packages/host-runtime/test/app-server-host.projection-1.test.ts +++ b/packages/host-runtime/test/app-server-host.projection-1.test.ts @@ -1363,7 +1363,7 @@ describe("AppServerHost HarnessAdapter projection", () => { updateAvailable: true, installationAvailable: true, releaseNotes: "Safer updates", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status: null, error: null, })), diff --git a/packages/host-runtime/test/runtime-maintenance.test.ts b/packages/host-runtime/test/runtime-maintenance.test.ts index 4ba7d75db..b31967964 100644 --- a/packages/host-runtime/test/runtime-maintenance.test.ts +++ b/packages/host-runtime/test/runtime-maintenance.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { async function fixture(remote = false, updaterStartTimeoutMs?: number) { const root = await mkdtemp(path.join(tmpdir(), "codexhost-maintenance-")); directories.push(root); - const pkg = path.join(root, "node_modules/@codexhost/cli-darwin-arm64"); + const pkg = path.join(root, "node_modules/@karlorz/codexhost-cli-darwin-arm64"); const runtimePath = path.join(pkg, "app/host-runtime.mjs"); await Promise.all( ["app", "libexec", "../cli/bin"].map((dir) => mkdir(path.join(pkg, dir), { recursive: true })), diff --git a/packages/host-runtime/test/update-coordinator.test.ts b/packages/host-runtime/test/update-coordinator.test.ts index ca364e627..a26c6b904 100644 --- a/packages/host-runtime/test/update-coordinator.test.ts +++ b/packages/host-runtime/test/update-coordinator.test.ts @@ -137,7 +137,7 @@ function release(version = "1.2.3"): CodexhostLatestRelease { return { version, releaseNotes: `Release ${version}`, - releaseNotesUrl: `https://github.com/BytePioneer-AI/codex-host/releases/tag/v${version}`, + releaseNotesUrl: `https://github.com/karlorz/codex-host/releases/tag/v${version}`, assets: [], }; } @@ -268,14 +268,14 @@ describe("Host update coordinator", () => { fetchLatest: async () => ({ version: "1.2.3", releaseNotes: "Release 1.2.3", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", assets: [ { name: "codexhost-1.2.3-macos-arm64.dmg", size: bytes.length, digest: `sha256:${digest(bytes)}`, downloadUrl: - "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-macos-arm64.dmg", + "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-macos-arm64.dmg", }, ], }), diff --git a/packages/renderer-extension/scripts/build.mjs b/packages/renderer-extension/scripts/build.mjs index fd00c89d8..d1f3bf35a 100644 --- a/packages/renderer-extension/scripts/build.mjs +++ b/packages/renderer-extension/scripts/build.mjs @@ -7,6 +7,7 @@ const packageRoot = path.resolve(import.meta.dirname, ".."); const entries = [ { entry: "src/index.ts", format: "esm", outfile: "dist/index.js" }, { entry: "src/production-entry.ts", format: "iife", outfile: "dist/production.js" }, + { entry: "src/cpa-live-voice-entry.ts", format: "iife", outfile: "dist/cpa-live-voice-hook.js" }, { entry: "src/probe-entry.ts", format: "iife", outfile: "dist/renderer-binding-probe.js" }, { entry: "src/audit-entry.ts", format: "iife", outfile: "dist/contract-audit.js" }, { entry: "src/console-entry.ts", format: "iife", outfile: "dist/console.js" }, diff --git a/packages/renderer-extension/src/cpa-live-voice-entry.ts b/packages/renderer-extension/src/cpa-live-voice-entry.ts new file mode 100644 index 000000000..22b0b134b --- /dev/null +++ b/packages/renderer-extension/src/cpa-live-voice-entry.ts @@ -0,0 +1,4 @@ +import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js"; + +// Document-start: do not wait for body. Voice constructs clientOwnsCall during start. +installCpaLiveVoiceHook(); diff --git a/packages/renderer-extension/src/production-entry.ts b/packages/renderer-extension/src/production-entry.ts index 2664bbed4..093af42cc 100644 --- a/packages/renderer-extension/src/production-entry.ts +++ b/packages/renderer-extension/src/production-entry.ts @@ -1,11 +1,14 @@ +import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js"; import { installRendererBinding } from "./install-renderer-binding.js"; -const install = (): void => { +installCpaLiveVoiceHook(); + +const installBinding = (): void => { installRendererBinding(); }; if (document.documentElement && document.body) { - install(); + installBinding(); } else { - window.addEventListener("DOMContentLoaded", install, { once: true }); + window.addEventListener("DOMContentLoaded", installBinding, { once: true }); } diff --git a/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts new file mode 100644 index 000000000..5a30e5343 --- /dev/null +++ b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts @@ -0,0 +1,198 @@ +import { cpaLiveVoicePreferenceAllowsInstall } from "./renderer-cpa-live-voice-preference.js"; + +/** + * Runtime equivalent of durable-only `clientOwnsCall` for Codex Desktop + * 26.1007.30912. Default on. Does not rewrite ASAR or copy Desktop source. + * + * Native: hostId === "durable" OR (app-server supports threadRealtimeExistingCall + * AND Statsig gate 206132468). The capability is a version comparison, so the + * hook forces that gate to false; the gate has no other reader in this build. + */ +export const CPA_LIVE_VOICE_HOOK_GLOBAL = "__codexhostCpaLiveVoiceHookV1"; +export const AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION = "26.1007.30912"; +export const AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET = "app-initial-6350d04672d0.js"; +export const THREAD_REALTIME_EXISTING_CALL_GATE = "206132468"; + +type RecordValue = Record; + +export interface CpaLiveVoiceHookConfig { + enabled: boolean; + auditedDesktopVersion: string; + auditedAppInitialAsset: string; + desktopVersion?: string; +} + +export type CpaLiveVoiceHookStatus = "disabled" | "pending" | "active" | "unsupported"; + +export interface CpaLiveVoiceStatsigClient { + checkGate(name: string, ...rest: unknown[]): unknown; +} + +export interface CpaLiveVoiceHookHandle { + status: CpaLiveVoiceHookStatus; + dispose(): void; +} + +export interface InstallCpaLiveVoiceHookOptions { + config?: CpaLiveVoiceHookConfig | null; + resourceNames?: readonly string[]; + globalThis?: object; + document?: ParentNode; +} + +const originalCheckGate = new WeakMap(); +const DISCOVERY_TIMEOUT_MS = 60_000; + +function isRecord(value: unknown): value is RecordValue { + return typeof value === "object" && value !== null; +} + +export function readCpaLiveVoiceHookConfig(globalObject: object): CpaLiveVoiceHookConfig | null { + const value = (globalObject as Record)[CPA_LIVE_VOICE_HOOK_GLOBAL]; + if (!isRecord(value) || value.enabled !== true) return null; + if ( + typeof value.auditedDesktopVersion !== "string" || + typeof value.auditedAppInitialAsset !== "string" + ) { + return null; + } + return { + enabled: true, + auditedDesktopVersion: value.auditedDesktopVersion, + auditedAppInitialAsset: value.auditedAppInitialAsset, + ...(typeof value.desktopVersion === "string" ? { desktopVersion: value.desktopVersion } : {}), + }; +} + +export function auditedAppInitialAssetPresent( + resourceNames: readonly string[], + asset: string, +): boolean { + return resourceNames.some((name) => { + try { + const url = new URL(name, "app://-/"); + return url.protocol === "app:" && url.hostname === "-" && url.pathname === `/assets/${asset}`; + } catch { + return false; + } + }); +} + +function resourceNamesFromPerformance(): string[] { + if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") { + return []; + } + return performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .filter((name): name is string => typeof name === "string"); +} + +function resourceNamesFromDocument(root: ParentNode | undefined): string[] { + const names = resourceNamesFromPerformance(); + if (!root || !("querySelectorAll" in root)) return names; + for (const element of root.querySelectorAll("script[src], link[href]")) { + const value = element.getAttribute("src") ?? element.getAttribute("href"); + if (value) names.push(value); + } + return names; +} + +/** Statsig JS clients register on `__STATSIG__` (`instances` by SDK key, plus `firstInstance`). */ +export function statsigClients(globalObject: object): CpaLiveVoiceStatsigClient[] { + const registry = (globalObject as Record).__STATSIG__; + if (!isRecord(registry)) return []; + const clients = new Set( + isRecord(registry.instances) ? Object.values(registry.instances) : [], + ); + if (registry.firstInstance) clients.add(registry.firstInstance); + return [...clients].filter( + (client): client is CpaLiveVoiceStatsigClient => + isRecord(client) && typeof client.checkGate === "function", + ); +} + +export function wrapCheckGate(client: CpaLiveVoiceStatsigClient): boolean { + if (originalCheckGate.has(client)) return false; + const original = client.checkGate; + originalCheckGate.set(client, original); + client.checkGate = function (this: unknown, name: string, ...rest: unknown[]) { + return name === THREAD_REALTIME_EXISTING_CALL_GATE ? false : original.call(this, name, ...rest); + }; + return true; +} + +function restoreCheckGate(client: CpaLiveVoiceStatsigClient): void { + const original = originalCheckGate.get(client); + if (!original) return; + client.checkGate = original; + originalCheckGate.delete(client); +} + +function configAllowsInstall( + config: CpaLiveVoiceHookConfig, + resourceNames: readonly string[], +): boolean { + if ( + config.desktopVersion !== undefined && + config.desktopVersion !== config.auditedDesktopVersion + ) { + return false; + } + if (config.auditedDesktopVersion !== AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION) return false; + if (config.auditedAppInitialAsset !== AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET) return false; + return auditedAppInitialAssetPresent(resourceNames, config.auditedAppInitialAsset); +} + +export function installCpaLiveVoiceHook( + options: InstallCpaLiveVoiceHookOptions = {}, +): CpaLiveVoiceHookHandle { + const globalObject = options.globalThis ?? globalThis; + const config = + options.config === undefined ? readCpaLiveVoiceHookConfig(globalObject) : options.config; + if (!config?.enabled || !cpaLiveVoicePreferenceAllowsInstall(globalObject)) { + return { status: "disabled", dispose() {} }; + } + const root = options.document ?? (globalObject as { document?: ParentNode }).document; + const wrapped: CpaLiveVoiceStatsigClient[] = []; + let disposed = false; + let auditedAssetFound = false; + const handle: CpaLiveVoiceHookHandle = { + status: "pending", + dispose() { + disposed = true; + for (const client of wrapped) restoreCheckGate(client); + wrapped.length = 0; + }, + }; + const attempt = (): boolean => { + if (!auditedAssetFound) { + const resourceNames = options.resourceNames ?? resourceNamesFromDocument(root); + auditedAssetFound = configAllowsInstall(config, resourceNames); + if (!auditedAssetFound) return false; + } + for (const client of statsigClients(globalObject)) { + if (wrapCheckGate(client)) wrapped.push(client); + } + if (wrapped.length === 0) return false; + handle.status = "active"; + return true; + }; + if (attempt()) return handle; + if (options.resourceNames && !configAllowsInstall(config, options.resourceNames)) { + handle.status = "unsupported"; + return handle; + } + // Document-start injection runs before the bundle constructs its Statsig client. + const started = Date.now(); + const tick = (): void => { + if (disposed || attempt()) return; + if (Date.now() - started > DISCOVERY_TIMEOUT_MS) { + handle.status = "unsupported"; + return; + } + globalThis.setTimeout(tick, 250); + }; + globalThis.setTimeout(tick, 250); + return handle; +} diff --git a/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts b/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts new file mode 100644 index 000000000..5c5641693 --- /dev/null +++ b/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts @@ -0,0 +1,30 @@ +export const CPA_LIVE_VOICE_STORAGE_KEY = "codexhost.cpa-live-voice.v1"; +export const CPA_LIVE_VOICE_CHANGE_EVENT = "codexhost:cpa-live-voice-changed"; + +export function readCpaLiveVoicePreference(owner: Window): boolean { + try { + return owner.localStorage.getItem(CPA_LIVE_VOICE_STORAGE_KEY) !== "0"; + } catch { + return true; + } +} + +export function writeCpaLiveVoicePreference(owner: Window, enabled: boolean): boolean { + try { + owner.localStorage.setItem(CPA_LIVE_VOICE_STORAGE_KEY, enabled ? "1" : "0"); + } catch { + return false; + } + owner.dispatchEvent(new Event(CPA_LIVE_VOICE_CHANGE_EVENT)); + return true; +} + +export function cpaLiveVoicePreferenceAllowsInstall(globalObject: object): boolean { + const storage = (globalObject as { localStorage?: Storage }).localStorage; + if (!storage) return true; + try { + return storage.getItem(CPA_LIVE_VOICE_STORAGE_KEY) !== "0"; + } catch { + return true; + } +} diff --git a/packages/renderer-extension/src/settings/appearance-page.ts b/packages/renderer-extension/src/settings/appearance-page.ts index 860c6d8df..51607c188 100644 --- a/packages/renderer-extension/src/settings/appearance-page.ts +++ b/packages/renderer-extension/src/settings/appearance-page.ts @@ -1,3 +1,9 @@ +import { readCpaLiveVoiceHookConfig } from "../renderer-cpa-live-voice-hook.js"; +import { + CPA_LIVE_VOICE_CHANGE_EVENT, + readCpaLiveVoicePreference, + writeCpaLiveVoicePreference, +} from "../renderer-cpa-live-voice-preference.js"; import { REASONING_SOFT_WRAP_CHANGE_EVENT, readReasoningTranscriptSoftWrap, @@ -35,6 +41,32 @@ export function createAppearanceSettingsPage( description.textContent = messages.appearanceDescription; const { group, card } = createPreferenceGroup(document, messages.appearanceGroup); + const liveVoiceId = preferenceId("cpa-live-voice"); + const liveVoiceRow = createPreferenceItem(document, { + title: messages.cpaLiveVoiceTitle, + description: messages.cpaLiveVoiceDescription, + controlId: liveVoiceId, + help: { label: messages.cpaLiveVoiceHelpLabel, lines: messages.cpaLiveVoiceHelp }, + }); + const liveVoice = createPreferenceSwitch(document, liveVoiceId, liveVoiceRow.description.id); + liveVoice.checked = readCpaLiveVoicePreference(ownerWindow); + liveVoice.addEventListener("change", () => { + writeCpaLiveVoicePreference(ownerWindow, liveVoice.checked); + }); + const syncLiveVoice = (): void => { + liveVoice.checked = readCpaLiveVoicePreference(ownerWindow); + }; + ownerWindow.addEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice); + liveVoiceRow.item.append(liveVoice); + if (!readCpaLiveVoiceHookConfig(ownerWindow)) { + const warning = document.createElement("div"); + warning.className = "text-xs leading-[18px] text-settings-warning"; + warning.setAttribute("role", "status"); + warning.textContent = messages.cpaLiveVoiceRestartRequired; + liveVoiceRow.description.after(warning); + } + card.append(liveVoiceRow.item); + const softWrapId = preferenceId("reasoning-soft-wrap"); const row = createPreferenceItem(document, { title: messages.reasoningSoftWrapTitle, @@ -60,6 +92,7 @@ export function createAppearanceSettingsPage( getLoadedSessionsClient, ); return () => { + ownerWindow.removeEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice); ownerWindow.removeEventListener(REASONING_SOFT_WRAP_CHANGE_EVENT, sync); disposeIdleRelease(); }; diff --git a/packages/renderer-extension/src/settings/connections-page.ts b/packages/renderer-extension/src/settings/connections-page.ts index c95a0ef85..01e500d55 100644 --- a/packages/renderer-extension/src/settings/connections-page.ts +++ b/packages/renderer-extension/src/settings/connections-page.ts @@ -23,8 +23,7 @@ import { harnessInstallStore } from "./harness-install-store.js"; import { createHarnessVersionPanel } from "./harness-version-panel.js"; import type { RendererSettingsMessages } from "./localization.js"; -export const CODEXHOST_GITHUB_ISSUES_NEW_URL = - "https://github.com/BytePioneer-AI/codex-host/issues/new"; +export const CODEXHOST_GITHUB_ISSUES_NEW_URL = "https://github.com/karlorz/codex-host/issues/new"; export interface RendererConnectionAgentSnapshot { readonly agent: ExternalRendererAgent; diff --git a/packages/renderer-extension/src/settings/localization.ts b/packages/renderer-extension/src/settings/localization.ts index f4eda0d85..88a89709c 100644 --- a/packages/renderer-extension/src/settings/localization.ts +++ b/packages/renderer-extension/src/settings/localization.ts @@ -38,6 +38,11 @@ export interface RendererSettingsMessages { readonly otherSection: string; readonly appearanceDescription: string; readonly appearanceGroup: string; + readonly cpaLiveVoiceTitle: string; + readonly cpaLiveVoiceDescription: string; + readonly cpaLiveVoiceHelpLabel: string; + readonly cpaLiveVoiceHelp: readonly string[]; + readonly cpaLiveVoiceRestartRequired: string; readonly loadedSessions: { title: string; description: string; @@ -290,6 +295,17 @@ const ENGLISH_MESSAGES: RendererSettingsMessages = Object.freeze({ otherSection: "Other", appearanceDescription: "Conversation display and local resource management.", appearanceGroup: "Appearance", + cpaLiveVoiceTitle: "Override live voice", + cpaLiveVoiceDescription: "On by default. Host creates the realtime call instead of ChatGPT.", + cpaLiveVoiceHelpLabel: "About overriding live voice", + cpaLiveVoiceHelp: Object.freeze([ + "Overrides Desktop's existing-call path on audited 26.1007.30912 so a local host can create the WebRTC call.", + "This switch applies on the next voice session. No relaunch.", + "Also set both keys in ~/.codex/config.toml and relaunch Codex: experimental_realtime_ws_base_url and experimental_realtime_webrtc_call_base_url, each pointing at your realtime host /v1.", + "CODEXHOST_CPA_LIVE_VOICE=0 is not live. Relaunch Codex after changing that environment variable.", + ]), + cpaLiveVoiceRestartRequired: + "This override is not injected. Unset CODEXHOST_CPA_LIVE_VOICE=0 and relaunch Codex. The switch cannot take effect until then.", loadedSessions: { title: "Loaded sessions", description: @@ -597,6 +613,17 @@ const CHINESE_MESSAGES: RendererSettingsMessages = Object.freeze({ otherSection: "其他", appearanceDescription: "会话显示与本地资源管理。", appearanceGroup: "外观", + cpaLiveVoiceTitle: "改写实时语音", + cpaLiveVoiceDescription: "默认开启。由 Host 创建 realtime 通话,而不是 ChatGPT。", + cpaLiveVoiceHelpLabel: "改写实时语音说明", + cpaLiveVoiceHelp: Object.freeze([ + "在已审计的 Desktop 26.1007.30912 上覆盖 existing-call 路径,让本地 Host 创建 WebRTC 通话。", + "此开关在下一次语音会话生效,无需重新启动。", + "还需要在 ~/.codex/config.toml 设置 experimental_realtime_ws_base_url 与 experimental_realtime_webrtc_call_base_url,都指向你的 realtime 主机 /v1,然后重新启动 Codex。", + "CODEXHOST_CPA_LIVE_VOICE=0 不会即时生效。修改该环境变量后必须重新启动 Codex。", + ]), + cpaLiveVoiceRestartRequired: + "当前未注入此改写。请取消 CODEXHOST_CPA_LIVE_VOICE=0 并重新启动 Codex,本开关才会生效。", loadedSessions: { title: "已加载会话", description: diff --git a/packages/renderer-extension/src/settings/pages.ts b/packages/renderer-extension/src/settings/pages.ts index a83aa236f..a054f489f 100644 --- a/packages/renderer-extension/src/settings/pages.ts +++ b/packages/renderer-extension/src/settings/pages.ts @@ -49,9 +49,9 @@ import { runBoundedRendererUpdateRequest, } from "./update-request.js"; -export const CODEXHOST_GITHUB_REPOSITORY_URL = "https://github.com/BytePioneer-AI/codex-host"; +export const CODEXHOST_GITHUB_REPOSITORY_URL = "https://github.com/karlorz/codex-host"; export const CODEXHOST_RELEASES_LATEST_URL = `${CODEXHOST_GITHUB_REPOSITORY_URL}/releases/latest`; -export const CODEXHOST_NPM_MANUAL_UPDATE_COMMAND = "npm install -g @codexhost/cli@latest"; +export const CODEXHOST_NPM_MANUAL_UPDATE_COMMAND = "npm install -g @karlorz/codexhost-cli@latest"; interface RendererUserAgentData { readonly platform?: string; @@ -75,7 +75,7 @@ function windowsInstallerDownloadUrl(window: Window | null | undefined, version: const hints = navigator ? rendererUserAgentData(navigator) : undefined; const identity = `${hints?.architecture ?? ""} ${hints?.platform ?? ""} ${navigator?.platform ?? ""} ${navigator?.userAgent ?? ""}`; const architecture = /arm64|aarch64|\barm\b/iu.test(identity) ? "arm64" : "x64"; - return `https://github.com/BytePioneer-AI/codex-host/releases/download/v${version}/codexhost-${version}-windows-${architecture}.exe`; + return `${CODEXHOST_GITHUB_REPOSITORY_URL}/releases/download/v${version}/codexhost-${version}-windows-${architecture}.exe`; } export const DEFAULT_RENDERER_SETTINGS_PAGE_IDS = [ diff --git a/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts b/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts new file mode 100644 index 000000000..c4af69792 --- /dev/null +++ b/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CPA_LIVE_VOICE_STORAGE_KEY } from "../src/renderer-cpa-live-voice-preference.js"; +import { + AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, + AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION, + THREAD_REALTIME_EXISTING_CALL_GATE, + auditedAppInitialAssetPresent, + installCpaLiveVoiceHook, + readCpaLiveVoiceHookConfig, + statsigClients, + wrapCheckGate, +} from "../src/renderer-cpa-live-voice-hook.js"; + +const AUDITED_ASSET = [`app://-/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`]; + +const enabledConfig = { + enabled: true as const, + auditedDesktopVersion: AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION, + auditedAppInitialAsset: AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, +}; + +function statsigClient() { + return { + checkGate(name: string): boolean { + return name.length > 0; + }, + }; +} + +function statsigGlobal(client = statsigClient()) { + return { + client, + globalObject: { __STATSIG__: { instances: { key: client }, firstInstance: client } }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("CPA live-voice hook gating", () => { + it.each([ + `app://-/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}.map`, + `app://-/assets/other.js?asset=${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`, + `https://example.test/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`, + `app://-/other/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`, + ])("rejects an asset-name substring in %s", (resourceName) => { + expect( + auditedAppInitialAssetPresent([resourceName], AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET), + ).toBe(false); + }); + + it("accepts the exact audited asset as a relative script URL", () => { + expect( + auditedAppInitialAssetPresent( + [`/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`], + AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, + ), + ).toBe(true); + }); + + it("rejects config that substitutes a different app-initial asset", () => { + const { client, globalObject } = statsigGlobal(); + const hook = installCpaLiveVoiceHook({ + config: { ...enabledConfig, auditedAppInitialAsset: "other.js" }, + resourceNames: ["app://-/assets/other.js"], + globalThis: globalObject, + }); + expect(hook.status).toBe("unsupported"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); + + it("stays off when Settings stores the preference as 0", () => { + const { client, globalObject } = statsigGlobal(); + Object.assign(globalObject, { + localStorage: { + getItem: (key: string) => (key === CPA_LIVE_VOICE_STORAGE_KEY ? "0" : null), + }, + }); + const hook = installCpaLiveVoiceHook({ + config: enabledConfig, + resourceNames: AUDITED_ASSET, + globalThis: globalObject, + }); + expect(hook.status).toBe("disabled"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); + + it("stays off without an explicit enable config", () => { + expect(readCpaLiveVoiceHookConfig({})).toBeNull(); + const { client, globalObject } = statsigGlobal(); + const hook = installCpaLiveVoiceHook({ + config: { ...enabledConfig, enabled: false }, + resourceNames: AUDITED_ASSET, + globalThis: globalObject, + }); + expect(hook.status).toBe("disabled"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); + + it("fails closed when the audited app-initial asset is absent", () => { + expect( + auditedAppInitialAssetPresent( + ["app://-/assets/other.js"], + enabledConfig.auditedAppInitialAsset, + ), + ).toBe(false); + const { client, globalObject } = statsigGlobal(); + const hook = installCpaLiveVoiceHook({ + config: enabledConfig, + resourceNames: ["app://-/assets/app-initial-deadbeef.js"], + globalThis: globalObject, + }); + expect(hook.status).toBe("unsupported"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); + + it("fails closed when an injected Desktop version is not the audited build", () => { + const { client, globalObject } = statsigGlobal(); + const hook = installCpaLiveVoiceHook({ + config: { ...enabledConfig, desktopVersion: "26.9999.0" }, + resourceNames: AUDITED_ASSET, + globalThis: globalObject, + }); + expect(hook.status).toBe("unsupported"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); +}); + +describe("CPA live-voice existing-call gate override", () => { + it("reads each registered Statsig client once", () => { + const { client, globalObject } = statsigGlobal(); + expect(statsigClients(globalObject)).toEqual([client]); + expect(statsigClients({})).toEqual([]); + }); + + it("forces only the existing-call gate off and passes other gates through", () => { + const client = statsigClient(); + expect(wrapCheckGate(client)).toBe(true); + expect(wrapCheckGate(client)).toBe(false); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false); + expect(client.checkGate("other_gate")).toBe(true); + }); + + it("activates on the audited asset and restores on dispose", () => { + const { client, globalObject } = statsigGlobal(); + const hook = installCpaLiveVoiceHook({ + config: enabledConfig, + resourceNames: AUDITED_ASSET, + globalThis: globalObject, + }); + expect(hook.status).toBe("active"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false); + hook.dispose(); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true); + }); + + it("wraps a Statsig client registered after document-start injection", () => { + vi.useFakeTimers(); + const globalObject: Record = {}; + const hook = installCpaLiveVoiceHook({ + config: enabledConfig, + resourceNames: AUDITED_ASSET, + globalThis: globalObject, + }); + expect(hook.status).toBe("pending"); + const client = statsigClient(); + globalObject.__STATSIG__ = { firstInstance: client }; + vi.advanceTimersByTime(250); + expect(hook.status).toBe("active"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false); + hook.dispose(); + }); + + it("stops rescanning assets while waiting for Statsig after an audited match", () => { + vi.useFakeTimers(); + const globalObject: Record = {}; + const querySelectorAll = vi.fn(() => [ + { getAttribute: () => `/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}` }, + ]); + const hook = installCpaLiveVoiceHook({ + config: enabledConfig, + globalThis: globalObject, + document: { querySelectorAll } as unknown as ParentNode, + }); + vi.advanceTimersByTime(1_000); + expect(hook.status).toBe("pending"); + expect(querySelectorAll).toHaveBeenCalledOnce(); + const client = statsigClient(); + globalObject.__STATSIG__ = { firstInstance: client }; + vi.advanceTimersByTime(250); + expect(hook.status).toBe("active"); + expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false); + expect(querySelectorAll).toHaveBeenCalledOnce(); + hook.dispose(); + }); +}); diff --git a/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts b/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts new file mode 100644 index 000000000..f48344468 --- /dev/null +++ b/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CPA_LIVE_VOICE_CHANGE_EVENT, + CPA_LIVE_VOICE_STORAGE_KEY, + cpaLiveVoicePreferenceAllowsInstall, + readCpaLiveVoicePreference, + writeCpaLiveVoicePreference, +} from "../src/renderer-cpa-live-voice-preference.js"; + +function fixture(initial?: string) { + const values = new Map(); + if (initial !== undefined) values.set(CPA_LIVE_VOICE_STORAGE_KEY, initial); + const storage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + }; + const owner = Object.assign(new EventTarget(), { localStorage: storage }) as unknown as Window; + return { owner, values, storage }; +} + +describe("CPA live-voice Settings preference", () => { + it("defaults on, persists off as 0, and allows install when storage is missing", () => { + const unset = fixture(); + expect(readCpaLiveVoicePreference(unset.owner)).toBe(true); + expect(cpaLiveVoicePreferenceAllowsInstall({})).toBe(true); + expect(cpaLiveVoicePreferenceAllowsInstall(unset.owner)).toBe(true); + + const events: string[] = []; + unset.owner.addEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, () => events.push("changed")); + expect(writeCpaLiveVoicePreference(unset.owner, false)).toBe(true); + expect(unset.values.get(CPA_LIVE_VOICE_STORAGE_KEY)).toBe("0"); + expect(readCpaLiveVoicePreference(unset.owner)).toBe(false); + expect(cpaLiveVoicePreferenceAllowsInstall(unset.owner)).toBe(false); + expect(events).toEqual(["changed"]); + + expect(writeCpaLiveVoicePreference(unset.owner, true)).toBe(true); + expect(unset.values.get(CPA_LIVE_VOICE_STORAGE_KEY)).toBe("1"); + expect(readCpaLiveVoicePreference(unset.owner)).toBe(true); + }); + + it("stays on when localStorage throws", () => { + const owner = Object.assign(new EventTarget(), { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }, + }) as unknown as Window; + expect(readCpaLiveVoicePreference(owner)).toBe(true); + expect(writeCpaLiveVoicePreference(owner, false)).toBe(false); + expect(cpaLiveVoicePreferenceAllowsInstall(owner)).toBe(true); + }); +}); diff --git a/packages/renderer-extension/test/renderer-model-client.test.ts b/packages/renderer-extension/test/renderer-model-client.test.ts index 538472261..9e89e1e33 100644 --- a/packages/renderer-extension/test/renderer-model-client.test.ts +++ b/packages/renderer-extension/test/renderer-model-client.test.ts @@ -378,7 +378,7 @@ describe("Renderer fixed Model request client", () => { updateAvailable: true, installationAvailable: true, releaseNotes: "Safer updates", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status: null, error: null, }) @@ -875,7 +875,7 @@ describe("Renderer fixed Model request client", () => { updateAvailable: true, installationAvailable: true, releaseNotes: "Safer updates", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status: null, error: null, artifactUrl: "https://example.com/update.exe", diff --git a/packages/renderer-extension/test/settings/localization.test.ts b/packages/renderer-extension/test/settings/localization.test.ts index fda26a971..3ad04d3b3 100644 --- a/packages/renderer-extension/test/settings/localization.test.ts +++ b/packages/renderer-extension/test/settings/localization.test.ts @@ -33,6 +33,19 @@ describe("Renderer settings localization", () => { expect(english.title).toBe("Settings"); expect(chinese.title).toBe("设置"); + expect(english.cpaLiveVoiceTitle).toBe("Override live voice"); + expect(chinese.cpaLiveVoiceTitle).toBe("改写实时语音"); + expect(english.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa"); + expect(chinese.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa"); + expect(english.cpaLiveVoiceDescription).toContain("On by default"); + expect(chinese.cpaLiveVoiceDescription).toContain("默认开启"); + expect(english.cpaLiveVoiceHelp.join(" ")).toContain("experimental_realtime_ws_base_url"); + expect(chinese.cpaLiveVoiceHelp.join(" ")).toContain( + "experimental_realtime_webrtc_call_base_url", + ); + expect(english.cpaLiveVoiceHelp.join(" ")).toContain("is not live"); + expect(english.cpaLiveVoiceRestartRequired).toContain("relaunch Codex"); + expect(chinese.cpaLiveVoiceRestartRequired).toContain("重新启动 Codex"); expect(chinese.openSettings).toBe("打开 codexhost 设置"); expect(chinese.starOnGitHub).toBe("Star 支持"); expect(english.starOnGitHub).toBe("Star to support"); diff --git a/packages/renderer-extension/test/settings/pages.test.ts b/packages/renderer-extension/test/settings/pages.test.ts index e75710cf5..a90cf2b5b 100644 --- a/packages/renderer-extension/test/settings/pages.test.ts +++ b/packages/renderer-extension/test/settings/pages.test.ts @@ -38,6 +38,8 @@ import { RendererSessionImportUnavailableError } from "../../src/renderer-sessio const HARNESS_SESSION_LIST_METHOD = "codexhost/harness/session-import/list"; const HARNESS_SESSION_IMPORT_METHOD = "codexhost/harness/session-import/import"; import { + CODEXHOST_GITHUB_REPOSITORY_URL, + CODEXHOST_NPM_MANUAL_UPDATE_COMMAND, CODEXHOST_RELEASES_LATEST_URL, createDefaultRendererSettingsPages, } from "../../src/settings/pages.js"; @@ -221,7 +223,7 @@ function updateCheck(status: UpdateStatus | null = null): UpdateCheckResult { updateAvailable: true, installationAvailable: true, releaseNotes: "Safer updates", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status, error: null, }; @@ -1759,7 +1761,7 @@ describe("Renderer Connections page", () => { expect(report).toContain("stderr:\ncheck ~/.pi/agent/settings.json"); const issueLink = descendants(content).find( ({ tagName, href }) => - tagName === "a" && href === "https://github.com/BytePioneer-AI/codex-host/issues/new", + tagName === "a" && href === "https://github.com/karlorz/codex-host/issues/new", ); expect(issueLink).toBeDefined(); const copyButton = descendants( @@ -1942,6 +1944,16 @@ describe("Renderer Codex Accounts page", () => { }); describe("Renderer Updates page", () => { + it("points Settings Updates at karlorz/codex-host and @karlorz/codexhost-cli", () => { + expect(CODEXHOST_GITHUB_REPOSITORY_URL).toBe("https://github.com/karlorz/codex-host"); + expect(CODEXHOST_RELEASES_LATEST_URL).toBe( + "https://github.com/karlorz/codex-host/releases/latest", + ); + expect(CODEXHOST_NPM_MANUAL_UPDATE_COMMAND).toBe( + "npm install -g @karlorz/codexhost-cli@latest", + ); + }); + it.each(["succeeded", "failed"] as const)( "keeps polling after start/status timeouts until the Host reports %s", async (terminalPhase) => { @@ -2188,9 +2200,7 @@ describe("Renderer Updates page", () => { expect(releaseLink.rel).toBe("noopener noreferrer"); await vi.waitFor(() => { - expect(releaseLink.href).toBe( - "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", - ); + expect(releaseLink.href).toBe("https://github.com/karlorz/codex-host/releases/tag/v1.2.3"); }); const panel = elementWithClass(content, "settings-update-panel"); @@ -2202,9 +2212,7 @@ describe("Renderer Updates page", () => { expect(panel.dataset.updateState).toBe("failed"); }); expect(descendants(content)).toContain(releaseLink); - expect(releaseLink.href).toBe( - "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", - ); + expect(releaseLink.href).toBe("https://github.com/karlorz/codex-host/releases/tag/v1.2.3"); cleanup?.(); scope.dispose(); @@ -2293,13 +2301,13 @@ describe("Renderer Updates page", () => { ).toBeUndefined(); expect(client.startUpdate).not.toHaveBeenCalled(); if (installation === "npm") { - expect(visibleText(content)).toContain("npm install -g @codexhost/cli@latest"); + expect(visibleText(content)).toContain("npm install -g @karlorz/codexhost-cli@latest"); } else { const link = descendants(content).find( ({ tagName, href }) => tagName === "a" && href === - "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", + "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", ); expect(link).toMatchObject({ target: "_blank", rel: "noopener noreferrer" }); } @@ -2331,12 +2339,11 @@ describe("Renderer Updates page", () => { expect(visibleText(content)).toContain("codexhost 是一个开源项目"); expect(visibleText(content)).toContain("请给我们一个 Star"); const repository = descendants(content).find( - ({ tagName, href }) => - tagName === "a" && href === "https://github.com/BytePioneer-AI/codex-host", + ({ tagName, href }) => tagName === "a" && href === "https://github.com/karlorz/codex-host", ); expect(repository).toMatchObject({ target: "_blank", rel: "noopener noreferrer" }); expect(visibleNotesText(repository as FakeElement)).toContain( - "https://github.com/BytePioneer-AI/codex-host", + "https://github.com/karlorz/codex-host", ); cleanup?.(); @@ -2446,7 +2453,7 @@ describe("Renderer Updates page", () => { expect(visibleText(starBanner)).toContain("如果 CodexHost 帮到了你,请在 GitHub 点个 Star"); const starLink = descendants(starBanner).find(({ tagName }) => tagName === "a"); expect(starLink).toMatchObject({ - href: "https://github.com/BytePioneer-AI/codex-host", + href: "https://github.com/karlorz/codex-host", target: "_blank", rel: "noopener noreferrer", }); diff --git a/packages/renderer-extension/test/settings/release-notes.test.ts b/packages/renderer-extension/test/settings/release-notes.test.ts index b3454e850..293229478 100644 --- a/packages/renderer-extension/test/settings/release-notes.test.ts +++ b/packages/renderer-extension/test/settings/release-notes.test.ts @@ -76,7 +76,7 @@ describe("Release notes Markdown", () => { "### npm", "", "```bash", - "npm install -g @codexhost/cli", + "npm install -g @karlorz/codexhost-cli", "codexhost", "```", ].join("\n"), @@ -94,13 +94,13 @@ describe("Release notes Markdown", () => { expect(visibleText(root)).toContain("本次发布"); expect(visibleText(root)).not.toContain("##"); expect(visibleText(root)).not.toContain("- 新增"); - expect(visibleText(root)).toContain("npm install -g @codexhost/cli"); + expect(visibleText(root)).toContain("npm install -g @karlorz/codexhost-cli"); const list = descendants(root).find((element) => element.tagName === "ul"); expect(list?.children).toHaveLength(2); const code = descendants(root).find((element) => element.tagName === "code"); expect(code?.className).toBe("language-bash"); - expect(code?.textContent).toBe("npm install -g @codexhost/cli\ncodexhost"); + expect(code?.textContent).toBe("npm install -g @karlorz/codexhost-cli\ncodexhost"); }); it("sets the translation line of a bilingual paragraph apart", () => { @@ -132,7 +132,7 @@ describe("Release notes Markdown", () => { "Use `codexhost` and **restart** after install.", "", "1. Download the package", - "2. Open [Releases](https://github.com/BytePioneer-AI/codex-host/releases)", + "2. Open [Releases](https://github.com/karlorz/codex-host/releases)", ].join("\n"), ); @@ -143,9 +143,7 @@ describe("Release notes Markdown", () => { expect(strong?.textContent).toBe("restart"); const link = descendants(root).find((element) => element.tagName === "a"); expect(link?.textContent).toBe("Releases"); - expect(link?.attributes.get("href")).toBe( - "https://github.com/BytePioneer-AI/codex-host/releases", - ); + expect(link?.attributes.get("href")).toBe("https://github.com/karlorz/codex-host/releases"); expect(link?.attributes.get("target")).toBe("_blank"); expect(link?.attributes.get("rel")).toBe("noopener noreferrer"); }); diff --git a/packages/shared-contracts/src/updates.ts b/packages/shared-contracts/src/updates.ts index cc4359bcc..f0e1a3ea2 100644 --- a/packages/shared-contracts/src/updates.ts +++ b/packages/shared-contracts/src/updates.ts @@ -46,7 +46,7 @@ const githubReleaseNotesUrlSchema = z .string() .max(300) .regex( - /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/releases\/tag\/v[0-9A-Za-z.+-]+$/u, + /^https:\/\/github\.com\/karlorz\/codex-host\/releases\/tag\/v[0-9A-Za-z.+-]+$/u, "release notes URL must identify a codexhost GitHub Release", ); diff --git a/packages/shared-contracts/test/updates.test.ts b/packages/shared-contracts/test/updates.test.ts index c191154d5..f91c8b875 100644 --- a/packages/shared-contracts/test/updates.test.ts +++ b/packages/shared-contracts/test/updates.test.ts @@ -26,7 +26,7 @@ describe("update runtime contracts", () => { updateAvailable: true, installationAvailable: true, releaseNotes: "## Changes\n\n- Safer updates", - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status: null, error: null, }), @@ -111,7 +111,7 @@ describe("update runtime contracts", () => { updateAvailable: true, installationAvailable: true, releaseNotes: "x".repeat(20_001), - releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", status: null, error: null, }).success, diff --git a/packages/update-manager/src/github-cli-release.ts b/packages/update-manager/src/github-cli-release.ts index 2718c2a98..d6e98c1c1 100644 --- a/packages/update-manager/src/github-cli-release.ts +++ b/packages/update-manager/src/github-cli-release.ts @@ -1,9 +1,13 @@ import { execFile } from "node:child_process"; import path from "node:path"; -import { parseLatestGitHubRelease, type CodexhostLatestRelease } from "./github-release.js"; +import { + GITHUB_REPOSITORY, + parseLatestGitHubRelease, + type CodexhostLatestRelease, +} from "./github-release.js"; -const GITHUB_LATEST_RELEASE_ENDPOINT = "repos/BytePioneer-AI/codex-host/releases/latest"; +const GITHUB_LATEST_RELEASE_ENDPOINT = `repos/${GITHUB_REPOSITORY}/releases/latest`; const MAX_RELEASE_RESPONSE_BYTES = 1024 * 1024; const GITHUB_CLI_TIMEOUT_MS = 5_000; diff --git a/packages/update-manager/src/github-release.ts b/packages/update-manager/src/github-release.ts index a8611cb42..326b744f9 100644 --- a/packages/update-manager/src/github-release.ts +++ b/packages/update-manager/src/github-release.ts @@ -1,13 +1,15 @@ import type { ArtifactSource } from "./artifact.js"; import { requireSemanticVersion } from "./status.js"; -export const CODEXHOST_LATEST_RELEASE_URL = - "https://api.github.com/repos/BytePioneer-AI/codex-host/releases/latest"; +export const GITHUB_REPOSITORY = "karlorz/codex-host"; +export const CODEXHOST_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest`; const SHA256_DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/u; -const RELEASE_NOTES_URL_PATTERN = - /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/releases\/tag\/(v[0-9A-Za-z.+-]+)$/u; -const DOWNLOAD_URL_PREFIX = "https://github.com/BytePioneer-AI/codex-host/releases/download/"; +const RELEASE_NOTES_URL_PATTERN = new RegExp( + `^https://github\\.com/${GITHUB_REPOSITORY.replaceAll("/", "\\/")}/releases/tag/(v[0-9A-Za-z.+-]+)$`, + "u", +); +const DOWNLOAD_URL_PREFIX = `https://github.com/${GITHUB_REPOSITORY}/releases/download/`; export type InstallerReleaseTarget = "macos-arm64" | "macos-x64" | "windows-x64" | "windows-arm64"; export type ReleaseTarget = InstallerReleaseTarget | "linux-x64" | "linux-arm64"; diff --git a/packages/update-manager/test/github-release.test.ts b/packages/update-manager/test/github-release.test.ts index df760c3e7..8997c7a8f 100644 --- a/packages/update-manager/test/github-release.test.ts +++ b/packages/update-manager/test/github-release.test.ts @@ -9,25 +9,33 @@ import { parseLatestGitHubRelease, selectInstallerReleaseArtifact, type GitHubCliRunner, + type InstallerReleaseTarget, } from "@codexhost/update-manager"; +function installerAsset( + version: string, + target: InstallerReleaseTarget, + overrides: Record = {}, +) { + const name = expectedInstallerAssetName(version, target); + return { + name, + size: 42, + digest: `sha256:${"ab".repeat(32)}`, + browser_download_url: `https://github.com/karlorz/codex-host/releases/download/v${version}/${name}`, + uploader: { login: "karlorz" }, + ...overrides, + }; +} + function release(overrides: Record = {}) { return { tag_name: "v1.2.3", - html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + html_url: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3", draft: false, prerelease: false, body: "## Changes\n\n- Safer updates", - assets: [ - { - name: "codexhost-1.2.3-windows-x64.exe", - size: 42, - digest: `sha256:${"ab".repeat(32)}`, - browser_download_url: - "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", - uploader: { login: "github-actions" }, - }, - ], + assets: [installerAsset("1.2.3", "windows-x64")], author: { login: "github-actions" }, ...overrides, }; @@ -41,7 +49,7 @@ describe("GitHub Release update discovery", () => { expect(selectInstallerReleaseArtifact(parsed, "windows-x64")).toEqual({ name: "codexhost-1.2.3-windows-x64.exe", source: { - url: "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", + url: "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", sha256: "ab".repeat(32), size: 42, }, @@ -51,12 +59,62 @@ describe("GitHub Release update discovery", () => { ); }); + it("discovers only karlorz/codex-host latest and rejects BytePioneer assets", () => { + expect(CODEXHOST_LATEST_RELEASE_URL).toBe( + "https://api.github.com/repos/karlorz/codex-host/releases/latest", + ); + expect(() => + parseLatestGitHubRelease( + release({ + html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3", + }), + ), + ).toThrow("does not match"); + expect(() => + parseLatestGitHubRelease( + release({ + assets: [ + installerAsset("1.2.3", "windows-x64", { + browser_download_url: + "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", + }), + ], + }), + ), + ).toThrow("invalid"); + }); + + it("parses a fork hyphen version and selects the matching installer asset", () => { + const parsed = parseLatestGitHubRelease( + release({ + tag_name: "v0.13.2-1", + html_url: "https://github.com/karlorz/codex-host/releases/tag/v0.13.2-1", + assets: [installerAsset("0.13.2-1", "macos-arm64")], + }), + ); + expect(parsed.version).toBe("0.13.2-1"); + expect(parsed.releaseNotesUrl).toBe( + "https://github.com/karlorz/codex-host/releases/tag/v0.13.2-1", + ); + expect(expectedInstallerAssetName("0.13.2-1", "macos-arm64")).toBe( + "codexhost-0.13.2-1-macos-arm64.dmg", + ); + expect(selectInstallerReleaseArtifact(parsed, "macos-arm64")).toEqual({ + name: "codexhost-0.13.2-1-macos-arm64.dmg", + source: { + url: "https://github.com/karlorz/codex-host/releases/download/v0.13.2-1/codexhost-0.13.2-1-macos-arm64.dmg", + sha256: "ab".repeat(32), + size: 42, + }, + }); + }); + it("rejects prereleases, mismatched notes, duplicate assets, and unverified selection", () => { expect(() => parseLatestGitHubRelease(release({ prerelease: true }))).toThrow("invalid"); expect(() => parseLatestGitHubRelease( release({ - html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v9.9.9", + html_url: "https://github.com/karlorz/codex-host/releases/tag/v9.9.9", }), ), ).toThrow("does not match"); @@ -67,7 +125,7 @@ describe("GitHub Release update discovery", () => { name: "codexhost-1.2.3-windows-x64.exe", size: 42, browser_download_url: - "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", + "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe", }, ], }), @@ -95,6 +153,9 @@ describe("GitHub Release update discovery", () => { expect(compareSemanticVersions("1.2.3-test.2", "1.2.3")).toBeLessThan(0); expect(compareSemanticVersions("1.2.4", "1.2.3")).toBeGreaterThan(0); expect(compareSemanticVersions("1.2.3+one", "1.2.3+two")).toBe(0); + expect(compareSemanticVersions("0.13.2-0", "0.13.2-1")).toBeLessThan(0); + expect(compareSemanticVersions("0.13.2-1", "0.13.2-1")).toBe(0); + expect(compareSemanticVersions("0.13.2-1", "0.13.2")).toBeLessThan(0); }); it("requests only the fixed GitHub latest endpoint", async () => { @@ -135,7 +196,7 @@ describe("GitHub Release update discovery", () => { "Accept: application/vnd.github+json", "--header", "X-GitHub-Api-Version: 2022-11-28", - "repos/BytePioneer-AI/codex-host/releases/latest", + "repos/karlorz/codex-host/releases/latest", ], { environment: { PATH: "/usr/bin:/bin" } }, ); diff --git a/scripts/release/macos/package.sh b/scripts/release/macos/package.sh index 93260be10..f9cdd7b92 100755 --- a/scripts/release/macos/package.sh +++ b/scripts/release/macos/package.sh @@ -31,6 +31,7 @@ for relative in \ app/desktop-controller.mjs \ app/host-runtime.mjs \ app/renderer-extension.js \ + app/cpa-live-voice-hook.js \ licenses/Node.js-LICENSE.txt \ licenses/Anthropic-SDK-LICENSE.txt \ licenses/Claude-Agent-SDK-LICENSE.md \ diff --git a/scripts/release/prepare-npm-meta.mjs b/scripts/release/prepare-npm-meta.mjs index 3a7c83030..3b1087f44 100644 --- a/scripts/release/prepare-npm-meta.mjs +++ b/scripts/release/prepare-npm-meta.mjs @@ -7,6 +7,7 @@ import { NPM_PACKAGE_NAME, NPM_PLATFORM_PACKAGE_NAMES, createNpmBinLauncherSource, + npmPackageRepositoryFields, packNpmPackage, resolveNpmPackageVersion, } from "./prepare-npm.mjs"; @@ -30,12 +31,7 @@ export function createNpmMetaPackageManifest({ version }) { Object.values(NPM_PLATFORM_PACKAGE_NAMES).map((packageName) => [packageName, version]), ), keywords: ["codex", "codexhost", "pi", "claude-code", "agent", "harness"], - repository: { - type: "git", - url: "git+https://github.com/BytePioneer-AI/codex-host.git", - }, - bugs: { url: "https://github.com/BytePioneer-AI/codex-host/issues" }, - homepage: "https://github.com/BytePioneer-AI/codex-host#readme", + ...npmPackageRepositoryFields(), publishConfig: { access: "public" }, }; } diff --git a/scripts/release/prepare-npm.mjs b/scripts/release/prepare-npm.mjs index d7583c8a1..ad9c85500 100644 --- a/scripts/release/prepare-npm.mjs +++ b/scripts/release/prepare-npm.mjs @@ -23,15 +23,40 @@ import { hostReleaseTarget, npmReleaseUsage, releaseTargetForHost } from "./targ const repositoryRoot = path.resolve(import.meta.dirname, "../.."); -export const NPM_PACKAGE_NAME = "@codexhost/cli"; +export const NPM_GITHUB_REPOSITORY = "karlorz/codex-host"; +export const NPM_PACKAGE_NAME = "@karlorz/codexhost-cli"; export const NPM_PLATFORM_PACKAGE_NAMES = Object.freeze({ - "macos-arm64": "@codexhost/cli-darwin-arm64", - "macos-x64": "@codexhost/cli-darwin-x64", - "windows-x64": "@codexhost/cli-win32-x64", - "windows-arm64": "@codexhost/cli-win32-arm64", - "linux-x64": "@codexhost/cli-linux-x64", - "linux-arm64": "@codexhost/cli-linux-arm64", + "macos-arm64": "@karlorz/codexhost-cli-darwin-arm64", + "macos-x64": "@karlorz/codexhost-cli-darwin-x64", + "windows-x64": "@karlorz/codexhost-cli-win32-x64", + "windows-arm64": "@karlorz/codexhost-cli-win32-arm64", + "linux-x64": "@karlorz/codexhost-cli-linux-x64", + "linux-arm64": "@karlorz/codexhost-cli-linux-arm64", }); + +export function requireForkNpmIdentity(packageName) { + if (!packageName.startsWith("@karlorz/")) { + throw new Error( + `refusing to publish ${packageName}; fork npm packages must use the @karlorz scope`, + ); + } +} + +requireForkNpmIdentity(NPM_PACKAGE_NAME); +for (const packageName of Object.values(NPM_PLATFORM_PACKAGE_NAMES)) { + requireForkNpmIdentity(packageName); +} + +export function npmPackageRepositoryFields() { + return { + repository: { + type: "git", + url: `git+https://github.com/${NPM_GITHUB_REPOSITORY}.git`, + }, + bugs: { url: `https://github.com/${NPM_GITHUB_REPOSITORY}/issues` }, + homepage: `https://github.com/${NPM_GITHUB_REPOSITORY}#readme`, + }; +} export const NPM_RUNTIME_PLATFORM_PACKAGES = Object.freeze({ "darwin-arm64": NPM_PLATFORM_PACKAGE_NAMES["macos-arm64"], "darwin-x64": NPM_PLATFORM_PACKAGE_NAMES["macos-x64"], @@ -248,6 +273,7 @@ export function expectedNpmPackagePaths(target) { "app/desktop-controller.mjs", "app/host-runtime.mjs", "app/renderer-extension.js", + "app/cpa-live-voice-hook.js", ...preinstalledHarnessPluginPaths(), "licenses/Anthropic-SDK-LICENSE.txt", "licenses/Agent-Client-Protocol-SDK-LICENSE.txt", @@ -288,14 +314,7 @@ export function createNpmPackageManifest({ version, target }) { os: npmPackageOs(target), cpu: npmPackageCpu(target), keywords: ["codex", "codexhost", "pi", "claude-code", "agent", "harness"], - repository: { - type: "git", - url: "git+https://github.com/BytePioneer-AI/codex-host.git", - }, - bugs: { - url: "https://github.com/BytePioneer-AI/codex-host/issues", - }, - homepage: "https://github.com/BytePioneer-AI/codex-host#readme", + ...npmPackageRepositoryFields(), publishConfig: { access: "public", }, @@ -313,7 +332,7 @@ import { fileURLToPath } from "node:url"; const version = ${JSON.stringify(version)}; const userArguments = process.argv.slice(2); -const repositoryUrl = "https://github.com/BytePioneer-AI/codex-host"; +const repositoryUrl = ${JSON.stringify(`https://github.com/${NPM_GITHUB_REPOSITORY}`)}; const startupTraceStartedAt = Date.now(); function startupTrace(stage) { if (process.env.CODEXHOST_STARTUP_TRACE !== "1") return; @@ -392,7 +411,7 @@ try { } if (platformVersion !== version) { fail( - \`platform package version mismatch: '\${platformPackage}' at '\${packageRoot}' has \${JSON.stringify(platformVersion) ?? "no version"}; expected \${version}. Close Codex Desktop, then run: npm install -g @codexhost/cli@\${version} \${platformPackage}@\${version}\`, + \`platform package version mismatch: '\${platformPackage}' at '\${packageRoot}' has \${JSON.stringify(platformVersion) ?? "no version"}; expected \${version}. Close Codex Desktop, then run: npm install -g ${NPM_PACKAGE_NAME}@\${version} \${platformPackage}@\${version}\`, ); } startupTrace("platform package resolved"); @@ -943,6 +962,7 @@ export async function validateNpmPackage({ packageRoot, target, root }) { "app/console-web.js", "app/desktop-controller.mjs", "app/renderer-extension.js", + "app/cpa-live-voice-hook.js", ].includes(file.relative) ) { forbiddenReferences.push("@anthropic-ai/", "@codexhost/adapter-claude-code"); @@ -1139,6 +1159,11 @@ export async function prepareNpmPackage({ path.join(packageRoot, "app", "renderer-extension.js"), "production Renderer Bundle", ); + await copyReleaseFile( + path.join(root, "packages", "renderer-extension", "dist", "cpa-live-voice-hook.js"), + path.join(packageRoot, "app", "cpa-live-voice-hook.js"), + "CPA live-voice overlay hook Bundle", + ); await copyReleaseFile( path.join(root, "packages", "renderer-extension", "dist", "console.js"), path.join(packageRoot, "app", "console-web.js"), diff --git a/scripts/release/prepare-payload.mjs b/scripts/release/prepare-payload.mjs index b670de999..7a507b28f 100644 --- a/scripts/release/prepare-payload.mjs +++ b/scripts/release/prepare-payload.mjs @@ -263,6 +263,7 @@ export function expectedPayloadPaths(target) { "app/desktop-controller.mjs", "app/host-runtime.mjs", "app/renderer-extension.js", + "app/cpa-live-voice-hook.js", ...preinstalledHarnessPluginPaths(), "licenses/Node.js-LICENSE.txt", "licenses/Agent-Client-Protocol-SDK-LICENSE.txt", @@ -327,6 +328,7 @@ export async function validatePayload({ payloadRoot, target, root }) { "app/console-web.js", "app/desktop-controller.mjs", "app/renderer-extension.js", + "app/cpa-live-voice-hook.js", ].includes(file.relative) ) { forbiddenReferences.push("@anthropic-ai/", "@codexhost/adapter-claude-code"); @@ -434,6 +436,11 @@ export async function prepareReleasePayload({ target, root = repositoryRoot }) { path.join(payloadRoot, "app", "renderer-extension.js"), "production Renderer Bundle", ); + await copyReleaseFile( + path.join(root, "packages", "renderer-extension", "dist", "cpa-live-voice-hook.js"), + path.join(payloadRoot, "app", "cpa-live-voice-hook.js"), + "CPA live-voice overlay hook Bundle", + ); await copyReleaseFile( path.join(root, "packages", "renderer-extension", "dist", "console.js"), path.join(payloadRoot, "app", "console-web.js"), diff --git a/scripts/release/publish-npm.mjs b/scripts/release/publish-npm.mjs index df570e480..c56db66e2 100644 --- a/scripts/release/publish-npm.mjs +++ b/scripts/release/publish-npm.mjs @@ -3,7 +3,12 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { NPM_PACKAGE_NAME, npmPlatformPackageName, npmTarballFileName } from "./prepare-npm.mjs"; +import { + NPM_PACKAGE_NAME, + npmPlatformPackageName, + npmTarballFileName, + requireForkNpmIdentity, +} from "./prepare-npm.mjs"; import { npmMetaTarballFileName } from "./prepare-npm-meta.mjs"; import { supportedReleaseTargets, releaseTarget } from "./targets.mjs"; @@ -91,6 +96,7 @@ export function npmPublishCommand(entry, { dryRun, provenance, registry, tag }) export function publishNpmPlan(plan, options) { for (const entry of plan) { + requireForkNpmIdentity(entry.packageName); const command = npmPublishCommand(entry, options); console.log( `${options.dryRun ? "checking" : "publishing"} ${entry.packageName}@${entry.version}`, diff --git a/tests/release/npm-package.test.mjs b/tests/release/npm-package.test.mjs index 061bd61f8..398eb76cd 100644 --- a/tests/release/npm-package.test.mjs +++ b/tests/release/npm-package.test.mjs @@ -90,6 +90,12 @@ async function createHomebrewNodeLayout(root) { await copyFile(process.execPath, cellarNode); await chmod(cellarNode, 0o755); } + // Homebrew Node can load libnode relative to its executable. + await symlink( + path.join(path.dirname(path.dirname(process.execPath)), "lib"), + path.join(path.dirname(path.dirname(cellarNode)), "lib"), + "dir", + ); await writeExecutable(prefixNpm, '#!/usr/bin/env node\nconsole.log("homebrew-prefix-npm");\n'); await writeExecutable(libexecNpm, '#!/usr/bin/env node\nconsole.log("homebrew-libexec-npm");\n'); await mkdir(path.join(brewPrefix, "bin"), { recursive: true }); @@ -107,15 +113,14 @@ async function createHomebrewNodeLayout(root) { async function createGlobalCodexhostInstall(prefix) { const platformPackage = process.platform === "win32" - ? `@codexhost/cli-win32-${process.arch}` - : `@codexhost/cli-darwin-${process.arch}`; + ? `@karlorz/codexhost-cli-win32-${process.arch}` + : `@karlorz/codexhost-cli-darwin-${process.arch}`; const packageRoot = path.join(prefix, "lib", "node_modules", platformPackage); const launcherPath = path.join( prefix, "lib", "node_modules", - "@codexhost", - "cli", + ...NPM_PACKAGE_NAME.split("/"), "bin", "codexhost.js", ); @@ -171,8 +176,14 @@ async function createNpmMetaPackageFixture(root) { } async function createLauncherLifecycleFixture(root, platform) { - const launcherPath = path.join(root, "node_modules", "@codexhost", "cli", "bin", "codexhost.js"); - const platformPackage = `@codexhost/cli-${platform}-x64`; + const launcherPath = path.join( + root, + "node_modules", + ...NPM_PACKAGE_NAME.split("/"), + "bin", + "codexhost.js", + ); + const platformPackage = `@karlorz/codexhost-cli-${platform}-x64`; const platformRoot = path.join(root, "node_modules", ...platformPackage.split("/")); const executableSuffix = platform === "win32" ? ".exe" : ""; const npmCliPath = path.join(root, "npm-cli.js"); @@ -241,7 +252,7 @@ async function runLauncherLifecycle( await createLauncherLifecycleFixture(root, platform); await writeFile( path.join(platformRoot, "package.json"), - JSON.stringify({ name: `@codexhost/cli-${platform}-x64`, version: platformVersion }), + JSON.stringify({ name: `@karlorz/codexhost-cli-${platform}-x64`, version: platformVersion }), ); const environment = { ...process.env, @@ -450,7 +461,7 @@ describe("npm package release", () => { it("publishes a scoped platform package with platform constraints", () => { const target = releaseTarget("macos-arm64"); const manifest = createNpmPackageManifest({ version: "0.1.0", target }); - expect(manifest.name).toBe("@codexhost/cli-darwin-arm64"); + expect(manifest.name).toBe("@karlorz/codexhost-cli-darwin-arm64"); expect(npmPlatformPackageName(target)).toBe(manifest.name); expect(manifest.private).toBeUndefined(); expect(manifest.bin).toBeUndefined(); @@ -491,9 +502,9 @@ describe("npm package release", () => { it("injects package resources when the user runs codexhost with no args", () => { const source = createNpmBinLauncherSource({ version: "0.1.0" }); - expect(source).toContain('"darwin-arm64": "@codexhost/cli-darwin-arm64"'); - expect(source).toContain('"linux-x64": "@codexhost/cli-linux-x64"'); - expect(source).toContain('"linux-arm64": "@codexhost/cli-linux-arm64"'); + expect(source).toContain('"darwin-arm64": "@karlorz/codexhost-cli-darwin-arm64"'); + expect(source).toContain('"linux-x64": "@karlorz/codexhost-cli-linux-x64"'); + expect(source).toContain('"linux-arm64": "@karlorz/codexhost-cli-linux-arm64"'); expect(source).toContain("require.resolve"); expect(source).toContain("--omit=optional"); expect(source).toContain('launchArguments = ["launch"]'); @@ -631,7 +642,7 @@ describe("npm package release", () => { expect(result.stderr).toContain("Launcher exited after ready"); expect(result.stdout).toContain( "⭐ If this project helps you, please give us a Star ⭐\n" + - "https://github.com/BytePioneer-AI/codex-host", + "https://github.com/karlorz/codex-host", ); expect(readme).toContain("On Windows, the command remains attached until Codex Desktop exits"); expect(readme).toContain("process trees of completed commands"); @@ -644,10 +655,10 @@ describe("npm package release", () => { const result = await runLauncherLifecycle(platform, { platformVersion }); expect(result.status, result.stderr).toBe(1); expect(result.stderr).toContain("platform package version mismatch"); - expect(result.stderr).toContain(`@codexhost/cli-${platform}-x64`); + expect(result.stderr).toContain(`@karlorz/codexhost-cli-${platform}-x64`); expect(result.stderr).toContain("expected 0.1.0"); expect(result.stderr).toContain( - `npm install -g @codexhost/cli@0.1.0 @codexhost/cli-${platform}-x64@0.1.0`, + `npm install -g @karlorz/codexhost-cli@0.1.0 @karlorz/codexhost-cli-${platform}-x64@0.1.0`, ); expect(result.stderr).not.toContain("received Launcher ready"); expect(result.stdout).not.toContain("startup:"); @@ -664,7 +675,7 @@ describe("npm package release", () => { expect(result.stderr).not.toContain("Launcher exited after ready"); expect(result.stdout).toBe( "⭐ If this project helps you, please give us a Star ⭐\n" + - "https://github.com/BytePioneer-AI/codex-host\n", + "https://github.com/karlorz/codex-host\n", ); }); @@ -674,7 +685,7 @@ describe("npm package release", () => { expect(result.status, result.stderr).toBe(0); expect(result.stdout).toBe( "⭐ 如果这个项目对你有帮助,请给我们一个 Star ⭐\n" + - "https://github.com/BytePioneer-AI/codex-host\n", + "https://github.com/karlorz/codex-host\n", ); }); @@ -685,12 +696,12 @@ describe("npm package release", () => { expect(colored.status, colored.stderr).toBe(0); expect(colored.stdout).toBe( "\u001B[33m⭐ If this project helps you, please give us a Star ⭐\u001B[0m\n" + - "\u001B[36mhttps://github.com/BytePioneer-AI/codex-host\u001B[0m\n", + "\u001B[36mhttps://github.com/karlorz/codex-host\u001B[0m\n", ); expect(plain.status, plain.stderr).toBe(0); expect(plain.stdout).toBe( "⭐ If this project helps you, please give us a Star ⭐\n" + - "https://github.com/BytePioneer-AI/codex-host\n", + "https://github.com/karlorz/codex-host\n", ); }); @@ -868,14 +879,14 @@ describe("npm package release", () => { path.resolve(import.meta.dirname, "../../scripts/release/prepare-npm.mjs"), "utf8", ); - expect(source).toContain('NPM_PACKAGE_NAME = "@codexhost/cli"'); + expect(source).toContain('NPM_PACKAGE_NAME = "@karlorz/codexhost-cli"'); expect(Object.values(NPM_PLATFORM_PACKAGE_NAMES)).toEqual([ - "@codexhost/cli-darwin-arm64", - "@codexhost/cli-darwin-x64", - "@codexhost/cli-win32-x64", - "@codexhost/cli-win32-arm64", - "@codexhost/cli-linux-x64", - "@codexhost/cli-linux-arm64", + "@karlorz/codexhost-cli-darwin-arm64", + "@karlorz/codexhost-cli-darwin-x64", + "@karlorz/codexhost-cli-win32-x64", + "@karlorz/codexhost-cli-win32-arm64", + "@karlorz/codexhost-cli-linux-x64", + "@karlorz/codexhost-cli-linux-arm64", ]); expect(source).toContain("publishConfig"); expect(source).toContain('access: "public"'); diff --git a/tests/release/npm-publish.test.mjs b/tests/release/npm-publish.test.mjs index 53c2ea72f..193807c09 100644 --- a/tests/release/npm-publish.test.mjs +++ b/tests/release/npm-publish.test.mjs @@ -49,13 +49,13 @@ describe("npm registry publishing", () => { for (const name of names) await writeFile(path.join(root, "nested", name), name); const plan = await createNpmPublishPlan({ artifactsRoot: root, version }); expect(plan.map((entry) => entry.packageName)).toEqual([ - "@codexhost/cli-darwin-arm64", - "@codexhost/cli-darwin-x64", - "@codexhost/cli-win32-x64", - "@codexhost/cli-win32-arm64", - "@codexhost/cli-linux-x64", - "@codexhost/cli-linux-arm64", - "@codexhost/cli", + "@karlorz/codexhost-cli-darwin-arm64", + "@karlorz/codexhost-cli-darwin-x64", + "@karlorz/codexhost-cli-win32-x64", + "@karlorz/codexhost-cli-win32-arm64", + "@karlorz/codexhost-cli-linux-x64", + "@karlorz/codexhost-cli-linux-arm64", + "@karlorz/codexhost-cli", ]); expect(plan.at(-1).kind).toBe("meta"); } finally { diff --git a/tests/release/payload.test.mjs b/tests/release/payload.test.mjs index 84e4742e7..92a70e7bd 100644 --- a/tests/release/payload.test.mjs +++ b/tests/release/payload.test.mjs @@ -92,9 +92,9 @@ describe("release Payload", () => { await createPayload(root, target); const paths = await validatePayload({ payloadRoot: root, target, root: "/repo/source" }); expect(paths).toEqual(expectedPayloadPaths(target)); - expect(paths).toHaveLength(28 + preinstalledHarnessPluginPaths().length); + expect(paths).toHaveLength(29 + preinstalledHarnessPluginPaths().length); expect(expectedPayloadPaths(releaseTarget("windows-x64"))).toHaveLength( - 30 + preinstalledHarnessPluginPaths().length, + 31 + preinstalledHarnessPluginPaths().length, ); expect(paths).toContain("licenses/tailwindcss-LICENSE.txt"); expect(paths).toContain("app/plugins/enabled.json"); diff --git a/tests/release/production-renderer.test.mjs b/tests/release/production-renderer.test.mjs index fc672e032..84752ab74 100644 --- a/tests/release/production-renderer.test.mjs +++ b/tests/release/production-renderer.test.mjs @@ -73,7 +73,10 @@ describe("production Renderer release chain", () => { expect(rendererBuild).toContain("src/production-entry.ts"); expect(rendererBuild).toContain("dist/production.js"); + expect(rendererBuild).toContain("src/cpa-live-voice-entry.ts"); + expect(rendererBuild).toContain("dist/cpa-live-voice-hook.js"); expect(releaseBuilder).toContain('dist", "production.js'); + expect(releaseBuilder).toContain('dist", "cpa-live-voice-hook.js'); expect(releaseBuilder).toContain("desktop-controller.mjs"); expect(releaseBuilder).toContain('packageName: "lucide"'); expect(releaseBuilder).toContain("lucide-LICENSE.txt");