diff --git a/crates/launcher/tests/support/windows_service.rs b/crates/launcher/tests/support/windows_service.rs new file mode 100644 index 000000000..c9c85a76f --- /dev/null +++ b/crates/launcher/tests/support/windows_service.rs @@ -0,0 +1,39 @@ +use std::env; +use std::fs; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +// Cargo's enclosing Windows Job does not allow service breakaway. Run the +// fixture in our own cleanup Job, as a real supervised Desktop caller does. +pub fn run_in_service_job() -> bool { + const ADMISSION: &str = "CODEXHOST_WEB_TEST_JOB_ADMISSION"; + if let Some(path) = env::var_os(ADMISSION) { + let deadline = Instant::now() + Duration::from_secs(10); + while !std::path::Path::new(&path).exists() { + assert!(Instant::now() < deadline, "fixture Job admission timed out"); + thread::sleep(Duration::from_millis(10)); + } + return false; + } + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("web-test-job-{}-{nonce}", std::process::id())); + fs::create_dir(&root).unwrap(); + let admission = root.join("admitted"); + let current = thread::current(); + let name = current.name().expect("named test thread"); + let mut command = Command::new(env::current_exe().unwrap()); + command + .args(["--exact", name, "--nocapture"]) + .env(ADMISSION, &admission) + .stdin(Stdio::null()); + let mut child = codexhost_platform::spawn_supervised(&mut command).unwrap(); + fs::write(&admission, "admitted").unwrap(); + let status = child.wait().unwrap(); + fs::remove_dir_all(&root).unwrap(); + assert!(status.success(), "service fixture {name} failed: {status}"); + true +} diff --git a/crates/launcher/tests/web_service.rs b/crates/launcher/tests/web_service.rs index 4b3a0c2a1..f5eb4ad9e 100644 --- a/crates/launcher/tests/web_service.rs +++ b/crates/launcher/tests/web_service.rs @@ -1,3 +1,7 @@ +#[cfg(windows)] +#[path = "support/windows_service.rs"] +mod windows_service; + use std::env; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -378,6 +382,10 @@ fn background_web_refreshes_only_enabled_autostart_without_printing_login_link() #[test] fn update_stop_leaves_console_and_remote_untouched() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let mut fixture = Fixture::new(); assert!(fixture.run("ensure").status.success()); fs::create_dir_all(fixture.root.join(".codexhost/remote")).unwrap(); @@ -407,6 +415,10 @@ fn update_stop_leaves_console_and_remote_untouched() { #[test] fn supervises_only_ready_web_and_serializes_ensure_and_stop() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); let mut first = fixture.command("web-service", "ensure").spawn().unwrap(); let second = fixture.run("ensure"); @@ -444,6 +456,10 @@ fn supervises_only_ready_web_and_serializes_ensure_and_stop() { #[test] fn connect_devices_flag_keeps_printed_login_and_ready_record_unchanged() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); let plain = fixture .public_command("web") @@ -485,6 +501,10 @@ fn connect_devices_flag_keeps_printed_login_and_ready_record_unchanged() { #[test] fn headless_web_saves_options_and_replaces_mismatched_services_without_exposing_tokens_in_status() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); let result = fixture .public_command("web") @@ -597,14 +617,16 @@ fn headless_web_saves_options_and_replaces_mismatched_services_without_exposing_ #[test] fn stop_continues_after_failures_and_keeps_configuration() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); + let started = fixture.public_command("web").output().unwrap(); assert!( - fixture - .public_command("web") - .output() - .unwrap() - .status - .success() + started.status.success(), + "{}", + String::from_utf8_lossy(&started.stderr) ); fs::create_dir_all(fixture.root.join(".codexhost/remote")).unwrap(); fs::write(fixture.root.join(".codexhost/remote/manifest.json"), "{}").unwrap(); @@ -615,11 +637,11 @@ fn stop_continues_after_failures_and_keeps_configuration() { fs::create_dir_all(&runtime).unwrap(); fs::write(runtime.join("desktop-runtime-v1.json"), "invalid").unwrap(); } - let stopped = fixture.public_command("stop").output().unwrap(); + let _stopped = fixture.public_command("stop").output().unwrap(); #[cfg(not(target_os = "windows"))] { - assert!(!stopped.status.success()); - assert!(String::from_utf8_lossy(&stopped.stderr).contains("Remote: stop failed")); + assert!(!_stopped.status.success()); + assert!(String::from_utf8_lossy(&_stopped.stderr).contains("Remote: stop failed")); } assert!(!fixture.root.join("web-service-ready.json").exists()); assert!(!fixture.root.join("shared-host-ready.json").exists()); @@ -629,12 +651,14 @@ fn stop_continues_after_failures_and_keeps_configuration() { .join(".codexhost/remote/manifest.json") .exists() ); - let events = fs::read_to_string(fixture.root.join("events")).unwrap(); #[cfg(unix)] - assert!( - events.contains("web-stop\nhost-stop\nremote-stop\nconsole-stop\n"), - "{events}" - ); + { + let events = fs::read_to_string(fixture.root.join("events")).unwrap(); + assert!( + events.contains("web-stop\nhost-stop\nremote-stop\nconsole-stop\n"), + "{events}" + ); + } let count = fixture.starts(); thread::sleep(Duration::from_millis(1200)); assert_eq!(fixture.starts(), count); @@ -682,6 +706,10 @@ fn stopped_remote_installation_does_not_report_running_services_or_raw_json() { #[test] fn missing_host_entry_reports_failure_but_still_stops_web_and_console() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); assert!( fixture @@ -1033,6 +1061,10 @@ setInterval(() => {{ #[test] fn background_web_reuses_saved_configuration_without_opening_a_window() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); let started = fixture .public_command("web") @@ -1062,6 +1094,10 @@ fn background_web_reuses_saved_configuration_without_opening_a_window() { #[test] fn desktop_update_recovery_prepares_host_even_with_web_disabled() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); // The fixture has no Desktop installation. Host preparation must still precede // that launch error, without starting Web or touching a real Desktop process. @@ -1082,6 +1118,10 @@ fn desktop_update_recovery_prepares_host_even_with_web_disabled() { #[test] fn update_recovery_and_stop_refuse_to_replace_another_installation() { + #[cfg(windows)] + if windows_service::run_in_service_job() { + return; + } let fixture = Fixture::new(); assert!(fixture.run("ensure").status.success()); let original = fixture.record("shared-host-ready.json"); diff --git a/crates/updater/src/services.rs b/crates/updater/src/services.rs index 46de631d3..8b66025bc 100644 --- a/crates/updater/src/services.rs +++ b/crates/updater/src/services.rs @@ -71,14 +71,16 @@ mod tests { })).unwrap(); let command = stop_command(&request); assert_eq!(command.get_program(), launcher); + let arguments = command.get_args().collect::>(); + assert_eq!(arguments.len(), 3); assert_eq!( - command.get_args().collect::>(), + &arguments[..2], [ std::ffi::OsStr::new("stop-for-update"), std::ffi::OsStr::new("--node"), - node.as_os_str(), ] ); + assert_eq!(std::path::Path::new(arguments[2]), node); } } } diff --git a/docs/architecture/host-client-channel.md b/docs/architecture/host-client-channel.md index e18f9eaaf..b0639772f 100644 --- a/docs/architecture/host-client-channel.md +++ b/docs/architecture/host-client-channel.md @@ -4,6 +4,12 @@ GUI、Web 和其他 App 的共享外部 Threads 由一个独立后台中的 `AppServerHost` 执行。Adapter、原生引用、Mapping Store 和 Thread/Turn 执行只属于该所有者;Desktop 的协议前端与 Web 网关不另开会话或第二份可写存储,也不保存共享 transcript。官方 Codex 路径仍使用原生协议与原生历史,不变成 Web 的外部 Harness。 +客户端通道也支持本机原生 Codex Thread。共享服务通过已有 `OfficialRuntimeClient` 连接与 Desktop 相同的 `OfficialRuntimeScope`,按 Mapping Store 归属区分 external 与 native,保留官方 Thread ID。`thread/list` 沿用原生/外部合并分页;原生不可用不影响外部目录,协议错误仍报错。不会通过 Codex CLI Adapter 再启动一份写入同一原生会话的 app-server。 + +原生快照用仅含 Thread ID 的 `thread/resume` 建立订阅,再调用官方 `thread/read` 与 `thread/turns/list`;没有模型、权限或沙箱默认值覆盖。`configuration.native` 只展示 resume 确认的原生 `model`、`reasoningEffort`,它们不是外部 Harness 的运输引用。游标在异步读取前捕获;读取中后台换代会拒绝快照,不能确认尚未包含的事件。后台退出清空原生订阅、配置与交互状态,并使已观察的会话失效。 + +原生命令/文件审批和提问可由 Web 回答。审批严格保留原生可选 decision;Web 使用代次限定的不透明请求 ID,服务端再映射到官方 ID,旧页面不能回答重启后复用编号的新请求。首次有效回应后删除待处理项,不自动重放结果未知的响应。未支持的原生权限授予请求不转换成通用允许/拒绝;Web 提问卡片没有密码输入契约,因此含 `isSecret` 的提问也不降级成明文字段。这两类请求仍需原生界面处理。 + - `shared-contracts/client-channel` 定义浏览器安全的版本、游标、快照和交互契约。 - `host-runtime` 提供本地认证 HTTP 通道,复用原请求分派、投影器、配置观察器和交互响应路径。 - `desktop-control` 的公共 `HostClientChannel` 负责 Node 端发现、通知恢复和只读快照访问;命令不自动重试。 @@ -23,13 +29,13 @@ Web / App → Web 网关 → 客户端通道 ─────────── - 同一 Host 数据目录中的 `host ensure`、`host stop`、`host restart` 共用 `shared-host-lifecycle.lock` 操作锁。`ensure` 持锁后重新检查后台是否可用;正常并发启动时,后续调用等待并复用已就绪的后台。`restart` 持锁完成停止、清理、启动和就绪确认。等待操作锁超过约 40 秒会报忙且不执行操作;启动就绪超时则表示启动结果未知,不会因此终止可能已在执行任务的后台。 - 锁由生命周期操作进程持有,操作结束或进程退出后释放,锁文件保留;监管进程不持有该锁。它不是后台整个存活期的所有权锁,也不锁原生 Harness home 或阻止其他进程写入同一原生会话。串行化仅覆盖使用该锁、针对同一数据目录的生命周期入口,不覆盖绕过入口或不支持该锁的旧客户端。Mapping Store 保留自身的存储锁。 - Node 后台拥有唯一生产 Mapping Store、插件注册表和 `SharedThreadOwner`。Desktop 协议前端复用同一存储对象和原生 Codex scope/account control,不通过另一份数据库或存储 RPC 合并状态。 -- 官方 Codex 延迟启动:纯 Web 外部会话不需要启动 Desktop 或 stock Codex;原生置顶操作或非空置顶的混合顺序查询可按需启动 stock Codex,但不启动 Desktop 窗口。首个 Desktop 的原生可执行文件、app-server 参数及功能标志在首个 backend generation 前确定,之后不因客户端接入而替换进程。官方 Codex 使用仍需要已安装的原生组件;后台不会安装 CLI、切换账号或自动登录。 +- 官方 Codex 延迟启动:连接 Web 本身不启动 Desktop 或 stock Codex;查询包含原生 Threads 的主列表时按需启动 stock Codex;原生置顶操作或非空置顶的混合顺序查询可按需启动 stock Codex,但不启动 Desktop 窗口。首个 Desktop 的原生可执行文件、app-server 参数及功能标志在首个 backend generation 前确定,之后不因客户端接入而替换进程。官方 Codex 使用仍需要已安装的原生组件;后台不会安装 CLI、切换账号或自动登录。 - 关闭 Web 或 Desktop 只断开对应传输,不停止后台,也不把断线解释成取消。Desktop 连接若仍拥有原生执行,会在后台排空真实结束事件;其他客户端不受其退出影响。待处理交互不会自动作答。 - 后台保持运行,最后一个界面退出不触发自动停机。显式 `codexhost host stop` 是管理操作,会停止后台及其任务,并等待所连接后台的监管进程完成正常清理后退出。优雅清理停滞时,原生层在短暂等待后终止关闭请求前记录的后台进程,由仍在运行的监管者清理子进程树。单独 `stop` 后,仍打开的客户端可能按需启动后台;保持界面打开并切换后台构建时使用 `restart`;源码入口 `npm start` 和 `npm start web` 的后台重启选择见[独立 Web 的启动说明](../product/standalone-web.md#代码与构建)。不支持生命周期锁的旧客户端需先更新或退出。不提供监管者被强杀后的安全交接保证;后台重启只恢复已有事实,不保证中断中的 Harness 任务继续执行,也不重发结果未知的命令。 - 认证描述文件标记 `owner: service`。发现不仅校验 PID,还用认证 hello/epoch 探测端点。Web 的服务模式断线后重新发现或按需启动同一数据目录的所有者;不切换到独立插件模式。 - 已运行的旧 Desktop-owned Host 不会被接管、停止或绕过存储锁。旧客户端通道与显式 CDP 仍可连接;切换独立后台需要用户另行更新并重启 Desktop。构建源码不等于正在运行的进程已经升级。 -既有 GUI 项目归属由后台只读读取 `CODEX_HOME/.codex-global-state.json`;没有文件时 GUI 集合为空,损坏文件明确失败,不写原生文件。`codexhost/workspace/read` 只投影 canonical external IDs,并合并唯一 Mapping Store 中明确记录的项目/无项目事实;生成的执行目录不凭 cwd 匹配变成项目。置顶统一通过 `codexhost/thread/pins/list` / `codexhost/thread/pins/set` 访问原生分区协议与同一 Mapping Store 的外部 Thread 分区记录,支持无 Desktop 窗口读写;查询与 Desktop 分区移动共用串行边界。`workspace/read` 的置顶成员从同一分区记录投影,不读取 GUI 全局文件中的置顶副本;原生混合顺序通过 `pins/list` 查询。不建立预置顶、补写队列或 Renderer 回退;原生能力不可用时明确失败,未知写入结果不自动重发。远程 SSH 的原有执行所有权保持原样,公开客户端通道仍只支持本地 external Threads;移动后台推送不是此改造的能力。 +既有 GUI 项目归属由后台只读读取 `CODEX_HOME/.codex-global-state.json`;没有文件时 GUI 集合为空,损坏文件明确失败,不写原生文件。`codexhost/workspace/read` 投影 canonical external IDs 及请求中指定的原生 Thread IDs,并合并唯一 Mapping Store 中明确记录的项目/无项目事实;生成的执行目录不凭 cwd 匹配变成项目。置顶统一通过 `codexhost/thread/pins/list` / `codexhost/thread/pins/set` 访问原生分区协议与同一 Mapping Store 的外部 Thread 分区记录,支持无 Desktop 窗口读写;查询与 Desktop 分区移动共用串行边界。`workspace/read` 的置顶成员从同一分区记录投影,不读取 GUI 全局文件中的置顶副本;原生混合顺序通过 `pins/list` 查询。不建立预置顶、补写队列或 Renderer 回退;原生能力不可用时明确失败,未知写入结果不自动重发。远程 SSH 的原有执行所有权保持原样,公开客户端通道支持本机原生与 external Threads,但不聚合远程连接;移动后台推送不是此改造的能力。 ## Codex CLI 是普通外部 Harness diff --git a/docs/index.md b/docs/index.md index 14a0a1759..b5bfe0dc2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ | [`architecture/harness-questions.md`](architecture/harness-questions.md) | 外部 Harness 多选提问卡片、原生单选分流、回答归属与生命周期;修改提问交互时阅读。 | | [`architecture/external-thread-steering.md`](architecture/external-thread-steering.md) | 外部 Thread 按 Session 能力选择原生同轮插入或停止后重发;修改 steering 时阅读。 | | [`architecture/external-thread-queue.md`](architecture/external-thread-queue.md) | 外部 Thread 跟进消息的 Host 原生 `thread/queue/*`、自动推进与中断暂停;修改排队或 Turn 启动时阅读。 | -| [`architecture/host-client-channel.md`](architecture/host-client-channel.md) | GUI、Web 与 App 按需连接独立后台的生命周期、认证通道、事件驱动快照、交互仲裁及恢复边界。 | +| [`architecture/host-client-channel.md`](architecture/host-client-channel.md) | GUI、Web 与 App 共享原生 Codex/外部 Harness 的生命周期、认证通道、事件驱动快照、交互仲裁及恢复边界。 | | [OpenSpec:状态刷新与轮询优化](../openspec/changes/reduce-redundant-state-polling/proposal.md) | Web 目录与组织状态分别校验,远程设置页和 Console 按可见性降频;查看方案、实现进度与验收边界时阅读。 | | [`architecture/gui-web-shared-state-design.md`](architecture/gui-web-shared-state-design.md) | 当前所有权概览:共享 Host 的外部 Thread、Codex CLI 的 Web-only 界面声明、存储分区、启动发现及简化后的生命周期边界;不提供旧 standalone 迁移。 | | [`architecture/external-thread-media.md`](architecture/external-thread-media.md) | 外部 Thread 中本地图片的 Desktop 加载条件、Agent 消息媒体路径改写和用户消息图片附件还原;修改媒体显示或排查图片不显示时阅读。 | diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index 3a00aa460..8e82309c7 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -1,6 +1,14 @@ # 独立 Web 应用 -Web 与 Codex Desktop 在同一仓库开发,浏览器通过 `packages/web-server` 的独立 Node 服务访问。默认 `codexhost` 会话来源按需启动或连接独立的本地 CH 后台,复用 CH 的 Thread 目录、创建、读取和操作;Web 网关不直接读写 Mapping Store、不为外部 Threads 启动第二份 Adapter,也不登记 Desktop 进程所有权。默认启用所有打包的会话 Harness,包括 Codex CLI(`codex-cli`);它们都由共享后台的外部 Thread 运行时执行,Web 网关只连接 Host,不在本地执行 Harness。Web/PWA 共用响应式界面;尚无 iOS/Android 原生封装。 +Web 与 Codex Desktop 在同一仓库开发,浏览器通过 `packages/web-server` 的独立 Node 服务访问。默认 `codexhost` 会话来源按需启动或连接独立的本地 CH 后台,复用 CH 的 Thread 目录、创建、读取和操作;Web 网关不直接读写 Mapping Store、不为外部 Threads 启动第二份 Adapter,也不登记 Desktop 进程所有权。主列表同时展示本机原生 Codex Threads 和共享外部 Harness Threads,不按原生会话的 Model Provider 排除会话。原生 Codex 保留原始 Thread ID,通过共享 Host 使用 Desktop 相同的官方 app-server;外部 Harness 继续由共享后台的外部 Thread 运行时执行。Web/PWA 共用响应式界面;尚无 iOS/Android 原生封装。 + +### 本机原生 Codex 会话 + +已有原生 Codex 会话不需要“导入”即可查看历史、续聊、停止当前 Turn、重命名和归档。浏览器只是同一运行时的客户端,不为每个原生会话启动新的 `codex app-server`,也不把原生 ID 转成外部 Harness ID。消息、任务状态和待处理交互通过共享通道重新同步;断线后不自动重发结果未知的写请求。原生会话续聊沿用 app-server 的模型和权限设置,本版本的 Web 模型/权限切换、编辑已发消息和 Fork 仍仅适用于已支持相应能力的外部 Harness;原生配置可在 Desktop 修改。 + +`codex-cli` 仍是独立的外部 Harness,与原生 `codex` 不是同一个运行时。旧版已经导入的记录保留,不在升级或列表读取时停止其任务、删除记录或改写原生归属。若旧导入记录仍持有同一个原生会话的 writer,原生入口会保留官方占用错误;先在旧入口安全结束/释放该会话,再使用原生条目。不能通过删除锁文件、批量重新导入或复制历史来解除占用。 + +这里的共享范围是本机。浏览器经 Relay、Tailscale 或局域网访问本机,不意味着主列表会自动聚合 Desktop 已连接的 SSH/Remote Control 主机;远程主机目录和按 Host 路由另行实现。 ## 安装版后台 Web @@ -250,7 +258,7 @@ GUI 原生路由仍不使用 Codex CLI 插件。共享 home 会共享原生配 已运行的旧 Desktop-owned 通道仍可连接,但不会被接管或停止;迁移须用户另行更新并重启 Desktop。单独指定 `--ch-cdp http://127.0.0.1:` 明确选择旧 CDP 入口;与 control directory 同用时,Thread 与置顶请求都使用事件通道,CDP 仅用于旧 Host 的项目读取。独立后台从原生保存文件只读读取项目,Windows 的共享聊天/项目读取不再要求 CDP。已选中的服务通道只重连或启动同一后台,不在 Web 网关创建执行 owner。仅重建 Web 不会升级正在运行的后台或 Desktop。 -共享目录只读取 `modelProvider: codexhost` 且 Host 确认属于 external 的 Threads,包括用于状态投影的已归档记录;主页默认隐藏已归档会话,不接入官方 Codex Threads或远程 Hosts。会话身份保持 CH Thread ID;列表、完整历史、模型和权限均从同一个 Host 查询。Web 不新增会话索引、原生引用映射或历史文件,DSH 格式只作可丢弃的内存展示缓冲。事件模式按 Host 通知刷新正在订阅的历史,100ms 合并同一 Thread 的连续变化;普通历史 1.5 秒轮询不再运行。通知只表示状态失效,客户端读取最近 5 轮的权威完整状态,不直接拼接重放的 token。读取期间的新事件会安排下一次校验,不会被正在进行的读取吞掉。目录变化也合并处理;项目和置顶没有完整聊天事件覆盖,继续单独定期同步。首次目录通过 `session/list` 一次性返回;后续查询只广播实际新增、修改、移除的条目,不将未变化的全部 Threads 重发为 `api-session/added`。比较覆盖标题、执行目录、更新时间、运行状态、父 Thread 和 Harness 归属;标题、模型、权限等全局投影走 `session/control`。快照查询失败不推进已发布目录基线,普通列表请求与定时刷新共用同一差量发布路径,避免会话数量较多时事件洪泛阻塞切换与历史帧处理。 +共享目录读取本机原生 Codex Threads,以及 `modelProvider: codexhost` 且 Host 确认属于 external 的 Threads,包括用于状态投影的已归档记录;主页默认隐藏已归档会话,尚不聚合远程 Hosts。原生 Codex 保持官方 Thread ID,外部 Harness 保持 CH Thread ID;列表、历史和操作均由同一个共享 Host 路由。Web 不新增会话索引、原生引用映射或历史文件,DSH 格式只作可丢弃的内存展示缓冲。事件模式按 Host 通知刷新正在订阅的历史,100ms 合并同一 Thread 的连续变化;普通历史 1.5 秒轮询不再运行。通知只表示状态失效,客户端读取最近 5 轮的权威完整状态,不直接拼接重放的 token。读取期间的新事件会安排下一次校验,不会被正在进行的读取吞掉。目录变化也合并处理;项目和置顶没有完整聊天事件覆盖,继续单独定期同步。首次目录通过 `session/list` 一次性返回;后续查询只广播实际新增、修改、移除的条目,不将未变化的全部 Threads 重发为 `api-session/added`。比较覆盖标题、执行目录、更新时间、运行状态、父 Thread 和 Harness 归属;标题、模型、权限等全局投影走 `session/control`。快照查询失败不推进已发布目录基线,普通列表请求与定时刷新共用同一差量发布路径,避免会话数量较多时事件洪泛阻塞切换与历史帧处理。 ### 聊天投影通道 @@ -270,7 +278,7 @@ follow 使用与 control 相同的 `projection { sessionId, key, value, seq }` CH external Thread 的归档状态以 Host Mapping Store 的 `archived` 为唯一事实来源,与 Codex GUI 共用。Web 分别查询 `thread/list(archived: false/true)`,仅将已确认 external 的归档成员投影到侧栏;不使用 Web `workspaces.json` 中遗留的共享会话归档标记,也不将这份投影写回该文件。GUI 归档/恢复通过 Host 通知触发目录刷新,旧 CDP 连接及漏失通知由定期目录查询校验;重连读取最新状态。 -Web 的归档和恢复操作调用同一 Host 的 `thread/archive` / `thread/unarchive`,不删除历史、不另行停止活动轮。点击归档立即在前端投影为已归档并隐藏其置顶状态,不等待 Host 返回;后台确认后才显示带撤销入口的成功通知,拒绝或请求失败时移除临时投影、按最新 Host 状态恢复显示并通知原因。并行归档互不回滚,工作区流刷新不覆盖尚未确认的临时投影;已收到归档确认后的更新仍以 Host 为准。活动会话被拒绝时保留原有“停止并归档”确认,不自动停止工作。Host 拒绝写入时不伪造成功,写入已确认但后续读取失败时不自动重发,取消归档失败也显示通知。侧栏保留「隐藏已归档」「全部对话(显示已归档)」「仅显示已归档」三个筛选,作用于最近、项目、单列表及搜索;已归档会话不进入置顶区。每次打开或刷新页面均从「隐藏已归档」开始,不沿用浏览器上一次的归档筛选,分组与排序偏好仍保留。用户可在当前页面切换筛选、查看归档条目并恢复,无须独立归档子页面;归档提示保留撤销及显示归档入口。Web 独立 Codex 会话仍使用其独立来源的归档状态,和共享 Thread 合并展示,不写入 GUI 官方 Codex 会话。 +Web 的归档和恢复操作调用同一 Host 的 `thread/archive` / `thread/unarchive`,不删除历史、不另行停止活动轮。点击归档立即在前端投影为已归档并隐藏其置顶状态,不等待 Host 返回;后台确认后才显示带撤销入口的成功通知,拒绝或请求失败时移除临时投影、按最新 Host 状态恢复显示并通知原因。并行归档互不回滚,工作区流刷新不覆盖尚未确认的临时投影;已收到归档确认后的更新仍以 Host 为准。活动会话被拒绝时保留原有“停止并归档”确认,不自动停止工作。Host 拒绝写入时不伪造成功,写入已确认但后续读取失败时不自动重发,取消归档失败也显示通知。侧栏保留「隐藏已归档」「全部对话(显示已归档)」「仅显示已归档」三个筛选,作用于最近、项目、单列表及搜索;已归档会话不进入置顶区。每次打开或刷新页面均从「隐藏已归档」开始,不沿用浏览器上一次的归档筛选,分组与排序偏好仍保留。用户可在当前页面切换筛选、查看归档条目并恢复,无须独立归档子页面;归档提示保留撤销及显示归档入口。旧 Codex CLI 外部会话保留自身归档状态;原生 Codex 条目的归档操作直接作用于同一个官方 Thread,Desktop 可看到该变化。 ### 会话标题与首条消息摘要 @@ -288,7 +296,7 @@ Host 在原生确认首轮开始后,将首条用户消息的文本规范化并 仅经 codexhost 官方或自建中转进入的浏览器连接,对已结束回合的工具文本使用 64 Ki 字符单项阈值、8 Ki 字符开头预览与每窗口 128 Ki 字符软预算;窗口预算从最新输出向旧输出分配,优先保留最新结果全文,记录返回顺序不变;预算不足时较旧的大结果改发预览。小结果、最小预览及活动回合不受软预算硬截断。localhost、局域网、Tailscale 和反向代理保持现有完整加载,共享历史缓存不被修改。 -预览仍使用正常工具卡片,底部提供“显示完整输出(共 xx KB)”轻量操作,点击后原位替换为全文。每个输出只保留一个复制入口;原有卡片复制控件先取得全文,再复制。同版本的成功读取在当前页面的会话内存中复用,失败可重试,版本失效提示刷新,不后台预取。全文是现有投影经 100,000 字符上限裁剪后的文本,与本地可见内容一致;不回读原生 Item 绕过上限。读取经现有 Session Remote 命名空间,沿用认证和 external Thread 所有权校验,引用绑定会话、日志代次、回合、工具条目与文本指纹;跨会话或失效引用不能取得其他内容。 +预览仍使用正常工具卡片,底部提供“显示完整输出(共 xx KB)”轻量操作,点击后原位替换为全文。每个输出只保留一个复制入口;原有卡片复制控件先取得全文,再复制。同版本的成功读取在当前页面的会话内存中复用,失败可重试,版本失效提示刷新,不后台预取。全文是现有投影经 100,000 字符上限裁剪后的文本,与本地可见内容一致;不回读原生 Item 绕过上限。读取经现有 Session Remote 命名空间,沿用认证和本机 Thread 所有权校验,引用绑定会话、日志代次、回合、工具条目与文本指纹;跨会话或失效引用不能取得其他内容。 ### 历史首屏与按需分页 @@ -392,7 +400,7 @@ Assistant 流式投影缓存各内容块是否可见,同类型追加不反复 发送时先核对所有回执、所属 Session 及字节完整性,再保存图片或创建原生 Thread。首次创建只迁移回执的内存归属到确认的 canonical ID,不建立持久 Thread 索引。文件/图片混合输入仍只沿用一份原生路径上下文;Host 只接收文本路径,不接收普通文件字节或 base64。确认接受后退休回执;拒绝保留草稿与回执,结果未知不自动重发或删除文件。历史文件卡片从原生路径恢复,已发送的文件不依赖上传回执跨重启存在。单个文件的内容如何读取、是否需要审批、是否能解释其格式仍由原生 Harness/工具决定。 -范围仍限本地 external-owned 普通 Thread;官方 Codex、子代理上传、远程/WSL 路径转换未接入。普通文件使用卡片,不声称实现了 PDF/Office/ZIP 等通用预览或下载。模拟测试验证路径可读取与字节保留,不等于每个真实 Harness/Model 的文件解释或图片理解能力均已验收。 +范围限已确认归属的本机普通 Thread,包括原生 Codex;子代理上传、远程/WSL 路径转换未接入。普通文件使用卡片,不声称实现了 PDF/Office/ZIP 等通用预览或下载。模拟测试验证路径可读取与字节保留,不等于每个真实 Harness/Model 的文件解释或图片理解能力均已验收。 ## 会话侧栏 @@ -426,7 +434,7 @@ Desktop 窗口未运行时,Host 使用同一条路径完成置顶和取消置 ## 右侧栏 -右侧栏直接装配 DSH Web 的文件树、文档预览、终端和浏览器插件,并复用其宿主服务、限额、错误提示和恢复行为;CodexHost 补充本地 Thread 的工作目录、Web 草稿保存的工作区、沙箱策略的工作区根目录和终端所属会话适配。文件树与预览使用服务端保存的本地 Thread 或草稿工作目录,不采信文件请求中指定的作用域根目录;终端允许已登记的本地草稿和本地 Thread:有工作目录时使用该目录,未设置时默认使用运行 Web 服务的系统用户主目录,不会把该默认值写回会话或注册为工作区。没有工作区的草稿仍不提供文件树与预览;官方 Codex Thread、远程/未知 Thread 不获得本地终端权限。打开终端不会创建 Thread、Native Session 或提交 Turn。右侧栏请求沿用现有 Web 登录认证与同源校验;不要以 `--no-auth` 暴露到不可信网络。 +右侧栏直接装配 DSH Web 的文件树、文档预览、终端和浏览器插件,并复用其宿主服务、限额、错误提示和恢复行为;CodexHost 补充本地 Thread 的工作目录、Web 草稿保存的工作区、沙箱策略的工作区根目录和终端所属会话适配。文件树与预览使用服务端保存的本地 Thread 或草稿工作目录,不采信文件请求中指定的作用域根目录;终端允许已登记的本地草稿和本地 Thread:有工作目录时使用该目录,未设置时默认使用运行 Web 服务的系统用户主目录,不会把该默认值写回会话或注册为工作区。没有工作区的草稿仍不提供文件树与预览;本机原生 Codex 条目使用官方返回的工作目录;远程/未知 Thread 不获得本地终端权限。打开终端不会创建 Thread、Native Session 或提交 Turn。右侧栏请求沿用现有 Web 登录认证与同源校验;不要以 `--no-auth` 暴露到不可信网络。 文件和预览请求、终端进程都以运行 Web 服务的系统用户权限执行,没有额外的 OS 用户隔离或容器边界。文件树列出目录时限制在 Thread 工作目录内,但 DSH 的文本/字节读取按文件系统读取权限运行,可读取该用户可访问的工作目录外绝对路径。终端使用系统用户默认 shell 与权限;关闭标签会结束对应进程,Web 服务退出时清理其保留的终端。Web 浏览器标签只接受 HTTP(S) 地址,由运行 Web 服务那台机器上的浏览器渲染,经已认证的同源 WebSocket 推送画面并接收输入,见下文。 diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index 116473c6d..4b92da21c 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -233,6 +233,7 @@ import { OfficialRuntimeClient, OfficialRuntimeScope, } from "./codex-runtime/official-runtime-scope.js"; +import { OfficialAdmissionError } from "./codex-runtime/official-work-gate.js"; import type { HostUpdateCoordinator } from "./update-coordinator.js"; import type { HostConsoleOpener } from "./console-opener.js"; import type { HostWebOpener } from "./web-opener.js"; @@ -723,6 +724,17 @@ export class AppServerHost { loadedThread: (threadId) => this.#externalRuntime?.get(threadId), clientHistoryTurns: (thread) => this.#externalHistoryTurns(thread, true), queueSnapshot: (threadId) => this.#externalQueue.snapshot(threadId), + ...(this.#options.externalOnly + ? { + native: { + events: this.clientEvents, + request: (method: string, params: JsonObject) => + this.#requestClientNative(method, params), + respond: (requestId: string | number, result: JsonValue) => + this.#respondClientNative(requestId, result), + }, + } + : {}), submit: (method, params, timeoutMs) => this.#handleControlRequest( method, @@ -796,6 +808,7 @@ export class AppServerHost { onBackendStopped: () => { this.#pendingOfficialTurnStarts.clear(); this.#activeOfficialTurns.clear(); + this.#client.resetNativeSession(); this.#signalActiveWorkChanged(); }, output: async (output) => @@ -1856,6 +1869,7 @@ export class AppServerHost { value: JsonValue; }): Promise { if (this.#options.externalOnly) { + this.#client.observeNativeMessage(input.value); // Native children created through Delegation still have a native owner // and lifecycle. Their rendering stays on the GUI's native connection. await this.#observeOfficialTurnLifecycle(input.value); @@ -1929,6 +1943,39 @@ export class AppServerHost { return this.#officialRuntime.request(method, params); } + async #requestClientNative(method: string, params: JsonObject): Promise { + try { + const response = await this.#requestOfficial(method, params); + if (isRecord(response.error)) { + return { + error: { + code: typeof response.error.code === "number" ? response.error.code : -32603, + message: + typeof response.error.message === "string" + ? response.error.message + : "Native Codex request failed", + }, + }; + } + return { result: (response.result ?? null) as JsonValue }; + } catch (error) { + if (error instanceof OfficialAdmissionError) + return { error: { code: -32090, message: "Native Codex is unavailable" } }; + if (isRecord(error) && typeof error.code === "number") + return { error: { code: error.code, message: errorMessage(error) } }; + return { + error: { + code: -32093, + message: "Native request outcome may be unknown; do not retry automatically", + }, + }; + } + } + + #respondClientNative(requestId: string | number, result: JsonValue): Promise { + return this.#officialRuntime.send({ id: requestId, result }); + } + #inspectHarnessAccount( adapter: HarnessAdapter, refresh = false, @@ -2550,12 +2597,31 @@ export class AppServerHost { : {}), runtimeFor: (threadId) => this.#listRuntimeState(threadId), placementOf: (threadId) => placementById.get(threadId), - requestOfficialPage: async (params) => - this.#options.externalOnly - ? { data: [], nextCursor: null, backwardsCursor: null } - : officialThreadListPageFromResponse( - await this.#officialRuntime.request("thread/list", params), - ), + requestOfficialPage: async (params) => { + if (this.#options.externalOnly && origin !== "client") + return { data: [], nextCursor: null, backwardsCursor: null }; + let official; + try { + official = officialThreadListPageFromResponse( + await (this.#options.externalOnly + ? this.#requestOfficial("thread/list", params) + : this.#officialRuntime.request("thread/list", params)), + ); + } catch (error) { + // Native Codex is optional for the shared external service. An + // unavailable binary must not hide external rows; protocol errors + // remain visible instead of being silently converted to an empty list. + if ( + this.#options.externalOnly && + origin === "client" && + error instanceof OfficialAdmissionError && + error.code === "unavailable" + ) + return { data: [], nextCursor: null, backwardsCursor: null }; + throw error; + } + return official; + }, }); await this.#writer.json(rpcEnvelope(request, { result })); } catch (error) { diff --git a/packages/host-runtime/src/client-channel-host.ts b/packages/host-runtime/src/client-channel-host.ts index edd743364..7ac5a83b4 100644 --- a/packages/host-runtime/src/client-channel-host.ts +++ b/packages/host-runtime/src/client-channel-host.ts @@ -21,6 +21,10 @@ import { listExternalTurns } from "./external-thread-history.js"; import type { ExternalThreadQueueSnapshot } from "./external-thread-queue.js"; import type { ExternalThread, ExternalThreadLocation } from "./external-thread-runtime.js"; import { readExternalWorkspaceSnapshot } from "./native-workspace-snapshot.js"; +import { + NativeClientChannel, + type NativeClientChannelDependencies, +} from "./native-client-channel.js"; import { classifyCreateRequestRoute } from "./route-observation.js"; function isRecord(value: unknown): value is Record { @@ -56,6 +60,8 @@ export interface ClientChannelHostDependencies { /** History served to clients, including the live snapshot of a non-ephemeral active Turn. */ clientHistoryTurns(thread: ExternalThread): JsonObject[]; queueSnapshot(threadId: string): ExternalThreadQueueSnapshot; + /** Native Codex calls use the shared service's already-attached official client. */ + readonly native?: NativeClientChannelDependencies; /** Runs one client request through the shared Desktop request handling. */ submit( method: string, @@ -65,17 +71,19 @@ export interface ClientChannelHostDependencies { } /** - * Client channel adapter of the external Thread owner: restricted RPC, absolute Thread + * Client channel adapter of the shared Host: restricted RPC, absolute Thread * snapshots, interaction answers and invalidation of Threads clients may read. */ export class ClientChannelHost { readonly clientEvents: ClientChannelEvents; readonly #host: ClientChannelHostDependencies; + readonly #native: NativeClientChannel | undefined; readonly #clientOwnedThreads = new Set(); readonly #clientReceipts = new ClientCommandReceipts(); constructor(host: ClientChannelHostDependencies) { this.#host = host; + this.#native = host.native ? new NativeClientChannel(host.native) : undefined; this.clientEvents = host.events; } @@ -102,7 +110,17 @@ export class ClientChannelHost { } } - /** Public client operations stay on this owner and never fall through to official Codex. */ + /** Native app-server notifications are invalidations and server requests for + * the same Web snapshot/response boundary. */ + observeNativeMessage(value: JsonValue): void { + this.#native?.observeMessage(value); + } + + resetNativeSession(): void { + this.#native?.resetGeneration(); + } + + /** Route only to the confirmed external owner or this service's shared native client. */ async handleClientRequest(method: string, input: unknown): Promise { if (!(CLIENT_CHANNEL_METHODS as readonly string[]).includes(method)) return { error: { code: -32601, message: "Client method is not available" } }; @@ -118,19 +136,25 @@ export class ClientChannelHost { }; if (method === "codexhost/workspace/read") { await this.#host.waitForPlugins(); + const nativeThreadIds = Array.isArray(params.threadIds) + ? params.threadIds.filter((value): value is string => typeof value === "string") + : []; return { result: jsonValueSchema.parse( await readExternalWorkspaceSnapshot( this.#host.environment(), await this.#host.listRecords(), await this.#host.listSectionPlacements(), + nativeThreadIds, ), ), }; } + let native = false; if (method === "thread/start") { const route = classifyCreateRequestRoute(parsed.data); - if (!route || route.selectedHarness === "codex") + native = !route || route.selectedHarness === "codex"; + if (native && !this.#native) return { error: { code: -32078, @@ -138,9 +162,23 @@ export class ClientChannelHost { }, }; } + const nativeMethods = new Set([ + "thread/read", + "thread/resume", + "thread/turns/list", + "thread/items/list", + "thread/name/set", + "thread/archive", + "thread/unarchive", + "turn/start", + "turn/steer", + "turn/interrupt", + ]); if (typeof params.threadId === "string") { const location = await this.#host.locateExternalThread(params.threadId); - if (location.kind !== "external") + native = native || (location.kind === "official" && nativeMethods.has(method)); + const inspection = method === "codexhost/thread/inspect"; + if (location.kind !== "external" && !native && !inspection) return { error: { code: -32078, message: "Thread is not owned by this external Host" } }; } else if ( (method.startsWith("thread/") || method.startsWith("turn/")) && @@ -149,12 +187,7 @@ export class ClientChannelHost { ) { return { error: { code: -32602, message: "threadId is required" } }; } - const submit = async (): Promise => { - const reply = await this.#host.submit( - method, - method === "thread/list" ? { ...params, modelProviders: ["codexhost"] } : params, - harnessInstallationRequestTimeoutMs(method, params), - ); + const normalize = (reply: ConsoleHostReply): ConsoleHostReply => { return "error" in reply && [-32090, -32093].includes(reply.error.code) ? { error: { @@ -165,13 +198,33 @@ export class ClientChannelHost { } : reply; }; + const submit = async (): Promise => + normalize( + await this.#host.submit( + method, + params, + harnessInstallationRequestTimeoutMs(method, params), + ), + ); + const nativeChannel = this.#native; + const nativeSubmit = async (): Promise => { + if (!nativeChannel) + return { + error: { + code: -32078, + message: "Only external Harness Threads are available to this client", + }, + }; + return normalize(await nativeChannel.request(method, params)); + }; + const operation = native ? nativeSubmit : submit; return method === "turn/start" && typeof params.clientUserMessageId === "string" ? this.#clientReceipts.run( JSON.stringify([params.threadId, params.clientUserMessageId]), params, - submit, + operation, ) - : submit(); + : operation(); } /** Absolute, bounded state; capture fields and delivery cursor without yielding. @@ -181,6 +234,13 @@ export class ClientChannelHost { threadId: string, options: ClientSnapshotOptions = {}, ): Promise { + const location = await this.#host.locateExternalThread(threadId); + if (location.kind === "error") + throw new ClientChannelRequestError(location.error.code, location.error.message); + if (location.kind === "official") { + if (!this.#native) throw new Error("Thread is not owned by this local Host"); + return this.#native.snapshot(threadId, options); + } let thread = this.#host.loadedThread(threadId); if (!thread) { const prepared = await this.handleClientRequest("thread/turns/list", { @@ -271,7 +331,19 @@ export class ClientChannelHost { ? interactions.questions.get(id) : undefined; const pending = approval ?? question; - if (!pending) return { resolved: true }; + if (!pending) { + if (this.#native) { + const resolved = await this.#native.respond( + input.requestId, + input.threadId, + jsonValueSchema.parse(input.result), + ); + if (resolved) return { resolved: true }; + if (this.#native.isStaleRequestId(input.requestId)) + throw new Error("Host generation changed; refresh the pending interaction"); + } + return { resolved: true }; + } if (pending.thread.id !== input.threadId) throw new Error("Interaction does not belong to this Thread"); pending.projection.parseResponse(input.result); // Reject malformed client responses before claiming. diff --git a/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts b/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts index ea8131657..e70e88e0f 100644 --- a/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts +++ b/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts @@ -416,6 +416,19 @@ export class OfficialRuntimeOwner { return; const value = event.value; if (!object(value)) return client.output(event); + if (value.method === "serverRequest/resolved" && object(value.params)) { + const nativeId = value.params.requestId; + const match = [...client.serverRequests].find(([, original]) => original === nativeId); + if (match) { + client.serverRequests.delete(match[0]); + const projected = { ...value, params: { ...value.params, requestId: match[0] } }; + return client.output({ + ...event, + value: projected, + frame: Buffer.from(`${JSON.stringify(projected)}\n`), + }); + } + } if (typeof value.id === "string" || typeof value.id === "number") { if (typeof value.method === "string") { const id = `codexhost:server:${client.id}:${event.generation}:${randomUUID()}`; diff --git a/packages/host-runtime/src/harness-display-settings.ts b/packages/host-runtime/src/harness-display-settings.ts index 42770aa34..f8daa5271 100644 --- a/packages/host-runtime/src/harness-display-settings.ts +++ b/packages/host-runtime/src/harness-display-settings.ts @@ -9,6 +9,28 @@ import { type HarnessDisplaySet, } from "@codexhost/shared-contracts"; +/** A Windows reader/scanner can briefly deny replacement. Retry only a failed + * atomic rename of the same complete snapshot; never delete the destination. */ +async function replaceSnapshot(temporary: string, destination: string): Promise { + const delays = [10, 30, 70, 150, 300]; + for (let attempt = 0; ; attempt++) { + try { + await rename(temporary, destination); + return; + } catch (error) { + const delay = delays[attempt]; + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform !== "win32" || + delay === undefined || + !["EPERM", "EACCES", "EBUSY"].includes(code ?? "") + ) + throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + /** Shared across local Host processes. Complete snapshots use last successful write wins. */ export class HarnessDisplaySettingsStore { readonly #file: string; @@ -88,7 +110,7 @@ export class HarnessDisplaySettingsStore { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } } else { - await rename(temporary, this.#file); + await replaceSnapshot(temporary, this.#file); } const snapshot = await this.get(); this.#publish(snapshot); diff --git a/packages/host-runtime/src/native-client-channel.ts b/packages/host-runtime/src/native-client-channel.ts new file mode 100644 index 000000000..0f6c6af18 --- /dev/null +++ b/packages/host-runtime/src/native-client-channel.ts @@ -0,0 +1,581 @@ +import { randomUUID } from "node:crypto"; +import type { JsonObject, JsonValue } from "@codexhost/protocol-core"; +import { + clientThreadSnapshotSchema, + type ClientInteraction, + type ClientSnapshotOptions, + type ClientThreadSnapshot, +} from "@codexhost/shared-contracts"; + +import type { ClientChannelEvents } from "./client-channel-events.js"; +import { ClientChannelRequestError } from "./client-channel-error.js"; +import { compactClientSnapshot } from "./client-snapshot-compaction.js"; + +export type NativeClientReply = + { result: JsonValue } | { error: { code: number; message: string } }; + +export interface NativeClientChannelDependencies { + readonly events: ClientChannelEvents; + request(method: string, params: JsonObject): Promise; + respond(requestId: string | number, result: JsonValue): Promise; +} + +interface PendingNativeInteraction { + readonly requestId: string; + readonly nativeRequestId: string | number; + readonly threadId: string; + readonly method: string; + readonly params: JsonObject; + readonly kind: "approval" | "question"; + readonly interaction: ClientInteraction["interaction"]; +} + +interface NativeConfiguration { + readonly model?: string; + readonly reasoningEffort?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isJsonObject = (value: unknown): value is JsonObject => isRecord(value); + +const requestKey = (id: string | number): string => JSON.stringify([typeof id, id]); +const NATIVE_REQUEST_PREFIX = "codexhost:native-interaction:"; + +function nativeConfigId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function threadIdFromParams(params: Record): string | undefined { + const direct = stringValue(params.threadId); + if (direct) return direct; + const thread = isRecord(params.thread) ? stringValue(params.thread.id) : undefined; + if (thread) return thread; + const turn = isRecord(params.turn) ? stringValue(params.turn.threadId) : undefined; + if (turn) return turn; + const item = isRecord(params.item) ? stringValue(params.item.threadId) : undefined; + return item; +} + +function resultObject(reply: NativeClientReply): JsonObject | undefined { + return "result" in reply && isJsonObject(reply.result) ? reply.result : undefined; +} + +function resultThread(reply: NativeClientReply): JsonObject | undefined { + const result = resultObject(reply); + return result && isJsonObject(result.thread) ? result.thread : undefined; +} + +function availableDecisions(params: Record): string[] { + if (!Array.isArray(params.availableDecisions)) return ["accept", "decline"]; + return params.availableDecisions.filter((value): value is string => typeof value === "string"); +} + +function decisionLabel(decision: string): string { + switch (decision) { + case "accept": + return "Allow once"; + case "acceptForSession": + return "Allow for session"; + case "acceptWithExecpolicyAmendment": + return "Allow and remember"; + case "decline": + return "Decline"; + case "cancel": + return "Cancel"; + default: + return decision; + } +} + +function decisionEffect(decision: string): string { + switch (decision) { + case "accept": + return "allowOnce"; + case "acceptForSession": + return "allowForSession"; + case "acceptWithExecpolicyAmendment": + return "allowAlways"; + case "decline": + case "cancel": + return "deny"; + default: + return "unknown"; + } +} + +function approvalInteraction( + method: string, + params: JsonObject, +): Pick | undefined { + if ( + method !== "item/commandExecution/requestApproval" && + method !== "item/fileChange/requestApproval" + ) + return undefined; + const decisions = availableDecisions(params); + const actions = decisions.map((id) => ({ + id, + label: decisionLabel(id), + effect: decisionEffect(id), + })); + const command = stringValue(params.command); + const reason = stringValue(params.reason); + return { + kind: "approval", + interaction: { + type: "approval", + title: + reason ?? + (method === "item/commandExecution/requestApproval" + ? "Allow command execution?" + : "Allow file changes?"), + ...(command ? { description: command } : {}), + subject: params, + actions, + }, + }; +} + +function optionValue(value: unknown): string | undefined { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") + return String(value); + return undefined; +} + +type ClientQuestion = Extract; +type ClientQuestionItem = ClientQuestion["questions"][number]; + +function nativeQuestions(params: JsonObject): ClientQuestion | undefined { + if (!Array.isArray(params.questions)) return undefined; + const questions: ClientQuestion["questions"] = []; + params.questions.forEach((value, index) => { + if (!isRecord(value)) return; + const id = stringValue(value.id) ?? `question-${index + 1}`; + const prompt = stringValue(value.question) ?? stringValue(value.header) ?? id; + const options = Array.isArray(value.options) + ? value.options.flatMap((option) => { + if (!isRecord(option)) return []; + const optionValueText = + optionValue(option.value) ?? optionValue(option.label) ?? optionValue(option.name); + if (!optionValueText) return []; + return [ + { + value: optionValueText, + label: stringValue(option.label) ?? optionValueText, + ...(stringValue(option.description) + ? { description: stringValue(option.description) } + : {}), + }, + ]; + }) + : []; + const question: ClientQuestionItem = options.length + ? { + type: "choice", + id, + prompt, + multiple: value.multiSelect === true, + allowOther: value.isOther === true, + options, + } + : { + type: "text", + id, + prompt, + ...(value.isSecret === true ? { secret: true } : {}), + ...(value.optional === true ? { optional: true } : {}), + }; + questions.push(question); + }); + return { type: "question", questions }; +} + +function schemaQuestions(params: JsonObject): ClientQuestion | undefined { + const schema = isRecord(params.requestedSchema) ? params.requestedSchema : undefined; + if (!schema || !isRecord(schema.properties)) return undefined; + const properties = schema.properties; + for (const value of Object.values(properties)) { + if (!isRecord(value)) return undefined; + if (value.type !== undefined && value.type !== "string") return undefined; + if (Array.isArray(value.enum) && value.enum.some((entry) => typeof entry !== "string")) + return undefined; + } + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((value): value is string => typeof value === "string") + : [], + ); + const questions: ClientQuestion["questions"] = []; + for (const [id, value] of Object.entries(properties)) { + if (!isRecord(value)) continue; + const prompt = stringValue(value.title) ?? stringValue(value.description) ?? id; + const enumValues = Array.isArray(value.enum) + ? value.enum.filter((entry): entry is string => typeof entry === "string") + : []; + const question: ClientQuestionItem = enumValues.length + ? { + type: "choice", + id, + prompt, + multiple: false, + allowOther: false, + ...(required.has(id) ? {} : { optional: true }), + options: enumValues.map((option) => ({ value: option, label: option })), + } + : { + type: "text", + id, + prompt, + ...(required.has(id) ? {} : { optional: true }), + }; + questions.push(question); + } + return { type: "question", questions }; +} + +function nativeInteraction( + method: string, + params: JsonObject, +): Pick | undefined { + const approval = approvalInteraction(method, params); + if (approval) return approval; + if (method === "item/tool/requestUserInput") { + // The browser question card has no masked-input contract. + if ( + Array.isArray(params.questions) && + params.questions.some((question) => isRecord(question) && question.isSecret === true) + ) + return undefined; + const interaction = nativeQuestions(params); + return interaction ? { kind: "question", interaction } : undefined; + } + if (method === "mcpServer/elicitation/request") { + // A form-shaped elicitation can be answered through the existing Web question card. + // Permission grants are deliberately not treated as this generic approval shape. + const interaction = schemaQuestions(params); + return interaction ? { kind: "question", interaction } : undefined; + } + if (method === "item/permissions/requestApproval") return undefined; + return undefined; +} + +function answersContent(value: unknown): JsonObject { + if (!isRecord(value)) return {}; + const answers = isRecord(value.answers) ? value.answers : value; + return Object.fromEntries( + Object.entries(answers).flatMap(([id, answer]) => { + if (isRecord(answer) && Array.isArray(answer.answers)) { + return [[id, answer.answers.length === 1 ? answer.answers[0] : answer.answers]]; + } + return [[id, answer]]; + }), + ) as JsonObject; +} + +function validMcpContent(params: JsonObject, content: JsonObject): boolean { + const schema = isRecord(params.requestedSchema) ? params.requestedSchema : undefined; + if (!schema || !isRecord(schema.properties)) return false; + const properties = schema.properties; + const required = Array.isArray(schema.required) + ? schema.required.filter((entry): entry is string => typeof entry === "string") + : []; + if (required.some((key) => !Object.hasOwn(content, key))) return false; + for (const [key, value] of Object.entries(content)) { + const definition = properties[key]; + if (!isRecord(definition)) return false; + if (definition.type === "string" && typeof value !== "string") return false; + if (definition.type === "number" && typeof value !== "number") return false; + if (definition.type === "integer" && (!Number.isInteger(value) || typeof value !== "number")) + return false; + if (definition.type === "boolean" && typeof value !== "boolean") return false; + if (Array.isArray(definition.enum) && !definition.enum.some((entry) => Object.is(entry, value))) + return false; + } + return true; +} + +function validatedDecision(pending: PendingNativeInteraction, value: JsonValue): JsonObject { + const allowed = availableDecisions(pending.params); + if (!isRecord(value)) throw new Error("Native approval response must be an object"); + if (typeof value.decision === "string") { + if (!allowed.includes(value.decision)) + throw new Error(`Native approval decision '${value.decision}' is not available`); + return value as JsonObject; + } + const action = typeof value.action === "string" ? value.action : undefined; + const content = isRecord(value.content) ? value.content : undefined; + const actionId = content && stringValue(content.actionId); + const decision = actionId ?? action; + if (!decision || !allowed.includes(decision)) + throw new Error("Native approval response does not select an available decision"); + return { decision }; +} + +function nativeResponse(pending: PendingNativeInteraction, value: JsonValue): JsonValue { + if (pending.method === "item/commandExecution/requestApproval") { + return validatedDecision(pending, value); + } + if (pending.method === "item/fileChange/requestApproval") { + return validatedDecision(pending, value); + } + if (pending.method === "mcpServer/elicitation/request" && isRecord(value)) { + if (typeof value.action === "string") return value as JsonObject; + if (isRecord(value.answers)) { + const content = answersContent(value); + if (!validMcpContent(pending.params, content)) + throw new Error("Native elicitation response does not match its schema"); + return { action: "accept", content }; + } + } + return value; +} + +function turnIdFromPage(turn: unknown): string | undefined { + if (!isRecord(turn) || typeof turn.id !== "string") return undefined; + return turn.status === "inProgress" ? turn.id : undefined; +} + +const RESUME_BEFORE = new Set(["turn/start", "turn/steer", "turn/interrupt"]); +const CHANGED_METHODS = new Set([ + "thread/resume", + "thread/start", + "thread/name/set", + "thread/archive", + "thread/unarchive", + "turn/start", + "turn/steer", + "turn/interrupt", +]); + +/** Native Codex side of the Host client channel. It is attached to the shared + * service's existing official session; it never creates an app-server. */ +export class NativeClientChannel { + readonly #host: NativeClientChannelDependencies; + readonly #knownThreads = new Set(); + readonly #resumedThreads = new Set(); + readonly #configuration = new Map(); + readonly #pending = new Map(); + readonly #pendingNativeIds = new Map(); + #generationToken = 0; + + constructor(host: NativeClientChannelDependencies) { + this.#host = host; + } + + noteThread(threadId: string): void { + this.#knownThreads.add(threadId); + } + + observeMessage(value: JsonValue): void { + if (!isRecord(value) || typeof value.method !== "string" || !isRecord(value.params)) return; + const params = value.params as JsonObject; + const threadId = threadIdFromParams(params); + if (threadId) { + this.noteThread(threadId); + this.#host.events.changed(threadId, value.method); + } + if (value.method === "serverRequest/resolved") { + const requestId = params.requestId; + if (typeof requestId === "string" || typeof requestId === "number") { + const publicId = + this.#pendingNativeIds.get(requestKey(requestId)) ?? + (typeof requestId === "string" && this.#pending.has(requestId) ? requestId : undefined); + if (publicId) { + const pending = this.#pending.get(publicId); + this.#deletePending(publicId); + if (pending) this.#host.events.changed(pending.threadId, value.method); + } + } + return; + } + if (typeof value.id !== "string" && typeof value.id !== "number") return; + const interaction = nativeInteraction(value.method, params); + if (!interaction || !threadId) return; + if (this.#pendingNativeIds.has(requestKey(value.id))) return; + const publicId = `${NATIVE_REQUEST_PREFIX}${this.#generationToken}:${randomUUID()}`; + this.#pending.set(publicId, { + requestId: publicId, + nativeRequestId: value.id, + threadId, + method: value.method, + params, + ...interaction, + }); + this.#pendingNativeIds.set(requestKey(value.id), publicId); + } + + #deletePending(publicId: string): void { + const pending = this.#pending.get(publicId); + if (!pending) return; + this.#pending.delete(publicId); + this.#pendingNativeIds.delete(requestKey(pending.nativeRequestId)); + } + + private observeResponse(method: string, params: JsonObject, reply: NativeClientReply): void { + if ("error" in reply) return; + const thread = resultThread(reply); + if (thread && typeof thread.id === "string") { + this.noteThread(thread.id); + if (method === "thread/resume" || method === "thread/start") + this.#resumedThreads.add(thread.id); + } + const threadId = threadIdFromParams(params); + if (threadId) { + this.noteThread(threadId); + if (method === "thread/resume") this.#resumedThreads.add(threadId); + } + if (method === "thread/resume" && threadId) { + const result = resultObject(reply); + const model = nativeConfigId(result?.model); + const reasoningEffort = nativeConfigId(result?.reasoningEffort); + if (model || reasoningEffort) + this.#configuration.set(threadId, { + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + } + if (method === "thread/list") { + const data = resultObject(reply)?.data; + if (Array.isArray(data)) + for (const item of data) + if (isRecord(item) && typeof item.id === "string") this.noteThread(item.id); + } + } + + async request(method: string, params: JsonObject): Promise { + const generation = this.#generationToken; + const stale = (): NativeClientReply => ({ + error: { + code: -32094, + message: + "Host generation changed; operation outcome may be unknown. Do not retry automatically.", + }, + }); + const threadId = threadIdFromParams(params); + if (threadId && RESUME_BEFORE.has(method) && !this.#resumedThreads.has(threadId)) { + const resumed = await this.#host.request("thread/resume", { threadId }); + if (generation !== this.#generationToken) return stale(); + this.observeResponse("thread/resume", { threadId }, resumed); + if ("error" in resumed) return resumed; + } + const reply = await this.#host.request(method, params); + if (generation !== this.#generationToken) return stale(); + this.observeResponse(method, params, reply); + if (threadId && CHANGED_METHODS.has(method) && !("error" in reply)) + this.#host.events.changed(threadId, method); + return reply; + } + + interactions(threadId: string): ClientInteraction[] { + return [...this.#pending.values()] + .filter((pending) => pending.threadId === threadId) + .map((pending) => ({ + requestId: pending.requestId, + threadId, + kind: pending.kind, + interaction: pending.interaction, + request: { method: pending.method, params: pending.params }, + })); + } + + async snapshot(threadId: string, options: ClientSnapshotOptions): Promise { + const cursor = this.#host.events.cursor; + const generation = this.#generationToken; + const assertGeneration = () => { + if (generation !== this.#generationToken) + throw new ClientChannelRequestError(-32094, "Host generation changed; refresh the Thread"); + }; + try { + if (!this.#resumedThreads.has(threadId)) { + const resumed = await this.request("thread/resume", { threadId }); + assertGeneration(); + if ("error" in resumed) + throw new ClientChannelRequestError(resumed.error.code, resumed.error.message); + } + const read = await this.request("thread/read", { threadId, includeTurns: false }); + assertGeneration(); + if ("error" in read) throw new ClientChannelRequestError(read.error.code, read.error.message); + const thread = resultThread(read); + if (!thread || thread.id !== threadId) + throw new Error("Native Codex did not return the requested Thread"); + if (typeof thread.modelProvider !== "string" || thread.modelProvider.length === 0) + throw new Error("Native Codex Thread has no modelProvider"); + const turnsReply = await this.request("thread/turns/list", { + threadId, + limit: 5, + sortDirection: "desc", + itemsView: "full", + }); + assertGeneration(); + if ("error" in turnsReply) + throw new ClientChannelRequestError(turnsReply.error.code, turnsReply.error.message); + const turnsResult = resultObject(turnsReply) ?? {}; + const data = Array.isArray(turnsResult.data) ? turnsResult.data : []; + const turnsPage = { + data, + nextCursor: typeof turnsResult.nextCursor === "string" ? turnsResult.nextCursor : null, + }; + const activeTurnId = data.map(turnIdFromPage).find((id): id is string => id !== undefined); + const configuration = this.#configuration.get(threadId); + const snapshot = clientThreadSnapshotSchema.parse({ + cursor, + ...(activeTurnId ? { activeTurnId } : {}), + thread: { ...thread, turns: [] }, + turnsPage, + // Native configuration is owned by official Codex. Only values returned + // by native resume are presented; no Host defaults are synthesized. + configuration: { + ...(configuration ? { native: configuration } : {}), + }, + interactions: this.interactions(threadId), + }); + assertGeneration(); + return compactClientSnapshot(snapshot, options); + } catch (error) { + if (generation !== this.#generationToken) + throw new ClientChannelRequestError(-32094, "Host generation changed; refresh the Thread"); + throw error; + } + } + + async respond(requestId: string | number, threadId: string, result: JsonValue): Promise { + const pending = typeof requestId === "string" ? this.#pending.get(requestId) : undefined; + if (!pending) return false; + if (pending.threadId !== threadId) + throw new Error("Interaction does not belong to this Thread"); + const response = nativeResponse(pending, result); + // Claim before delivery: a transport failure has an ambiguous outcome and + // must not permit a second native writer to answer the request. + this.#deletePending(pending.requestId); + await this.#host.respond(pending.nativeRequestId, response); + this.#host.events.changed(pending.threadId, "serverRequest/resolved"); + return true; + } + + resetGeneration(): void { + const threads = [...this.#knownThreads]; + this.#generationToken += 1; + this.#knownThreads.clear(); + this.#resumedThreads.clear(); + this.#pending.clear(); + this.#pendingNativeIds.clear(); + this.#configuration.clear(); + for (const threadId of threads) this.#host.events.changed(threadId, "thread/status/changed"); + } + + isNativeRequestId(value: string | number): value is string { + return typeof value === "string" && value.startsWith(NATIVE_REQUEST_PREFIX); + } + + isStaleRequestId(value: string | number): boolean { + if (!this.isNativeRequestId(value)) return false; + const prefix = `${NATIVE_REQUEST_PREFIX}${this.#generationToken}:`; + return !value.startsWith(prefix); + } +} diff --git a/packages/host-runtime/src/native-workspace-snapshot.ts b/packages/host-runtime/src/native-workspace-snapshot.ts index 0a5ff0315..89298d389 100644 --- a/packages/host-runtime/src/native-workspace-snapshot.ts +++ b/packages/host-runtime/src/native-workspace-snapshot.ts @@ -69,14 +69,18 @@ export async function readNativeWorkspaceSnapshot( }); } -/** Combine read-only GUI facts with this owner's canonical no-project records. */ +/** Combine requested native GUI facts with this owner's canonical no-project records. */ export async function readExternalWorkspaceSnapshot( environment: NodeJS.ProcessEnv, threads: readonly StoredThreadRecordV1[], placements: readonly StoredSectionPlacementV1[], + nativeThreadIds: readonly string[] = [], ): Promise { const snapshot = await readNativeWorkspaceSnapshot(environment); - const owned = new Set(threads.map((thread) => String(thread.hostThreadId))); + const owned = new Set([ + ...threads.map((thread) => String(thread.hostThreadId)), + ...nativeThreadIds, + ]); const assignments = Object.fromEntries( Object.entries(snapshot.assignments).filter(([id]) => owned.has(id)), ); diff --git a/packages/host-runtime/test/client-channel.test.ts b/packages/host-runtime/test/client-channel.test.ts index bc94530dd..01c8f3cc2 100644 --- a/packages/host-runtime/test/client-channel.test.ts +++ b/packages/host-runtime/test/client-channel.test.ts @@ -206,9 +206,9 @@ describe("public authenticated owner channel", () => { ); await expect( a.request("thread/read", { threadId: "official", includeTurns: false }), - ).rejects.toThrow("not owned"); + ).rejects.toThrow("Only external"); await expect(a.request("thread/resume", { threadId: "official" })).rejects.toThrow( - "not available", + "Only external", ); expect(fixture.adapter.sessions).toHaveLength(0); }); diff --git a/packages/host-runtime/test/fixtures/shared-host-native-codex.ts b/packages/host-runtime/test/fixtures/shared-host-native-codex.ts index 953b33ed7..bd16e9e72 100644 --- a/packages/host-runtime/test/fixtures/shared-host-native-codex.ts +++ b/packages/host-runtime/test/fixtures/shared-host-native-codex.ts @@ -2,7 +2,7 @@ import { appendFileSync, writeFileSync, readFileSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; -import { WebSocketServer } from "ws"; +import { WebSocketServer, type WebSocket } from "ws"; const home = process.env.CODEX_HOME; if (!home) throw new Error("Native fixture requires an isolated Codex home"); @@ -17,12 +17,15 @@ try { /* First startup. */ } const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); +const clients = new Set(); server.on("listening", () => process.stderr.write( `listening on: ws://127.0.0.1:${(server.address() as { port: number }).port}\n`, ), ); server.on("connection", (socket) => { + clients.add(socket); + socket.on("close", () => clients.delete(socket)); socket.on("error", () => undefined); socket.on("message", (data) => { const request = JSON.parse(data.toString()) as { @@ -58,19 +61,27 @@ server.on("connection", (socket) => { status: "completed", items: [{ id: "reply", type: "agentMessage", text: "native turn survived Desktop exit" }], }; - thread = { ...thread, turns: [turn] }; + thread = { + ...thread, + turns: [...(Array.isArray(thread?.turns) ? thread.turns : []), turn], + }; writeFileSync(file, JSON.stringify(thread)); log({ pid: process.pid, type: "turn/completed" }); - if (socket.readyState === socket.OPEN) - socket.send( - JSON.stringify({ method: "turn/completed", params: { threadId: thread.id, turn } }), - ); + const message = JSON.stringify({ + method: "turn/completed", + params: { threadId: thread.id, turn }, + }); + for (const client of clients) if (client.readyState === client.OPEN) client.send(message); }, 400); } else if (request.method === "thread/list") result = { data: thread ? [thread] : [], nextCursor: null }; - else if (request.method === "thread/turns/list") - result = { data: thread?.turns ?? [], nextCursor: null }; - else if (request.method === "model/list") result = { data: [] }; + else if (request.method === "thread/turns/list") { + const turns = Array.isArray(thread?.turns) ? thread.turns : []; + result = { + data: request.params?.sortDirection === "desc" ? [...turns].reverse() : turns, + nextCursor: null, + }; + } else if (request.method === "model/list") result = { data: [] }; socket.send(JSON.stringify({ id: request.id, result })); }); }); diff --git a/packages/host-runtime/test/harness-display-settings.windows.test.ts b/packages/host-runtime/test/harness-display-settings.windows.test.ts new file mode 100644 index 000000000..0abcc3c27 --- /dev/null +++ b/packages/host-runtime/test/harness-display-settings.windows.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import type * as FsPromises from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { HarnessDisplaySettingsStore } from "../src/harness-display-settings.js"; + +vi.mock("node:fs/promises", async (original) => { + const actual = await original(); + return { ...actual, rename: vi.fn(actual.rename) }; +}); +beforeEach(async () => { + const actual = await vi.importActual("node:fs/promises"); + vi.mocked(rename).mockReset().mockImplementation(actual.rename); +}); +afterEach(() => vi.mocked(rename).mockClear()); + +async function fixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "display-replace-")); + const file = path.join(directory, "harness-display-settings-v1.json"); + const previous = JSON.stringify([{ agent: "pi", section: "more" }]); + await writeFile(file, previous); + return { + directory, + file, + previous, + store: new HarnessDisplaySettingsStore({ CODEXHOST_DATA_DIR: directory }), + }; +} +it.runIf(process.platform === "win32")( + "retries only a rejected rename, preserving the old snapshot until replacement", + async () => { + const f = await fixture(); + try { + vi.mocked(rename).mockImplementationOnce(async (_source, target) => { + expect(await readFile(target, "utf8")).toBe(f.previous); + throw Object.assign(new Error("Windows sharing violation"), { code: "EPERM" }); + }); + expect(await f.store.set({ entries: [] })).toEqual({ entries: [] }); + expect(rename).toHaveBeenCalledTimes(2); + expect(vi.mocked(rename).mock.calls[0]).toEqual(vi.mocked(rename).mock.calls[1]); + expect(JSON.parse(await readFile(f.file, "utf8"))).toEqual([]); + } finally { + f.store.close(); + await rm(f.directory, { recursive: true, force: true }); + } + }, +); +it.runIf(process.platform === "win32")( + "bounds sharing-violation retries without deleting the previous snapshot", + async () => { + const f = await fixture(); + try { + vi.mocked(rename).mockRejectedValue( + Object.assign(new Error("Still locked"), { code: "EACCES" }), + ); + await expect(f.store.set({ entries: [] })).rejects.toThrow("Still locked"); + expect(rename).toHaveBeenCalledTimes(6); + expect(await readFile(f.file, "utf8")).toBe(f.previous); + expect(await readdir(f.directory)).toEqual([path.basename(f.file)]); + } finally { + f.store.close(); + await rm(f.directory, { recursive: true, force: true }); + } + }, +); +it("does not retry an unrelated filesystem error", async () => { + const f = await fixture(); + try { + vi.mocked(rename).mockRejectedValue(Object.assign(new Error("Disk failure"), { code: "EIO" })); + await expect(f.store.set({ entries: [] })).rejects.toThrow("Disk failure"); + expect(rename).toHaveBeenCalledTimes(1); + expect(await readFile(f.file, "utf8")).toBe(f.previous); + } finally { + f.store.close(); + await rm(f.directory, { recursive: true, force: true }); + } +}); diff --git a/packages/host-runtime/test/native-client-channel.test.ts b/packages/host-runtime/test/native-client-channel.test.ts new file mode 100644 index 000000000..77441ac45 --- /dev/null +++ b/packages/host-runtime/test/native-client-channel.test.ts @@ -0,0 +1,247 @@ +import { expect, it, vi } from "vitest"; + +import type { JsonObject } from "@codexhost/protocol-core"; + +import { ClientChannelEvents } from "../src/client-channel-events.js"; +import { NativeClientChannel } from "../src/native-client-channel.js"; + +const thread: JsonObject = { + id: "native-thread", + cwd: "/synthetic", + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + turns: [], +}; + +function snapshotChannel() { + const events = new ClientChannelEvents(); + const calls: string[] = []; + const request = vi.fn(async (method: string) => { + calls.push(method); + if (method === "thread/resume") { + events.changed("native-thread", method); + return { result: { thread, model: "gpt-5.4", reasoningEffort: "high" } }; + } + if (method === "thread/read") { + events.changed("native-thread", "turn/completed"); + return { result: { thread } }; + } + if (method === "thread/turns/list") return { result: { data: [], nextCursor: null } }; + throw new Error(`Unexpected native method ${method}`); + }); + const channel = new NativeClientChannel({ + events, + request, + respond: vi.fn(async () => undefined), + }); + return { channel, calls, events }; +} + +it("captures the native snapshot cursor before resume/read I/O and keeps real config", async () => { + const { channel, calls } = snapshotChannel(); + + const first = await channel.snapshot("native-thread", {}); + + expect(first.cursor.sequence).toBe(0); + expect(first.configuration).toEqual({ + native: { model: "gpt-5.4", reasoningEffort: "high" }, + }); + expect(calls).toEqual(["thread/resume", "thread/read", "thread/turns/list"]); + + await channel.snapshot("native-thread", {}); + expect(calls.filter((method) => method === "thread/resume")).toHaveLength(1); + + channel.resetGeneration(); + await channel.snapshot("native-thread", {}); + expect(calls.filter((method) => method === "thread/resume")).toHaveLength(2); +}); + +it("validates native command decisions and preserves first-answer semantics", async () => { + const events = new ClientChannelEvents(); + const respond = vi.fn(async () => undefined); + const channel = new NativeClientChannel({ + events, + request: vi.fn(async () => ({ result: {} })), + respond, + }); + channel.observeMessage({ + id: "approval-1", + method: "item/commandExecution/requestApproval", + params: { + threadId: "native-thread", + command: "echo safe", + availableDecisions: ["acceptForSession", "decline", "cancel"], + }, + }); + + const publicId = channel.interactions("native-thread")[0]?.requestId; + expect(publicId).toBeDefined(); + if (publicId === undefined) throw new Error("Missing native approval"); + await expect(channel.respond(publicId, "native-thread", { decision: "accept" })).rejects.toThrow( + "not available", + ); + expect(respond).not.toHaveBeenCalled(); + expect(channel.interactions("native-thread")).toHaveLength(1); + + await channel.respond(publicId, "native-thread", { decision: "acceptForSession" }); + expect(respond).toHaveBeenCalledWith("approval-1", { decision: "acceptForSession" }); + expect(await channel.respond(publicId, "native-thread", { decision: "decline" })).toBe(false); +}); + +it("rejects stale approval handles after a native backend reuses a request ID", async () => { + const events = new ClientChannelEvents(); + const changed: unknown[] = []; + events.subscribe((event) => changed.push(event)); + const respond = vi.fn(async () => undefined); + const channel = new NativeClientChannel({ + events, + request: vi.fn(async () => ({ result: {} })), + respond, + }); + const approval = { + id: 1, + method: "item/fileChange/requestApproval", + params: { threadId: "native-thread", availableDecisions: ["accept", "decline"] }, + }; + channel.observeMessage(approval); + const oldId = channel.interactions("native-thread")[0]?.requestId; + if (oldId === undefined) throw new Error("Missing old approval"); + channel.observeMessage(approval); + expect(channel.interactions("native-thread")).toHaveLength(1); + expect(channel.interactions("native-thread")[0]?.requestId).toBe(oldId); + channel.resetGeneration(); + expect(changed).toContainEqual( + expect.objectContaining({ threadId: "native-thread", method: "thread/status/changed" }), + ); + channel.observeMessage(approval); + const newId = channel.interactions("native-thread")[0]?.requestId; + if (newId === undefined) throw new Error("Missing new approval"); + expect(newId).not.toBe(oldId); + expect(channel.isStaleRequestId(oldId)).toBe(true); + expect(await channel.respond(oldId, "native-thread", { decision: "accept" })).toBe(false); + expect(respond).not.toHaveBeenCalled(); + expect(await channel.respond(newId, "native-thread", { decision: "accept" })).toBe(true); + expect(respond).toHaveBeenCalledExactlyOnceWith(1, { decision: "accept" }); +}); + +it("discards snapshots and resumed state from a retired backend generation", async () => { + let reset = true; + const events = new ClientChannelEvents(); + const request = vi.fn(async (method: string) => { + if (method === "thread/resume") { + if (reset) { + reset = false; + channel.resetGeneration(); + } + return { result: { thread, model: "provider/model:preview" } }; + } + if (method === "thread/read") return { result: { thread } }; + return { result: { data: [], nextCursor: null } }; + }); + const channel = new NativeClientChannel({ + events, + request, + respond: vi.fn(async () => undefined), + }); + await expect(channel.snapshot("native-thread", {})).rejects.toMatchObject({ code: -32094 }); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]); + const snapshot = await channel.snapshot("native-thread", {}); + expect(request.mock.calls.filter(([method]) => method === "thread/resume")).toHaveLength(2); + expect(snapshot.configuration.native?.model).toBe("provider/model:preview"); +}); + +it("does not expose native permission grants as generic approvals", () => { + const channel = new NativeClientChannel({ + events: new ClientChannelEvents(), + request: vi.fn(async () => ({ result: {} })), + respond: vi.fn(async () => undefined), + }); + channel.observeMessage({ + id: "permission-1", + method: "item/permissions/requestApproval", + params: { threadId: "native-thread", permissions: { read: ["/synthetic"] } }, + }); + expect(channel.interactions("native-thread")).toEqual([]); +}); + +it("reads the native isOther flag for custom answers", () => { + const channel = new NativeClientChannel({ + events: new ClientChannelEvents(), + request: vi.fn(async () => ({ result: {} })), + respond: vi.fn(async () => undefined), + }); + channel.observeMessage({ + id: 4, + method: "item/tool/requestUserInput", + params: { + threadId: "native-thread", + questions: [ + { id: "choice", question: "Choose or type", isOther: true, options: [{ label: "Option" }] }, + ], + }, + }); + expect(channel.interactions("native-thread")[0]?.interaction).toMatchObject({ + type: "question", + questions: [{ id: "choice", allowOther: true }], + }); +}); + +it("does not present a native secret question in an unmasked browser field", () => { + const channel = new NativeClientChannel({ + events: new ClientChannelEvents(), + request: vi.fn(async () => ({ result: {} })), + respond: vi.fn(async () => undefined), + }); + channel.observeMessage({ + id: 8, + method: "item/tool/requestUserInput", + params: { + threadId: "native-thread", + questions: [{ id: "secret", question: "Secret input", isSecret: true, options: null }], + }, + }); + expect(channel.interactions("native-thread")).toEqual([]); +}); + +it("withdraws a resolved native card and invalidates its Thread without a Thread ID in the notification", () => { + const events = new ClientChannelEvents(); + const updates: unknown[] = []; + events.subscribe((value) => updates.push(value)); + const channel = new NativeClientChannel({ + events, + request: vi.fn(async () => ({ result: {} })), + respond: vi.fn(async () => undefined), + }); + channel.observeMessage({ + id: "projected-native-id", + method: "item/fileChange/requestApproval", + params: { threadId: "native-thread" }, + }); + const before = updates.length; + channel.observeMessage({ + method: "serverRequest/resolved", + params: { requestId: "projected-native-id" }, + }); + expect(channel.interactions("native-thread")).toEqual([]); + expect(updates.slice(before)).toContainEqual( + expect.objectContaining({ threadId: "native-thread", method: "serverRequest/resolved" }), + ); +}); + +it("drops native pending interactions and subscriptions on backend generation reset", () => { + const channel = new NativeClientChannel({ + events: new ClientChannelEvents(), + request: vi.fn(async () => ({ result: {} })), + respond: vi.fn(async () => undefined), + }); + channel.observeMessage({ + id: "approval-2", + method: "item/fileChange/requestApproval", + params: { threadId: "native-thread", availableDecisions: ["accept", "decline"] }, + }); + expect(channel.interactions("native-thread")).toHaveLength(1); + channel.resetGeneration(); + expect(channel.interactions("native-thread")).toEqual([]); +}); diff --git a/packages/host-runtime/test/native/desktop-bridge.test.ts b/packages/host-runtime/test/native/desktop-bridge.test.ts index e4224a105..764b3cc4c 100644 --- a/packages/host-runtime/test/native/desktop-bridge.test.ts +++ b/packages/host-runtime/test/native/desktop-bridge.test.ts @@ -200,19 +200,49 @@ it( JSON.stringify(await second.request("thread/read", { threadId: id, includeTurns: true })), /native turn survived Desktop exit/u, ); + const web2 = new HostClientChannel(path.join(data, "client-hosts"), endpoint); + t.after(() => web2.close()); + const web2Events: Array<{ type: string; threadId?: string }> = []; + web2.subscribe((event) => web2Events.push(event)); + await web2.start(); + const listed = await web.request<{ data: Array<{ id: string; modelProvider?: string }> }>( + "thread/list", + {}, + ); + assert.ok(listed.data.some((entry) => entry.id === id)); + const observed = await web.snapshot(id); + assert.equal(observed.thread.id, id); + assert.equal(observed.thread.modelProvider, "openai"); + assert.match(JSON.stringify(observed), /native turn survived Desktop exit/u); + await web.request("turn/start", { + threadId: id, + input: [{ type: "text", text: "native Web follow-up" }], + clientUserMessageId: "web-native-follow-up", + }); + const followUpDeadline = Date.now() + 10_000; + while ( + !(await readFile(path.join(codexHome, "native-calls.jsonl"), "utf8")).match( + /turn\/completed[\s\S]*turn\/completed/u, + ) + ) { + assert.ok(Date.now() < followUpDeadline, "Web native Turn did not finish"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.ok(web2Events.some((event) => event.type === "changed" && event.threadId === id)); + const final = await web2.snapshot(id); + assert.match(JSON.stringify(final), /native turn survived Desktop exit/u); + assert.equal(final.turnsPage.data.length, 2); const log = (await readFile(path.join(codexHome, "native-calls.jsonl"), "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line) as { type: string; feature?: boolean }); assert.equal(log.filter((entry) => entry.type === "startup").length, 1); assert.equal(log.find((entry) => entry.type === "startup")?.feature, true); - assert.equal(log.filter((entry) => entry.type === "turn/start").length, 1); + assert.equal(log.filter((entry) => entry.type === "turn/start").length, 2); assert.equal( (await discoverHostClientChannel(path.join(data, "client-hosts")))?.pid, endpoint.pid, ); - // Independent lifecycle does not broaden Web into the official Codex path. - await assert.rejects(web.snapshot(id), /external|owned/u); const secondExited = once(second.child, "exit"); second.child.stdin.end(); assert.deepEqual(await secondExited, [0, null]); diff --git a/packages/host-runtime/test/native/shared-host.test.ts b/packages/host-runtime/test/native/shared-host.test.ts index 4604c6d2a..502f293c2 100644 --- a/packages/host-runtime/test/native/shared-host.test.ts +++ b/packages/host-runtime/test/native/shared-host.test.ts @@ -146,6 +146,10 @@ for (const first of ["Web", "Desktop"]) return client; }; const web = await connect(); + // This fixture deliberately uses Node as the unavailable stock Codex. + // Native discovery must not take the shared external catalog offline. + const catalog = await web.request<{ data: Array<{ id: string }> }>("thread/list", {}); + assert.ok(Array.isArray(catalog.data)); const desktop = await connectLocalSharedHost({ launcher, runtime, dataDirectory: data }); t.after(() => desktop.close()); const id = diff --git a/packages/host-runtime/test/official-runtime-owner.test.ts b/packages/host-runtime/test/official-runtime-owner.test.ts index 6a3f766c3..c6dd49d86 100644 --- a/packages/host-runtime/test/official-runtime-owner.test.ts +++ b/packages/host-runtime/test/official-runtime-owner.test.ts @@ -154,6 +154,48 @@ const initialization = { capabilities: { experimentalApi: true }, }; +it("projects resolution IDs per client and retires another client's answered request", async () => { + const f = fixture(); + const a = f.attach(), + b = f.attach(); + try { + await f.owner.start(); + await a.client.initialize(initialization); + await b.client.initialize(initialization); + f.gate.initialized(); + const request = { + id: 17, + method: "item/commandExecution/requestApproval", + params: { threadId: "thread-one" }, + }; + f.connection(0, 0).emit(request); + f.connection(0, 1).emit(request); + await vi.waitFor(() => + expect(b.output.some((message) => message.method === request.method)).toBe(true), + ); + const aId = a.output.find((message) => message.method === request.method)?.id; + const bId = b.output.find((message) => message.method === request.method)?.id; + if (typeof aId !== "string" || typeof bId !== "string") + throw new Error("Missing projected request"); + expect(aId).not.toBe(bId); + await a.client.send({ id: aId, result: { decision: "accept" } }); + f.connection(0, 1).emit({ method: "serverRequest/resolved", params: { requestId: 17 } }); + await vi.waitFor(() => + expect(b.output).toContainEqual({ + method: "serverRequest/resolved", + params: { requestId: bId }, + }), + ); + await expect(b.client.send({ id: bId, result: { decision: "accept" } })).rejects.toThrow( + "Retired", + ); + } finally { + await f.owner.stop(); + a.client.close(); + b.client.close(); + } +}); + describe("owned loopback backend", () => { it("requires readiness, hashes capability authentication, and discards managed stderr", async () => { const child = Object.assign(new EventEmitter(), { diff --git a/packages/host-runtime/test/projectless-workspace.test.ts b/packages/host-runtime/test/projectless-workspace.test.ts index c20663f46..2699dbb2f 100644 --- a/packages/host-runtime/test/projectless-workspace.test.ts +++ b/packages/host-runtime/test/projectless-workspace.test.ts @@ -82,6 +82,30 @@ describe("owner-allocated projectless execution directories", () => { } }); + it("retains requested native project assignments without importing or rewriting them", async () => { + const home = await homeDirectory(); + const file = path.join(home, ".codex-global-state.json"); + const original = JSON.stringify({ + "project-order": ["project"], + "local-projects": { project: { id: "project", name: "Product", rootPaths: ["/product"] } }, + "thread-project-assignments": { + "native-worktree": { projectKind: "local", projectId: "project" }, + "not-requested": { projectKind: "local", projectId: "project" }, + }, + "projectless-thread-ids": ["native-projectless", "other-projectless"], + }); + await writeFile(file, original); + const snapshot = await readExternalWorkspaceSnapshot( + { CODEX_HOME: home }, + [], + [], + ["native-worktree", "native-projectless"], + ); + expect(snapshot.assignments).toEqual({ "native-worktree": "project" }); + expect(snapshot.projectless).toEqual(["native-projectless"]); + expect(await readFile(file, "utf8")).toBe(original); + }); + it("keeps no-project affiliation in the canonical store across reload, not GUI metadata", async () => { const home = await homeDirectory(); const codexHome = path.join(home, ".codex"); diff --git a/packages/shared-contracts/src/client-channel.ts b/packages/shared-contracts/src/client-channel.ts index f3f93e4ce..fbf752725 100644 --- a/packages/shared-contracts/src/client-channel.ts +++ b/packages/shared-contracts/src/client-channel.ts @@ -17,6 +17,7 @@ export const CLIENT_CHANNEL_METHODS = [ "codexhost/harness/preferences/patch", "thread/list", "thread/read", + "thread/resume", "thread/turns/list", "thread/revert", "thread/items/list", @@ -240,7 +241,8 @@ export const clientThreadSnapshotSchema = z.object({ .object({ id: z.string().min(1), cwd: z.string(), - modelProvider: z.literal("codexhost"), + /** External rows use codexhost; native Codex rows retain their official provider. */ + modelProvider: z.string().min(1), createdAt: z.number(), updatedAt: z.number(), status: z.object({ type: z.string() }).passthrough(), @@ -255,6 +257,13 @@ export const clientThreadSnapshotSchema = z.object({ nextCursor: z.string().nullable(), }), configuration: z.object({ + /** Native Codex model names are not external Harness transport refs. */ + native: z + .object({ + model: z.string().min(1).max(1024).optional(), + reasoningEffort: z.string().min(1).max(128).optional(), + }) + .optional(), effectiveModel: harnessModelRefSchema.optional(), effectiveThinkingOptionId: harnessThinkingOptionIdSchema.optional(), effectivePermissionModeId: harnessPermissionModeIdSchema.optional(), diff --git a/packages/web-server/src/ch-harness-catalog.ts b/packages/web-server/src/ch-harness-catalog.ts index 78faceee7..f56bb8baa 100644 --- a/packages/web-server/src/ch-harness-catalog.ts +++ b/packages/web-server/src/ch-harness-catalog.ts @@ -101,6 +101,7 @@ export class ChHarnessCatalog { return this.preferences ? this.preferences.decorate(catalog) : catalog; } async name(id: string): Promise { + if (id === "codex") return "Codex"; return (await this.list()).find((plugin) => plugin.id === id)?.name ?? id; } async icon(id: string): Promise<{ body: Buffer; contentType: string } | undefined> { diff --git a/packages/web-server/src/ch-interactions.ts b/packages/web-server/src/ch-interactions.ts index c002a8221..8f574ea6e 100644 --- a/packages/web-server/src/ch-interactions.ts +++ b/packages/web-server/src/ch-interactions.ts @@ -39,14 +39,26 @@ function presentation(pending: ClientInteraction): { }, ], result(value) { + const nativeApproval = + pending.request.method === "item/commandExecution/requestApproval" || + pending.request.method === "item/fileChange/requestApproval"; const answer = (value as Answer | undefined)?.answers?.find( (entry) => entry.id === "approval", ); - if (value == null || (answer?.selected?.length === 0 && !answer.custom)) + if (value == null || (answer?.selected?.length === 0 && !answer.custom)) { + if (nativeApproval) { + const deny = + interaction.actions.find((action) => action.id === "decline") ?? + interaction.actions.find((action) => action.id === "cancel"); + if (!deny) throw new Error("Choose one of the native approval actions"); + return { decision: deny.id }; + } return { action: "decline" }; + } const action = interaction.actions[labels.indexOf(answer?.selected?.[0] ?? "")]; if (!action || answer?.custom || answer?.selected?.length !== 1) throw new Error("Choose one of the native approval actions"); + if (nativeApproval) return { decision: action.id }; const params = record(pending.request.params); if ("actionId" in record(record(params.requestedSchema).properties)) return { action: "accept", content: { actionId: action.id } }; diff --git a/packages/web-server/src/ch-sessions.ts b/packages/web-server/src/ch-sessions.ts index 364c28413..03c9156e0 100644 --- a/packages/web-server/src/ch-sessions.ts +++ b/packages/web-server/src/ch-sessions.ts @@ -195,12 +195,11 @@ export class ChSessions { const page: { data: ChThread[]; nextCursor: string | null } = await this.host.request<{ data: ChThread[]; nextCursor: string | null; - }>("thread/list", { limit: 200, cursor, archived, modelProviders: ["codexhost"] }); - for (const row of page.data) - if (row.modelProvider === "codexhost") { - found.set(row.id, row); - if (archived) archivedIds.add(row.id); - } + }>("thread/list", { limit: 200, cursor, archived }); + for (const row of page.data) { + found.set(row.id, row); + if (archived) archivedIds.add(row.id); + } cursor = page.nextCursor; if (cursor && seen.has(cursor)) throw new RpcError("host/pagination-invalid", "CH cursor did not advance."); @@ -213,9 +212,15 @@ export class ChSessions { const result = await this.host.request<{ threads: Array<{ threadId: string; owner: string; harnessId?: string }>; }>("codexhost/thread/ownership/list", { threadIds: ids.slice(start, start + 100) }); - for (const item of result.threads) - if (item.owner === "external" && item.harnessId) + for (const item of result.threads) { + if ( + item.owner === "external" && + item.harnessId && + found.get(item.threadId)?.modelProvider === "codexhost" + ) ownership.set(item.threadId, item.harnessId); + else if (item.owner === "codex") ownership.set(item.threadId, "codex"); + } } const rows = new Map([...found].filter(([id]) => ownership.has(id))); for (const [id, row] of this.rows) { @@ -308,11 +313,16 @@ export class ChSessions { const inspection = threadInspectionSchema.parse( await this.host.request("codexhost/thread/inspect", { threadId: this.id(id) }), ); - if (inspection.owner !== "external") - throw new RpcError( - "session/not-external", - "This Web connection only handles external Harness Threads.", - ); + // Native Codex retains its identity and is routed by the shared Host. It is + // not imported into the external codex-cli Adapter merely to use Web. + if (inspection.owner === "codex") + return { + ...inspection, + harnessId: "codex", + effectiveModel: undefined, + effectiveThinkingOptionId: undefined, + effectivePermissionModeId: undefined, + }; return inspection; } private view(id: string, reuseIdle = false): Promise { @@ -363,7 +373,7 @@ export class ChSessions { if (!row) throw new RpcError("session/not-found", "CH Thread not found.", { sessionId: id }); const harnessId = this.threads.harnessId(id); if (!harnessId) - throw new RpcError("session/not-external", "CH did not confirm external Thread ownership."); + throw new RpcError("session/ownership-unconfirmed", "CH did not confirm Thread ownership."); let view = this.threads.view(id); if (!view) { const history: ChThreadHistory = new ChThreadHistory( @@ -413,7 +423,9 @@ export class ChSessions { throw new RpcError("host/resync", "Host snapshot was superseded by a new generation"); view.log.setProjection( "nativeDiscovery", - !view.thread.parentThreadId && view.thread.canAcceptDirectInput !== false, + harnessId !== "codex" && + !view.thread.parentThreadId && + view.thread.canAcceptDirectInput !== false, ); if (view.ownerSnapshot && this.hostOnline && !this.closed) this.interactions?.update(view.ownerSnapshot); @@ -476,6 +488,28 @@ export class ChSessions { configurationRevision: number, ): void { const id = view.thread.id; + if (harnessId === "codex") { + // Do not apply external Harness defaults to an existing native Thread. + // Its model and approval policy continue to be owned by app-server. + view.log.setProjection("sentMessageEdit", { seq: null }); + const configuration = view.ownerSnapshot?.configuration; + const selection = { + provider: "codex", + model: configuration?.native?.model ?? "harness", + ...(configuration?.native?.reasoningEffort + ? { reasoningEffort: configuration.native.reasoningEffort } + : {}), + }; + view.log.setProjection("modelSelection", { lastUsed: selection, next: selection }); + view.log.setProjection("nativePermissions", { + harnessId: "codex", + catalog: null, + scope: "live", + selectable: false, + locked: true, + }); + return; + } if ((this.threads.get(id)?.configurationRevision ?? 0) !== configurationRevision) return; if (!force && this.threads.get(id)?.controls) return; // Runtime inspection/config discovery must not gate the history snapshot. @@ -531,7 +565,7 @@ export class ChSessions { private async summary(row: ChThread) { const harnessId = this.threads.harnessId(row.id); if (!harnessId) - throw new RpcError("session/not-external", "CH did not confirm external Thread ownership."); + throw new RpcError("session/ownership-unconfirmed", "CH did not confirm Thread ownership."); const values = { ...defaultProjections(), ...globalProjectionValues(this.threads.view(row.id)?.log.projections ?? {}), @@ -541,7 +575,8 @@ export class ChSessions { imageLimits: WEB_IMAGE_LIMITS, } : {}), - nativeDiscovery: !row.parentThreadId && row.canAcceptDirectInput !== false, + nativeDiscovery: + harnessId !== "codex" && !row.parentThreadId && row.canAcceptDirectInput !== false, threadReference: { hostId: "local" }, title: threadListTitle(row), codexhostImportSource: row.codexhostImportSource ?? null, @@ -710,7 +745,7 @@ export class ChSessions { /** * Working directory of one Host-confirmed local Thread. * - * Drafts, official Codex Threads, and remote Threads have no local directory + * Drafts and remote Threads have no local directory * here: file access reports them unavailable rather than inventing one. * @param sessionId - Session identity the browser addressed. * @returns the Host-recorded directory, or undefined when this Thread has none. @@ -932,6 +967,11 @@ export class ChSessions { return {}; } const owner = await this.owner(request.sessionId); + if (owner.owner === "codex") + throw new RpcError( + "session/model-unavailable", + "This native Codex conversation keeps its app-server settings. Change its model in Desktop.", + ); if (owner.harnessId !== request.provider) throw new RpcError( "session/model-unavailable", @@ -1315,7 +1355,9 @@ export class ChSessions { throw new RpcError("command/unavailable", "Commands require an existing external Thread."); if (this.closed || !this.hostOnline) throw new RpcError("host/offline", "The Host is disconnected."); - await this.owner(id); + const owner = await this.owner(id); + if (owner.owner !== "external") + throw new RpcError("command/unavailable", "Harness commands require an external Thread."); const { thread } = await this.host.request<{ thread: ChThread }>("thread/read", { threadId: id, includeTurns: false, diff --git a/packages/web-server/src/ui-extension-install.ts b/packages/web-server/src/ui-extension-install.ts index 598f7c8b1..477e85888 100644 --- a/packages/web-server/src/ui-extension-install.ts +++ b/packages/web-server/src/ui-extension-install.ts @@ -2,7 +2,11 @@ import { execFile } from "node:child_process"; import { stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { commandInvocation, withNodeRuntimeOnPath } from "@codexhost/harness-discovery"; +import { + commandInvocation, + resolveHarnessExecutable, + withNodeRuntimeOnPath, +} from "@codexhost/harness-discovery"; import { webUiExtensionRegistrySchema } from "@codexhost/shared-contracts"; import semver from "semver"; import { parseInstallSpec } from "./dsh-install-spec.ts"; @@ -111,9 +115,16 @@ export async function installExtensionPackage( } environment.npm_config_ignore_scripts = "true"; const scope = /^(@[^/]+)\//u.exec(spec)?.[1]; + // Resolve before entering the package directory: a bare npm.cmd can derive + // its own prefix from cwd on Windows rather than from the installed shim. + const npm = resolveHarnessExecutable( + { id: "npm", command: process.platform === "win32" ? "npm.cmd" : "npm" }, + { environment }, + ); + if (!npm) throw new Error("npm is unavailable on the installation PATH."); // Command-line options override inherited npm configuration for execution and installation scope. const invocation = commandInvocation( - process.platform === "win32" ? "npm.cmd" : "npm", + npm.executable, [ "install", "--prefix", diff --git a/packages/web-server/test/access-tokens-http.test.ts b/packages/web-server/test/access-tokens-http.test.ts index b4f5c6ab1..0abc67423 100644 --- a/packages/web-server/test/access-tokens-http.test.ts +++ b/packages/web-server/test/access-tokens-http.test.ts @@ -84,7 +84,6 @@ async function start( environment?: NodeJS.ProcessEnv, ): Promise<{ base: URL; data: string; access(): Promise }> { const data = mkdtempSync(join(tmpdir(), "codexhost-auth-http-")); - t.after(() => rmSync(data, { recursive: true, force: true })); const url = await startServer( t, resolve(import.meta.dirname, ".."), @@ -92,6 +91,7 @@ async function start( ["--data", data, "--token", LOCAL_TOKEN, ...args], { authenticated: true, ...(environment === undefined ? {} : { environment }) }, ); + t.after(() => rmSync(data, { recursive: true, force: true })); const base = new URL(url); base.search = ""; return { @@ -541,7 +541,6 @@ it( { timeout: 60_000 }, async (t) => { const directory = mkdtempSync(join(tmpdir(), "codexhost-auth-revoke-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); const workspace = join(directory, "workspace"); mkdirSync(workspace); const host = new FakeChChannel(); @@ -556,6 +555,7 @@ it( "--ch-control-directory", directory, ]); + t.after(() => rmSync(directory, { recursive: true, force: true })); const device = (await access()).secret; const openSockets = async (secret: string) => { @@ -588,78 +588,96 @@ it( }); await new Promise((settled) => setTimeout(settled, 300)); - const terminal = resultOf( - await rpcCall( - base, - "terminal/create", - { agentId: "local-thread", request: { id: "term", cols: 80, rows: 24 } }, - bearer(device), - ), - ); - assert.equal(terminal.ok, true, JSON.stringify(terminal)); - - const attachedToTerminal = (socket: WebSocket): Promise => - new Promise((done) => { - const streamId = `attach-${String(Math.random())}`; - const timer = setTimeout(() => done(false), 10_000); - socket.on("message", (data) => { - const frame = JSON.parse(String(data)) as { - streamId?: string; - value?: { type?: string }; - }; - if (frame.streamId !== streamId || frame.value?.type !== "snapshot") return; - clearTimeout(timer); - done(true); + let terminalCreated = false; + try { + const terminal = resultOf( + await rpcCall( + base, + "terminal/create", + { agentId: "local-thread", request: { id: "term", cols: 80, rows: 24 } }, + bearer(device), + ), + ); + assert.equal(terminal.ok, true, JSON.stringify(terminal)); + terminalCreated = true; + + const attachedToTerminal = (socket: WebSocket): Promise => + new Promise((done) => { + const streamId = `attach-${String(Math.random())}`; + const timer = setTimeout(() => done(false), 10_000); + socket.on("message", (data) => { + const frame = JSON.parse(String(data)) as { + streamId?: string; + value?: { type?: string }; + }; + if (frame.streamId !== streamId || frame.value?.type !== "snapshot") return; + clearTimeout(timer); + done(true); + }); + socket.send( + JSON.stringify({ + type: "open", + streamId, + endpoint: "terminal/attach", + payload: { args: { agentId: "local-thread", id: "term" } }, + }), + ); }); - socket.send( - JSON.stringify({ - type: "open", - streamId, - endpoint: "terminal/attach", - payload: { args: { agentId: "local-thread", id: "term" } }, - }), - ); - }); - assert.equal(await attachedToTerminal(socketsA[0] as WebSocket), true); + assert.equal(await attachedToTerminal(socketsA[0] as WebSocket), true); - const closedA = socketsA.map((socket) => once(socket, "close")); - const regenerated = resultOf( - await rpcCall(base, "codexhost/accessTokenRegenerate", {}, bearer(LOCAL_TOKEN)), - ); - assert.equal(regenerated.ok, true, JSON.stringify(regenerated)); - await Promise.all(closedA); - await inFlight; - assert.equal((await send(base, "/api/auth/status", { headers: bearer(device) })).status, 401); - for (const socket of socketsB) assert.equal(socket.readyState, WebSocket.OPEN); - assert.equal( - (await send(base, "/api/auth/status", { headers: bearer(LOCAL_TOKEN) })).status, - 200, - ); + const closedA = socketsA.map((socket) => once(socket, "close")); + const regenerated = resultOf( + await rpcCall(base, "codexhost/accessTokenRegenerate", {}, bearer(LOCAL_TOKEN)), + ); + assert.equal(regenerated.ok, true, JSON.stringify(regenerated)); + await Promise.all(closedA); + await inFlight; + assert.equal((await send(base, "/api/auth/status", { headers: bearer(device) })).status, 401); + for (const socket of socketsB) assert.equal(socket.readyState, WebSocket.OPEN); + assert.equal( + (await send(base, "/api/auth/status", { headers: bearer(LOCAL_TOKEN) })).status, + 200, + ); - // The terminal that token opened keeps running for whoever is still allowed in. - assert.equal(await attachedToTerminal(socketsB[0] as WebSocket), true); - assert.equal( - host.requests.some(({ method }) => /interrupt|cancel|archive/iu.test(method)), - false, - ); + // The terminal that token opened keeps running for whoever is still allowed in. + assert.equal(await attachedToTerminal(socketsB[0] as WebSocket), true); + assert.equal( + host.requests.some(({ method }) => /interrupt|cancel|archive/iu.test(method)), + false, + ); - // Replaced by another process sharing the data directory: new requests fail at once, - // and connections close within the replacement check interval. - const replacement = (regenerated.value as unknown as AccessState).secret; - const socketsC = await openSockets(replacement); - t.after(() => { - for (const socket of socketsC) socket.terminate(); - }); - const other = new AccessTokenStore(new DataDir(data)); - t.after(() => other.close()); - const closedC = socketsC.map((socket) => once(socket, "close")); - const started = Date.now(); - other.regenerate(); - assert.equal( - (await send(base, "/api/auth/status", { headers: bearer(replacement) })).status, - 401, - ); - await Promise.all(closedC); - assert.ok(Date.now() - started < 7_000); + // Replaced by another process sharing the data directory: new requests fail at once, + // and connections close within the replacement check interval. + const replacement = (regenerated.value as unknown as AccessState).secret; + const socketsC = await openSockets(replacement); + t.after(() => { + for (const socket of socketsC) socket.terminate(); + }); + const other = new AccessTokenStore(new DataDir(data)); + t.after(() => other.close()); + const closedC = socketsC.map((socket) => once(socket, "close")); + const started = Date.now(); + other.regenerate(); + assert.equal( + (await send(base, "/api/auth/status", { headers: bearer(replacement) })).status, + 401, + ); + await Promise.all(closedC); + assert.ok(Date.now() - started < 7_000); + } finally { + if (terminalCreated) { + // Server termination on Windows does not gracefully close its PTY child. + // Dispose this fixture's terminal before after-hooks remove its cwd. + const closed = resultOf( + await rpcCall( + base, + "terminal/close", + { agentId: "local-thread", id: "term" }, + bearer(LOCAL_TOKEN), + ), + ); + assert.equal(closed.ok, true, JSON.stringify(closed)); + } + } }, ); diff --git a/packages/web-server/test/browser/ch-native-codex.test.ts b/packages/web-server/test/browser/ch-native-codex.test.ts new file mode 100644 index 000000000..a3548b5ba --- /dev/null +++ b/packages/web-server/test/browser/ch-native-codex.test.ts @@ -0,0 +1,127 @@ +/** Browser contract test: the fixture represents one native backend shared with Desktop. */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { chromium } from "playwright"; +import { FakeChChannel, startFakeChChannel } from "../support/ch-channel.ts"; +import { nativeCodex } from "../support/native-codex.ts"; +import { startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; + +it( + "native Codex appears without import and shares live history with desktop and mobile browsers", + { timeout: 60_000 }, + async (t) => { + const directory = mkdtempSync(join(tmpdir(), "web-native-browser-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const host = nativeCodex(new FakeChChannel()); + const row = host.add("native-original", "/computer/shared", "Original native Codex chat"); + row.modelProvider = "openai"; + const channel = await startFakeChChannel(host, directory); + t.after(() => channel.close()); + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + [ + "--session-source", + "codexhost", + "--ch-control-directory", + directory, + "--ch-cdp", + debuggerServer.endpoint, + ], + ); + const browser = await chromium.launch({ + ...(process.env.CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH + ? { executablePath: process.env.CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH } + : {}), + }); + t.after(() => browser.close()); + const pages = [ + await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: "en-US" }), + await browser.newPage({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + locale: "en-US", + }), + ]; + const errors: string[] = []; + for (const page of pages) { + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await page.waitForLoadState("networkidle"); + const sidebar = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (await sidebar.first().isVisible()) await sidebar.first().click(); + const folder = page.getByRole("treeitem").filter({ hasText: /^shared$/u }); + await folder.waitFor({ timeout: 5000 }).catch(async (error: unknown) => { + throw new Error( + `${String(error)}\n${await page.locator("body").innerText()}\n${errors.join("\n")}`, + ); + }); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + await page.locator('[data-row-key="session:native-original"]').click(); + await page.getByText("existing CH answer", { exact: true }).waitFor(); + assert.equal(await page.locator("[data-codexhost-import-source]").count(), 0); + } + row.turns.push({ + id: "desktop-turn", + status: "inProgress", + items: [ + { + id: "desktop-user", + type: "userMessage", + content: [{ type: "text", text: "Sent in Desktop" }], + }, + { id: "desktop-answer", type: "agentMessage", text: "Desktop streamed answer" }, + ], + }); + row.status = { type: "active" }; + host.changed(row.id, "turn/started"); + for (const page of pages) + await page.getByText("Desktop streamed answer", { exact: true }).waitFor({ timeout: 3000 }); + const desktopTurn = row.turns.at(-1); + assert.ok(desktopTurn); + desktopTurn.status = "completed"; + row.status = { type: "idle" }; + host.changed(row.id, "turn/completed"); + for (const [index, page] of pages.entries()) { + await page + .getByRole("textbox", { + name: "Message or run a task, / commands, @ files or sessions", + exact: true, + }) + .fill(`Native follow-up ${index}`); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + for (const observer of pages) { + await observer + .getByText(`CH response: Native follow-up ${index}`, { exact: true }) + .waitFor({ timeout: 5000 }); + assert.equal( + await observer + .getByText(`CH response: Native follow-up ${index}`, { exact: true }) + .count(), + 1, + ); + } + } + assert.equal(host.requests.filter((r) => r.method === "turn/start").length, 2); + assert.ok( + host.requests + .filter((r) => r.method === "turn/start") + .every((r) => r.params.threadId === row.id), + ); + assert.equal( + host.requests.some( + (r) => r.method === "thread/start" || r.method.includes("session-import/import"), + ), + false, + ); + assert.deepEqual(errors, []); + }, +); diff --git a/packages/web-server/test/ch-native-approvals.test.ts b/packages/web-server/test/ch-native-approvals.test.ts new file mode 100644 index 000000000..4564e1373 --- /dev/null +++ b/packages/web-server/test/ch-native-approvals.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { it } from "node:test"; +import { ChInteractions } from "../src/ch-interactions.ts"; +import { EventHub, type StreamSink } from "../src/transport.ts"; +import { FakeChChannel } from "./support/ch-channel.ts"; + +for (const method of ["item/commandExecution/requestApproval", "item/fileChange/requestApproval"]) + for (const [decision, effect] of [ + ["accept", "allowOnce"], + ["acceptForSession", "allowForSession"], + ["cancel", "deny"], + ] as const) + it(`preserves the exact native ${decision} response for ${method}`, async (t) => { + const root = mkdtempSync(join(tmpdir(), "web-native-approval-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChChannel(); + host.add("native", root); + host.pending = [ + { + requestId: "native-request", + threadId: "native", + kind: "approval", + interaction: { + type: "approval", + title: "Native action", + actions: [{ id: decision, label: decision, effect }], + }, + request: { method, params: { availableDecisions: [decision] } }, + }, + ]; + const events = new EventHub(root); + const frames: Array> = []; + const sink: StreamSink = { + id: "native-client", + closed: false, + push: (frame) => frames.push(frame as Record), + end() {}, + fail() {}, + onClose() {}, + }; + events.handler({}, sink); + const interactions = new ChInteractions(host.realtime, events, () => {}); + t.after(() => interactions.clear()); + interactions.update(await host.snapshot("native")); + const request = frames.find((frame) => frame.type === "waterfall"); + assert.ok(request); + events.settle({ + clientId: String(frames[0]?.clientId), + eventId: String(request.eventId), + outcome: { + kind: "returned", + value: { answers: [{ id: "approval", selected: [decision] }] }, + }, + }); + for (let i = 0; i < 100 && !host.answers.length; i++) await delay(10); + assert.deepEqual(host.answers[0]?.result, { decision }); + }); diff --git a/packages/web-server/test/ch-native-codex.test.ts b/packages/web-server/test/ch-native-codex.test.ts new file mode 100644 index 000000000..e01aa86b6 --- /dev/null +++ b/packages/web-server/test/ch-native-codex.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it } from "node:test"; +import { DataDir } from "../src/store.ts"; +import { Workspaces } from "../src/workspaces.ts"; +import { EventHub, RpcRegistry, StreamRegistry } from "../src/transport.ts"; +import { ChSessions } from "../src/ch-sessions.ts"; +import { FakeChHost } from "./support/ch-host.ts"; +import { nativeCodex } from "./support/native-codex.ts"; + +it("lists native and external Threads and continues native history with its original identity", async (t) => { + const root = mkdtempSync(join(tmpdir(), "web-native-codex-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = nativeCodex(new FakeChHost()); + host.add("native-existing", root).modelProvider = "openai"; + host.add("native-other-provider", root).modelProvider = "custom-provider"; + host.add("external-existing", root); + host.add("native-archived", root).modelProvider = "openai"; + host.archivedIds.add("native-archived"); + const workspaces = new Workspaces(new DataDir(root), root); + const sessions = new ChSessions(host, workspaces, new EventHub(root)); + t.after(() => sessions.close()); + const rpc = new RpcRegistry(); + sessions.register(rpc, new StreamRegistry()); + async function call(method: string, request = {}) { + const result = await rpc.dispatch(method, { args: { request } }); + assert.ok(result.ok, result.ok ? undefined : result.error.message); + return result.value as T; + } + const list = await call<{ + items: Array<{ sessionId: string; projections: { values: Record } }>; + }>("session/list"); + assert.deepEqual(list.items.map((r) => r.sessionId).sort(), [ + "external-existing", + "native-archived", + "native-existing", + "native-other-provider", + ]); + assert.deepEqual( + list.items.find((r) => r.sessionId === "native-existing")?.projections.values.harnessIdentity, + { id: "codex", name: "Codex" }, + ); + assert.ok(workspaces.archivedSessionIds().includes("native-archived")); + assert.ok( + host.requests + .filter((r) => r.method === "thread/list") + .every((r) => r.params.modelProviders === undefined), + ); + const projections = await call<{ values: Record }>("session/projections", { + sessionId: "native-existing", + }); + assert.deepEqual(projections.values.threadReference, { hostId: "local" }); + await call("session/prompt", { + sessionId: "native-existing", + requestId: "native-followup", + content: [{ type: "text", text: "continue here" }], + }); + const starts = host.requests.filter((r) => r.method === "turn/start"); + assert.equal(starts.length, 1); + assert.equal(starts[0]?.params.threadId, "native-existing"); + assert.equal(starts[0]?.params.model, undefined); + assert.equal(starts[0]?.params.approvalPolicy, undefined); + assert.equal(host.threads.get("native-existing")?.turns.length, 2); + assert.equal( + host.requests.some( + (r) => r.method === "thread/start" || r.method.includes("session-import/import"), + ), + false, + ); + await call("session/rename", { sessionId: "native-existing", title: "same native chat" }); + assert.equal(host.threads.get("native-existing")?.name, "same native chat"); +}); diff --git a/packages/web-server/test/ch-turn-diffs.test.ts b/packages/web-server/test/ch-turn-diffs.test.ts index 998642f27..dd8b953b5 100644 --- a/packages/web-server/test/ch-turn-diffs.test.ts +++ b/packages/web-server/test/ch-turn-diffs.test.ts @@ -184,53 +184,57 @@ it("invalidates equal-count summaries when full diff content changes", () => { assert.notEqual(changed[0]?.revision, first[0]?.revision); }); -it("authenticates diff RPCs and rejects non-external Threads", { timeout: 20_000 }, async (t) => { - const directory = mkdtempSync(join(tmpdir(), "ch-turn-diffs-auth-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const host = new FakeChChannel(); - host.add("official", directory); - const request = host.request.bind(host); - host.request = async (method: string, params: Record): Promise => { - if (method === "codexhost/thread/inspect" && params.threadId === "official") - return { owner: "codex", locked: true } as T; - return request(method, params); - }; - const channel = await startFakeChChannel(host, directory); - t.after(() => channel.close()); - const debug = await startFakeChDebugger(host); - t.after(() => debug.close()); - const url = await startServer( - t, - resolve(import.meta.dirname, ".."), - ["--import", "tsx", "src/main.ts"], - [ - "--session-source", - "codexhost", - "--ch-cdp", - debug.endpoint, - "--ch-control-directory", - directory, - ], - { authenticated: true }, - ); - const send = (headers: Record) => - fetch(new URL("/api/codexhost/turnDiffs", url), { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify({ - type: "client-request", - rpcId: "diff", - method: "codexhost/turnDiffs", - payload: { args: { request: { sessionId: "official", turn: 1 } } }, - }), - }); - assert.equal((await send({})).status, 401); - const token = defined(new URL(url).searchParams.get("token")); - const headers = { authorization: `Bearer ${token}` }; - assert.equal((await send({ ...headers, origin: "http://untrusted.invalid" })).status, 403); - const result = (await (await send(headers)).json()) as { - result: { ok: boolean; error: { code: string } }; - }; - assert.equal(result.result.ok, false); - assert.equal(result.result.error.code, "session/not-external"); -}); +it( + "authenticates native Thread diff RPCs and reports a missing diff", + { timeout: 20_000 }, + async (t) => { + const directory = mkdtempSync(join(tmpdir(), "ch-turn-diffs-auth-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const host = new FakeChChannel(); + host.add("official", directory); + const request = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "codexhost/thread/inspect" && params.threadId === "official") + return { owner: "codex", locked: true } as T; + return request(method, params); + }; + const channel = await startFakeChChannel(host, directory); + t.after(() => channel.close()); + const debug = await startFakeChDebugger(host); + t.after(() => debug.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, ".."), + ["--import", "tsx", "src/main.ts"], + [ + "--session-source", + "codexhost", + "--ch-cdp", + debug.endpoint, + "--ch-control-directory", + directory, + ], + { authenticated: true }, + ); + const send = (headers: Record) => + fetch(new URL("/api/codexhost/turnDiffs", url), { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + type: "client-request", + rpcId: "diff", + method: "codexhost/turnDiffs", + payload: { args: { request: { sessionId: "official", turn: 1 } } }, + }), + }); + assert.equal((await send({})).status, 401); + const token = defined(new URL(url).searchParams.get("token")); + const headers = { authorization: `Bearer ${token}` }; + assert.equal((await send({ ...headers, origin: "http://untrusted.invalid" })).status, 403); + const result = (await (await send(headers)).json()) as { + result: { ok: boolean; error: { code: string } }; + }; + assert.equal(result.result.ok, false); + assert.equal(result.result.error.code, "session/diff-not-found"); + }, +); diff --git a/packages/web-server/test/dsh-terminal.test.ts b/packages/web-server/test/dsh-terminal.test.ts index 81c6c630f..24eb68165 100644 --- a/packages/web-server/test/dsh-terminal.test.ts +++ b/packages/web-server/test/dsh-terminal.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { it } from "node:test"; @@ -15,7 +15,8 @@ it( { timeout: 60_000 }, async (t) => { const workspace = mkdtempSync(join(tmpdir(), "codexhost-web-terminal-")); - t.after(() => rmSync(workspace, { recursive: true, force: true })); + const home = join(workspace, "home"); + mkdirSync(home); const pidFile = join(workspace, "shell.pid"); const host = new FakeChHost(); host.add("local-thread", workspace, "Local Thread"); @@ -35,9 +36,17 @@ it( "--adapters", "test/fake-harness", ], - { authenticated: true, environment: { ...process.env, SHELL: "/bin/sh" } }, + { + authenticated: true, + environment: { + HOME: home, + USERPROFILE: home, + ...(process.platform === "win32" ? {} : { SHELL: "/bin/sh" }), + }, + }, ), ); + t.after(() => rmSync(workspace, { recursive: true, force: true })); const token = url.searchParams.get("token"); assert.ok(token); const call = async (method: string, args: Record) => { @@ -66,13 +75,13 @@ it( assert.equal(typeof draftId, "string"); const draftEnvironment = await call("terminal/environment", { agentId: draftId }); assert.equal(draftEnvironment.result.ok, true, JSON.stringify(draftEnvironment)); - assert.equal(draftEnvironment.result.value?.cwd, homedir()); + assert.equal(draftEnvironment.result.value?.cwd, home); const homeTerminal = await call("terminal/create", { agentId: draftId, request: { id: "home-terminal", cols: 80, rows: 24 }, }); assert.equal(homeTerminal.result.ok, true, JSON.stringify(homeTerminal)); - assert.equal(homeTerminal.result.value?.cwd, homedir()); + assert.equal(homeTerminal.result.value?.cwd, home); assert.equal( (await call("terminal/close", { agentId: draftId, id: "home-terminal" })).result.ok, true, @@ -101,159 +110,169 @@ it( assert.ok(Array.isArray(shells.result.value) && shells.result.value.length > 0); const created = await call("terminal/create", { agentId: "local-thread", - request: { id: "term-1", cols: 80, rows: 24 }, + request: { id: "term-1", cols: 160, rows: 24 }, }); assert.equal(created.result.ok, true, JSON.stringify(created)); assert.equal(created.result.value?.cwd, workspace); - // Unknown/remote identities cannot acquire a local terminal execution scope. - assert.equal( - ( - await call("terminal/create", { - agentId: "unknown-thread", - request: { id: "forbidden", cols: 80, rows: 24 }, - }) - ).result.ok, - false, - ); + try { + // Unknown/remote identities cannot acquire a local terminal execution scope. + assert.equal( + ( + await call("terminal/create", { + agentId: "unknown-thread", + request: { id: "forbidden", cols: 80, rows: 24 }, + }) + ).result.ok, + false, + ); - const socket = new WebSocket(new URL("/api/remote.mux", url), { - headers: { authorization: `Bearer ${token}` }, - }); - t.after(() => socket.close()); - await once(socket, "open"); - interface MuxFrame { - streamId?: string; - type?: string; - error?: { code: string }; - value?: { + const socket = new WebSocket(new URL("/api/remote.mux", url), { + headers: { authorization: `Bearer ${token}` }, + }); + t.after(() => socket.close()); + await once(socket, "open"); + interface MuxFrame { + streamId?: string; type?: string; - screen?: string; - data?: string; - sequence?: number; - writable?: boolean; - refusal?: string; - info?: { cols: number; rows: number }; - inputWindow?: { maxBytes: number; frameOverheadBytes: number }; + error?: { code: string }; + value?: { + type?: string; + screen?: string; + data?: string; + sequence?: number; + writable?: boolean; + refusal?: string; + info?: { cols: number; rows: number }; + inputWindow?: { maxBytes: number; frameOverheadBytes: number }; + }; + } + const frames: MuxFrame[] = []; + socket.on("message", (data) => frames.push(JSON.parse(String(data)) as MuxFrame)); + const send = (frame: object): void => socket.send(JSON.stringify(frame)); + const attach = (streamId: string): void => + send({ + type: "open", + streamId, + endpoint: "terminal/attach", + payload: { args: { agentId: "local-thread", id: "term-1" } }, + }); + const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`terminal frame timed out: ${JSON.stringify(frames)}`); }; - } - const frames: MuxFrame[] = []; - socket.on("message", (data) => frames.push(JSON.parse(String(data)) as MuxFrame)); - const send = (frame: object): void => socket.send(JSON.stringify(frame)); - const attach = (streamId: string): void => + const screen = (streamId: string): string => + frames + .filter((frame) => frame.streamId === streamId && frame.type === "item") + .map(({ value }) => + value?.type === "snapshot" ? (value.screen ?? "") : (value?.data ?? ""), + ) + .join(""); + const snapshot = (streamId: string) => + frames.find((frame) => frame.streamId === streamId && frame.value?.type === "snapshot") + ?.value; + const ack = (streamId: string, sequence: number) => + frames.find( + (frame) => + frame.streamId === streamId && + frame.value?.type === "ack" && + frame.value.sequence === sequence, + )?.value; + + attach("first"); + await waitFor(() => snapshot("first") !== undefined); + assert.equal(snapshot("first")?.writable, true); + assert.deepEqual(snapshot("first")?.inputWindow, { + maxBytes: 262_144, + frameOverheadBytes: + Buffer.byteLength(JSON.stringify({ type: "item", streamId: "first", value: null })) - 4, + }); send({ - type: "open", - streamId, - endpoint: "terminal/attach", - payload: { args: { agentId: "local-thread", id: "term-1" } }, + type: "item", + streamId: "first", + value: { + type: "write", + sequence: 1, + data: + process.platform === "win32" + ? `cd & "${join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe")}" -NoProfile -Command "(Get-CimInstance Win32_Process -Filter ('ProcessId=' + $PID)).ParentProcessId | Set-Content -Encoding ascii -LiteralPath '${pidFile.replaceAll("'", "''")}'" & echo hello-web-terminal\r` + : `pwd; echo $$ > ${pidFile}; printf 'hello-%s\\n' web-terminal\r`, + }, }); - const waitFor = async (predicate: () => boolean): Promise => { - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error(`terminal frame timed out: ${JSON.stringify(frames)}`); - }; - const screen = (streamId: string): string => - frames - .filter((frame) => frame.streamId === streamId && frame.type === "item") - .map(({ value }) => - value?.type === "snapshot" ? (value.screen ?? "") : (value?.data ?? ""), - ) - .join(""); - const snapshot = (streamId: string) => - frames.find((frame) => frame.streamId === streamId && frame.value?.type === "snapshot") - ?.value; - const ack = (streamId: string, sequence: number) => - frames.find( - (frame) => - frame.streamId === streamId && - frame.value?.type === "ack" && - frame.value.sequence === sequence, - )?.value; + await waitFor( + () => ack("first", 1) !== undefined && screen("first").includes("hello-web-terminal"), + ); + assert.ok(screen("first").includes(workspace)); + send({ + type: "item", + streamId: "first", + value: { type: "resize", sequence: 2, cols: 100, rows: 30 }, + }); + await waitFor(() => ack("first", 2) !== undefined); + assert.ok( + frames.some((frame) => frame.value?.info?.cols === 100 && frame.value.info.rows === 30), + ); - attach("first"); - await waitFor(() => snapshot("first") !== undefined); - assert.equal(snapshot("first")?.writable, true); - assert.deepEqual(snapshot("first")?.inputWindow, { - maxBytes: 262_144, - frameOverheadBytes: - Buffer.byteLength(JSON.stringify({ type: "item", streamId: "first", value: null })) - 4, - }); - send({ - type: "item", - streamId: "first", - value: { - type: "write", - sequence: 1, - data: `pwd; echo $$ > ${pidFile}; printf 'hello-%s\\n' web-terminal\r`, - }, - }); - await waitFor( - () => ack("first", 1) !== undefined && screen("first").includes("hello-web-terminal"), - ); - assert.ok(screen("first").includes(workspace)); - send({ - type: "item", - streamId: "first", - value: { type: "resize", sequence: 2, cols: 100, rows: 30 }, - }); - await waitFor(() => ack("first", 2) !== undefined); - assert.ok( - frames.some((frame) => frame.value?.info?.cols === 100 && frame.value.info.rows === 30), - ); + // A fresh attachment restores the same screen and takes control, not the process lifetime. + attach("second"); + await waitFor(() => snapshot("second") !== undefined); + assert.ok(snapshot("second")?.screen?.includes("hello-web-terminal")); + assert.equal(snapshot("second")?.writable, true); + send({ + type: "item", + streamId: "first", + value: { type: "write", sequence: 3, data: "IGNORED" }, + }); + await waitFor(() => ack("first", 3) !== undefined); + assert.equal(ack("first", 3)?.refusal, "read-only"); + // Half-closing input still allows final acknowledgements and output to drain. + send({ type: "end", streamId: "first" }); + const command = + process.platform === "win32" + ? "chcp 65001 >nul & echo 中文-abcdefghij\r" + : "printf '中文-%s\\n' abcdefghij\r"; + let sequence = 0; + for (const character of command) + send({ + type: "item", + streamId: "second", + value: { type: "write", sequence: ++sequence, data: character }, + }); + await waitFor( + () => ack("second", sequence) !== undefined && screen("first").includes("中文-abcdefghij"), + ); + assert.ok(!screen("first").includes("IGNORED")); + assert.ok(!frames.some((frame) => frame.streamId === "first" && frame.type === "end")); - // A fresh attachment restores the same screen and takes control, not the process lifetime. - attach("second"); - await waitFor(() => snapshot("second") !== undefined); - assert.ok(snapshot("second")?.screen?.includes("hello-web-terminal")); - assert.equal(snapshot("second")?.writable, true); - send({ - type: "item", - streamId: "first", - value: { type: "write", sequence: 3, data: "IGNORED" }, - }); - await waitFor(() => ack("first", 3) !== undefined); - assert.equal(ack("first", 3)?.refusal, "read-only"); - // Half-closing input still allows final acknowledgements and output to drain. - send({ type: "end", streamId: "first" }); - const command = "printf '中文-%s\\n' abcdefghij\r"; - let sequence = 0; - for (const character of command) + // Generated upstream codecs reject malformed input on just that logical stream. send({ type: "item", streamId: "second", - value: { type: "write", sequence: ++sequence, data: character }, + value: { type: "write", sequence: ++sequence, data: 42 }, }); - await waitFor( - () => ack("second", sequence) !== undefined && screen("first").includes("中文-abcdefghij"), - ); - assert.ok(!screen("first").includes("IGNORED")); - assert.ok(!frames.some((frame) => frame.streamId === "first" && frame.type === "end")); + await waitFor(() => + frames.some((frame) => frame.streamId === "second" && frame.type === "error"), + ); + assert.equal( + frames.find((frame) => frame.streamId === "second" && frame.type === "error")?.error?.code, + "gateway/input-invalid", + ); + attach("recovered"); + await waitFor(() => snapshot("recovered") !== undefined); + assert.ok(snapshot("recovered")?.screen?.includes("中文-abcdefghij")); + send({ type: "cancel", streamId: "recovered" }); - // Generated upstream codecs reject malformed input on just that logical stream. - send({ - type: "item", - streamId: "second", - value: { type: "write", sequence: ++sequence, data: 42 }, - }); - await waitFor(() => - frames.some((frame) => frame.streamId === "second" && frame.type === "error"), - ); - assert.equal( - frames.find((frame) => frame.streamId === "second" && frame.type === "error")?.error?.code, - "gateway/input-invalid", - ); - attach("recovered"); - await waitFor(() => snapshot("recovered") !== undefined); - assert.ok(snapshot("recovered")?.screen?.includes("中文-abcdefghij")); - send({ type: "cancel", streamId: "recovered" }); - - const closed = await call("terminal/close", { agentId: "local-thread", id: "term-1" }); - assert.equal(closed.result.ok, true); - await new Promise((resolve) => setTimeout(resolve, 500)); - const pid = Number(existsSync(pidFile) ? readFileSync(pidFile, "utf8").trim() : ""); - assert.ok(Number.isSafeInteger(pid) && pid > 0); - assert.throws(() => process.kill(pid, 0)); + const closed = await call("terminal/close", { agentId: "local-thread", id: "term-1" }); + assert.equal(closed.result.ok, true); + await new Promise((resolve) => setTimeout(resolve, 500)); + const pid = Number(existsSync(pidFile) ? readFileSync(pidFile, "utf8").trim() : ""); + assert.ok(Number.isSafeInteger(pid) && pid > 0); + assert.throws(() => process.kill(pid, 0)); + } finally { + await call("terminal/close", { agentId: "local-thread", id: "term-1" }); + } }, ); diff --git a/packages/web-server/test/dsh-web-plugin-http.test.ts b/packages/web-server/test/dsh-web-plugin-http.test.ts index e3e650a4c..8ca42c48c 100644 --- a/packages/web-server/test/dsh-web-plugin-http.test.ts +++ b/packages/web-server/test/dsh-web-plugin-http.test.ts @@ -111,7 +111,19 @@ async function subscribe(t: TestContext, base: string) { const response = await new Promise((resolve) => get(new URL("/plugins/events", base), { headers }, resolve), ); - t.after(() => response.destroy()); + let closing = false; + let failure: Error | undefined; + response.on("error", (error: Error) => { + if (!closing) failure = error; + }); + const close = () => { + closing = true; + response.destroy(); + }; + t.after(() => { + close(); + assert.ifError(failure); + }); assert.equal(response.statusCode, 200); assert.equal(response.headers["content-type"], "text/event-stream"); const graphs: { entries: { id: string }[] }[] = []; @@ -130,9 +142,11 @@ async function subscribe(t: TestContext, base: string) { }); return { response, + close, async contains(id: string, present: boolean) { const deadline = Date.now() + 5000; while (graphs.at(-1)?.entries.some((row) => row.id === id) !== present) { + assert.ifError(failure); assert.ok(Date.now() < deadline, "SSE graph did not converge"); await new Promise((resolve) => setTimeout(resolve, 10)); } @@ -210,7 +224,7 @@ it( assert.equal((await get("/test-host/resource")).status, 404); assert.ok(existsSync(pkg.directory)); assert.doesNotMatch(await (await get("/")).text(), /plugin-injection|plugin-tap/); - first.response.destroy(); + first.close(); await rpc(url, "setEnabled", { id: pkg.id, enabled: true }); await second.contains(pkg.id, true); const reconnected = await subscribe(t, url); @@ -221,6 +235,8 @@ it( await reconnected.contains(pkg.id, false); assert.equal((await get("/test-host/resource")).status, 404); assert.equal(existsSync(pkg.directory), false); + second.close(); + reconnected.close(); const exited = once(child, "exit"); child.kill("SIGTERM"); await exited; diff --git a/packages/web-server/test/office-engine.test.ts b/packages/web-server/test/office-engine.test.ts index 27ce1b1f7..6a2b9b8ba 100644 --- a/packages/web-server/test/office-engine.test.ts +++ b/packages/web-server/test/office-engine.test.ts @@ -107,6 +107,9 @@ async function untilReady(ensure: () => boolean): Promise { } it("returns downloading immediately through the preview RPC and keeps downloading after its request aborts", async (t) => { + const backendCleanup: { close?: () => Promise } = {}; + // Dispose retained terminal children before fixture cleanup removes their cwd. + t.after(() => backendCleanup.close?.()); const f = await fixture(t); copyFileSync( join(import.meta.dirname, "fixtures/office/sample.docx"), @@ -124,7 +127,7 @@ it("returns downloading immediately through the preview RPC and keeps downloadin threadWorkspace: () => f.root, ensureOfficeEngine: ensure, }); - t.after(() => backend.close()); + backendCleanup.close = () => backend.close(); const request = new AbortController(); const response = await backend.fetch( new Request("http://localhost/api/officeToPdf/render", { diff --git a/packages/web-server/test/relay-certificates.test.ts b/packages/web-server/test/relay-certificates.test.ts index b43963dd4..e5005f860 100644 --- a/packages/web-server/test/relay-certificates.test.ts +++ b/packages/web-server/test/relay-certificates.test.ts @@ -127,7 +127,9 @@ it("creates and reuses exclusive P-256 PKCS#8 private keys with 0600 permissions const key = certificateKey(f.data, name); assert.match(key.toString(), /BEGIN PRIVATE KEY/); assert.deepEqual(certificateKey(f.data, name), key); - assert.equal(statSync(f.data.path("remote-relay", name)).mode & 0o777, 0o600); + // Windows reports DOS attributes through stat.mode, not POSIX permission bits. + if (process.platform !== "win32") + assert.equal(statSync(f.data.path("remote-relay", name)).mode & 0o777, 0o600); } }); @@ -340,7 +342,8 @@ it("installs only usable certificates atomically, removes success records, and w await until(() => !f.manager.state.issuing); assert.equal(f.tls.available, true); assert.equal(existsSync(f.data.path(stateFile)), false); - assert.equal(statSync(f.data.path(endpoint, "certificate.pem")).mode & 0o777, 0o600); + if (process.platform !== "win32") + assert.equal(statSync(f.data.path(endpoint, "certificate.pem")).mode & 0o777, 0o600); assert.equal(f.manager.state.expiresAt, new Date(notAfter).toISOString()); f.save({ retryAt: notAfter + 100_000 }); now = notAfter; diff --git a/packages/web-server/test/relay-client.test.ts b/packages/web-server/test/relay-client.test.ts index 5f47ae114..4151d0623 100644 --- a/packages/web-server/test/relay-client.test.ts +++ b/packages/web-server/test/relay-client.test.ts @@ -15,8 +15,9 @@ import { credentials, delay, relayData, startRelay, until } from "./support/rela function proxyEnvironment(t: TestContext, value = "") { const upper = process.env.HTTPS_PROXY; const lower = process.env.https_proxy; - process.env.HTTPS_PROXY = value; process.env.https_proxy = ""; + // Windows aliases environment names case-insensitively: clear first, then set. + process.env.HTTPS_PROXY = value; t.after(() => { if (upper === undefined) delete process.env.HTTPS_PROXY; else process.env.HTTPS_PROXY = upper; @@ -90,7 +91,9 @@ it("retries after Relay reaches maxConnections and registers when capacity is re await until(() => occupying.registrations.length === 1); const waiting = client(t, `localhost:${relay.port}`); // connecting runs both when an attempt starts and when a failure schedules its retry. - await until(() => waiting.attempts() >= 4); + // maxConnections can drop TCP before TLS completes without an immediate client + // error. Allow two real 30-second attempt deadlines plus their retry backoff. + await until(() => waiting.attempts() >= 4, 65_000); assert.equal(waiting.registrations.length, 0); assert.deepEqual(waiting.stopped, []); assert.equal(occupying.registrations.length, 1); diff --git a/packages/web-server/test/support/connect-devices.ts b/packages/web-server/test/support/connect-devices.ts index 1e5876e44..0efddfbee 100644 --- a/packages/web-server/test/support/connect-devices.ts +++ b/packages/web-server/test/support/connect-devices.ts @@ -1,5 +1,5 @@ /** Isolated Web + fake Launcher; exit 42 drives the test supervisor, never a file watcher. */ -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { request } from "node:http"; import { once } from "node:events"; @@ -13,6 +13,7 @@ import type { WebTailscaleState, } from "@codexhost/shared-contracts"; import { defined } from "./defined.ts"; +import { nodeCommand } from "./node-command.ts"; type Replies = { "codexhost/accessTokenGet": WebAccessState; @@ -52,13 +53,12 @@ export async function connectServer( const root = mkdtempSync(join(tmpdir(), "web-connect-")); t.after(() => rmSync(root, { recursive: true, force: true })); const data = new DataDir(join(root, "web")); - const launcher = join(root, "launcher"); const autostart = join(root, "autostart"); const failure = join(root, "failure"); writeFileSync(autostart, "off"); - writeFileSync( - launcher, - `#!${process.execPath} + const cli = nodeCommand( + "launcher", + ` const fs = require("node:fs"); const [command, action] = process.argv.slice(2); fs.writeFileSync(${JSON.stringify(root)} + "/launcher-env-" + action, JSON.stringify({ node: process.env.CODEXHOST_NPM_NODE_PATH, wrapper: process.env.CODEXHOST_NPM_LAUNCHER_PATH })); @@ -68,7 +68,7 @@ else if (fs.existsSync(${JSON.stringify(failure)})) { console.error(fs.readFileS else fs.writeFileSync(${JSON.stringify(autostart)}, action); `, ); - chmodSync(launcher, 0o755); + const launcher = cli.command; saveServiceOptions(data, { port: "4474" }); const exits: number[] = []; let current: ChildProcess | undefined; @@ -92,6 +92,7 @@ else fs.writeFileSync(${JSON.stringify(autostart)}, action); authenticated: true, environment: { ...environment, + ...cli.environment, HOME: root, CODEXHOST_DATA_DIR: join(root, "host"), CODEX_HOME: join(root, "codex"), diff --git a/packages/web-server/test/support/native-codex.ts b/packages/web-server/test/support/native-codex.ts new file mode 100644 index 000000000..eed5bd1a3 --- /dev/null +++ b/packages/web-server/test/support/native-codex.ts @@ -0,0 +1,24 @@ +import type { FakeChHost } from "./ch-host.ts"; + +/** Native ownership is independent of the Provider chosen for the original Thread. */ +export function nativeCodex(host: T): T { + const request = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "codexhost/thread/ownership/list") { + host.requests.push({ method, params }); + return { + threads: (params.threadIds as string[]).map((threadId) => + threadId.startsWith("native-") + ? { threadId, owner: "codex" } + : { threadId, owner: "external", harnessId: "fake" }, + ), + } as R; + } + if (method === "codexhost/thread/inspect" && String(params.threadId).startsWith("native-")) { + host.requests.push({ method, params }); + return { owner: "codex", locked: true } as R; + } + return request(method, params); + }; + return host; +} diff --git a/packages/web-server/test/support/node-command.ts b/packages/web-server/test/support/node-command.ts new file mode 100644 index 000000000..4e3d2825a --- /dev/null +++ b/packages/web-server/test/support/node-command.ts @@ -0,0 +1,60 @@ +/** Synchronous verb-based test CLIs, executable by execFile without a shell. */ +import { + chmodSync, + copyFileSync, + linkSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "codexhost-test-cli-")); +const windows = process.platform === "win32"; +process.once("exit", () => + rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }), +); +if (windows) { + const bootstrap = join(root, "bootstrap.cjs"); + writeFileSync( + bootstrap, + ` +const path = require("node:path"); +if (path.dirname(process.execPath).toLowerCase().startsWith(${JSON.stringify(root.toLowerCase())} + path.sep)) { + const fs = require("node:fs"); + const entry = path.join(path.dirname(process.execPath), "entry.cjs"); + process.argv = [process.execPath, entry, path.basename(process.argv[1] ?? ""), ...process.argv.slice(2)]; + console.log = (...args) => fs.writeSync(1, args.join(" ") + "\\n"); + console.error = (...args) => fs.writeSync(2, args.join(" ") + "\\n"); + require(entry); + process.exit(0); +} +`, + ); + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --require ${JSON.stringify(bootstrap.replaceAll("\\", "/"))}`; +} + +export function nodeCommand(name: string, source: string) { + const directory = mkdtempSync(join(root, "command-")); + const command = join(directory, windows ? `${name}.exe` : name); + const entry = join(directory, "entry.cjs"); + writeFileSync(entry, `#!${process.execPath}\n${source}`); + if (windows) { + // Hard links avoid copying Node for every fixture. Never chmod the shared executable. + try { + linkSync(process.execPath, command); + } catch { + copyFileSync(process.execPath, command); + } + } else { + chmodSync(entry, 0o755); + symlinkSync(entry, command); + } + return { + directory, + command, + environment: windows ? { NODE_OPTIONS: process.env.NODE_OPTIONS } : {}, + }; +} diff --git a/packages/web-server/test/support/relay.ts b/packages/web-server/test/support/relay.ts index 4b3268711..73cc774a4 100644 --- a/packages/web-server/test/support/relay.ts +++ b/packages/web-server/test/support/relay.ts @@ -3,13 +3,14 @@ import { once } from "node:events"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { getCACertificates, setDefaultCACertificates } from "node:tls"; import type { TestContext } from "node:test"; import { createRelay, type RelayOptions } from "@codexhost/remote-relay"; import { DataDir } from "../../src/store.ts"; -export const certPath = new URL("../fixtures/relay/cert.pem", import.meta.url).pathname; -export const keyPath = new URL("../fixtures/relay/key.pem", import.meta.url).pathname; +export const certPath = fileURLToPath(new URL("../fixtures/relay/cert.pem", import.meta.url)); +export const keyPath = fileURLToPath(new URL("../fixtures/relay/key.pem", import.meta.url)); export const credentials = { cert: readFileSync(certPath), key: readFileSync(keyPath) }; setDefaultCACertificates([...getCACertificates("default"), credentials.cert.toString()]); diff --git a/packages/web-server/test/support/serve-control.ts b/packages/web-server/test/support/serve-control.ts index 5e4fe1422..9149ce422 100644 --- a/packages/web-server/test/support/serve-control.ts +++ b/packages/web-server/test/support/serve-control.ts @@ -1,9 +1,10 @@ /** Writable fake CLI: never falls through to an installed Tailscale executable. */ -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { TestContext } from "node:test"; import { RUNNING, SERVE_HOST } from "./tailscale.ts"; +import { nodeCommand } from "./node-command.ts"; export function fakeServe(t: TestContext) { const directory = mkdtempSync(join(tmpdir(), "web-serve-control-")); @@ -11,10 +12,9 @@ export function fakeServe(t: TestContext) { const file = join(directory, "fixture.json"); const log = join(directory, "calls"); writeFileSync(log, ""); - const command = join(directory, "tailscale"); - writeFileSync( - command, - `#!${process.execPath} + const cli = nodeCommand( + "tailscale", + ` const fs = require("node:fs"); const file = ${JSON.stringify(file)}; const args = process.argv.slice(2); @@ -38,7 +38,6 @@ else if (args[0] === "serve") { } else process.exit(99); `, ); - chmodSync(command, 0o755); const set = (serve: unknown = {}, extra: { noop?: boolean; failure?: string } = {}) => writeFileSync(file, JSON.stringify({ status: RUNNING, serve, ...extra })); set(); @@ -49,6 +48,6 @@ else if (args[0] === "serve") { readFileSync(log, "utf8") .split("\n") .filter((line) => line && !line.endsWith("--json")), - environment: { PATH: directory }, + environment: { PATH: cli.directory, ...cli.environment }, }; } diff --git a/packages/web-server/test/support/server.ts b/packages/web-server/test/support/server.ts index af11af412..c343ae659 100644 --- a/packages/web-server/test/support/server.ts +++ b/packages/web-server/test/support/server.ts @@ -79,7 +79,7 @@ export async function startServer( CODEX_HOME: join(data, "codex"), CODEXHOST_CONSOLE_PORT: "4499", SSH_CONNECTION: "smoke", - PATH: tailscale ? `${dirname(tailscale.command)}:/usr/bin:/bin` : "/usr/bin:/bin", + PATH: tailscale?.environment.PATH ?? "/usr/bin:/bin", ...options.environment, }, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/web-server/test/support/tailscale.ts b/packages/web-server/test/support/tailscale.ts index 645895cc6..0901ad058 100644 --- a/packages/web-server/test/support/tailscale.ts +++ b/packages/web-server/test/support/tailscale.ts @@ -1,32 +1,27 @@ /** PATH-first, read-only Tailscale fixture shared by unit, HTTP and browser tests. */ import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { TestContext } from "node:test"; - -// Keep fake CLIs discoverable until all test-owned servers have exited. -const root = mkdtempSync(join(tmpdir(), "codexhost-tailscale-")); -process.once("exit", () => rmSync(root, { recursive: true, force: true })); +import { nodeCommand } from "./node-command.ts"; export function fakeTailscale(t: TestContext) { - const directory = mkdtempSync(join(root, "cli-")); - const command = join(directory, "tailscale"); - const log = join(directory, "calls"); - writeFileSync(log, ""); - writeFileSync( - command, - `#!${process.execPath} + const cli = nodeCommand( + "tailscale", + ` const fs = require("node:fs"); const path = require("node:path"); const args = process.argv.slice(2).join(" "); fs.appendFileSync(path.join(__dirname, "calls"), args + "\\n"); if (args !== "status --json" && args !== "serve status --json") process.exit(99); const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, "fixture.json"), "utf8")); -setTimeout(() => process.stdout.write(fixture.raw ?? JSON.stringify(args === "status --json" ? fixture.status : fixture.serve)), fixture.delay ?? 0); +if (fixture.delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, fixture.delay); +fs.writeSync(1, fixture.raw ?? JSON.stringify(args === "status --json" ? fixture.status : fixture.serve)); `, ); - chmodSync(command, 0o755); + const { directory, command } = cli; + const log = join(directory, "calls"); + writeFileSync(log, ""); const set = ( status: unknown, serve: unknown = {}, @@ -42,10 +37,12 @@ setTimeout(() => process.stdout.write(fixture.raw ?? JSON.stringify(args === "st command, log, set, - environment: { PATH: `${directory}:/usr/bin:/bin` }, + environment: { + PATH: process.platform === "win32" ? directory : `${directory}:/usr/bin:/bin`, + ...cli.environment, + }, }; } - export const RUNNING = { BackendState: "Running", Self: { CapMap: { https: [] } } }; export const SERVE_HOST = "laptop.example.ts.net"; export function serveStatus(port: number, funnel = false) { diff --git a/packages/web-server/test/tool-output-rpc.test.ts b/packages/web-server/test/tool-output-rpc.test.ts index 2759b7201..b7418b175 100644 --- a/packages/web-server/test/tool-output-rpc.test.ts +++ b/packages/web-server/test/tool-output-rpc.test.ts @@ -10,6 +10,7 @@ import { DataDir } from "../src/store.ts"; import { Workspaces } from "../src/workspaces.ts"; import { EventHub, + RpcError, RpcRegistry, StreamRegistry, type StreamHandler, @@ -42,8 +43,10 @@ it("authorizes projected output reads, previews relay follow/page, and preserves const original = host.request.bind(host); let denied = false; host.request = async (method: string, params: Record): Promise => { + // Native Codex is a supported owner now. Access revocation must be an + // authoritative Host refusal, not merely a native ownership label. if (denied && method === "codexhost/thread/inspect") - return { owner: "codex", locked: true } as T; + throw new RpcError("host/rejected", "Thread access revoked"); return original(method, params); }; const sessions = new ChSessions(host, new Workspaces(new DataDir(dir), dir), new EventHub(dir)); @@ -122,5 +125,5 @@ it("authorizes projected output reads, previews relay follow/page, and preserves denied = true; const inaccessible = await call(ref); assert.ok(!inaccessible.ok); - assert.equal(inaccessible.error.code, "session/not-external"); + assert.equal(inaccessible.error.code, "host/rejected"); }); diff --git a/packages/web-server/test/ui-extensions.test.ts b/packages/web-server/test/ui-extensions.test.ts index ff5718bee..462677b1f 100644 --- a/packages/web-server/test/ui-extensions.test.ts +++ b/packages/web-server/test/ui-extensions.test.ts @@ -16,7 +16,11 @@ import { it, type TestContext } from "node:test"; import { promisify } from "node:util"; import { pathToFileURL } from "node:url"; -import { commandInvocation, withNodeRuntimeOnPath } from "@codexhost/harness-discovery"; +import { + commandInvocation, + resolveHarnessExecutable, + withNodeRuntimeOnPath, +} from "@codexhost/harness-discovery"; import { DataDir } from "../src/store.ts"; import { extensionInstallSpec } from "../src/ui-extension-install.ts"; @@ -495,8 +499,13 @@ it("uses real offline npm to install a local archive without running lifecycle s }); } const environment = withNodeRuntimeOnPath(process.env); + const npm = resolveHarnessExecutable( + { id: "npm", command: process.platform === "win32" ? "npm.cmd" : "npm" }, + { environment }, + ); + assert.ok(npm); const invocation = commandInvocation( - process.platform === "win32" ? "npm.cmd" : "npm", + npm.executable, ["pack", "--offline", "--ignore-scripts", "--json", "--pack-destination", fixture], environment, ); diff --git a/tests/release/packagers.test.mjs b/tests/release/packagers.test.mjs index 9bdc6f3d7..2e1104a59 100644 --- a/tests/release/packagers.test.mjs +++ b/tests/release/packagers.test.mjs @@ -50,8 +50,12 @@ describe("platform packagers", () => { expect(workflow).toContain("npm_tag: result.npmTag"); expect(workflow).toContain("ref: ${{ needs.prepare.outputs.commit_sha }}"); expect(workflow).not.toContain("ref: ${{ needs.prepare.outputs.tag }}"); - expect(workflow).toContain("Revalidate tag and CI immediately before npm publication"); - expect(workflow).toContain("Revalidate tag and CI immediately before GitHub publication"); + expect(workflow).toContain( + "Revalidate tag and selected CI policy immediately before npm publication", + ); + expect(workflow).toContain( + "Revalidate tag and selected CI policy immediately before GitHub publication", + ); expect(workflow).toContain("codexhost-*.dmg"); expect(workflow).toContain("codexhost-*.exe");