diff --git a/README.md b/README.md index d8ba799..527f3b9 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Steer, Mic, Submit, and Latest Task & Scroll Encoder actions. | Local-only transport | Bind CDP and the Bridge API to loopback addresses only | This Ulanzi-maintained project is an unofficial integration with Codex Desktop -for **macOS, Ulanzi Studio, and Ulanzi D200 Series**. It is not affiliated with or -endorsed by OpenAI. The entire project was vibe-coded with Codex. +for **Windows or macOS, Ulanzi Studio, and Ulanzi D200 Series**. It is not +affiliated with or endorsed by OpenAI. Ulanzi's implementation and maintenance scope is limited to `integration/com.ulanzi.codexmicro.ulanziPlugin/`. The plugin only consumes @@ -38,8 +38,8 @@ responsibility boundary. ### Installation environment -- macOS 13 or later; -- Codex Desktop; +- Windows 10 or later, or macOS 13 or later; +- Codex Desktop Stable or Beta; - Ulanzi Studio 3.0.1 or later; - Ulanzi D200 Series connected to Ulanzi Studio; - Node.js 20 or newer only for repository-based manual installation. @@ -49,7 +49,8 @@ responsibility boundary. An LLM or coding agent must follow these steps: 1. Read [AGENTS.md](AGENTS.md) and inspect the installed Ulanzi Studio plugin - manifests under `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/`. + manifests under `%APPDATA%\Ulanzi\UlanziDeck\Plugins` on Windows or + `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins` on macOS. 2. Treat Codex Micro as installed only when manifest UUID `com.ulanzi.ulanzistudio.codexmicro` is present and its `CodePath` exists. 3. If it is missing or invalid, load @@ -57,7 +58,7 @@ An LLM or coding agent must follow these steps: install the plugin, and verify its manifest and entry point. 4. After the plugin is verified, load [`$setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md), install or repair - `Codex Bridge.app` and its sidecar, and verify the local health endpoint. + Codex Bridge and its sidecar, and verify the local health endpoint. 5. Report plugin installation and Bridge connectivity separately. Do not reinstall a valid plugin unless an update or repair was requested. @@ -66,13 +67,17 @@ An LLM or coding agent must follow these steps: After installing the Codex Micro plugin, drag any Codex Micro action onto a key and select it. Its shared **Codex Bridge Setup** page can: -- show whether `Codex Bridge.app`, the Bridge service, and CDP are available; +- show whether Codex Bridge, its user-level service, and CDP are available; - install or repair the bundled Bridge without a repository or npm directory; -- launch `~/Applications/Codex Bridge.app`; and +- launch Codex with loopback-only CDP arguments; and - recheck the connection or open the full installation guide. -The installer writes only to the current user's Applications, Application -Support, and LaunchAgents directories and does not require `sudo`. +The installer uses `%LOCALAPPDATA%\OpenCodexMicro` for Windows user data and a +hash-verified protected runtime under `%PROGRAMDATA%\OpenCodexMicro\Bridge`. +Windows requests administrator approval for first installation and protected +Bridge code updates; normal starts remain silent. On macOS it uses the current +user's Applications, Application Support, and LaunchAgents directories without +administrator access. ### 3. Manual installation @@ -85,13 +90,18 @@ npm run setup ``` Quit Ulanzi Studio before running `npm run install:plugin`. The command -validates and atomically installs the prebuilt plugin. `npm run setup` builds -the loopback Bridge sidecar, registers its user LaunchAgent, and installs -`Codex Bridge.app` in `~/Applications`. - -Reopen Ulanzi Studio. Quit Codex Desktop, then open -`~/Applications/Codex Bridge.app`; the wrapper starts Codex with a -loopback-only CDP endpoint. Confirm the connection with: +validates and atomically installs the prebuilt plugin. If Bridge is already +installed, the command also verifies its version and runtime hash, then safely +updates and restarts it when needed. `npm run setup` performs the initial Bridge +installation or an explicit repair. + +Reopen Ulanzi Studio. **Launch Codex Bridge** and `npm run bridge:start` remain +available for manual startup and diagnostics, but a configured Task key also +invokes the same launch path whenever ChatGPT/CDP is unavailable. On Windows the +launcher discovers the current Stable or Beta Appx package dynamically; it never +pins a versioned WindowsApps directory. A desktop shortcut whose target points +at a version-specific `WindowsApps` directory is neither required nor used. +Confirm the connection with: ```bash curl http://127.0.0.1:17373/health @@ -102,14 +112,12 @@ In Ulanzi Studio, drag the Codex Micro actions onto the desired keys. See [Setup and operations](docs/setup-and-operations.md) for installed paths, diagnostics, updates, and uninstall instructions. -> **Important:** When using Codex Micro, always launch Codex through -> `~/Applications/Codex Bridge.app`. Do not open Codex Desktop directly. -> -> Launch command: -> -> ```bash -> open ~/Applications/Codex\ Bridge.app -> ``` +> **Important:** Codex must be started with the loopback CDP arguments shown in +> the setup page. If `9222` is already available, a Task key or Launch only +> focuses the current Codex instance. If it is unavailable, the first Task press +> may restart that channel's main ChatGPT process once with the required +> arguments, wait for Bridge state to become connected, and then continue the +> requested task activation. ## Configure @@ -118,6 +126,17 @@ actions: Codex Task 1–5, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit, and Latest Task & Scroll for Encoder controls. No separate device daemon or shortcut mapping is required. +On Windows, focus stays bound to the PID that owns the active CDP listener. The +adapter revalidates cached windows, prefers unowned displayed top-level windows, +and falls back to another displayed window from that same PID without relying on +window titles, versioned Appx paths, or a global keyboard hook. + +A Task press is successful only after both the requested renderer task is +confirmed active and the matching ChatGPT window is confirmed in the foreground. +Native Micro navigation is attempted first; DOM activation is a bounded fallback, +and a focus or activation failure is reported instead of being logged as partial +success. + Select any configured action to open the shared **Codex Bridge Setup** page. It shows Bridge installation, service, and CDP status and provides Install / Repair and Launch controls. @@ -132,7 +151,7 @@ The repository includes two reusable Codex skills: | Skill | Purpose | | --- | --- | -| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | Install, update, verify, or repair `Codex Bridge.app` and its sidecar | +| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | Install, update, verify, or repair Codex Bridge and its sidecar | | [`install-ulanzi-studio-plugin`](skills/install-ulanzi-studio-plugin/SKILL.md) | Install the repository's prebuilt plugin directory into Ulanzi Studio | ## Documentation diff --git a/README_zh.md b/README_zh.md index 4543475..0de68ef 100644 --- a/README_zh.md +++ b/README_zh.md @@ -34,8 +34,8 @@ Ulanzi 的实现与维护范围仅限 ### 安装环境 -- macOS 13 或更高版本; -- Codex Desktop; +- Windows 10 或更高版本,或 macOS 13 或更高版本; +- Codex Desktop Stable 或 Beta; - Ulanzi Studio 3.0.1 或更高版本; - 已在 Ulanzi Studio 中连接 Ulanzi D200 Series; - 仅使用仓库手动安装时需要 Node.js 20 或更高版本。 @@ -44,9 +44,9 @@ Ulanzi 的实现与维护范围仅限 LLM 或代码 Agent 必须按以下步骤执行: -1. 阅读 [AGENTS.md](AGENTS.md),检查 - `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/` 下已经安装的插件 - manifest。 +1. 阅读 [AGENTS.md](AGENTS.md),检查 Windows 的 + `%APPDATA%\Ulanzi\UlanziDeck\Plugins` 或 macOS 的 + `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins` 下已经安装的插件 manifest。 2. 仅当 manifest UUID 为 `com.ulanzi.ulanzistudio.codexmicro`,且其 `CodePath` 确实存在时,才判断 Codex Micro 已安装。 3. 如果插件缺失或无效,加载 @@ -54,7 +54,7 @@ LLM 或代码 Agent 必须按以下步骤执行: 安装插件并验证 manifest 和入口文件。 4. 插件验证通过后,加载 [`$setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md),安装或修复 - `Codex Bridge.app` 与 sidecar,并验证本机 health 接口。 + Codex Bridge 与 sidecar,并验证本机 health 接口。 5. 分别报告插件安装状态和 Bridge 连接状态。除非用户要求更新或修复,否则不要重复 安装有效插件。 @@ -63,13 +63,13 @@ LLM 或代码 Agent 必须按以下步骤执行: 安装 Codex Micro 插件后,把任意 Codex Micro Action 拖到按键并选中。所有 Action 共用的 **Codex Bridge 设置**页可以: -- 显示 `Codex Bridge.app`、Bridge 后台服务和 CDP 的状态; +- 显示 Codex Bridge、用户级后台进程和 CDP 的状态; - 使用插件内置资源安装或修复 Bridge,不需要仓库目录或 npm 项目目录; -- 启动 `~/Applications/Codex Bridge.app`; +- 使用仅限回环地址的 CDP 参数启动 Codex; - 重新检测连接或打开完整安装说明。 -安装器只写入当前用户的 Applications、Application Support 和 LaunchAgents 目录, -不需要 `sudo`。 +Windows 安装器写入 `%LOCALAPPDATA%\OpenCodexMicro`;macOS 安装器写入当前用户的 +Applications、Application Support 和 LaunchAgents 目录。两者都不需要管理员权限。 ### 3. 手动安装 @@ -82,11 +82,12 @@ npm run setup ``` 运行 `npm run install:plugin` 前请退出 Ulanzi Studio。该命令会校验并原子安装仓库 -中的预构建插件;`npm run setup` 会构建本机 Bridge sidecar、注册用户级 -LaunchAgent,并把 `Codex Bridge.app` 安装到 `~/Applications`。 +中的预构建插件;若 Bridge 已安装,还会校验其版本与 runtime hash,并在需要时安全 +更新和重启。`npm run setup` 用于首次安装 Bridge 或显式修复。 -重新打开 Ulanzi Studio。完全退出 Codex Desktop,再打开 -`~/Applications/Codex Bridge.app`。可用以下命令确认连接: +重新打开 Ulanzi Studio,在任意 Action 设置页点击“启动 Codex Bridge”,或运行 +`npm run bridge:start`。Windows 会从 Appx 包元数据动态发现 Stable 或 Beta, +不会固定带版本号的 WindowsApps 目录。可用以下命令确认连接: ```bash curl http://127.0.0.1:17373/health @@ -96,14 +97,8 @@ curl http://127.0.0.1:17373/state 最后在 Ulanzi Studio 中把 Codex Micro actions 拖到需要的按键。安装路径、诊断、 更新与卸载方法见 [安装与运行](docs/setup-and-operations.md)。 -> **注意事项:** 使用 Codex Micro 时,必须通过 -> `~/Applications/Codex Bridge.app` 启动 Codex,请勿直接打开 Codex Desktop。 -> -> 启动命令: -> -> ```bash -> open ~/Applications/Codex\ Bridge.app -> ``` +> **注意事项:** Codex 必须带设置页所示的本机回环 CDP 参数启动。如果 `9222` +> 已在线,“启动”只会聚焦当前 Codex;否则会启动所选 Stable 或 Beta 通道。 ## 配置 @@ -111,6 +106,10 @@ curl http://127.0.0.1:17373/state New、Fork、Steer、Mic、Submit 和 Latest Task & Scroll 旋钮 Action,不需要额外的 设备 daemon 或快捷键映射。 +Windows 聚焦始终绑定当前 CDP listener 对应的 PID。adapter 会重新校验缓存窗口, +优先选择无 owner 的可见顶层主窗口,并只在同一 PID 内回退到其他可见窗口;不会依赖 +窗口标题、带版本号的 Appx 路径或全局键盘钩子。 + 选中任意已配置的 Action,即可打开共用的 **Codex Bridge 设置**页。该页面显示 Bridge 安装、后台服务和 CDP 状态,并提供“安装 / 修复”和“启动”操作。 @@ -123,7 +122,7 @@ Action 行为和布局建议见[配置详解](docs/configuration.md)。 | Skill | 用途 | | --- | --- | -| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | 安装、更新、验证或修复 `Codex Bridge.app` 与 sidecar | +| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | 安装、更新、验证或修复 Codex Bridge 与 sidecar | | [`install-ulanzi-studio-plugin`](skills/install-ulanzi-studio-plugin/SKILL.md) | 把仓库中的预构建插件目录安装到 Ulanzi Studio | ## 文档 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2ee2354..9431442 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -37,6 +37,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Koffi + +- Project: https://koffi.dev/ +- Use: Low-overhead in-process Win32 FFI for Codex Desktop window activation +- License: MIT + +Copyright 2023 Niels Martignène + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## esbuild - Project: https://github.com/evanw/esbuild diff --git a/docs/architecture.md b/docs/architecture.md index fe3e9f7..04d8f9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,8 +19,11 @@ Codex renderer Micro store ## Codex Bridge -`Codex Bridge.app` launches the real Codex executable with CDP restricted to -`127.0.0.1:9222`. The sidecar keeps a persistent renderer connection and +Codex Bridge launches the real Codex executable with CDP restricted to +`127.0.0.1:9222`. Windows discovers Stable or Beta through Appx metadata and the +Ulanzi plugin supervises a current-user, on-demand scheduled task running at +`HighestAvailable`; macOS retains the wrapper app and user LaunchAgent. The +sidecar keeps a persistent renderer connection and refreshes an in-memory snapshot every 500 ms. `/state` reads that cache rather than triggering a fresh renderer scan for every plugin poll. @@ -29,9 +32,11 @@ context map, and usage query clients. Those references are cached for the renderer lifecycle and rediscovered only when they become invalid. Task activation and Fast, Fork, Submit, and Mic use Codex Micro events. Pin, -New, and Steer invoke the matching semantic renderer controls. An uncertain -HTTP failure is never replayed through another mechanism because the first -request may already have executed. +New, Steer, and the three model presets invoke semantic renderer controls. The +model adapter requires the visible intelligence trigger, validates the exact +model option and reasoning-effort ordering, and verifies the final selection. +An uncertain HTTP failure is never replayed through another mechanism because +the first request may already have executed. ## Ulanzi Studio plugin @@ -39,9 +44,27 @@ The plugin is a Node.js JavaScript plugin using protocol V3.0.0. It polls the Bridge state, keeps each Ulanzi action instance keyed by its context, updates task icons and titles, and forwards keydown/keyup events to the Bridge. +The installed Bridge is identified by plugin version plus the SHA-256 of its +runtime. `/health` reports the running version and runtime hash. Plugin startup +reconciles an existing Bridge when metadata, the installed file, or the running +process differs from the bundled runtime; replacement uses a targeted Bridge-only +stop, a cross-process lifecycle lock, a recoverable runtime swap, restart +confirmation, and rollback on failure. +On Windows, task code and its Node runtime are copied to an +administrator-protected `%PROGRAMDATA%` root. The task has no automatic trigger +and contains no capability token. A hash-validating protected launcher selects +only previously approved, hash-addressed runtimes; this permits silent starts and +rollback without making `%LOCALAPPDATA%` executable as administrator. Installing, +updating, or removing protected code requires one UAC confirmation. +Interrupted transactions restore runtime and metadata together before retrying; +plugin startup also applies bounded backoff for transient reconciliation errors. + The distributed entry point is the committed CommonJS file `dist/app.js`. Build and smoke checks happen during development; `scripts/install-plugin.mjs` -only validates and atomically copies the prebuilt `.ulanziPlugin` directory. +validates and atomically copies the prebuilt `.ulanziPlugin` directory, then +reconciles an already-installed Bridge before discarding the plugin backup. +Independent `install:plugin` processes share an owner-checked filesystem lock; +Bridge mutations use a separate owner-checked lifecycle lock. Ulanzi's implementation and maintenance scope is limited to `integration/com.ulanzi.codexmicro.ulanziPlugin/`. The plugin is only the @@ -51,7 +74,10 @@ development, or maintenance of CDP or Codex's CDP implementation. ## Installation boundary -- `scripts/install.mjs` installs only the Bridge sidecar and - `~/Applications/Codex Bridge.app`. -- `scripts/install-plugin.mjs` installs only the Ulanzi Studio plugin directory. +- `scripts/install.mjs` installs only the Bridge sidecar and its + platform-specific launcher/lifecycle files. Windows additionally registers the + current-user highest-privilege scheduled task; macOS remains user-level. +- `scripts/install-plugin.mjs` atomically installs the Ulanzi Studio plugin + directory and reconciles an already-installed Bridge to the bundled runtime. + It does not perform the initial Bridge installation. - `scripts/uninstall.mjs` removes those two installed components. diff --git a/docs/configuration.md b/docs/configuration.md index 62ef190..be7d55b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,11 +9,25 @@ service, and Codex CDP connection. It can install or repair the Bridge from resources bundled with the plugin, launch Codex Bridge, and recheck status. No repository or npm working directory is required for this flow. +On Windows, the native adapter revalidates a cached window against the connected +CDP process, visibility, and a non-empty title. When discovery is needed it +enumerates every displayed top-level window for that PID, prefers unowned main +window candidates, and uses an owned displayed window only as an uncached +fallback. It never selects by title across processes. + +An access-denied input-queue attachment automatically allows one +protected, complete Alt press/release after the first forced-focus verification +fails. The shared page also provides a global **Force focus** option, off by +default, that enables the same fallback for other bounded foreground denials on +Task 1–5 key presses. Neither path adds an idle hook, timer, polling loop, or +helper process. If Windows still denies focus, task navigation remains +successful and the Codex taskbar button flashes briefly. + ## Actions | Action | Behavior | | --- | --- | -| Codex Task 1–5 | Show the matching Most Recent task title and state; press to open it | +| Codex Task 1–5 | Show the matching Most Recent task title and state; press to open it and request focus, optionally using the global Windows Force focus mode | | Fast | Toggle Fast mode for the active task | | Usage | Show remaining usage; press to focus Codex | | Pin | Pin or unpin the active task | @@ -23,11 +37,22 @@ No repository or npm working directory is required for this flow. | Steer | Send the visible composer text as steering input to a running task | | Mic | Press and release the Codex Micro microphone action | | Submit | Submit or queue the composer text | +| Sol High | Switch the visible task to GPT-5.6 Sol with high reasoning effort | +| Luna Max | Switch the visible task to GPT-5.6 Luna with maximum reasoning effort | +| Sol Medium | Switch the visible task to GPT-5.6 Sol with medium reasoning effort | + +Task actions render a compact 196×196 SVG at runtime. A colored outer frame and +accent identify idle, blue working, flashing green complete, attention, or error +state. The high-contrast inner surface reserves most of the key for the task +title, which Ulanzi Studio overlays in as many as four short lines with safe +horizontal margins. +Usage is rendered at runtime from the allowance returned by the Bridge. The +Encoder action mirrors task 1's current title and status color. -Task actions dynamically use the plugin's idle, working, complete, attention, -error, and offline artwork. Usage is rendered at runtime from the allowance -returned by the Bridge. The Encoder action mirrors task 1's current title and -status artwork. +Model presets operate the visible Codex Desktop intelligence picker. They +validate the target model, the current reasoning-effort ordering, and the final +selection. A running turn is not moved to another model; the preset applies to +the next turn submitted from the visible task. ## Recommended layout @@ -37,10 +62,12 @@ placed on more than one key. ## Runtime requirements -- Start Codex through `~/Applications/Codex Bridge.app`. +- Start Codex from the Bridge setup page or with `npm run bridge:start`; Windows + resolves the current Stable or Beta Appx package dynamically. - Keep the Bridge sidecar running at `127.0.0.1:17373`. -- Allow Ulanzi Studio under macOS System Settings > Privacy & Security > - Accessibility so the Encoder can emit mouse-wheel events. +- On macOS, allow Ulanzi Studio under System Settings > Privacy & Security > + Accessibility so the Encoder can emit mouse-wheel events. On Windows, confirm + Ulanzi Studio is allowed to send the configured hotkey events. - Restart Ulanzi Studio after installing a new plugin build. The plugin does not require a separate device service, shortcut file, or theme diff --git a/docs/errors.md b/docs/errors.md index 94d8dcc..c901087 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -32,8 +32,10 @@ - Keep the last usable display during routine refreshes; use offline artwork only when the Bridge is actually unavailable. -- Task updates use relative-path icons and must not send a state index when the - manifest disables automatic states. +- Task updates use self-contained runtime SVG backgrounds and the protocol + `textData` overlay for the title. They must not send a state index when the + manifest disables automatic states. The legacy lowercase `textdata` field is + sent with the same value for compatibility with older raw-protocol hosts. - Usage refreshes must not block task input or action dispatch. - Runtime assets referenced by the manifest or plugin code must exist in the installed `.ulanziPlugin` directory. diff --git a/docs/model-picker-compatibility.md b/docs/model-picker-compatibility.md new file mode 100644 index 0000000..0986e24 --- /dev/null +++ b/docs/model-picker-compatibility.md @@ -0,0 +1,14 @@ +# 模型选择器兼容记录 + +核对日期:2026-09-07。来源:本机 Windows Stable 包 +`OpenAI.Codex_26.901.6511.0_x64__2p2nqsd0c76g0` 中的只读 `app/resources/app.asar`。 + +- `webview/assets/app-primary-428a0a65766f.js`:模型列表选择后转为 simple power 面板;两个视图同时保留在 DOM,以祖先的 `aria-hidden`、`inert` 和 `data-active` 隔离。不能把隐藏列表当作当前选项。 +- 该文件的 power 控件使用 `data-reasoning-slider`,接收 ArrowLeft/ArrowRight,逐档选择;可访问名称与等级标签经过本地化,不能用标签或截图坐标推断等级。 +- 同一控件提交到 DOM 的 React children 含 power slider 的 `options` 与 `selectedOptionId`;只读提取有界字段,不调用私有 React 回调。 +- `webview/assets/app-initial-f87238153a19.js`:档位 ID 为 `model:reasoningEffort`,方向键按当前选项数组相邻移动。数组可含其他模型或锁定档位,插件必须在移动前检查完整路径,不跨模型、不越过锁定项。 +- 输入框旁 trigger 可只显示本地化等级,不再保证含模型名称。面板打开时根据 slider 的当前 model ID,并结合 trigger 的 `data-selected-reasoning-effort` 校验。 + +这些是版本敏感的 Desktop adapter 契约,不是 OpenAI 公共 API。 +自动回归执行真实生成表达式,覆盖独立视图、隐藏列表、中文标签、多步升降、 +禁用/缺失/歧义元数据、锁定路径及超时不重放。尚不代表 Studio/D200 的真实按键验收。 diff --git a/docs/setup-and-operations.md b/docs/setup-and-operations.md index f21d2b6..008a724 100644 --- a/docs/setup-and-operations.md +++ b/docs/setup-and-operations.md @@ -8,72 +8,107 @@ Codex Desktop <-> Codex Bridge sidecar <-> Ulanzi Studio plugin <-> D200 ## Dependencies -- macOS; -- Codex Desktop; -- Ulanzi Studio with a supported keypad device; -- Node.js 20 or newer only for repository-based manual installation. - -## 1. Set up Codex Bridge.app in Ulanzi Studio - -Drag any Codex Micro action onto a key and select it. In the shared -**Codex Bridge Setup** page, choose **Install / Repair**. The plugin installs -its bundled Bridge runtime, wrapper app, and user LaunchAgent without requiring -a repository path, npm, or administrator access. Then choose -**Launch Codex Bridge** and wait for the CDP status to become connected. - -## 2. Set up Codex Bridge.app from the repository - -From the repository root: - -```bash -npm install -npm run setup -``` - -The installer builds the sidecar, writes and starts -`io.opencodexmicro.bridge`, and ad-hoc signs: +- Windows 10+ or macOS 13+; +- Codex Desktop Stable or Beta; +- Ulanzi Studio 3.0.1+ with a supported D200 Series device; +- Node.js 20+ only for repository-based installation. + +## Install from Ulanzi Studio + +Drag any Codex Micro Action onto a key and select it. In the shared **Codex +Bridge Setup** page, choose **Install / Repair**. **Launch Codex Bridge** remains +available for manual startup and diagnostics, but it is not required before each +Task press. +The bundled installer requires no repository path, npm directory, or fixed Codex +package version. On Windows, **Install / Repair** requests administrator approval +once to register or update a highest-privilege on-demand scheduled task. Normal +Bridge starts are silent and do not display UAC again. + +Initial Bridge installation remains an explicit setup action. After that, every +plugin startup compares the bundled version and SHA-256 with both the installed +runtime and the identity reported by the running Bridge. Windows code updates are +deferred to the next explicit **Install / Repair** action because replacing +administrator-protected code requires UAC; ordinary offline restarts remain +silent. An approved update changes only the managed Bridge files, restarts that +Bridge process, and waits for the new process to report the expected build; Codex +Desktop is not terminated. +Interrupted updates restore both runtime and metadata backups and retry once in +the same lifecycle operation. Other transient startup failures use three bounded +background retries with backoff while Ulanzi Studio remains open. + +After Bridge is installed and current, pressing a configured Task key while +ChatGPT/CDP is unavailable invokes the same launch path automatically. The plugin +waits for Bridge state to reconnect before resolving the displayed slot and +requesting navigation. This automatic path does not use a desktop shortcut and +does not store a versioned `WindowsApps` executable path. + +Windows installs Bridge data under: ```text -~/Applications/Codex Bridge.app +%LOCALAPPDATA%\OpenCodexMicro ``` -To install without starting the sidecar LaunchAgent: +The capability token and active runtime metadata remain in that user directory. +Executable copies used by the highest-privilege task are hash-addressed and +administrator-protected under: -```bash -npm run setup -- --no-start +```text +%PROGRAMDATA%\OpenCodexMicro\Bridge ``` -## 3. Install the Ulanzi Studio plugin +The on-demand task is named `OpenCodexMicro Codex Bridge`, has no trigger, is +restricted to the current interactive user, and uses `RunLevel Highest`. Its +definition contains protected paths and hashes but never the capability token. +The Ulanzi plugin starts and stops that task silently. The +launcher reads Appx metadata for `OpenAI.Codex` and `OpenAI.CodexBeta`, preferring +Stable unless `CODEX_DESKTOP_CHANNEL=beta` is configured. -Quit Ulanzi Studio before replacing a plugin that is currently loaded, then -run: +macOS keeps the wrapper and LaunchAgent flow: -```bash -npm run install:plugin +```text +~/Applications/Codex Bridge.app +~/Library/Application Support/OpenCodexMicro +~/Library/LaunchAgents/io.opencodexmicro.bridge.plist ``` -The repository ships the prebuilt `dist/app.js`. The command validates that -the manifest entry point exists and atomically replaces: +## Install from the repository + +From `plugins/codex-console`: ```text -~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin +npm install +npm run install:plugin +npm run setup ``` -Restart Ulanzi Studio and confirm the **Codex Micro** category and actions are -visible. +Quit Ulanzi Studio before replacing a loaded plugin. `install:plugin` validates +the manifest, entry point, Property Inspector, icons, banners, locales, and +bundled Bridge resources before atomically replacing the installed directory. +When Bridge is already installed, the same command verifies it and, when Bridge +code changed, requests one administrator confirmation to update the protected +runtime before safely restarting it. A failed Bridge restart restores the +previous Bridge runtime and rolls the plugin directory back to its previous +installation. -Plugin development still uses `npm run build:plugin` and `npm run check`; commit -the updated `dist/app.js` and `dist/package.json` with source changes. +The Windows elevated helper restores the machine-level Windows PowerShell module +path before loading `Microsoft.PowerShell.Security`, so an inherited PowerShell 7 +module path cannot shadow the system `Set-Acl` implementation. If the repository +installer is already elevated, it executes the protected transaction directly +instead of requesting a redundant second elevation. -## Starting Codex - -Quit a normally launched Codex instance, then open: +Plugin destinations are: ```text -~/Applications/Codex Bridge.app +Windows: %APPDATA%\Ulanzi\UlanziDeck\Plugins\com.ulanzi.codexmicro.ulanziPlugin +macOS: ~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin ``` -The wrapper launches `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` with: +Use `npm run setup -- --no-start` to install Bridge without launching Codex, or +`npm run bridge:start` to launch an existing installation. + +## Codex and CDP lifecycle + +Codex must be launched with: ```text --remote-debugging-address=127.0.0.1 @@ -81,77 +116,77 @@ The wrapper launches `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` with: --remote-allow-origins=http://127.0.0.1:9222 ``` -CDP binds to `127.0.0.1:9222` and the sidecar API to `127.0.0.1:17373`. -Neither endpoint is reachable from the LAN. - -## Installed files and service - -```text -~/Applications/Codex Bridge.app -~/Library/Application Support/OpenCodexMicro/bridge.mjs -~/Library/Application Support/OpenCodexMicro/bridge.log -~/Library/Application Support/OpenCodexMicro/bridge-error.log -~/Library/LaunchAgents/io.opencodexmicro.bridge.plist -~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin -``` +Bridge probes `127.0.0.1:9222` first. If it is already available, Launch only +focuses Codex. If it is unavailable on Windows, Launch dynamically resolves the +selected Appx package, stops only that channel's main process, and starts its +current executable with the arguments above. No versioned WindowsApps path is +stored. A Task key uses this same behavior when the cached Bridge state is +disconnected, then polls for readiness for up to approximately six seconds. -Inspect or restart the Bridge sidecar: +Task navigation and native window focus run concurrently, but the request returns +success only after both postconditions hold. Bridge confirms the target thread +from the composer/sidebar state after native Micro dispatch, uses a bounded DOM +fallback when needed, and treats either final activation or foreground failure as +an HTTP error. The plugin logs a redacted failure category and shows an alert; it +does not report partial success or replay an uncertain write. -```bash -launchctl print "gui/$(id -u)/io.opencodexmicro.bridge" -launchctl kickstart -k "gui/$(id -u)/io.opencodexmicro.bridge" -``` +CDP binds to `127.0.0.1:9222` and Bridge to `127.0.0.1:17373`. Bridge `POST` +actions require the random capability token stored in the user data directory; +the plugin reads it locally and never sends it to the Property Inspector. ## Diagnostics -```bash -curl http://127.0.0.1:17373/health -curl http://127.0.0.1:17373/state -tail -f "$HOME/Library/Application Support/OpenCodexMicro/bridge.log" -tail -f "$HOME/Library/Application Support/OpenCodexMicro/bridge-error.log" +Read-only health endpoints remain available on loopback: + +```text +http://127.0.0.1:17373/health +http://127.0.0.1:17373/state +http://127.0.0.1:9222/json/version +http://127.0.0.1:9222/json/list ``` +Windows logs can be inspected from the Ulanzi Studio plugin log and the Bridge +status page. macOS sidecar logs remain under +`~/Library/Application Support/OpenCodexMicro`. + | Symptom | Check | | --- | --- | -| Plugin category is missing | Confirm the installed plugin directory contains `manifest.json` and `dist/app.js`, then restart Ulanzi Studio | -| Plugin keys show offline | Start Codex with `Codex Bridge.app` and check `/health` and `/state` | -| A task key does not switch | Check `bridge-error.log` and confirm `/state` contains the displayed thread | -| Steer does nothing | Confirm a running task exposes the visible composer Steer action | -| An action has no effect | Confirm the plugin can reach `127.0.0.1:17373` and inspect the Bridge log | +| Plugin category is missing | Confirm the platform plugin directory contains `manifest.json` and `dist/app.js`, then restart Ulanzi Studio | +| Bridge is not installed | Use **Install / Repair** and confirm the user data directory contains `bridge.mjs`, `bridge-token`, and `install.json` | +| Bridge is offline | Press a configured Task key once or use **Launch Codex Bridge**; on Windows verify the `OpenCodexMicro Codex Bridge` task exists and reports `HighestAvailable` | +| CDP is disconnected | A Task key should dynamically relaunch the current Appx channel; if it still fails, confirm the three loopback arguments and `/json/version` | +| A task key does not switch or focus | Confirm `/state` contains the displayed thread, then inspect the redacted trace for `task.verify-final` and `focus.complete`; success requires both | +| Usage is blank | Treat `rate-limit-status: null` as unavailable account data, not as a Windows transport failure | -## Update +## Update and uninstall -```bash -git pull +```text npm install npm run check npm run setup npm run install:plugin ``` -Restart Ulanzi Studio after updating the plugin. +`install:plugin` reconciles an existing Bridge; Windows asks for administrator +approval only when protected Bridge code must be installed or updated. `setup` +remains available for first installation or an explicit repair. Restart Ulanzi +Studio after updating the plugin. Remove Bridge and the installed plugin only +when explicitly intended: -## Uninstall - -```bash +```text npm run uninstall ``` -This stops and removes the Bridge LaunchAgent, Bridge runtime, wrapper app, and -installed Codex Micro plugin directory. +The Windows uninstaller stops the managed scheduled task, verifies that the +loopback Bridge is offline, requests administrator approval to remove the task +and protected runtime, and then removes user data. It does not remove or +terminate Codex Desktop. ## Development -```bash -npm install -npm run check -``` - -Main entry points: - -- `src/bridge/`: Codex renderer bridge; -- `scripts/build-bridge.mjs`: bundle the loopback sidecar; -- `scripts/install.mjs`: install the sidecar and wrapper app; -- `scripts/install-plugin.mjs`: validate and install the prebuilt Ulanzi plugin; -- `integration/com.ulanzi.codexmicro.ulanziPlugin/`: Ulanzi Studio plugin; -- `scripts/uninstall.mjs`: remove both installed components. +The committed release entry points are +`integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js` and +`integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs` plus +`installer/elevated-launcher.mjs`. Run the +repository verification gate after platform, installer, manifest, localization, +or build changes. diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg new file mode 100644 index 0000000..8a1bce3 --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg new file mode 100644 index 0000000..faeb66b --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg new file mode 100644 index 0000000..eddd09d --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif new file mode 100644 index 0000000..89c8bdb Binary files /dev/null and b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif differ diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg new file mode 100644 index 0000000..7723aed --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs index 965a6a7..5318b4d 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs @@ -1,10 +1,17 @@ -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { build } from "esbuild"; import { resolve } from "node:path"; const packageRoot = resolve("."); const repositoryRoot = resolve(packageRoot, "../.."); +const nodeModulesRoot = existsSync(resolve(repositoryRoot, "node_modules/koffi")) + ? resolve(repositoryRoot, "node_modules") + : resolve(repositoryRoot, "../../node_modules"); const installerRoot = resolve(packageRoot, "installer"); +const nativeRuntimeRoot = resolve(installerRoot, "native-runtime"); +const manifest = JSON.parse(await readFile(resolve(packageRoot, "manifest.json"), "utf8")); await mkdir("dist", { recursive: true }); await build({ @@ -26,10 +33,50 @@ await build({ outfile: resolve(installerRoot, "bridge.mjs"), platform: "node", target: "node20", + external: ["koffi"], + define: { + __CODEX_BRIDGE_VERSION__: JSON.stringify(String(manifest.Version)) + }, banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" } }); +await copyFile( + resolve(repositoryRoot, "src/bridge/elevated-launcher.mjs"), + resolve(installerRoot, "elevated-launcher.mjs") +); + +const nativeRuntimeFiles = [ + ["koffi/index.cjs", "node_modules/koffi/index.cjs"], + ["koffi/src/koffi/index.cjs", "node_modules/koffi/src/koffi/index.cjs"], + ["koffi/src/koffi/src/static.cjs", "node_modules/koffi/src/koffi/src/static.cjs"], + ["koffi/package.json", "node_modules/koffi/package.json"], + ["koffi/LICENSE.txt", "node_modules/koffi/LICENSE.txt"], + ["@koromix/koffi-win32-x64/index.js", "node_modules/@koromix/koffi-win32-x64/index.js"], + ["@koromix/koffi-win32-x64/package.json", "node_modules/@koromix/koffi-win32-x64/package.json"], + ["@koromix/koffi-win32-x64/README.md", "node_modules/@koromix/koffi-win32-x64/README.md"], + ["@koromix/koffi-win32-x64/win32_x64/koffi.node", "node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node"] +]; +await rm(nativeRuntimeRoot, { recursive: true, force: true }); +const manifestFiles = []; +for (const [sourceRelative, destinationRelative] of nativeRuntimeFiles) { + const source = resolve(nodeModulesRoot, sourceRelative); + const destination = resolve(nativeRuntimeRoot, destinationRelative); + await mkdir(resolve(destination, ".."), { recursive: true }); + await copyFile(source, destination); + const sha256 = createHash("sha256").update(await readFile(destination)).digest("hex"); + manifestFiles.push({ path: destinationRelative.replaceAll("\\", "/"), sha256 }); +} +const nativeRuntimeHash = createHash("sha256") + .update(manifestFiles.map(file => `${file.path}:${file.sha256}`).join("\n")) + .digest("hex"); +await writeFile(resolve(nativeRuntimeRoot, "native-runtime.json"), `${JSON.stringify({ + version: 1, + runtimeHash: nativeRuntimeHash, + platform: "win32", + architecture: "x64", + files: manifestFiles +}, null, 2)}\n`); await copyFile(resolve(repositoryRoot, "bridge/CodexBridge.png"), resolve(installerRoot, "CodexBridge.png")); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { await copyFile(resolve(repositoryRoot, notice), resolve(installerRoot, notice)); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json index fb0a678..758d644 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Codex-Desktop-Aufgaben und -Aktionen mit der Ulanzi D200 Series in Ulanzi Studio steuern.", - "Description": "Verwandeln Sie die Ulanzi D200 Series über Ulanzi Studio in eine dedizierte Steueroberfläche für Codex Desktop.\n\nFUNKTIONEN\n- Zeigt fünf aktuelle Codex-Aufgaben mit Live-Status und öffnet exakt die ausgewählte Aufgabe.\n- Bietet Fast, Usage, Pin, New, Fork, Steer, Mic und Submit.\n- Mit Latest Task & Scroll öffnen Sie task1 und scrollen Codex über den Encoder.\n- Codex Bridge über die Einstellungsseite jeder Action installieren, reparieren, starten und prüfen.\n- Hält die Kommunikation über Codex Bridge.app und ausschließlich lokale Loopback-Endpunkte auf dem Mac.\n\nINSTALLATIONSUMGEBUNG\n- macOS 13 oder neuer\n- Codex Desktop\n- Ulanzi Studio 3.0.1 oder neuer\n- In Ulanzi Studio verbundene Ulanzi D200 Series\n- Node.js 20 oder neuer nur für die manuelle Einrichtung aus dem Repository\n\nLLM-/AGENT-INSTALLATION\nGeben Sie in Ihrem Agent ein:\nInstalliere bitte das Ulanzi-Studio-Plugin für mich: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUELLE INSTALLATION\n1. https://github.com/UlanziTechnology/OpenCodexMicro klonen und das Repository öffnen.\n2. Ulanzi Studio beenden.\n3. Ausführen: npm install\n4. Ausführen: npm run install:plugin\n5. Ausführen: npm run setup\n6. Ulanzi Studio erneut öffnen, Codex Desktop beenden und ~/Applications/Codex Bridge.app starten.\n7. Die Codex-Micro-Aktionen in Ulanzi Studio den gewünschten Tasten hinzufügen.\n8. Eine Action auswählen und Installation, Dienst sowie CDP auf der Codex-Bridge-Seite prüfen.\n\nWICHTIG\nStarten Sie Codex bei Verwendung von Codex Micro immer über ~/Applications/Codex Bridge.app. Öffnen Sie Codex Desktop nicht direkt.\nStartbefehl: open ~/Applications/Codex\\ Bridge.app", + "Description": "Steuert Codex Desktop mit der Ulanzi D200 Series in Ulanzi Studio. Unterstützt fünf aktuelle Aufgaben, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit, drei GPT-5.6-Modellvoreinstellungen und Encoder-Navigation unter Windows 10 oder neuer sowie macOS 13 oder neuer. Unter Windows erkennt die Bridge Stable oder Beta dynamisch; Schreibaktionen sind durch ein lokales Capability-Token geschützt und CDP bleibt auf Loopback. Für die Repository-Einrichtung: npm install, npm run install:plugin und npm run setup.", "Actions": [ { "Name": "Codex-Aufgabe 1", "Tooltip": "Öffnet den letzten Codex-Aufgabenplatz 1 und zeigt seinen Live-Status." }, { "Name": "Codex-Aufgabe 2", "Tooltip": "Öffnet den letzten Codex-Aufgabenplatz 2 und zeigt seinen Live-Status." }, @@ -16,12 +16,28 @@ { "Name": "Codex-Aufgabe abzweigen", "Tooltip": "Zweigt die aktive Codex-Aufgabe in eine neue Aufgabe ab." }, { "Name": "Codex steuern", "Tooltip": "Sendet den aktuellen Eingabetext als Steueranweisung an die laufende Aufgabe." }, { "Name": "Codex-Mikrofon", "Tooltip": "Schaltet die Codex-Mikrofonaktion um." }, - { "Name": "An Codex senden", "Tooltip": "Sendet den aktuellen Codex-Eingabetext oder stellt ihn in die Warteschlange." } + { "Name": "An Codex senden", "Tooltip": "Sendet den aktuellen Codex-Eingabetext oder stellt ihn in die Warteschlange." }, + { "Name": "Codex Sol Hoch", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Sol mit hoher Denkintensität." }, + { "Name": "Codex Luna Maximum", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Luna mit maximaler Denkintensität." }, + { "Name": "Codex Sol Mittel", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Sol mit mittlerer Denkintensität." } ], "Localization": { + "ModelSettingsTitle": "Modellkurzbefehl (nur diese Taste)", + "PresetLabel": "Standardoptionen", + "PresetHelp": "Wählen Sie eine Standardoption oder geben Sie unten benutzerdefinierte Einstellungen ein.", + "PresetCustom": "Standard / Benutzerdefiniert", + "ModelLabel": "Modell", + "EffortLabel": "Denkintensität", + "ModelHelp": "Exakten Modellnamen aus dem Codex-Menü oder einen bekannten offiziellen ID-Alias eingeben. Eigene Anzeigenamen sind möglich. Leer verwendet den Standard dieser Taste. Änderungen werden automatisch gespeichert.", + "EffortHelp": "Nur eine in Codex für dieses Modell tatsächlich verfügbare Stufe wählen. Nicht unterstützte Werte schlagen ohne Ausweichstufe fehl. Leer verwendet die Standardstufe dieser Taste.", + "EffortDefault": "Standard", + "ModelDefaults": "Standard: {model} ({id}) / {effort}", + "ModelInvalid": "Höchstens 100 Zeichen: Buchstaben, Zahlen, Leerzeichen oder . _ + ( ) / : -. Keine Steuerzeichen oder Code-/HTML-Sonderzeichen.", + "EffortInvalid": "Eine aufgeführte Denkintensität wählen.", + "ModelLoading": "Einstellungen dieser Taste werden wiederhergestellt…", "BridgeSetup": "Codex Bridge einrichten", "BridgeSetupDescription": "Lokale Codex-Verbindung installieren, starten und prüfen.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge-Dienst", "CDPConnection": "CDP-Verbindung", "InstallRepair": "Installieren / Reparieren", @@ -30,19 +46,21 @@ "UninstallingBridge": "Wird deinstalliert…", "UninstallBridgeDone": "Codex Bridge wurde deinstalliert. Das Ulanzi-Plugin und Codex Desktop bleiben erhalten.", "UninstallBridgeConfirm": "Codex Bridge und den Hintergrunddienst deinstallieren? Das Ulanzi-Plugin und Codex Desktop werden nicht entfernt.", - "BridgeAppInfoTitle": "Was ist Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app ist ein lokaler Starter. Falls Codex bereits läuft, wird es regulär beendet und anschließend die offizielle Codex-Desktop-Datei mit ausschließlich lokalen CDP-Startparametern gestartet.", - "BridgeAppSafety": "Der Starter verändert weder Codex Desktop noch Konten, Projekte oder Benutzerdateien und gibt CDP nicht außerhalb dieses Macs frei. Er führt keine weiteren privilegierten oder destruktiven Aktionen aus. Der separate Bridge-Dienst übernimmt lokalen Status und Action-Kommunikation.", + "BridgeAppInfoTitle": "Wie startet Codex Bridge Codex?", + "BridgeAppDescription": "Unter Windows erkennt Codex Bridge Stable oder Beta dynamisch und startet die offizielle Anwendung mit Loopback-CDP-Argumenten; unter macOS verwendet es die lokale Wrapper-App.", + "BridgeAppSafety": "Der Starter verändert weder Codex Desktop noch Konten, Projekte oder Benutzerdateien. CDP bleibt lokal und Bridge-Schreibaktionen benötigen ein lokales Capability-Token.", "BackgroundActivityTitle": "Hintergrundaktivität und Node.js", - "LaunchAgentDescription": "Der LaunchAgent startet den lokalen Codex-Bridge-Dienst bei der Anmeldung und startet ihn nach einem Beenden neu. Der Dienst lauscht nur über eine Loopback-Adresse auf diesem Mac.", + "LaunchAgentDescription": "Windows verwendet eine nur für den aktuellen Benutzer bedarfsgesteuerte Aufgabe mit höchsten Rechten; normale Starts erfolgen ohne UAC. macOS verwendet einen Benutzer-LaunchAgent.", "NodeSelectionStrategy": "Installieren / Reparieren bevorzugt Node.js 20 oder neuer aus Anmeldeumgebung, PATH, Homebrew oder üblichen Installationspfaden. Nur ersatzweise wird das in Ulanzi Studio enthaltene Node verwendet.", "NodeInstallRecommendation": "Empfohlen: Installieren Sie die neueste Node.js-Version im Terminal und führen Sie Installieren / Reparieren erneut aus:", - "UlanziNodeSignatureNotice": "Beim Rückgriff auf Ulanzi Studio Node kann macOS unter Hintergrundobjekte eine Aktivität mit Ulanzi-Studio-Signatur anzeigen. Dabei läuft der Codex-Bridge-Dienst mit der Node-Datei von Ulanzi Studio.", + "UlanziNodeSignatureNotice": "Windows führt nur hashgeprüften, geschützten Bridge-Code aus. Installation, Aktualisierung und Deinstallation benötigen UAC; die Aufgabe speichert kein Capability-Token.", "CurrentNodeRuntime": "Aktuelle Laufzeit: {runtime} {version}", "SystemNode": "System-Node.js", "UlanziNode": "Ulanzi Studio Node", "UnknownNode": "Nicht installiert oder nicht erfasst", "CheckAgain": "Erneut prüfen", + "ForceFocus": "Fokus erzwingen (Windows)", + "ForceFocusDescription": "Bei einer von Windows mit „Zugriff verweigert“ abgelehnten Fokussierung wird Alt bereits einmal automatisch und geschützt simuliert. Aktivieren Sie diese Option für denselben Fallback bei anderen Fokusverweigerungen. Keine Hooks, Abfragen oder Hilfsprozesse im Leerlauf.", "InstallationGuide": "Installationsanleitung" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js index 4e698d7..e39de2e 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js @@ -301,7 +301,7 @@ var require_permessage_deflate = __commonJS({ acceptAsServer(offers) { const opts = this._options; const accepted = offers.find((params) => { - if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { return false; } return true; @@ -2263,9 +2263,9 @@ var require_websocket = __commonJS({ var http = require("http"); var net = require("net"); var tls = require("tls"); - var { randomBytes, createHash } = require("crypto"); + var { randomBytes: randomBytes2, createHash: createHash2 } = require("crypto"); var { Duplex, Readable } = require("stream"); - var { URL } = require("url"); + var { URL: URL2 } = require("url"); var PerMessageDeflate2 = require_permessage_deflate(); var Receiver2 = require_receiver(); var Sender2 = require_sender(); @@ -2766,11 +2766,11 @@ var require_websocket = __commonJS({ ); } let parsedUrl; - if (address2 instanceof URL) { + if (address2 instanceof URL2) { parsedUrl = address2; } else { try { - parsedUrl = new URL(address2); + parsedUrl = new URL2(address2); } catch { throw new SyntaxError(`Invalid URL: ${address2}`); } @@ -2801,7 +2801,7 @@ var require_websocket = __commonJS({ } } const defaultPort = isSecure ? 443 : 80; - const key = randomBytes(16).toString("base64"); + const key = randomBytes2(16).toString("base64"); const request = isSecure ? https.request : http.request; const protocolSet = /* @__PURE__ */ new Set(); let perMessageDeflate; @@ -2907,7 +2907,7 @@ var require_websocket = __commonJS({ req.abort(); let addr; try { - addr = new URL(location, address2); + addr = new URL2(location, address2); } catch (e) { const err = new SyntaxError(`Invalid URL: ${location}`); emitErrorAndClose(websocket, err); @@ -2931,7 +2931,7 @@ var require_websocket = __commonJS({ abortHandshake(websocket, socket2, "Invalid Upgrade header"); return; } - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); if (res.headers["sec-websocket-accept"] !== digest) { abortHandshake(websocket, socket2, "Invalid Sec-WebSocket-Accept header"); return; @@ -3300,7 +3300,7 @@ var require_websocket_server = __commonJS({ var EventEmitter = require("events"); var http = require("http"); var { Duplex } = require("stream"); - var { createHash } = require("crypto"); + var { createHash: createHash2 } = require("crypto"); var extension2 = require_extension(); var PerMessageDeflate2 = require_permessage_deflate(); var subprotocol2 = require_subprotocol(); @@ -3607,7 +3607,7 @@ var require_websocket_server = __commonJS({ ); } if (this._state > RUNNING) return abortHandshake(socket2, 503); - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); const headers = [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", @@ -3706,21 +3706,487 @@ var import_websocket_server = __toESM(require_websocket_server(), 1); var wrapper_default = import_websocket.default; // plugin/app.js -var import_node_fs2 = require("node:fs"); -var import_node_path2 = require("node:path"); +var import_node_crypto2 = require("node:crypto"); +var import_node_fs3 = require("node:fs"); +var import_node_path4 = require("node:path"); // plugin/bridge-installer.js -var import_node_fs = require("node:fs"); +var import_node_fs2 = require("node:fs"); var import_promises = require("node:fs/promises"); -var import_node_child_process = require("node:child_process"); -var import_node_util = require("node:util"); +var import_node_child_process2 = require("node:child_process"); +var import_node_crypto = require("node:crypto"); +var import_node_util2 = require("node:util"); var import_node_os = require("node:os"); +var import_node_path3 = require("node:path"); + +// ../../src/bridge/platform.mjs +var import_node_child_process = require("node:child_process"); +var import_node_fs = require("node:fs"); var import_node_path = require("node:path"); +var import_node_util = require("node:util"); var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile); +var CDP_HOST = "127.0.0.1"; +var DEFAULT_CDP_PORT = 9222; +var CDP_ARGUMENTS = Object.freeze([ + `--remote-debugging-address=${CDP_HOST}`, + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, + `--remote-allow-origins=http://${CDP_HOST}:${DEFAULT_CDP_PORT}` +]); +var WINDOWS_PROCESS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$processNames = @('ChatGPT', 'ChatGPT (Beta)', 'Codex', 'Codex (Beta)') +$existing = @(Get-Process -Name $processNames -ErrorAction SilentlyContinue) +if ($existing.Count -eq 0) { '[]'; exit 0 } +$listenerOwners = @{} +try { + netstat.exe -ano -p tcp | ForEach-Object { + if ($_ -match '^\s*TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)') { + $listenerOwners[('{0}:{1}' -f $matches[1], $matches[2])] = $true + } + } +} catch { + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | + ForEach-Object { $listenerOwners[('{0}:{1}' -f $_.LocalPort, $_.OwningProcess)] = $true } +} +$rows = Get-CimInstance Win32_Process -Filter "Name LIKE 'ChatGPT%.exe' OR Name LIKE 'Codex%.exe'" | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | + ForEach-Object { + $portMatch = [regex]::Match([string]$_.CommandLine, '--remote-debugging-port(?:=|\s+)(\d+)') + $debugPort = if ($portMatch.Success) { [int]$portMatch.Groups[1].Value } else { 0 } + [pscustomobject]@{ + processId = [int]$_.ProcessId + executable = [string]$_.ExecutablePath + commandLine = [string]$_.CommandLine + ownsDebugPort = $debugPort -gt 0 -and $listenerOwners.ContainsKey(('{0}:{1}' -f $debugPort, $_.ProcessId)) + } + } +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_PACKAGE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { + $package = Get-AppxPackage -Name $name | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $package) { continue } + $app = Join-Path $package.InstallLocation 'app' + $names = if ($name -eq 'OpenAI.CodexBeta') { + @('ChatGPT (Beta).exe', 'Codex (Beta).exe', 'ChatGPT.exe') + } else { + @('ChatGPT.exe', 'Codex.exe') + } + foreach ($file in $names) { + $candidate = Join-Path $app $file + if (Test-Path -LiteralPath $candidate) { + [pscustomobject]@{ + channel = if ($name -eq 'OpenAI.CodexBeta') { 'beta' } else { 'stable' } + packageName = $name + packageFullName = $package.PackageFullName + executable = $candidate + } + break + } + } +} +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE +$processNames = @('ChatGPT', 'ChatGPT (Beta)', 'Codex', 'Codex (Beta)') +$existing = @(Get-Process -Name $processNames -ErrorAction SilentlyContinue) +if ($existing.Count -eq 0) { exit 0 } +$processes = @(Get-CimInstance Win32_Process -Filter "Name LIKE 'ChatGPT%.exe' OR Name LIKE 'Codex%.exe'" | + Where-Object { + $_.ExecutablePath -eq $target -and + $_.CommandLine -notlike '*--type=*' -and + $_.CommandLine -notlike '*crashpad-handler*' + }) +foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -ErrorAction Stop } +foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout 8 -ErrorAction SilentlyContinue } +`; +function powershellArgs(command, extra = []) { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command, + ...extra + ]; +} +function powershellOptions(options = {}) { + return { ...options, windowsHide: true }; +} +function processChannel(executable, commandLine) { + const identity = `${executable || ""} ${commandLine || ""}`.toLowerCase(); + return /codexbeta|chatgpt\s*\(beta\)|codex\s*\(beta\)/.test(identity) ? "beta" : "stable"; +} +function debugProcessesFromCommandLines(text) { + const source = String(text || "").trim(); + let rows = []; + if (source.startsWith("[") || source.startsWith("{")) { + try { + const parsed = JSON.parse(source); + rows = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + } + } + if (rows.length === 0) { + rows = source.split(/\r?\n/).filter(Boolean).map((commandLine) => ({ commandLine })); + } + return rows.flatMap((row) => { + const commandLine = String(row?.commandLine || ""); + if (!commandLine.includes("--remote-debugging-address=127.0.0.1")) return []; + if (commandLine.includes("--type=")) return []; + const port2 = Number(commandLine.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535) return []; + const processId = Number(row?.processId); + const executable = typeof row?.executable === "string" ? row.executable : null; + return [{ + port: port2, + processId: Number.isInteger(processId) && processId > 0 ? processId : null, + executable, + channel: processChannel(executable, commandLine), + ownsDebugPort: row?.ownsDebugPort === true + }]; + }); +} +async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); +} +async function processCommandLines(platform, execute) { + if (platform === "win32") { + return (await execute( + "powershell.exe", + powershellArgs(WINDOWS_PROCESS_COMMAND), + powershellOptions({ timeout: 4e3 }) + )).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4e3 })).stdout; + } + return ""; +} +async function discoverDebugEndpoint({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + let processes = []; + try { + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); + } catch { + } + const candidates = [preferredPort, ...processes.map((item) => item.port)]; + for (const port2 of [...new Set(candidates)]) { + try { + await fetchJson(`http://${CDP_HOST}:${port2}/json/version`, 500, fetchImpl); + const process2 = processes.find((item) => item.port === port2 && item.ownsDebugPort) ?? processes.find((item) => item.port === port2); + return { + port: port2, + processId: process2?.processId ?? null, + executable: process2?.executable ?? null, + channel: process2?.channel ?? null + }; + } catch { + } + } + throw new Error("Codex is not running with the local debug bridge"); +} +function fastDiscoverWindowsCodexExecutables() { + if (process.platform !== "win32") return []; + const programFiles = process.env.ProgramFiles || "C:\\Program Files"; + const windowsApps = (0, import_node_path.join)(programFiles, "WindowsApps"); + if (!(0, import_node_fs.existsSync)(windowsApps)) return []; + let entries = []; + try { + entries = (0, import_node_fs.readdirSync)(windowsApps); + } catch { + return []; + } + const rows = []; + for (const name of ["OpenAI.Codex", "OpenAI.CodexBeta"]) { + const pkgs = entries.filter((entry) => entry.startsWith(`${name}_`)); + if (pkgs.length === 0) continue; + pkgs.sort().reverse(); + for (const pkg of pkgs) { + const appDir = (0, import_node_path.join)(windowsApps, pkg, "app"); + const candidates = name === "OpenAI.CodexBeta" ? ["ChatGPT (Beta).exe", "Codex (Beta).exe", "ChatGPT.exe"] : ["ChatGPT.exe", "Codex.exe"]; + let found = null; + for (const file of candidates) { + const fullPath = (0, import_node_path.join)(appDir, file); + if ((0, import_node_fs.existsSync)(fullPath)) { + found = fullPath; + break; + } + } + if (found) { + rows.push({ + channel: name === "OpenAI.CodexBeta" ? "beta" : "stable", + packageName: name, + packageFullName: pkg, + executable: found + }); + break; + } + } + } + return rows; +} +async function discoverWindowsCodexExecutables({ execute = execFileAsync } = {}) { + if (execute === execFileAsync) { + try { + const fast = fastDiscoverWindowsCodexExecutables(); + if (fast.length > 0) return fast; + } catch { + } + } + const { stdout = "" } = await execute( + "powershell.exe", + powershellArgs(WINDOWS_PACKAGE_COMMAND), + powershellOptions({ timeout: 8e3 }) + ); + if (!String(stdout).trim()) return []; + const parsed = JSON.parse(String(stdout)); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.filter( + (row) => ["stable", "beta"].includes(row?.channel) && typeof row?.executable === "string" && row.executable.toLowerCase().endsWith(".exe") + ); +} +async function launchWindowsCodex({ + channel = "stable", + execute = execFileAsync, + spawnProcess = import_node_child_process.spawn +} = {}) { + const installations = await discoverWindowsCodexExecutables({ execute }); + const selected = installations.find((item) => item.channel === channel) ?? installations.find((item) => item.channel === "stable") ?? installations[0]; + if (!selected) { + throw new Error("Codex Desktop Stable or Beta was not found in the current Windows account."); + } + await execute( + "powershell.exe", + powershellArgs(WINDOWS_STOP_EXECUTABLE_COMMAND), + powershellOptions({ + timeout: 1e4, + env: { ...process.env, CODEX_BRIDGE_TARGET_EXECUTABLE: selected.executable } + }) + ); + const child = spawnProcess(selected.executable, [...CDP_ARGUMENTS], { + detached: true, + stdio: "ignore", + windowsHide: false + }); + child.unref?.(); + return selected; +} + +// plugin/windows-elevated-task.js +var import_node_buffer = require("node:buffer"); +var import_node_path2 = require("node:path"); +var WINDOWS_BRIDGE_TASK_NAME = "OpenCodexMicro Codex Bridge"; +function psLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} +function nativeFileAssertions(rootExpression, files) { + return files.map((file) => { + const relative = String(file.path).replaceAll("/", "\\"); + return `Assert-FileHash (Join-Path ${rootExpression} ${psLiteral(relative)}) ${psLiteral(file.sha256)}`; + }).join("\n"); +} +function createWindowsElevatedTaskManager({ + execute, + dataRoot, + secureRoot, + bridgePort = 17373, + taskName = WINDOWS_BRIDGE_TASK_NAME +}) { + const secureNode = (0, import_node_path2.join)(secureRoot, "node.exe"); + const secureLauncher = (0, import_node_path2.join)(secureRoot, "elevated-launcher.mjs"); + const expectedArguments = `"${secureLauncher}" "${dataRoot}"`; + async function runElevated(script) { + const systemModuleBootstrap = "$env:PSModulePath=[Environment]::GetEnvironmentVariable('PSModulePath','Machine'); Import-Module Microsoft.PowerShell.Security -ErrorAction Stop"; + const encoded = import_node_buffer.Buffer.from(`${systemModuleBootstrap} +${script}`, "utf16le").toString("base64"); + const bootstrap = "$isAdmin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); if ($isAdmin) { & powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand $env:CODEX_BRIDGE_ELEVATED_SCRIPT; exit $LASTEXITCODE }; $p=Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$env:CODEX_BRIDGE_ELEVATED_SCRIPT); if ($p.ExitCode -ne 0) { exit $p.ExitCode }"; + try { + await execute("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + bootstrap + ], { + timeout: 12e4, + windowsHide: true, + env: { ...process.env, CODEX_BRIDGE_ELEVATED_SCRIPT: encoded } + }); + } catch (error) { + throw new Error(`Administrator approval or elevated Codex Bridge operation failed: ${error.message}`); + } + } + async function status() { + try { + const { stdout = "" } = await execute("schtasks.exe", [ + "/Query", + "/TN", + taskName, + "/XML" + ], { + timeout: 3e3, + windowsHide: true + }); + const output = String(stdout); + const commandMatch = output.match(/([\s\S]*?)<\/Command>/i); + const argumentsMatch = output.match(/([\s\S]*?)<\/Arguments>/i); + const runLevelMatch = output.match(/([\s\S]*?)<\/RunLevel>/i); + if (commandMatch && argumentsMatch && runLevelMatch) { + const command = commandMatch[1].trim(); + const args = argumentsMatch[1].trim(); + const runLevel = runLevelMatch[1].trim(); + const isHighest = /highest/i.test(runLevel); + return { + installed: isHighest && command.toLowerCase() === secureNode.toLowerCase() && args.toLowerCase() === expectedArguments.toLowerCase(), + runLevel: isHighest ? "highest" : null + }; + } + } catch { + } + try { + const statusScript = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $task = Get-ScheduledTask -TaskName ${psLiteral(taskName)} -ErrorAction Stop; $action = @($task.Actions)[0]; @{ runLevel = [string]$task.Principal.RunLevel; execute = [string]$action.Execute; arguments = [string]$action.Arguments } | ConvertTo-Json -Compress`; + const { stdout = "" } = await execute("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + statusScript + ], { + timeout: 5e3, + windowsHide: true + }); + const definition = JSON.parse(String(stdout).trim()); + return { + installed: definition.runLevel === "Highest" && String(definition.execute).toLowerCase() === secureNode.toLowerCase() && String(definition.arguments).toLowerCase() === expectedArguments.toLowerCase(), + runLevel: definition.runLevel === "Highest" ? "highest" : null + }; + } catch { + return { installed: false, runLevel: null }; + } + } + async function deploy({ + nodeSource, + nodeHash, + launcherSource, + launcherHash, + runtimeSource, + runtimeHash, + nativeSource, + nativeRuntimeHash, + nativeFiles, + takeOverExistingBridge = false + }) { + const runtimeTarget = (0, import_node_path2.join)(secureRoot, "runtimes", runtimeHash); + const nativeTarget = (0, import_node_path2.join)(secureRoot, "native-runtimes", nativeRuntimeHash); + const script = `$ErrorActionPreference = 'Stop' +$secureRoot = ${psLiteral(secureRoot)} +$nodeSource = ${psLiteral(nodeSource)} +$launcherSource = ${psLiteral(launcherSource)} +$runtimeSource = ${psLiteral(runtimeSource)} +$nativeSource = ${psLiteral(nativeSource)} +$runtimeTarget = ${psLiteral(runtimeTarget)} +$nativeTarget = ${psLiteral(nativeTarget)} +$nodeTarget = ${psLiteral(secureNode)} +$launcherTarget = ${psLiteral(secureLauncher)} +${takeOverExistingBridge ? `$listenerPids = @(Get-NetTCPConnection -LocalPort ${Number(bridgePort)} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) +foreach ($listenerPid in $listenerPids) { + $listenerProcess = Get-Process -Id $listenerPid -ErrorAction Stop + if ($listenerProcess.ProcessName -notlike 'node*') { throw 'The Codex Bridge port is owned by an unexpected process.' } + Stop-Process -Id $listenerPid -Force -ErrorAction Stop + Wait-Process -Id $listenerPid -Timeout 5 -ErrorAction SilentlyContinue +} +$remainingListener = @(Get-NetTCPConnection -LocalPort ${Number(bridgePort)} -State Listen -ErrorAction SilentlyContinue) +if ($remainingListener.Count -gt 0) { throw 'The existing Codex Bridge listener could not be stopped.' }` : ""} +function Assert-FileHash([string]$Path, [string]$Expected) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing protected Bridge source file." } + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected) { throw "Protected Bridge source hash verification failed." } +} +Assert-FileHash $nodeSource ${psLiteral(nodeHash)} +Assert-FileHash $launcherSource ${psLiteral(launcherHash)} +Assert-FileHash $runtimeSource ${psLiteral(runtimeHash)} +${nativeFileAssertions("$nativeSource", nativeFiles)} +New-Item -ItemType Directory -Path $runtimeTarget -Force | Out-Null +New-Item -ItemType Directory -Path $nativeTarget -Force | Out-Null +Copy-Item -LiteralPath $nodeSource -Destination $nodeTarget -Force +Copy-Item -LiteralPath $launcherSource -Destination $launcherTarget -Force +Copy-Item -LiteralPath $runtimeSource -Destination (Join-Path $runtimeTarget 'bridge.mjs') -Force +Copy-Item -Path (Join-Path $nativeSource '*') -Destination $nativeTarget -Recurse -Force +Assert-FileHash $nodeTarget ${psLiteral(nodeHash)} +Assert-FileHash $launcherTarget ${psLiteral(launcherHash)} +Assert-FileHash (Join-Path $runtimeTarget 'bridge.mjs') ${psLiteral(runtimeHash)} +${nativeFileAssertions("$nativeTarget", nativeFiles)} +$nodeVersion = (& $nodeTarget --version 2>$null | Select-Object -First 1) +if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^v?(2[0-9]|[3-9][0-9])\\.') { throw 'The protected Node.js runtime is not compatible.' } +$administrators = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +function Protect-BridgeItem([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force + $isDirectory = $item.PSIsContainer + $acl = if ($isDirectory) { [System.Security.AccessControl.DirectorySecurity]::new() } else { [System.Security.AccessControl.FileSecurity]::new() } + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administrators) + $inherit = if ($isDirectory) { [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' } else { [System.Security.AccessControl.InheritanceFlags]::None } + $propagation = [System.Security.AccessControl.PropagationFlags]::None + $allow = [System.Security.AccessControl.AccessControlType]::Allow + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($administrators, 'FullControl', $inherit, $propagation, $allow)) + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($system, 'FullControl', $inherit, $propagation, $allow)) + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($currentUser, 'ReadAndExecute', $inherit, $propagation, $allow)) + Set-Acl -LiteralPath $Path -AclObject $acl +} +Protect-BridgeItem $secureRoot +Get-ChildItem -LiteralPath $secureRoot -Recurse -Force | ForEach-Object { Protect-BridgeItem $_.FullName } +$action = New-ScheduledTaskAction -Execute $nodeTarget -Argument (${psLiteral(`"${secureLauncher}" "${dataRoot}"`)}) -WorkingDirectory $secureRoot +$principal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName ${psLiteral(taskName)} -Action $action -Principal $principal -Settings $settings -Force | Out-Null +`; + await runElevated(script); + } + async function start() { + await execute("schtasks.exe", ["/Run", "/TN", taskName], { timeout: 5e3, windowsHide: true }); + } + async function stop() { + try { + await execute("schtasks.exe", ["/End", "/TN", taskName], { timeout: 7e3, windowsHide: true }); + } catch { + } + } + async function uninstall() { + const script = `$ErrorActionPreference = 'Stop' +$task = Get-ScheduledTask -TaskName ${psLiteral(taskName)} -ErrorAction SilentlyContinue +if ($task) { Stop-ScheduledTask -InputObject $task -ErrorAction SilentlyContinue; Unregister-ScheduledTask -InputObject $task -Confirm:$false } +if (Test-Path -LiteralPath ${psLiteral(secureRoot)}) { Remove-Item -LiteralPath ${psLiteral(secureRoot)} -Recurse -Force } +`; + await runElevated(script); + } + return { status, deploy, start, stop, uninstall, taskName, secureRoot, secureNode, secureLauncher }; +} + +// plugin/bridge-installer.js +var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile); function xml(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } -async function exists(path, mode = import_node_fs.constants.F_OK) { +async function exists(path, mode = import_node_fs2.constants.F_OK) { try { await (0, import_promises.access)(path, mode); return true; @@ -3735,6 +4201,84 @@ async function readJson(path) { return null; } } +async function fileSha256(path) { + try { + return (0, import_node_crypto.createHash)("sha256").update(await (0, import_promises.readFile)(path)).digest("hex"); + } catch { + return null; + } +} +async function verifiedNativeRuntimeHash(root) { + const manifest = await readJson((0, import_node_path3.join)(root, "native-runtime.json")); + if (manifest?.version !== 1 || !/^[a-f0-9]{64}$/.test(String(manifest?.runtimeHash || "")) || !Array.isArray(manifest?.files)) { + return null; + } + const verified = []; + for (const file of manifest.files) { + const relative = String(file?.path || "").replaceAll("\\", "/"); + if (!relative || relative.startsWith("/") || relative.split("/").includes("..")) return null; + const sha256 = await fileSha256((0, import_node_path3.join)(root, ...relative.split("/"))); + if (!sha256 || sha256 !== file.sha256) return null; + verified.push({ path: relative, sha256 }); + } + const computed = (0, import_node_crypto.createHash)("sha256").update(verified.map((file) => `${file.path}:${file.sha256}`).join("\n")).digest("hex"); + return computed === manifest.runtimeHash ? computed : null; +} +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} +async function acquireFilesystemLock({ + lockPath, + timeoutMs = 15e3, + staleMs = 3e5, + wait = (milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)) +}) { + const ownerToken = (0, import_node_crypto.randomBytes)(24).toString("base64url"); + await (0, import_promises.mkdir)((0, import_node_path3.dirname)(lockPath), { recursive: true }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await (0, import_promises.mkdir)(lockPath, { mode: 448 }); + await (0, import_promises.writeFile)((0, import_node_path3.join)(lockPath, "owner.json"), `${JSON.stringify({ + pid: process.pid, + ownerToken, + createdAt: (/* @__PURE__ */ new Date()).toISOString() + })} +`); + return async () => { + const owner = await readJson((0, import_node_path3.join)(lockPath, "owner.json")); + if (owner?.ownerToken === ownerToken) { + await (0, import_promises.rm)(lockPath, { recursive: true, force: true }); + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const [lockInfo, owner] = await Promise.all([ + (0, import_promises.stat)(lockPath).catch(() => null), + readJson((0, import_node_path3.join)(lockPath, "owner.json")) + ]); + if (lockInfo && Date.now() - lockInfo.mtimeMs > staleMs && !processIsAlive(Number(owner?.pid))) { + const abandonedLock = `${lockPath}.abandoned-${process.pid}-${ownerToken}`; + try { + await (0, import_promises.rename)(lockPath, abandonedLock); + await (0, import_promises.rm)(abandonedLock, { recursive: true, force: true }); + } catch (recoveryError) { + if (!await exists(lockPath)) continue; + if (!["ENOENT", "EEXIST", "EPERM", "EACCES"].includes(recoveryError?.code)) throw recoveryError; + } + continue; + } + await wait(100); + } + } + throw new Error("Another lifecycle operation is still running."); +} async function nodeVersion(executable, execute) { try { const { stdout = "" } = await execute(executable, ["--version"]); @@ -3745,15 +4289,25 @@ async function nodeVersion(executable, execute) { return null; } } +function bridgeDataRoot({ + platform = process.platform, + home = (0, import_node_os.homedir)(), + localAppData = process.env.LOCALAPPDATA +} = {}) { + if (platform === "win32") { + return (0, import_node_path3.join)(localAppData || (0, import_node_path3.join)(home, "AppData", "Local"), "OpenCodexMicro"); + } + return (0, import_node_path3.join)(home, "Library", "Application Support", "OpenCodexMicro"); +} async function selectBridgeNodeRuntime({ home = (0, import_node_os.homedir)(), fallbackNodeExecutable = process.execPath, environmentPath = process.env.PATH || "", platform = process.platform, - execute = execFileAsync + execute = execFileAsync2 } = {}) { const candidates = []; - if (platform === "darwin" && await exists("/bin/zsh", import_node_fs.constants.X_OK)) { + if (platform === "darwin" && await exists("/bin/zsh", import_node_fs2.constants.X_OK)) { try { const { stdout = "" } = await execute("/bin/zsh", ["-lic", "node -p process.execPath"]); const discovered = String(stdout).split(/\r?\n/).map((line) => line.trim()).find((line) => line.startsWith("/") && line.split("/").at(-1) === "node"); @@ -3761,34 +4315,37 @@ async function selectBridgeNodeRuntime({ } catch { } } - for (const directory of environmentPath.split(import_node_path.delimiter).filter(Boolean)) { - candidates.push((0, import_node_path.join)(directory, "node")); + const nodeName = platform === "win32" ? "node.exe" : "node"; + for (const directory of environmentPath.split(import_node_path3.delimiter).filter(Boolean)) { + candidates.push((0, import_node_path3.join)(directory, nodeName)); + } + if (platform === "darwin") { + candidates.push( + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node", + (0, import_node_path3.join)(home, ".local", "bin", "node") + ); } - candidates.push( - "/opt/homebrew/bin/node", - "/usr/local/bin/node", - "/usr/bin/node", - (0, import_node_path.join)(home, ".local", "bin", "node") - ); let resolvedFallback = fallbackNodeExecutable; try { resolvedFallback = await (0, import_promises.realpath)(fallbackNodeExecutable); } catch { } for (const executable of [...new Set(candidates)]) { - if (!executable.startsWith("/")) continue; - if (!await exists(executable, import_node_fs.constants.X_OK)) continue; + if (!(0, import_node_path3.isAbsolute)(executable)) continue; + if (!await exists(executable, platform === "win32" ? import_node_fs2.constants.F_OK : import_node_fs2.constants.X_OK)) continue; const resolvedExecutable = await (0, import_promises.realpath)(executable); if (resolvedExecutable === resolvedFallback) continue; - const version = await nodeVersion(resolvedExecutable, execute); - if (version?.major >= 20) { - return { executable: resolvedExecutable, version: version.text, source: "system" }; + const candidateVersion = await nodeVersion(resolvedExecutable, execute); + if (candidateVersion?.major >= 20) { + return { executable: resolvedExecutable, version: candidateVersion.text, source: "system" }; } } - if (await exists(resolvedFallback, import_node_fs.constants.X_OK)) { - const version = await nodeVersion(resolvedFallback, execute); - if (version?.major >= 20) { - return { executable: resolvedFallback, version: version.text, source: "ulanzi" }; + if (await exists(resolvedFallback, platform === "win32" ? import_node_fs2.constants.F_OK : import_node_fs2.constants.X_OK)) { + const fallbackVersion = await nodeVersion(resolvedFallback, execute); + if (fallbackVersion?.major >= 20) { + return { executable: resolvedFallback, version: fallbackVersion.text, source: "ulanzi" }; } } throw new Error("No compatible Node.js 20 or later runtime was found."); @@ -3798,66 +4355,193 @@ function createBridgeInstaller({ bridgeUrl, version, home = (0, import_node_os.homedir)(), + localAppData = process.env.LOCALAPPDATA, + programData = process.env.PROGRAMDATA || "C:\\ProgramData", uid = process.getuid?.(), platform = process.platform, nodeExecutable = process.execPath, environmentPath = process.env.PATH || "", - execute = execFileAsync + codexChannel = process.env.CODEX_DESKTOP_CHANNEL || "stable", + execute = execFileAsync2, + spawnProcess = import_node_child_process2.spawn, + fetchImpl = fetch, + windowsTaskManager = null, + serviceStartTimeoutMs = 8e3, + wait = (milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)) }) { - const appRoot = (0, import_node_path.join)(home, "Library", "Application Support", "OpenCodexMicro"); - const userApplications = (0, import_node_path.join)(home, "Applications"); - const bridgeApp = (0, import_node_path.join)(userApplications, "Codex Bridge.app"); - const bridgeContents = (0, import_node_path.join)(bridgeApp, "Contents"); - const bridgeMacOS = (0, import_node_path.join)(bridgeContents, "MacOS"); - const bridgeResources = (0, import_node_path.join)(bridgeContents, "Resources"); - const bridgeLicenses = (0, import_node_path.join)(bridgeResources, "licenses"); - const bridgeExecutable = (0, import_node_path.join)(bridgeMacOS, "Codex Bridge"); - const bridgeIcon = (0, import_node_path.join)(bridgeResources, "CodexBridge.icns"); - const bridgeRuntime = (0, import_node_path.join)(appRoot, "bridge.mjs"); - const installMetadata = (0, import_node_path.join)(appRoot, "install.json"); - const agentsRoot = (0, import_node_path.join)(home, "Library", "LaunchAgents"); - const bridgeAgent = (0, import_node_path.join)(agentsRoot, "io.opencodexmicro.bridge.plist"); - const installerRoot = (0, import_node_path.resolve)(pluginRoot, "installer"); - const bundledRuntime = (0, import_node_path.join)(installerRoot, "bridge.mjs"); - const bundledIcon = (0, import_node_path.join)(installerRoot, "CodexBridge.png"); + const appRoot = bridgeDataRoot({ platform, home, localAppData }); + const bridgeRuntime = (0, import_node_path3.join)(appRoot, "bridge.mjs"); + const tokenPath = (0, import_node_path3.join)(appRoot, "bridge-token"); + const pidPath = (0, import_node_path3.join)(appRoot, "bridge.pid"); + const installMetadata = (0, import_node_path3.join)(appRoot, "install.json"); + const lifecycleLock = (0, import_node_path3.join)((0, import_node_path3.dirname)(appRoot), ".OpenCodexMicro.lifecycle.lock"); + const installerRoot = (0, import_node_path3.resolve)(pluginRoot, "installer"); + const bundledRuntime = (0, import_node_path3.join)(installerRoot, "bridge.mjs"); + const bundledElevatedLauncher = (0, import_node_path3.join)(installerRoot, "elevated-launcher.mjs"); + const bundledIcon = (0, import_node_path3.join)(installerRoot, "CodexBridge.png"); + const bundledNativeRuntime = (0, import_node_path3.join)(installerRoot, "native-runtime"); + const nativeRuntimesRoot = (0, import_node_path3.join)(appRoot, "native-runtimes"); + const runtimeBackup = (0, import_node_path3.join)(appRoot, ".bridge.mjs.previous"); + const metadataBackup = (0, import_node_path3.join)(appRoot, ".install.json.previous"); + const bridgePort = (() => { + try { + return Number(new URL(bridgeUrl).port || 17373); + } catch { + return 17373; + } + })(); + const elevatedRoot = (0, import_node_path3.join)(programData, "OpenCodexMicro", "Bridge"); + const elevatedNode = (0, import_node_path3.join)(elevatedRoot, "node.exe"); + const elevatedLauncher = (0, import_node_path3.join)(elevatedRoot, "elevated-launcher.mjs"); + const windowsTask = platform === "win32" ? windowsTaskManager || createWindowsElevatedTaskManager({ + execute, + dataRoot: appRoot, + secureRoot: elevatedRoot, + bridgePort, + taskName: WINDOWS_BRIDGE_TASK_NAME + }) : null; + const userApplications = (0, import_node_path3.join)(home, "Applications"); + const bridgeApp = (0, import_node_path3.join)(userApplications, "Codex Bridge.app"); + const bridgeContents = (0, import_node_path3.join)(bridgeApp, "Contents"); + const bridgeMacOS = (0, import_node_path3.join)(bridgeContents, "MacOS"); + const bridgeResources = (0, import_node_path3.join)(bridgeContents, "Resources"); + const bridgeLicenses = (0, import_node_path3.join)(bridgeResources, "licenses"); + const bridgeExecutable = (0, import_node_path3.join)(bridgeMacOS, "Codex Bridge"); + const bridgeIcon = (0, import_node_path3.join)(bridgeResources, "CodexBridge.icns"); + const agentsRoot = (0, import_node_path3.join)(home, "Library", "LaunchAgents"); + const bridgeAgent = (0, import_node_path3.join)(agentsRoot, "io.opencodexmicro.bridge.plist"); + let lastWindowsServiceStart = 0; + let authorizationCache = null; + let authorizationPromise = null; + let authorizationGeneration = 0; + let lifecycleOperation = null; + function resetAuthorizationCache() { + authorizationGeneration += 1; + authorizationCache = null; + authorizationPromise = null; + } + async function authorizationHeaders() { + if (authorizationCache) return authorizationCache; + if (authorizationPromise) return authorizationPromise; + const generation = authorizationGeneration; + const operation = (async () => { + try { + const token = (await (0, import_promises.readFile)(tokenPath, "utf8")).trim(); + const headers = token ? Object.freeze({ Authorization: `Bearer ${token}` }) : {}; + if (token && generation === authorizationGeneration) authorizationCache = headers; + return headers; + } catch { + return {}; + } + })(); + authorizationPromise = operation; + try { + return await operation; + } finally { + if (authorizationPromise === operation) authorizationPromise = null; + } + } async function probeBridge() { try { - const response = await fetch(`${bridgeUrl}/health`, { + const response = await fetchImpl(`${bridgeUrl}/health`, { + headers: await authorizationHeaders(), signal: AbortSignal.timeout(1200) }); const payload = await response.json(); if (!response.ok || payload.ok === false) throw new Error(payload.error || `Bridge HTTP ${response.status}`); - return { serviceOnline: true, cdpConnected: Boolean(payload.codexConnected), serviceError: null }; + return { + serviceOnline: true, + cdpConnected: Boolean(payload.codexConnected), + serviceVersion: typeof payload.bridgeVersion === "string" ? payload.bridgeVersion : null, + serviceRuntimeHash: typeof payload.runtimeHash === "string" ? payload.runtimeHash : null, + serviceNativeRuntimeHash: typeof payload.nativeRuntimeHash === "string" ? payload.nativeRuntimeHash : null, + serviceError: null + }; } catch (error) { - return { serviceOnline: false, cdpConnected: false, serviceError: error.message }; + return { + serviceOnline: false, + cdpConnected: false, + serviceVersion: null, + serviceRuntimeHash: null, + serviceNativeRuntimeHash: null, + serviceError: error.message + }; } } async function status() { - const [appInstalled, runtimeInstalled, agentInstalled, metadata, probe] = await Promise.all([ - exists(bridgeExecutable, import_node_fs.constants.X_OK), + const [ + runtimeInstalled, + tokenInstalled, + metadata, + probe, + bundledRuntimeHash, + installedRuntimeHash, + bundledLauncherHash, + installedLauncherHash, + taskStatus + ] = await Promise.all([ exists(bridgeRuntime), - exists(bridgeAgent), + exists(tokenPath), readJson(installMetadata), - probeBridge() + probeBridge(), + fileSha256(bundledRuntime), + fileSha256(bridgeRuntime), + platform === "win32" ? fileSha256(bundledElevatedLauncher) : null, + platform === "win32" ? fileSha256(elevatedLauncher) : null, + platform === "win32" ? windowsTask.status() : { installed: true, runLevel: null } ]); - const installed = appInstalled && runtimeInstalled && agentInstalled; + const bundledNativeRuntimeHash = platform === "win32" ? await verifiedNativeRuntimeHash(bundledNativeRuntime) : null; + const installedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash ? await verifiedNativeRuntimeHash((0, import_node_path3.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash)) : null; + const nativeRuntimeInstalled = platform !== "win32" || Boolean( + bundledNativeRuntimeHash && installedNativeRuntimeHash === bundledNativeRuntimeHash && metadata?.nativeRuntimeHash === bundledNativeRuntimeHash + ); + const protectedRuntimeHash = platform === "win32" && metadata?.runtimeHash ? await fileSha256((0, import_node_path3.join)(elevatedRoot, "runtimes", metadata.runtimeHash, "bridge.mjs")) : null; + const protectedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash ? await verifiedNativeRuntimeHash((0, import_node_path3.join)(elevatedRoot, "native-runtimes", metadata.nativeRuntimeHash)) : null; + const sourceNodeHash = platform === "win32" && metadata?.nodeExecutable ? await fileSha256(metadata.nodeExecutable) : null; + const protectedNodeHash = platform === "win32" ? await fileSha256(elevatedNode) : null; + const macAppInstalled = platform === "darwin" ? await exists(bridgeExecutable, import_node_fs2.constants.X_OK) : runtimeInstalled; + const agentInstalled = platform === "darwin" ? await exists(bridgeAgent) : platform === "win32" ? taskStatus.installed : true; + const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled && nativeRuntimeInstalled; + const installationDetected = Boolean( + runtimeInstalled || tokenInstalled || metadata || probe.serviceOnline || platform === "darwin" && (macAppInstalled || agentInstalled) || platform === "win32" && taskStatus.installed + ); + const metadataMatchesBundle = Boolean( + bundledRuntimeHash && metadata?.version === version && metadata?.runtimeHash === bundledRuntimeHash && installedRuntimeHash === bundledRuntimeHash && nativeRuntimeInstalled && (platform !== "win32" || Boolean( + metadata?.elevatedTask === true && metadata?.taskName === WINDOWS_BRIDGE_TASK_NAME && metadata?.launcherHash === bundledLauncherHash && installedLauncherHash === bundledLauncherHash && protectedRuntimeHash === bundledRuntimeHash && protectedNativeRuntimeHash === bundledNativeRuntimeHash && metadata?.nodeHash && sourceNodeHash === metadata.nodeHash && protectedNodeHash === metadata.nodeHash && taskStatus.runLevel === "highest" + )) + ); + const runningMatchesBundle = !probe.serviceOnline || Boolean( + bundledRuntimeHash && probe.serviceVersion === version && probe.serviceRuntimeHash === bundledRuntimeHash && (platform !== "win32" || probe.serviceNativeRuntimeHash === bundledNativeRuntimeHash) + ); return { - supported: platform === "darwin" && Number.isInteger(uid), + supported: platform === "win32" || platform === "darwin" && Number.isInteger(uid), + platform, installed, - appInstalled, - serviceInstalled: runtimeInstalled && agentInstalled, + installationDetected, + appInstalled: macAppInstalled, + serviceInstalled: runtimeInstalled && tokenInstalled && agentInstalled, installedVersion: metadata?.version || null, bundledVersion: version, - needsUpdate: !installed || metadata?.version !== version, - appPath: bridgeApp, + bundledRuntimeHash, + installedRuntimeHash, + bundledNativeRuntimeHash, + installedNativeRuntimeHash, + protectedRuntimeHash, + protectedNativeRuntimeHash, + taskInstalled: platform === "win32" ? taskStatus.installed : null, + serviceRunLevel: platform === "win32" ? taskStatus.runLevel : null, + administratorApprovalRequired: platform === "win32" && installationDetected && (!installed || !metadataMatchesBundle), + needsUpdate: !installed || !metadataMatchesBundle || !runningMatchesBundle, + appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, nodeVersion: metadata?.nodeVersion || null, nodeSource: metadata?.nodeSource || null, + codexChannel: metadata?.codexChannel || codexChannel, ...probe }; } - async function buildIcon() { - const iconset = (0, import_node_path.join)(appRoot, "CodexBridge.iconset"); + async function buildMacIcon() { + const iconset = (0, import_node_path3.join)(appRoot, "CodexBridge.iconset"); await (0, import_promises.rm)(iconset, { recursive: true, force: true }); await (0, import_promises.mkdir)(iconset, { recursive: true }); try { @@ -3873,49 +4557,24 @@ function createBridgeInstaller({ ["icon_512x512.png", 512], ["icon_512x512@2x.png", 1024] ]) { - await execute("/usr/bin/sips", [ - "-z", - String(size), - String(size), - bundledIcon, - "--out", - (0, import_node_path.join)(iconset, name) - ]); + await execute("/usr/bin/sips", ["-z", String(size), String(size), bundledIcon, "--out", (0, import_node_path3.join)(iconset, name)]); } await execute("/usr/bin/iconutil", ["-c", "icns", iconset, "-o", bridgeIcon]); } finally { await (0, import_promises.rm)(iconset, { recursive: true, force: true }); } } - async function install() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge installation is supported on macOS only."); - } - if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { - throw new Error("The plugin does not contain the Codex Bridge installation resources."); - } - const nodeRuntime = await selectBridgeNodeRuntime({ - home, - fallbackNodeExecutable: nodeExecutable, - environmentPath, - platform, - execute - }); - await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); - await (0, import_promises.chmod)(appRoot, 448); + async function installMac(nodeRuntime, appVersion = version) { await (0, import_promises.mkdir)(userApplications, { recursive: true }); await (0, import_promises.mkdir)(agentsRoot, { recursive: true }); - await (0, import_promises.copyFile)(bundledRuntime, bridgeRuntime); await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); await (0, import_promises.mkdir)(bridgeMacOS, { recursive: true }); await (0, import_promises.mkdir)(bridgeLicenses, { recursive: true }); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - const source = (0, import_node_path.join)(installerRoot, notice); - await (0, import_promises.copyFile)(source, (0, import_node_path.join)(bridgeLicenses, notice)); - await (0, import_promises.copyFile)(source, (0, import_node_path.join)(appRoot, notice)); + await (0, import_promises.copyFile)((0, import_node_path3.join)(installerRoot, notice), (0, import_node_path3.join)(bridgeLicenses, notice)); } - await buildIcon(); - const info = ` + await buildMacIcon(); + await (0, import_promises.writeFile)((0, import_node_path3.join)(bridgeContents, "Info.plist"), ` CFBundleDisplayNameCodex Bridge @@ -3924,115 +4583,615 @@ function createBridgeInstaller({ CFBundleIdentifierio.opencodexmicro.bridge CFBundleNameCodex Bridge CFBundlePackageTypeAPPL - CFBundleShortVersionString${xml(version)} - CFBundleVersion${xml(version)} + CFBundleShortVersionString${xml(appVersion)} + CFBundleVersion${xml(appVersion)} LSMinimumSystemVersion13.0 LSUIElement - NSHighResolutionCapable -`; - await (0, import_promises.writeFile)((0, import_node_path.join)(bridgeContents, "Info.plist"), info); - const launcher = `#!/bin/zsh +`); + await (0, import_promises.writeFile)(bridgeExecutable, `#!/bin/zsh set -u -unsetopt BG_NICE - codex_binary="/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" -bridge_log="$HOME/Library/Logs/OpenCodexMicro-codex-bridge.log" - -if [[ ! -x "$codex_binary" ]]; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex was not found at /Applications/ChatGPT.app." as critical' - exit 1 -fi - +if [[ ! -x "$codex_binary" ]]; then exit 1; fi if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' - for attempt in {1..80}; do - /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break - /bin/sleep 0.1 - done -fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex did not quit, so bridge parameters could not be applied. Quit Codex and try again." as critical' - exit 1 + for attempt in {1..80}; do /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break; /bin/sleep 0.1; done fi - -/usr/bin/nohup "$codex_binary" \\ - --remote-debugging-address=127.0.0.1 \\ - --remote-debugging-port=9222 \\ - --remote-allow-origins=http://127.0.0.1:9222 \\ - >>"$bridge_log" 2>&1 & - -for attempt in {1..300}; do - if /usr/bin/curl --noproxy '*' --silent --fail --max-time 0.2 \\ - http://127.0.0.1:9222/json/version >/dev/null 2>&1; then - exit 0 - fi - if ! /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - break - fi - /bin/sleep 0.1 -done - -/usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex started, but the bridge endpoint is unavailable. Quit Codex and launch Codex Bridge again." as critical' -exit 1 -`; - await (0, import_promises.writeFile)(bridgeExecutable, launcher, { mode: 493 }); +if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi +/usr/bin/nohup "$codex_binary" ${["--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--remote-allow-origins=http://127.0.0.1:9222"].join(" ")} >/dev/null 2>&1 & +`, { mode: 493 }); await (0, import_promises.chmod)(bridgeExecutable, 493); await execute("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", bridgeApp]); - const plist = ` + await (0, import_promises.writeFile)(bridgeAgent, ` Labelio.opencodexmicro.bridge - ProgramArguments - ${xml(nodeRuntime.executable)} - ${xml(bridgeRuntime)} - - RunAtLoad - KeepAlive - ProcessTypeBackground - ThrottleInterval2 - StandardOutPath${xml((0, import_node_path.join)(appRoot, "bridge.log"))} - StandardErrorPath${xml((0, import_node_path.join)(appRoot, "bridge-error.log"))} + ProgramArguments${xml(nodeRuntime.executable)}${xml(bridgeRuntime)} + EnvironmentVariables + CODEX_BRIDGE_DATA_ROOT${xml(appRoot)} + + RunAtLoadKeepAlive + ProcessTypeBackgroundThrottleInterval2 + StandardOutPath${xml((0, import_node_path3.join)(appRoot, "bridge.log"))} + StandardErrorPath${xml((0, import_node_path3.join)(appRoot, "bridge-error.log"))} +`, { mode: 420 }); + await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + async function acquireLifecycleLock() { + await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); + return acquireFilesystemLock({ lockPath: lifecycleLock, wait }); + } + function serializeLifecycle(operation) { + if (lifecycleOperation) return lifecycleOperation; + const running = (async () => { + const release = await acquireLifecycleLock(); + try { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + return await operation(); + } finally { + await release(); + } + })(); + lifecycleOperation = running; + return running.finally(() => { + if (lifecycleOperation === running) lifecycleOperation = null; + }); + } + async function stopWindowsService() { + await windowsTask.stop(); + const recordedPid = Number((await (0, import_promises.readFile)(pidPath, "utf8").catch(() => "")).trim()); + const stopScript = `$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; $targetPid=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PID, [ref]$targetPid); $targetPort=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PORT, [ref]$targetPort); $processes=@(); if ($targetPid -gt 0) { $proc=Get-Process -Id $targetPid -ErrorAction SilentlyContinue; if ($proc -and $proc.ProcessName -like 'node*') { $processes += $proc } }; if ($processes.Count -eq 0 -and $targetPort -gt 0) { $portPids=@(Get-NetTCPConnection -LocalPort $targetPort -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique); foreach ($p in $portPids) { $proc=Get-Process -Id $p -ErrorAction SilentlyContinue; if ($proc -and $proc.ProcessName -like 'node*') { $processes += $proc } } }; if ($processes.Count -eq 0) { $candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -and ($_.CommandLine.TrimEnd().EndsWith($target, [System.StringComparison]::OrdinalIgnoreCase) -or $_.CommandLine.TrimEnd().EndsWith(('"' + $target + '"'), [System.StringComparison]::OrdinalIgnoreCase)) }); $processes=$candidates }; $processes | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $processes | ForEach-Object { try { Wait-Process -Id $_.ProcessId -Timeout 5 -ErrorAction Stop } catch {} }`; + await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", stopScript], { + timeout: 7e3, + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime, + CODEX_BRIDGE_TARGET_PID: Number.isInteger(recordedPid) && recordedPid > 0 ? String(recordedPid) : "", + CODEX_BRIDGE_TARGET_PORT: String(bridgePort) + } + }); + await (0, import_promises.rm)(pidPath, { force: true }); + lastWindowsServiceStart = 0; + } + async function waitForServiceOffline() { + const deadline = Date.now() + Math.min(serviceStartTimeoutMs, 5e3); + do { + if (!(await probeBridge()).serviceOnline) return; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The managed Codex Bridge process did not stop before replacement."); + } + async function stopService() { + const wasOnline = (await probeBridge()).serviceOnline; + if (platform === "darwin") { + if (await exists(bridgeAgent)) { + try { + await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); + } catch (error) { + if (wasOnline) throw new Error(`Codex Bridge LaunchAgent could not be stopped: ${error.message}`); + } + } + if (wasOnline) await waitForServiceOffline(); + return; + } + if (platform === "win32") { + await stopWindowsService(); + if (wasOnline) await waitForServiceOffline(); + } + } + async function commitRuntime(stagedRuntime) { + await (0, import_promises.rm)(runtimeBackup, { force: true }); + if (await exists(bridgeRuntime)) await (0, import_promises.rename)(bridgeRuntime, runtimeBackup); + try { + await (0, import_promises.rename)(stagedRuntime, bridgeRuntime); + } catch (error) { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + throw error; + } + } + async function restorePreviousRuntime(previousRuntime) { + await (0, import_promises.rm)(bridgeRuntime, { force: true }); + if (await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + return; + } + if (previousRuntime) { + const rollbackRuntime = (0, import_node_path3.join)(appRoot, `.bridge.mjs.rollback-${process.pid}`); + await (0, import_promises.writeFile)(rollbackRuntime, previousRuntime); + await (0, import_promises.rename)(rollbackRuntime, bridgeRuntime); + } + } + async function commitMetadata(metadataText) { + const stagedMetadata = (0, import_node_path3.join)(appRoot, `.install.json.installing-${process.pid}`); + await (0, import_promises.writeFile)(stagedMetadata, metadataText, { mode: 384 }); + await (0, import_promises.rm)(metadataBackup, { force: true }); + if (await exists(installMetadata)) await (0, import_promises.rename)(installMetadata, metadataBackup); + try { + await (0, import_promises.rename)(stagedMetadata, installMetadata); + } catch (error) { + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + throw error; + } + } + async function restorePreviousMetadata(previousMetadataText, useBackup = true) { + await (0, import_promises.rm)(installMetadata, { force: true }); + if (useBackup && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + return; + } + await (0, import_promises.rm)(metadataBackup, { force: true }); + if (previousMetadataText) { + await (0, import_promises.writeFile)(installMetadata, previousMetadataText, { mode: 384 }); + } + } + async function discardTransactionBackups() { + await Promise.all([ + (0, import_promises.rm)(runtimeBackup, { force: true }), + (0, import_promises.rm)(metadataBackup, { force: true }) + ]); + } + async function startInstalledService(metadata) { + if (!metadata?.nodeExecutable) { + throw new Error("Codex Bridge runtime metadata is missing its Node.js executable."); + } + if (platform === "darwin") { + await installMac({ + executable: metadata.nodeExecutable, + version: metadata.nodeVersion || "unknown", + source: metadata.nodeSource || "unknown" + }, metadata.version || "0.0.0"); + } + if (platform === "win32") await ensureServiceUnlocked(); + } + async function waitForAnyService() { + const deadline = Date.now() + serviceStartTimeoutMs; + do { + const probe = await probeBridge(); + if (probe.serviceOnline) return probe; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The restored Codex Bridge service did not become reachable."); + } + async function rollbackInterruptedUpdate() { + const hasRuntimeBackup = await exists(runtimeBackup); + const hasMetadataBackup = await exists(metadataBackup); + if (!hasRuntimeBackup && !hasMetadataBackup) return false; + await stopService(); + if (hasRuntimeBackup) { + await (0, import_promises.rm)(bridgeRuntime, { force: true }); + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + if (hasMetadataBackup) { + await (0, import_promises.rm)(installMetadata, { force: true }); + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + await startInstalledService(restoredMetadata); + await waitForAnyService(); + return true; + } + async function waitForExpectedService(expectedRuntimeHash, expectedVersion = version, expectedNativeRuntimeHash = null) { + const deadline = Date.now() + serviceStartTimeoutMs; + let lastProbe = null; + do { + lastProbe = await probeBridge(); + if (lastProbe.serviceOnline && lastProbe.serviceVersion === expectedVersion && lastProbe.serviceRuntimeHash === expectedRuntimeHash && (platform !== "win32" || lastProbe.serviceNativeRuntimeHash === expectedNativeRuntimeHash)) { + return lastProbe; + } + await wait(100); + } while (Date.now() < deadline); + const detail = lastProbe?.serviceOnline ? "the running process reported a different build" : "the restarted service did not become reachable"; + throw new Error(`Codex Bridge ${expectedVersion} restart failed: ${detail}.`); + } + async function installUnlocked() { + if (!(platform === "win32" || platform === "darwin" && Number.isInteger(uid))) { + throw new Error("Codex Bridge installation is supported on Windows and macOS only."); + } + if (!await exists(bundledRuntime) || platform === "win32" && !await exists(bundledElevatedLauncher) || !await exists(bundledIcon) || platform === "win32" && !await verifiedNativeRuntimeHash(bundledNativeRuntime)) { + throw new Error("The plugin does not contain the Codex Bridge installation resources."); + } + const nodeRuntime = await selectBridgeNodeRuntime({ + home, + fallbackNodeExecutable: nodeExecutable, + environmentPath, + platform, + execute + }); + const nodeHash = platform === "win32" ? await fileSha256(nodeRuntime.executable) : null; + const launcherHash = platform === "win32" ? await fileSha256(bundledElevatedLauncher) : null; + if (platform === "win32" && (!nodeHash || !launcherHash)) { + throw new Error("The Windows elevated Bridge launch resources could not be verified."); + } + const previousStatus = await status(); + const previousRuntime = await (0, import_promises.readFile)(bridgeRuntime).catch(() => null); + const previousRuntimeHash = previousRuntime ? (0, import_node_crypto.createHash)("sha256").update(previousRuntime).digest("hex") : null; + const previousMetadataText = await (0, import_promises.readFile)(installMetadata, "utf8").catch(() => null); + const previousMetadata = await readJson(installMetadata); + const nativeRuntimeHash = platform === "win32" ? previousStatus.bundledNativeRuntimeHash : null; + const nativeRuntime = nativeRuntimeHash ? (0, import_node_path3.join)(nativeRuntimesRoot, nativeRuntimeHash) : null; + let createdNativeRuntime = false; + if (nativeRuntimeHash && await verifiedNativeRuntimeHash(nativeRuntime) !== nativeRuntimeHash) { + const stagedNativeRuntime = (0, import_node_path3.join)(appRoot, `.native-runtime.installing-${process.pid}`); + await (0, import_promises.rm)(stagedNativeRuntime, { recursive: true, force: true }); + await (0, import_promises.cp)(bundledNativeRuntime, stagedNativeRuntime, { recursive: true }); + if (await verifiedNativeRuntimeHash(stagedNativeRuntime) !== nativeRuntimeHash) { + await (0, import_promises.rm)(stagedNativeRuntime, { recursive: true, force: true }); + throw new Error("The bundled native focus runtime could not be verified."); + } + await (0, import_promises.mkdir)(nativeRuntimesRoot, { recursive: true }); + await (0, import_promises.rm)(nativeRuntime, { recursive: true, force: true }); + await (0, import_promises.rename)(stagedNativeRuntime, nativeRuntime); + createdNativeRuntime = true; + } + const stagedRuntime = (0, import_node_path3.join)(appRoot, `.bridge.mjs.installing-${process.pid}`); + await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); + if (platform !== "win32") await (0, import_promises.chmod)(appRoot, 448); + await (0, import_promises.rm)(stagedRuntime, { force: true }); + await (0, import_promises.copyFile)(bundledRuntime, stagedRuntime); + const runtimeHash = await fileSha256(stagedRuntime); + if (!runtimeHash) throw new Error("The bundled Codex Bridge runtime could not be verified."); + console.log(`[Codex Bridge] ${previousStatus.installed ? "Updating" : "Installing"} ${version}; stopping only the managed Bridge process.`); + let elevatedTakeoverRequired = false; + try { + try { + await stopService(); + } catch (stopError) { + if (platform === "win32" && previousStatus.serviceOnline && !previousStatus.taskInstalled) { + elevatedTakeoverRequired = true; + console.log("[Codex Bridge] Existing higher-privilege Bridge will be stopped inside the approved task-registration transaction."); + } else { + throw stopError; + } + } + await commitRuntime(stagedRuntime); + for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { + await (0, import_promises.copyFile)((0, import_node_path3.join)(installerRoot, notice), (0, import_node_path3.join)(appRoot, notice)); + } + if (!await exists(tokenPath)) { + await (0, import_promises.writeFile)(tokenPath, `${(0, import_node_crypto.randomBytes)(32).toString("base64url")} +`, { mode: 384 }); + } + resetAuthorizationCache(); + if (platform === "win32") { + const nativeManifest = await readJson((0, import_node_path3.join)(bundledNativeRuntime, "native-runtime.json")); + await windowsTask.deploy({ + nodeSource: nodeRuntime.executable, + nodeHash, + launcherSource: bundledElevatedLauncher, + launcherHash, + runtimeSource: bridgeRuntime, + runtimeHash, + nativeSource: bundledNativeRuntime, + nativeRuntimeHash, + nativeFiles: nativeManifest.files, + takeOverExistingBridge: elevatedTakeoverRequired + }); + if (elevatedTakeoverRequired) await waitForServiceOffline(); + } + await commitMetadata(`${JSON.stringify({ + version, + runtimeHash, + nativeRuntimeHash, + platform, + codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + ...platform === "win32" ? { + elevatedTask: true, + taskName: WINDOWS_BRIDGE_TASK_NAME, + secureRoot: elevatedRoot, + launcherHash, + nodeHash + } : {}, + installedAt: (/* @__PURE__ */ new Date()).toISOString() + }, null, 2)} +`); + if (platform === "darwin") await installMac(nodeRuntime); + if (platform === "win32") await ensureServiceUnlocked(); + await waitForExpectedService(runtimeHash, version, nativeRuntimeHash); + const installed = await status(); + if (installed.needsUpdate) throw new Error("The restarted Codex Bridge did not match the bundled runtime."); + await discardTransactionBackups(); + console.log(`[Codex Bridge] ${version} is installed and the restarted process reported the expected build.`); + return installed; + } catch (error) { + console.error(`[Codex Bridge] ${version} update failed; restoring the previous managed runtime.`); + let rollbackError = null; + try { + await stopService(); + if (previousRuntime) { + await restorePreviousRuntime(previousRuntime); + const recoveryVersion = previousMetadata?.version || previousStatus.serviceVersion || "0.0.0"; + const recoveryMetadataText = previousMetadata ? previousMetadataText : `${JSON.stringify({ + version: recoveryVersion, + runtimeHash: previousRuntimeHash, + nativeRuntimeHash: previousMetadata?.nativeRuntimeHash || null, + platform, + codexChannel: previousStatus.codexChannel || codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: (/* @__PURE__ */ new Date()).toISOString(), + recovered: true + }, null, 2)} `; - await (0, import_promises.writeFile)(bridgeAgent, plist, { mode: 420 }); - await (0, import_promises.writeFile)(installMetadata, `${JSON.stringify({ - version, - nodeExecutable: nodeRuntime.executable, - nodeVersion: nodeRuntime.version, - nodeSource: nodeRuntime.source, - installedAt: (/* @__PURE__ */ new Date()).toISOString() - }, null, 2)} + await restorePreviousMetadata(recoveryMetadataText, Boolean(previousMetadata)); + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + if (restoredMetadata?.nodeExecutable) { + const previousNodeRuntime = { + executable: restoredMetadata.nodeExecutable, + version: restoredMetadata.nodeVersion || "unknown", + source: restoredMetadata.nodeSource || "unknown" + }; + if (platform === "darwin") await installMac(previousNodeRuntime, restoredMetadata.version || "0.0.0"); + if (platform === "win32") await ensureServiceUnlocked(); + } + } else { + if (platform === "darwin") { + await (0, import_promises.rm)(bridgeAgent, { force: true }); + await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + } + if (platform === "win32") await windowsTask.uninstall(); + await (0, import_promises.rm)(appRoot, { recursive: true, force: true }); + resetAuthorizationCache(); + } + if (createdNativeRuntime && nativeRuntime && previousMetadata?.nativeRuntimeHash !== nativeRuntimeHash) { + await (0, import_promises.rm)(nativeRuntime, { recursive: true, force: true }); + } + } catch (rollbackFailure) { + rollbackError = rollbackFailure; + } + const rollbackDetail = rollbackError ? ` Rollback also failed: ${rollbackError.message}` : " Previous runtime restored."; + throw new Error(`Codex Bridge installation failed: ${error.message}${rollbackDetail}`); + } finally { + await (0, import_promises.rm)(stagedRuntime, { force: true }); + } + } + async function install() { + return serializeLifecycle(installUnlocked); + } + async function ensureServiceUnlocked() { + if (platform !== "win32") return status(); + const probe = await probeBridge(); + if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2e3) return { ...await status(), ...probe }; + const metadata = await readJson(installMetadata); + const nativeRuntimeValid = !metadata?.nativeRuntimeHash || await verifiedNativeRuntimeHash((0, import_node_path3.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash)) === metadata.nativeRuntimeHash; + if (!metadata?.nodeExecutable || !await exists(bridgeRuntime) || !await exists(tokenPath) || !nativeRuntimeValid) { + throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + } + if (metadata.elevatedTask === true) { + const taskStatus = await windowsTask.status(); + if (!taskStatus.installed || taskStatus.runLevel !== "highest") { + throw new Error("Codex Bridge elevated task is missing or invalid. Use Install / Repair first."); + } + await windowsTask.start(); + lastWindowsServiceStart = Date.now(); + return status(); + } + const token = (await (0, import_promises.readFile)(tokenPath, "utf8")).trim(); + const child = spawnProcess(metadata.nodeExecutable, [bridgeRuntime], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_DATA_ROOT: appRoot, + CODEX_BRIDGE_TOKEN: token, + ...metadata.nativeRuntimeHash ? { + CODEX_BRIDGE_NATIVE_ROOT: (0, import_node_path3.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash), + CODEX_BRIDGE_NATIVE_HASH: metadata.nativeRuntimeHash + } : {} + } + }); + if (Number.isInteger(child.pid) && child.pid > 0) { + await (0, import_promises.writeFile)(pidPath, `${child.pid} `, { mode: 384 }); - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { + } else { + await (0, import_promises.rm)(pidPath, { force: true }); } - await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + child.unref?.(); + lastWindowsServiceStart = Date.now(); return status(); } + async function ensureService() { + return serializeLifecycle(ensureServiceUnlocked); + } + async function ensureCurrent() { + return serializeLifecycle(async () => { + const current = await status(); + if (!current.installationDetected) return current; + const interruptedUpdate = await exists(runtimeBackup) || await exists(metadataBackup); + if (interruptedUpdate && current.needsUpdate) { + await rollbackInterruptedUpdate(); + console.log("[Codex Bridge] Interrupted update rolled back; retrying the bundled update in the same lifecycle operation."); + return installUnlocked(); + } + if (!current.installed || current.needsUpdate) { + console.log(`[Codex Bridge] Installed or running build differs from bundled ${version}; starting automatic update.`); + return installUnlocked(); + } + if (!current.serviceOnline) { + try { + await startInstalledService(await readJson(installMetadata)); + await waitForExpectedService( + current.bundledRuntimeHash, + version, + current.bundledNativeRuntimeHash + ); + const ready = await status(); + await discardTransactionBackups(); + return ready; + } catch (error) { + if (interruptedUpdate && await rollbackInterruptedUpdate()) { + console.log("[Codex Bridge] Interrupted restart rolled back; retrying the bundled update once."); + return installUnlocked(); + } + throw error; + } + } + await discardTransactionBackups(); + return current; + }); + } async function launch() { - if (!await exists(bridgeExecutable, import_node_fs.constants.X_OK)) { - throw new Error("Codex Bridge.app is not installed."); + const current = await ensureCurrent(); + if (!current.installed) throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + if (platform === "darwin") { + await execute("/usr/bin/open", [bridgeApp]); + return status(); } - await execute("/usr/bin/open", [bridgeApp]); - return status(); + if (platform === "win32") { + if (!current.serviceOnline) await ensureService(); + try { + await discoverDebugEndpoint({ platform, execute, fetchImpl }); + const response = await fetchImpl(`${bridgeUrl}/focus`, { + method: "POST", + headers: await authorizationHeaders(), + signal: AbortSignal.timeout(1200) + }); + const payload = await response.json(); + if (!response.ok || payload?.ok !== true) { + throw new Error(payload?.error || "Codex focus request failed"); + } + } catch (error) { + if (!/local debug bridge/.test(error.message)) throw error; + await launchWindowsCodex({ channel: codexChannel, execute, spawnProcess }); + } + return current; + } + throw new Error(`Codex Bridge launch is not supported on ${platform}.`); } - async function uninstall() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge uninstallation is supported on macOS only."); + async function uninstallUnlocked() { + if (!(platform === "win32" || platform === "darwin" && Number.isInteger(uid))) { + throw new Error("Codex Bridge uninstallation is supported on Windows and macOS only."); } - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { + if (platform === "darwin") { + await stopService(); + await (0, import_promises.rm)(bridgeAgent, { force: true }); + await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + } else { + await stopService(); + const taskStatus = await windowsTask.status(); + if (taskStatus.installed || (await readJson(installMetadata))?.elevatedTask === true) { + await windowsTask.uninstall(); + if ((await windowsTask.status()).installed) { + throw new Error("The elevated Codex Bridge scheduled task could not be removed."); + } + } } - await (0, import_promises.rm)(bridgeAgent, { force: true }); await (0, import_promises.rm)(appRoot, { recursive: true, force: true }); - await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + resetAuthorizationCache(); return status(); } - return { status, install, launch, uninstall }; + async function uninstall() { + return serializeLifecycle(uninstallUnlocked); + } + return { status, install, launch, uninstall, ensureService, ensureCurrent, authorizationHeaders }; +} + +// ../../src/bridge/model-selection.mjs +var REASONING_EFFORTS = Object.freeze([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" +]); +var MODEL_LABELS = Object.freeze({ + "gpt-5.6-sol": "5.6 Sol", + "gpt-5.6-luna": "5.6 Luna", + "gpt-reserve": "Luna Reserve", + "gpt-6-astra": "GPT-6 Astra", + "gemini-3.8-flash-high": "Gemini 3.8 Flash High" +}); +var DEFAULT_MODEL_OPTIONS = Object.freeze([ + Object.freeze({ model: "gpt-6-astra", effort: "low", label: "ASTRA LOW" }), + Object.freeze({ model: "gpt-6-astra", effort: "medium", label: "ASTRA MED" }), + Object.freeze({ model: "gemini-3.8-flash-high", effort: "high", label: "GEMINI HIGH" }), + Object.freeze({ model: "gpt-5.6-sol", effort: "high", label: "SOL HIGH" }) +]); +var MODEL_DISPLAY_NAMES = Object.freeze({ + "gpt-6-astra": "ASTRA", + "gemini-3.8-flash-high": "GEMINI", + "gpt-5.6-sol": "SOL", + "gpt-5.6-luna": "LUNA", + "gpt-reserve": "RESERVE" +}); +var EFFORT_DISPLAY_NAMES = Object.freeze({ + none: "NONE", + minimal: "MIN", + low: "LOW", + medium: "MED", + high: "HIGH", + xhigh: "XHIGH", + max: "MAX", + ultra: "ULTRA" +}); +function modelDisplayLabel(selection) { + if (!selection || typeof selection !== "object") return "CUSTOM MODEL"; + const model = selection.model; + const effort = selection.effort; + const defaultMatch = DEFAULT_MODEL_OPTIONS.find( + (opt) => opt.model === model && opt.effort === effort + ); + if (defaultMatch) return defaultMatch.label; + const modelKey = (value) => String(value || "").toLowerCase().replace(/^gpt[- ]/, "").replace(/[- ]/g, ""); + const key = modelKey(model); + let modelShort = MODEL_DISPLAY_NAMES[model]; + if (!modelShort) { + if (key === "6astra" || key === "astra") modelShort = "ASTRA"; + else if (key.includes("gemini") || key.includes("flash")) modelShort = "GEMINI"; + else if (key === "5.6sol" || key === "sol") modelShort = "SOL"; + else if (key === "5.6luna" || key === "luna") modelShort = "LUNA"; + else if (key === "reserve" || key === "lunareserve") modelShort = "RESERVE"; + } + const effortShort = EFFORT_DISPLAY_NAMES[effort] || (typeof effort === "string" && effort.length <= 5 ? effort.toUpperCase() : ""); + if (modelShort && effortShort) { + return `${modelShort} ${effortShort}`; + } + if (modelShort) { + return modelShort; + } + return "CUSTOM MODEL"; +} +function resolveModelSelection(defaults, settings) { + if (settings === void 0) return defaults; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error("Invalid model shortcut settings"); + } + const { model, effort } = settings; + if (model !== void 0 && (typeof model !== "string" || model.length > 100 || !/^[\p{L}\p{N}\p{M} ._+()/:\-]*$/u.test(model))) { + throw new Error("Invalid model shortcut model"); + } + if (effort !== void 0 && (typeof effort !== "string" || effort !== "" && !REASONING_EFFORTS.includes(effort))) { + throw new Error("Invalid model shortcut reasoning effort"); + } + const requestedModel = model?.trim() || defaults.model; + const modelKey = (value) => value.toLowerCase().replace(/^gpt[- ]/, "").replace(/[- ]/g, ""); + const alias = Object.entries(MODEL_LABELS).find(([id, label]) => modelKey(id) === modelKey(requestedModel) || modelKey(label) === modelKey(requestedModel)); + const selectedModel = alias?.[0] || requestedModel; + return Object.freeze({ + model: selectedModel, + displayName: selectedModel === defaults.model ? defaults.displayName : MODEL_LABELS[selectedModel] || selectedModel, + effort: effort || defaults.effort + }); } // plugin/app.js @@ -4041,15 +5200,60 @@ var BRIDGE_URL = process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373"; var [address = "127.0.0.1", port = "3906"] = process.argv.slice(2); var HOST_URL = `ws://${address}:${port}`; var instances = /* @__PURE__ */ new Map(); -var PLUGIN_ROOT = (0, import_node_path2.resolve)((0, import_node_path2.dirname)((0, import_node_path2.resolve)(process.argv[1])), ".."); -var MANIFEST = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.resolve)(PLUGIN_ROOT, "manifest.json"), "utf8")); +var PLUGIN_ROOT = (0, import_node_path4.resolve)((0, import_node_path4.dirname)((0, import_node_path4.resolve)(process.argv[1])), ".."); +var MANIFEST = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path4.resolve)(PLUGIN_ROOT, "manifest.json"), "utf8")); var bridgeSetup = createBridgeInstaller({ pluginRoot: PLUGIN_ROOT, bridgeUrl: BRIDGE_URL, version: MANIFEST.Version }); -var USAGE_BASE64 = (0, import_node_fs2.readFileSync)( - (0, import_node_path2.resolve)(PLUGIN_ROOT, "assets/icons/usage-base.png") +var bridgeReconcilePromise = null; +var bridgeReconcileRetryTimer = null; +var bridgeReconcileFailures = 0; +function scheduleBridgeReconcileRetry() { + if (bridgeReconcileRetryTimer || bridgeReconcileFailures >= 3) return; + const delay = Math.min(1e4, 2e3 * 2 ** bridgeReconcileFailures); + bridgeReconcileFailures += 1; + bridgeReconcileRetryTimer = setTimeout(() => { + bridgeReconcileRetryTimer = null; + void ensureBundledBridgeCurrent().catch((error) => { + console.error(`[Codex Micro] Bridge reconciliation retry failed: ${error.message}`); + }); + }, delay); + bridgeReconcileRetryTimer.unref?.(); +} +function ensureBundledBridgeCurrent() { + if (!bridgeReconcilePromise) { + const running = (async () => { + const before = await bridgeSetup.status(); + if (!before.installationDetected) return before; + if (before.needsUpdate) { + console.log(`[Codex Micro] Updating the installed Bridge to bundled version ${before.bundledVersion}.`); + } + if (before.administratorApprovalRequired) { + console.log("[Codex Micro] Bridge update requires one administrator confirmation from Install / Repair; automatic background reconciliation was deferred."); + return before; + } + const after = await bridgeSetup.ensureCurrent(); + if (before.needsUpdate) { + console.log(`[Codex Micro] Bridge ${after.installedVersion} restarted with the bundled runtime.`); + } + return after; + })(); + bridgeReconcilePromise = running; + void running.then(() => { + bridgeReconcileFailures = 0; + if (bridgeReconcileRetryTimer) clearTimeout(bridgeReconcileRetryTimer); + bridgeReconcileRetryTimer = null; + }, () => { + if (bridgeReconcilePromise === running) bridgeReconcilePromise = null; + scheduleBridgeReconcileRetry(); + }); + } + return bridgeReconcilePromise; +} +var USAGE_BASE64 = (0, import_node_fs3.readFileSync)( + (0, import_node_path4.resolve)(PLUGIN_ROOT, "assets/icons/usage-base.png") ).toString("base64"); var ACTION_LABELS = Object.freeze({ fast: "FAST", @@ -4060,21 +5264,38 @@ var ACTION_LABELS = Object.freeze({ fork: "FORK", steer: "STEER", mic: "MIC", - submit: "SUBMIT" + submit: "SUBMIT", + "model-sol-high": "SOL HIGH", + "model-luna-max": "LUNA MAX", + "model-sol-medium": "SOL MED" +}); +var DEBUG_MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", effort: "medium" }) }); -var TASK_ICON_PATHS = Object.freeze({ - idle: "assets/icons/task-idle.png", - working: "assets/icons/task-working.png", - complete: "assets/icons/task-complete.png", - attention: "assets/icons/task-attention.png", - error: "assets/icons/task-error.png" +var TASK_STATUS_PALETTES = Object.freeze({ + idle: { frame: "#475467", accent: "#98a2b3" }, + working: { frame: "#0b5fcc", accent: "#2589f5" }, + complete: { frame: "#087443", accent: "#28b875" }, + attention: { frame: "#9a6700", accent: "#ed9f20" }, + error: { frame: "#b42318", accent: "#e34d62" } }); +var TASK_COMPLETE_FLASH_PALETTE = Object.freeze({ frame: "#12b76a", accent: "#6ce9a6" }); +var TASK_TITLE_MAX_UNITS = 10; +var TASK_TITLE_MAX_LINES = 4; +var TASK_TITLE_SEGMENTER = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null; var socket; var reconnectTimer; var pollTimer; var pollInFlight = false; var latestState = null; +var desktopReadyPromise = null; var setupOperation = null; +var completeFlashOn = false; +var shortcutInvocationSequence = 0; +var globalSettings = Object.freeze({ forceFocus: false }); +var forwardedTraceEvents = /* @__PURE__ */ new Map(); function contextOf(message) { return String(message.actionid || `${message.uuid}___${message.key}`); } @@ -4200,17 +5421,215 @@ async function handleBridgeSetupMessage(message) { } await sendBridgeSetupStatus(message, { result, error: failure }); } -function taskIconPath(status) { +function taskStatusKind(status) { const value = String(status || "").toLowerCase(); - if (["working", "thinking", "running", "in_progress"].includes(value)) return TASK_ICON_PATHS.working; - if (["unread", "complete", "completed", "done", "success"].includes(value)) return TASK_ICON_PATHS.complete; - if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return TASK_ICON_PATHS.attention; - if (["error", "failed", "failure"].includes(value)) return TASK_ICON_PATHS.error; - return TASK_ICON_PATHS.idle; + if (["working", "thinking", "running", "in_progress"].includes(value)) return "working"; + if (["unread", "complete", "completed", "done", "success"].includes(value)) return "complete"; + if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return "attention"; + if (["error", "failed", "failure"].includes(value)) return "error"; + return "idle"; +} +function debugShortcut(uuid, settings = {}) { + const slot = taskSlot(uuid); + if (slot !== null) { + return Object.freeze({ name: `task-${slot + 1}`, kind: "task", target: `slot-${slot + 1}` }); + } + const action = actionName(uuid); + const preset = DEBUG_MODEL_PRESETS[action]; + if (!preset) return null; + return Object.freeze({ + name: action, + kind: "model-preset", + target: settings.model || settings.effort ? "custom-model-selection" : `model-${preset.model} effort-${preset.effort}` + }); +} +function shortcutErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timeout")) return "bridge-timeout"; + if (message.includes("is empty")) return "empty-task-slot"; + if (message.includes("fetch failed") || message.includes("econnrefused")) return "bridge-unavailable"; + if (message.includes("authorization") || message.includes("unauthorized") || message.includes("forbidden")) { + return "bridge-rejected"; + } + return "bridge-error"; +} +function logShortcut(instance, shortcut, level, event, fields = []) { + const message = `[Codex Micro] shortcut event=${event} name=${shortcut.name} kind=${shortcut.kind} ${fields.join(" ")}`.trim(); + if (level === "error") console.error(message); + else if (level === "warn") console.warn(message); + else console.log(message); + send({ + cmd: "logMessage", + uuid: PLUGIN_UUID, + actionid: "", + key: "", + level, + message + }); +} +function safeDiagnosticFields(event) { + const allowed = /* @__PURE__ */ new Set([ + "accepted", + "action", + "alreadyForeground", + "alreadyMaximized", + "attentionRequested", + "attempts", + "automatic", + "background", + "bringToTopAccepted", + "cacheHit", + "cacheValid", + "candidateCount", + "category", + "channel", + "complete", + "connection", + "currentEffort", + "detachAccepted", + "durationMs", + "effortMatched", + "focusOk", + "foregroundAttach", + "foregroundAttachErrorCode", + "forceFocus", + "inputErrorCode", + "inputEventsInserted", + "matchesTarget", + "modelMatched", + "modifierStateClear", + "outcome", + "path", + "phase", + "platform", + "primaryCandidate", + "releaseAccepted", + "reused", + "route", + "rowCount", + "setFocusReturnedWindow", + "setForegroundAccepted", + "slot", + "source", + "stage", + "switchRequested", + "targetAttach", + "targetAttachErrorCode", + "targetEffort", + "targetFound", + "wasActiveBeforeAttention" + ]); + return Object.entries(event || {}).flatMap(([key, value]) => { + if (!allowed.has(key)) return []; + if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) { + return [`${key}=${value}`]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [`${key}=${normalized}`] : []; + }); +} +async function forwardBridgeDiagnostics(instance, shortcut, traceId) { + try { + if (forwardedTraceEvents.get(traceId) === Infinity) return true; + const payload = await bridgeRequest("/diagnostics/trace", "POST", traceId); + const diagnostics = payload?.diagnostics; + if (!diagnostics || diagnostics.traceId !== traceId || !Array.isArray(diagnostics.events)) return false; + const seen = forwardedTraceEvents.get(traceId) || 0; + for (const event of diagnostics.events.slice(seen, 160)) { + const eventName = String(event?.event || ""); + if (!/^[a-z0-9.-]{1,80}$/.test(eventName)) continue; + logShortcut(instance, shortcut, event.outcome === "failed" ? "error" : "debug", "bridge-trace", [ + `trace=${traceId}`, + `bridgeEvent=${eventName}`, + `offsetMs=${Math.max(0, Math.round(Number(event.offsetMs) || 0))}`, + ...safeDiagnosticFields(event) + ]); + } + forwardedTraceEvents.set(traceId, diagnostics.events.length); + if (diagnostics.complete) forwardedTraceEvents.set(traceId, Infinity); + return Boolean(diagnostics.complete); + } catch { + return false; + } +} +function collectBridgeDiagnostics(instance, shortcut, traceId) { + const delays = [0, 400, 1200, 2600, 5200]; + for (const delay of delays) { + const timer = setTimeout(() => void forwardBridgeDiagnostics(instance, shortcut, traceId), delay); + timer.unref?.(); + } + const cleanup = setTimeout(() => forwardedTraceEvents.delete(traceId), 1e4); + cleanup.unref?.(); } -function shortTitle(value) { - const title = String(value || "Untitled").replace(/\s+/g, " ").trim(); - return title.length > 18 ? `${title.slice(0, 17)}\u2026` : title; +function titleGraphemes(value) { + const title = String(value || "Untitled").replace(/\s+/g, " ").trim() || "Untitled"; + if (!TASK_TITLE_SEGMENTER) return Array.from(title); + return Array.from(TASK_TITLE_SEGMENTER.segment(title), (item) => item.segment); +} +function graphemeUnits(value) { + if (/^\s$/u.test(value)) return 0.55; + if (new RegExp("^\\p{Mark}+$", "u").test(value)) return 0; + if (new RegExp("\\p{Extended_Pictographic}", "u").test(value) || /[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/u.test(value)) return 2; + if (/^[ilI1.,'`:;|!\[\](){}]$/u.test(value)) return 0.55; + if (/^[mwMW@#%&]$/u.test(value)) return 1.35; + return 1; +} +function lineUnits(values) { + return values.reduce((total, value) => total + graphemeUnits(value), 0); +} +function trimLine(values) { + const result = [...values]; + while (result[0] === " ") result.shift(); + while (result.at(-1) === " ") result.pop(); + return result; +} +function taskTitleLines(value) { + let graphemes = titleGraphemes(value); + const maxTotalUnits = TASK_TITLE_MAX_UNITS * TASK_TITLE_MAX_LINES; + if (lineUnits(graphemes) > maxTotalUnits) { + const ellipsisUnits = graphemeUnits("\u2026"); + const clipped = []; + let used = 0; + for (const grapheme of graphemes) { + const units = graphemeUnits(grapheme); + if (used + units + ellipsisUnits > maxTotalUnits) break; + clipped.push(grapheme); + used += units; + } + graphemes = trimLine(clipped); + graphemes.push("\u2026"); + } + const lines = []; + while (graphemes.length && lines.length < TASK_TITLE_MAX_LINES) { + const line = []; + while (graphemes.length && lineUnits([...line, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { + line.push(graphemes.shift()); + } + if (!line.length) line.push(graphemes.shift()); + const trimmed = trimLine(line); + if (trimmed.length) lines.push(trimmed.join("")); + while (graphemes[0] === " ") graphemes.shift(); + } + if (graphemes.length && lines.length === TASK_TITLE_MAX_LINES && !lines.at(-1).endsWith("\u2026")) { + const lastLine = titleGraphemes(lines.at(-1)); + while (lastLine.length && lineUnits([...lastLine, "\u2026"]) > TASK_TITLE_MAX_UNITS) lastLine.pop(); + lines[lines.length - 1] = `${trimLine(lastLine).join("")}\u2026`; + } + return lines; +} +function taskIconData(kind, flashOn = false) { + const palette = kind === "complete" && flashOn ? TASK_COMPLETE_FLASH_PALETTE : TASK_STATUS_PALETTES[kind]; + const svg = ` + + + + + + + + `; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } function setDisplay(instance, state, text) { const digest = `${state}:${text}`; @@ -4231,8 +5650,12 @@ function setDisplay(instance, state, text) { } }); } -function setTaskDisplay(instance, path, text) { - const digest = `path:${path}:${text}`; +function setTaskDisplay(instance, status, title) { + const kind = taskStatusKind(status); + const lines = taskTitleLines(title); + const displayTitle = lines.join("\n"); + const flashOn = kind === "complete" && completeFlashOn; + const digest = `task:${kind}:${flashOn}:${displayTitle}`; if (!instance.active || instance.lastDisplay === digest) return; instance.lastDisplay = digest; send({ @@ -4242,10 +5665,11 @@ function setTaskDisplay(instance, path, text) { uuid: instance.uuid, actionid: instance.actionid, key: instance.key, - type: 2, - path, + type: 1, + data: taskIconData(kind, flashOn), showtext: true, - textdata: text + textData: displayTitle, + textdata: displayTitle }] } }); @@ -4276,7 +5700,7 @@ function renderInstance(instance) { const action = actionName(instance.uuid); if (!latestState?.connected) { if (action === "navigate") { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); } else { setDisplay(instance, 0, "Bridge Offline"); } @@ -4289,33 +5713,49 @@ function renderInstance(instance) { if (action === "navigate") { const task2 = latestState.slots?.[0]; if (!task2?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Latest Task"); + setTaskDisplay(instance, "idle", "Latest Task"); return; } - setTaskDisplay(instance, taskIconPath(task2.status), shortTitle(task2.title)); + setTaskDisplay(instance, task2.status, task2.title); + return; + } + if (DEBUG_MODEL_PRESETS[action]) { + let label = ACTION_LABELS[action] || "CODEX"; + try { + const resolved = resolveModelSelection(DEBUG_MODEL_PRESETS[action], instance.settings || {}); + label = modelDisplayLabel(resolved); + } catch { + label = "CUSTOM MODEL"; + } + setDisplay(instance, 0, label); return; } setDisplay(instance, 0, ACTION_LABELS[action] || "CODEX"); return; } if (!latestState?.connected) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); return; } const task = latestState.slots?.[slot]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, `Task ${slot + 1}`); + setTaskDisplay(instance, "idle", `Task ${slot + 1}`); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); } function renderAll() { for (const instance of instances.values()) renderInstance(instance); } -async function bridgeRequest(path, method = "GET") { +async function bridgeRequest(path, method = "GET", traceId = null, timeoutMs = 1200, body = void 0) { + const headers = { ...await bridgeSetup.authorizationHeaders() }; + if (traceId) headers["X-Codex-Trace-Id"] = traceId; + if (body !== void 0) headers["Content-Type"] = "application/json"; const response = await fetch(`${BRIDGE_URL}${path}`, { method, - signal: AbortSignal.timeout(1200) + headers, + ...body === void 0 ? {} : { body: JSON.stringify(body) }, + signal: AbortSignal.timeout(timeoutMs) }); const payload = await response.json(); if (!response.ok || payload.ok === false) { @@ -4323,10 +5763,45 @@ async function bridgeRequest(path, method = "GET") { } return payload; } -async function openTaskSlot(slot) { +function ensureCodexDesktopReady() { + if (latestState?.connected) return Promise.resolve(latestState); + if (desktopReadyPromise) return desktopReadyPromise; + const running = (async () => { + console.log("[Codex Micro] ChatGPT is unavailable; starting the current installed app build with the local debug bridge."); + await bridgeSetup.launch(); + let lastError = null; + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) await new Promise((resolve3) => setTimeout(resolve3, 200)); + try { + latestState = await bridgeRequest("/state?refresh=1", "GET", null, 1600); + renderAll(); + if (latestState.connected) return latestState; + lastError = new Error(latestState.error || "ChatGPT is not connected"); + } catch (error) { + lastError = error; + } + } + throw new Error(`ChatGPT did not become ready after launch: ${lastError?.message || "unknown error"}`); + })(); + desktopReadyPromise = running; + void running.finally(() => { + if (desktopReadyPromise === running) desktopReadyPromise = null; + }).catch(() => { + }); + return running; +} +async function openTaskSlot(slot, traceId, forceFocus = false) { + await ensureCodexDesktopReady(); const task = latestState?.slots?.[slot]; if (!task?.threadKey) throw new Error(`Codex task slot ${slot + 1} is empty`); - await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST"); + const query = new URLSearchParams({ slot: String(slot) }); + if (forceFocus) query.set("forceFocus", "1"); + return bridgeRequest( + `/thread/${encodeURIComponent(task.threadKey)}/click?${query}`, + "POST", + traceId, + 1800 + ); } async function pollBridge() { if (pollInFlight) return; @@ -4337,15 +5812,55 @@ async function pollBridge() { latestState = { connected: false, error: error.message, slots: [] }; } finally { pollInFlight = false; + completeFlashOn = !completeFlashOn; renderAll(); } } async function invoke(instance, pressed) { const slot = taskSlot(instance.uuid); + const shortcut = debugShortcut(instance.uuid, instance.settings); + const invocation = shortcut ? ++shortcutInvocationSequence : null; + const traceId = shortcut ? (0, import_node_crypto2.randomUUID)() : null; + const phase = pressed ? "down" : "up"; + const startedAt = Date.now(); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "received", [ + `invocation=${invocation}`, + `trace=${traceId}`, + `phase=${phase}`, + `target=${shortcut.target}` + ]); + } try { if (slot !== null) { - if (!pressed) return; - await openTaskSlot(slot); + if (!pressed) { + if (shortcut) { + logShortcut(instance, shortcut, "debug", "ignored", [ + `invocation=${invocation}`, + `phase=${phase}`, + "reason=keydown-only" + ]); + } + return; + } + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http", + `forceFocus=${globalSettings.forceFocus}` + ]); + } + const result = await openTaskSlot(slot, traceId, globalSettings.forceFocus); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `focusOk=${result?.focusOk === true}`, + `attentionRequested=${Boolean(result?.attentionRequested)}` + ]); + } return; } const action = actionName(instance.uuid); @@ -4354,10 +5869,53 @@ async function invoke(instance, pressed) { if (pressed) await bridgeRequest("/focus", "POST"); return; } - await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST"); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + let selection; + if (DEBUG_MODEL_PRESETS[action] && pressed) { + const resolved = resolveModelSelection(DEBUG_MODEL_PRESETS[action], instance.settings || {}); + selection = { model: resolved.model, effort: resolved.effort }; + } + await bridgeRequest( + `/action/${action}/${pressed ? "down" : "up"}`, + "POST", + traceId, + DEBUG_MODEL_PRESETS[action] ? 15e3 : 1200, + selection + ); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } } catch (error) { - send({ cmd: "logMessage", uuid: instance.uuid, actionid: instance.actionid, key: instance.key, level: "error", message: error.message }); + if (shortcut) { + logShortcut(instance, shortcut, "error", "failed", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `category=${shortcutErrorCategory(error)}` + ]); + } else { + send({ + cmd: "logMessage", + uuid: instance.uuid, + actionid: instance.actionid, + key: instance.key, + level: "error", + message: `[Codex Micro] action failed category=${shortcutErrorCategory(error)}` + }); + } send({ cmd: "showAlert", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } finally { + if (shortcut && pressed) collectBridgeDiagnostics(instance, shortcut, traceId); } } async function invokeEncoder(instance, message) { @@ -4390,7 +5948,16 @@ function addInstance(message) { lastDisplay: null }; instance.active = true; + const settings = message.settings ?? message.param; + if (settings && typeof settings === "object" && !Array.isArray(settings)) { + instance.settings = { ...settings }; + } else if (!existing) { + instance.settings = {}; + } instances.set(context, instance); + if (!existing && DEBUG_MODEL_PRESETS[actionName(instance.uuid)]) { + send({ cmd: "getSettings", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } renderInstance(instance); return instance; } @@ -4428,6 +5995,19 @@ function handleMessage(raw) { } return; } + if (message.cmd === "didReceiveGlobalSettings") { + globalSettings = Object.freeze({ forceFocus: message.settings?.forceFocus === true }); + return; + } + if (message.cmd === "didReceiveSettings" || message.cmd === "paramfromplugin") { + const instance = instances.get(contextOf(message)); + const settings = message.settings ?? message.param; + if (instance && settings && typeof settings === "object" && !Array.isArray(settings)) { + instance.settings = { ...settings }; + renderInstance(instance); + } + return; + } if (message.cmd === "run") { ack(message); return; @@ -4449,6 +6029,12 @@ function connect() { socket = new wrapper_default(HOST_URL); socket.on("open", () => { send({ code: 0, cmd: "connected", uuid: PLUGIN_UUID }); + send({ cmd: "getGlobalSettings", uuid: PLUGIN_UUID, actionid: "", key: "" }); + if (process.env.CODEX_BRIDGE_AUTOSTART !== "0") { + void ensureBundledBridgeCurrent().catch((error) => { + console.error(`[Codex Micro] Automatic Bridge reconciliation failed: ${error.message}`); + }); + } clearInterval(pollTimer); pollTimer = setInterval(() => void pollBridge(), 500); pollTimer.unref(); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json index 4df2379..d30536b 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Control Codex Desktop tasks and actions from Ulanzi D200 Series in Ulanzi Studio.", - "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep communication local through Codex Bridge.app and loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- macOS 13 or later\n- Codex Desktop\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nLLM / AGENT INSTALLATION\nIn your Agent, enter:\nInstall the Ulanzi Studio plugin for me: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUAL INSTALLATION\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro and open the repository directory.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio, quit Codex Desktop, and launch ~/Applications/Codex Bridge.app.\n7. Add the Codex Micro actions to your keys in Ulanzi Studio.\n8. Select any Action to check Bridge installation, service, and CDP status.\n\nIMPORTANT\nWhen using Codex Micro, always launch Codex through ~/Applications/Codex Bridge.app. Do not open Codex Desktop directly.\nLaunch command: open ~/Applications/Codex\\ Bridge.app", + "Description": "Control Codex Desktop from Ulanzi D200 Series through Ulanzi Studio. View five recent tasks, use Fast, Usage, Pin, New, Fork, Steer, Mic and Submit, switch among three GPT-5.6 model presets, and navigate with the Encoder. Supports Windows 10 or later and macOS 13 or later. Codex Bridge dynamically locates Windows Stable or Beta and keeps authenticated Bridge actions and CDP on loopback. For repository setup run npm install, npm run install:plugin, and npm run setup.", "Actions": [ { "Name": "Codex Task 1", "Tooltip": "Open recent Codex task slot 1 and show its live status." }, { "Name": "Codex Task 2", "Tooltip": "Open recent Codex task slot 2 and show its live status." }, @@ -16,12 +16,28 @@ { "Name": "Fork Codex Task", "Tooltip": "Fork the active Codex task into a new task." }, { "Name": "Steer Codex", "Tooltip": "Send the current composer text as steering input to the running task." }, { "Name": "Codex Microphone", "Tooltip": "Toggle the Codex microphone action." }, - { "Name": "Submit to Codex", "Tooltip": "Submit or queue the current Codex composer text." } + { "Name": "Submit to Codex", "Tooltip": "Submit or queue the current Codex composer text." }, + { "Name": "Codex Sol High", "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with high reasoning effort." }, + { "Name": "Codex Luna Max", "Tooltip": "Switch the visible Codex task to GPT-5.6 Luna with maximum reasoning effort." }, + { "Name": "Codex Sol Medium", "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with medium reasoning effort." } ], "Localization": { + "ModelSettingsTitle": "Model shortcut (this key)", + "PresetLabel": "Default options", + "PresetHelp": "Select a default model preset or enter custom settings below.", + "PresetCustom": "Default / Custom", + "ModelLabel": "Model", + "EffortLabel": "Reasoning effort", + "ModelHelp": "Enter the exact model name shown in the Codex menu, or a known official ID alias. Custom display names are supported. Leave blank for this key's default. Changes save automatically.", + "EffortHelp": "Choose only an effort actually available for this model in Codex. Unsupported choices fail without fallback. Blank uses this key's default effort.", + "EffortDefault": "Default", + "ModelDefaults": "Defaults: {model} ({id}) / {effort}", + "ModelInvalid": "Use at most 100 characters: letters, numbers, spaces or . _ + ( ) / : -. Control characters and code/HTML punctuation are not allowed.", + "EffortInvalid": "Choose a listed reasoning effort.", + "ModelLoading": "Restoring this key's settings…", "BridgeSetup": "Codex Bridge Setup", "BridgeSetupDescription": "Install, launch, and check the local Codex connection.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge service", "CDPConnection": "CDP connection", "InstallRepair": "Install / Repair", @@ -30,19 +46,21 @@ "UninstallingBridge": "Uninstalling…", "UninstallBridgeDone": "Codex Bridge was uninstalled. The Ulanzi plugin and Codex Desktop were kept.", "UninstallBridgeConfirm": "Uninstall Codex Bridge and its background service? The Ulanzi plugin and Codex Desktop will not be removed.", - "BridgeAppInfoTitle": "What is Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app is a local launcher. If Codex is already running, it asks Codex to quit, then starts the official Codex Desktop executable with loopback-only CDP arguments.", - "BridgeAppSafety": "The launcher does not modify Codex Desktop, accounts, projects, or user files, and it does not expose CDP outside this Mac. It performs no other privileged or destructive operation. The separate Bridge service handles local status and Action communication.", + "BridgeAppInfoTitle": "How does Codex Bridge start Codex?", + "BridgeAppDescription": "On Windows, Codex Bridge dynamically locates Stable or Beta and starts the official executable with loopback-only CDP arguments. On macOS it uses the local wrapper app.", + "BridgeAppSafety": "The launcher does not modify Codex Desktop, accounts, projects, or user files. CDP stays on this computer, and Bridge write actions require a local capability token.", "BackgroundActivityTitle": "Background activity and Node.js", - "LaunchAgentDescription": "The LaunchAgent starts the local Codex Bridge service when you sign in and restarts it if it exits. The service listens only on this Mac through a loopback address.", - "NodeSelectionStrategy": "Install / Repair first selects a system Node.js 20 or later from your login environment, PATH, Homebrew, or common install paths. It uses Ulanzi Studio's bundled Node only as a fallback.", - "NodeInstallRecommendation": "Recommended: install the latest Node.js from Terminal, then run Install / Repair again:", - "UlanziNodeSignatureNotice": "If the Ulanzi Studio Node fallback is used, macOS Background Items may show an activity carrying the Ulanzi Studio signature. This is the Codex Bridge service running with Ulanzi Studio's Node executable.", + "LaunchAgentDescription": "Windows uses a current-user, on-demand highest-privilege task; normal starts are silent. macOS uses a user LaunchAgent. Bridge listens only on loopback.", + "NodeSelectionStrategy": "Install / Repair selects Node.js 20 or later from PATH, then uses Ulanzi Studio's bundled Node as a fallback.", + "NodeInstallRecommendation": "For repository setup, verify that Node.js 20 or later is available:", + "UlanziNodeSignatureNotice": "Windows runs only hash-verified protected Bridge code. Install, update, and uninstall require UAC; the task never stores the capability token.", "CurrentNodeRuntime": "Current runtime: {runtime} {version}", "SystemNode": "System Node.js", "UlanziNode": "Ulanzi Studio Node", "UnknownNode": "Not installed or not recorded", "CheckAgain": "Check again", + "ForceFocus": "Force focus (Windows)", + "ForceFocusDescription": "Windows access-denied focus failures already receive one automatic protected Alt fallback. Enable this to use the same fallback for other focus denials. No idle hooks, polling, or helper process is added.", "InstallationGuide": "Installation guide" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json index 85f7856..9bee0f1 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Controla tareas y acciones de Codex Desktop con Ulanzi D200 Series en Ulanzi Studio.", - "Description": "Convierte Ulanzi D200 Series en una superficie de control dedicada para Codex Desktop mediante Ulanzi Studio.\n\nFUNCIONES\n- Muestra cinco tareas recientes de Codex con estado en tiempo real y abre exactamente la tarea seleccionada.\n- Incluye las acciones Fast, Usage, Pin, New, Fork, Steer, Mic y Submit.\n- Usa el Encoder Latest Task & Scroll para abrir task1 y desplazarte por Codex.\n- Instala, repara, inicia y comprueba Codex Bridge desde la página de configuración de cada Action.\n- Mantiene la comunicación local mediante Codex Bridge.app y endpoints exclusivos de loopback.\n\nENTORNO DE INSTALACIÓN\n- macOS 13 o posterior\n- Codex Desktop\n- Ulanzi Studio 3.0.1 o posterior\n- Ulanzi D200 Series conectada en Ulanzi Studio\n- Node.js 20 o posterior solo para la configuración manual desde el repositorio\n\nINSTALACIÓN CON LLM / AGENT\nEscribe en tu Agent:\nInstálame el plugin de Ulanzi Studio: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nINSTALACIÓN MANUAL\n1. Clona https://github.com/UlanziTechnology/OpenCodexMicro y abre la carpeta del repositorio.\n2. Cierra Ulanzi Studio.\n3. Ejecuta: npm install\n4. Ejecuta: npm run install:plugin\n5. Ejecuta: npm run setup\n6. Vuelve a abrir Ulanzi Studio, cierra Codex Desktop e inicia ~/Applications/Codex Bridge.app.\n7. Añade las acciones de Codex Micro a las teclas deseadas en Ulanzi Studio.\n8. Selecciona una Action y comprueba la instalación, el servicio y CDP en la página de Codex Bridge.\n\nIMPORTANTE\nAl usar Codex Micro, inicia siempre Codex mediante ~/Applications/Codex Bridge.app. No abras Codex Desktop directamente.\nComando de inicio: open ~/Applications/Codex\\ Bridge.app", + "Description": "Controla Codex Desktop con Ulanzi D200 Series en Ulanzi Studio. Incluye cinco tareas recientes, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit, tres preajustes de modelo GPT-5.6 y navegación con Encoder en Windows 10 o posterior y macOS 13 o posterior. En Windows, Bridge encuentra Stable o Beta dinámicamente; las acciones de escritura usan un token local y CDP permanece en loopback. Para configurar desde el repositorio ejecuta npm install, npm run install:plugin y npm run setup.", "Actions": [ { "Name": "Tarea Codex 1", "Tooltip": "Abre la posición 1 de las tareas recientes de Codex y muestra su estado en tiempo real." }, { "Name": "Tarea Codex 2", "Tooltip": "Abre la posición 2 de las tareas recientes de Codex y muestra su estado en tiempo real." }, @@ -16,12 +16,28 @@ { "Name": "Bifurcar tarea de Codex", "Tooltip": "Bifurca la tarea activa de Codex en una tarea nueva." }, { "Name": "Orientar Codex", "Tooltip": "Envía el texto actual del editor como orientación a la tarea en ejecución." }, { "Name": "Micrófono de Codex", "Tooltip": "Activa o desactiva la acción de micrófono de Codex." }, - { "Name": "Enviar a Codex", "Tooltip": "Envía o pone en cola el texto actual del editor de Codex." } + { "Name": "Enviar a Codex", "Tooltip": "Envía o pone en cola el texto actual del editor de Codex." }, + { "Name": "Codex Sol Alto", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Sol con razonamiento alto." }, + { "Name": "Codex Luna Máximo", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Luna con razonamiento máximo." }, + { "Name": "Codex Sol Medio", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Sol con razonamiento medio." } ], "Localization": { + "ModelSettingsTitle": "Atajo de modelo (solo esta tecla)", + "PresetLabel": "Opciones predeterminadas", + "PresetHelp": "Seleccione una opción predeterminada o personalice a continuación.", + "PresetCustom": "Predeterminado / Personalizado", + "ModelLabel": "Modelo", + "EffortLabel": "Nivel de razonamiento", + "ModelHelp": "Introduce el nombre exacto del modelo del menú Codex o un alias de ID oficial conocido. Admite nombres personalizados. Vacío usa el modelo predeterminado de esta tecla. Los cambios se guardan automáticamente.", + "EffortHelp": "Selecciona solo un nivel realmente disponible para este modelo en Codex. Las opciones no compatibles fallan sin alternativa. Vacío usa el nivel predeterminado de esta tecla.", + "EffortDefault": "Predeterminado", + "ModelDefaults": "Valores predeterminados: {model} ({id}) / {effort}", + "ModelInvalid": "Máximo de 100 caracteres: letras, números, espacios o . _ + ( ) / : -. No se permiten caracteres de control ni signos de código/HTML.", + "EffortInvalid": "Selecciona un nivel de razonamiento de la lista.", + "ModelLoading": "Restaurando los ajustes de esta tecla…", "BridgeSetup": "Configuración de Codex Bridge", "BridgeSetupDescription": "Instala, inicia y comprueba la conexión local de Codex.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Servicio Bridge", "CDPConnection": "Conexión CDP", "InstallRepair": "Instalar / Reparar", @@ -30,19 +46,21 @@ "UninstallingBridge": "Desinstalando…", "UninstallBridgeDone": "Codex Bridge se ha desinstalado. El plugin de Ulanzi y Codex Desktop se conservaron.", "UninstallBridgeConfirm": "¿Desinstalar Codex Bridge y su servicio en segundo plano? El plugin de Ulanzi y Codex Desktop no se eliminarán.", - "BridgeAppInfoTitle": "¿Qué es Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app es un iniciador local. Si Codex ya está abierto, solicita su cierre normal y después inicia el ejecutable oficial de Codex Desktop con argumentos CDP limitados al bucle local.", - "BridgeAppSafety": "El iniciador no modifica Codex Desktop, cuentas, proyectos ni archivos del usuario, y no expone CDP fuera de este Mac. No realiza ninguna otra operación privilegiada o destructiva. El servicio Bridge independiente gestiona el estado local y la comunicación con las Actions.", + "BridgeAppInfoTitle": "¿Cómo inicia Codex Bridge a Codex?", + "BridgeAppDescription": "En Windows, Codex Bridge encuentra Stable o Beta dinámicamente e inicia la aplicación oficial con argumentos CDP de loopback; en macOS usa la aplicación envoltorio local.", + "BridgeAppSafety": "El iniciador no modifica Codex Desktop, cuentas, proyectos ni archivos del usuario. CDP permanece local y las acciones de escritura de Bridge requieren un token local.", "BackgroundActivityTitle": "Actividad en segundo plano y Node.js", - "LaunchAgentDescription": "LaunchAgent inicia el servicio local Codex Bridge al entrar en la sesión y lo reinicia si termina. El servicio solo escucha en este Mac mediante una dirección de bucle local.", + "LaunchAgentDescription": "Windows usa una tarea bajo demanda, solo para el usuario actual y con privilegios máximos; los inicios normales son silenciosos. macOS usa un LaunchAgent del usuario.", "NodeSelectionStrategy": "Instalar / Reparar prioriza Node.js 20 o posterior del entorno de inicio, PATH, Homebrew o rutas habituales. Solo usa el Node incluido en Ulanzi Studio como alternativa.", "NodeInstallRecommendation": "Recomendado: instala la versión más reciente de Node.js desde Terminal y vuelve a ejecutar Instalar / Reparar:", - "UlanziNodeSignatureNotice": "Si se usa el Node de Ulanzi Studio como alternativa, los elementos en segundo plano de macOS pueden mostrar una actividad con la firma de Ulanzi Studio. Es el servicio Codex Bridge ejecutándose con el Node de Ulanzi Studio.", + "UlanziNodeSignatureNotice": "Windows ejecuta solo código Bridge protegido y verificado por hash. Instalar, actualizar y desinstalar requieren UAC; la tarea nunca guarda el token local.", "CurrentNodeRuntime": "Entorno actual: {runtime} {version}", "SystemNode": "Node.js del sistema", "UlanziNode": "Node de Ulanzi Studio", "UnknownNode": "No instalado o sin registrar", "CheckAgain": "Comprobar de nuevo", + "ForceFocus": "Forzar foco (Windows)", + "ForceFocusDescription": "Los rechazos de foco de Windows por acceso denegado ya reciben una simulación automática y protegida de Alt. Active esta opción para usarla también con otros rechazos de foco. No añade hooks, sondeos ni procesos auxiliares en reposo.", "InstallationGuide": "Guía de instalación" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md index 2ee2354..9431442 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md @@ -37,6 +37,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Koffi + +- Project: https://koffi.dev/ +- Use: Low-overhead in-process Win32 FFI for Codex Desktop window activation +- License: MIT + +Copyright 2023 Niels Martignène + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## esbuild - Project: https://github.com/evanw/esbuild diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs index bdf0f45..b1967c3 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs @@ -11,9 +11,16 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; +}; var __commonJS = (cb, mod) => function __require2() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) @@ -31,6 +38,484 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); +// ../../src/bridge/windows-focus.mjs +var windows_focus_exports = {}; +__export(windows_focus_exports, { + activateWindowsProcess: () => activateWindowsProcess, + clearWindowsFocusCache: () => clearWindowsFocusCache, + initializeWindowsFocusRuntime: () => initializeWindowsFocusRuntime +}); +import { createRequire } from "node:module"; +import { join } from "node:path"; +function sameWindow(left, right) { + return left != null && right != null && String(left) === String(right); +} +function emitDiagnostic(onDiagnostic, stage, fields = {}) { + try { + onDiagnostic?.(stage, fields); + } catch { + } +} +async function loadKoffi() { + const nativeRoot = process.env.CODEX_BRIDGE_NATIVE_ROOT; + if (nativeRoot) { + return createRequire(join(nativeRoot, "bridge-native.cjs"))( + join(nativeRoot, "node_modules", "koffi", "index.cjs") + ); + } + return (await import("koffi")).default; +} +async function createWindowsApi() { + if (process.platform !== "win32") { + throw new Error("Native Codex window activation is available on Windows only"); + } + const koffi = await loadKoffi(); + const user32 = koffi.load("user32.dll"); + const kernel32 = koffi.load("kernel32.dll"); + const HANDLE = koffi.pointer("HANDLE", koffi.opaque()); + koffi.alias("HWND", HANDLE); + const FLASHWINFO = koffi.struct("FLASHWINFO", { + cbSize: "uint32_t", + hwnd: "HWND", + dwFlags: "uint32_t", + uCount: "uint32_t", + dwTimeout: "uint32_t" + }); + const KEYBDINPUT = koffi.struct("KEYBDINPUT", { + wVk: "uint16_t", + wScan: "uint16_t", + dwFlags: "uint32_t", + time: "uint32_t", + dwExtraInfo: "uintptr_t" + }); + const MOUSEINPUT = koffi.struct("MOUSEINPUT", { + dx: "long", + dy: "long", + mouseData: "uint32_t", + dwFlags: "uint32_t", + time: "uint32_t", + dwExtraInfo: "uintptr_t" + }); + const HARDWAREINPUT = koffi.struct("HARDWAREINPUT", { + uMsg: "uint32_t", + wParamL: "uint16_t", + wParamH: "uint16_t" + }); + const INPUT = koffi.struct("INPUT", { + type: "uint32_t", + u: koffi.union({ mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT }) + }); + const findWindowEx = user32.func( + "HWND __stdcall FindWindowExW(HWND parent, HWND childAfter, const char16_t *className, const char16_t *windowName)" + ); + const getWindowThreadProcessId = user32.func( + "uint32_t __stdcall GetWindowThreadProcessId(HWND hwnd, _Out_ uint32_t *processId)" + ); + const isWindow = user32.func("bool __stdcall IsWindow(HWND hwnd)"); + const isWindowVisible = user32.func("bool __stdcall IsWindowVisible(HWND hwnd)"); + const getWindowTextLength = user32.func("int __stdcall GetWindowTextLengthW(HWND hwnd)"); + const isZoomed = user32.func("bool __stdcall IsZoomed(HWND hwnd)"); + const showWindowAsync = user32.func("bool __stdcall ShowWindowAsync(HWND hwnd, int command)"); + const setForegroundWindow = user32.func("bool __stdcall SetForegroundWindow(HWND hwnd)"); + const getForegroundWindow = user32.func("HWND __stdcall GetForegroundWindow()"); + const getWindow = user32.func("HWND __stdcall GetWindow(HWND hwnd, uint32_t command)"); + const bringWindowToTop = user32.func("bool __stdcall BringWindowToTop(HWND hwnd)"); + const setFocus = user32.func("HWND __stdcall SetFocus(HWND hwnd)"); + const attachThreadInput = user32.func( + "bool __stdcall AttachThreadInput(uint32_t sourceThreadId, uint32_t targetThreadId, bool attach)" + ); + const flashWindowEx = user32.func("bool __stdcall FlashWindowEx(_In_ FLASHWINFO *info)"); + const getAsyncKeyState = user32.func("int16_t __stdcall GetAsyncKeyState(int virtualKey)"); + const sendInput = user32.func( + "uint32_t __stdcall SendInput(uint32_t inputCount, INPUT *inputs, int inputSize)" + ); + const getCurrentThreadId = kernel32.func("uint32_t __stdcall GetCurrentThreadId()"); + const getLastError = kernel32.func("uint32_t __stdcall GetLastError()"); + const setLastError = kernel32.func("void __stdcall SetLastError(uint32_t errorCode)"); + const keyboardEvent = (virtualKey, pressed) => ({ + type: INPUT_KEYBOARD, + u: { + ki: { + wVk: virtualKey, + wScan: 0, + dwFlags: pressed ? 0 : KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0 + } + } + }); + function identityForWindow(hwnd) { + const processId = [0]; + const threadId = getWindowThreadProcessId(hwnd, processId); + return { + processId: Number(processId[0]) || 0, + threadId: Number(threadId) || 0 + }; + } + function isWindowCandidate(hwnd, processId) { + return hwnd != null && identityForWindow(hwnd).processId === processId && isWindow(hwnd) && isWindowVisible(hwnd) && getWindowTextLength(hwnd) > 0; + } + function isPrimaryWindowCandidate(hwnd, processId) { + return isWindowCandidate(hwnd, processId) && getWindow(hwnd, GW_OWNER) == null; + } + function findWindowsForProcess(processId) { + const primary = []; + const displayedFallbacks = []; + let previous = null; + for (let index = 0; index < 4096; index += 1) { + const hwnd = findWindowEx(null, previous, null, null); + if (hwnd == null) return [...primary, ...displayedFallbacks]; + previous = hwnd; + if (!isWindowCandidate(hwnd, processId)) continue; + (getWindow(hwnd, GW_OWNER) == null ? primary : displayedFallbacks).push(hwnd); + } + throw new Error("Windows window enumeration exceeded its safety bound"); + } + return { + isWindow, + isWindowCandidate, + isPrimaryWindowCandidate, + isZoomed, + getForegroundWindow, + processIdForWindow(hwnd) { + return identityForWindow(hwnd).processId; + }, + findWindowsForProcess, + findWindowForProcess(processId) { + return findWindowsForProcess(processId)[0] ?? null; + }, + maximize(hwnd) { + return showWindowAsync(hwnd, SW_MAXIMIZE); + }, + foreground(hwnd) { + return setForegroundWindow(hwnd); + }, + forceForeground(hwnd) { + const currentThreadId = Number(getCurrentThreadId()) || 0; + const foregroundWindow = getForegroundWindow(); + const foregroundThreadId = foregroundWindow == null ? 0 : identityForWindow(foregroundWindow).threadId; + const targetThreadId = identityForWindow(hwnd).threadId; + const attached = []; + const attach = (threadId) => { + if (threadId <= 0 || threadId === currentThreadId || attached.includes(threadId)) { + return { outcome: "skipped", errorCode: 0 }; + } + setLastError(0); + const accepted = Boolean(attachThreadInput(currentThreadId, threadId, true)); + if (accepted) attached.push(threadId); + return { + outcome: accepted ? "succeeded" : "failed", + errorCode: accepted ? 0 : Number(getLastError()) || 0 + }; + }; + const result = { + foregroundAttach: "skipped", + foregroundAttachErrorCode: 0, + targetAttach: "skipped", + targetAttachErrorCode: 0, + bringToTopAccepted: false, + setForegroundAccepted: false, + setFocusReturnedWindow: false, + detachAccepted: true + }; + try { + const foregroundAttachment = attach(foregroundThreadId); + result.foregroundAttach = foregroundAttachment.outcome; + result.foregroundAttachErrorCode = foregroundAttachment.errorCode; + const targetAttachment = attach(targetThreadId); + result.targetAttach = targetAttachment.outcome; + result.targetAttachErrorCode = targetAttachment.errorCode; + result.bringToTopAccepted = Boolean(bringWindowToTop(hwnd)); + result.setForegroundAccepted = Boolean(setForegroundWindow(hwnd)); + result.setFocusReturnedWindow = setFocus(hwnd) != null; + } finally { + for (const threadId of attached.reverse()) { + result.detachAccepted = Boolean( + attachThreadInput(currentThreadId, threadId, false) + ) && result.detachAccepted; + } + } + return result; + }, + aggressiveForeground(hwnd) { + const modifierStateClear = MODIFIER_KEYS.every( + (virtualKey) => (Number(getAsyncKeyState(virtualKey)) & 32768) === 0 + ); + const result = { + modifierStateClear, + inputEventsInserted: 0, + inputErrorCode: 0, + releaseAccepted: true, + setForegroundAccepted: false + }; + if (!modifierStateClear) return result; + const events = [keyboardEvent(VK_MENU, true), keyboardEvent(VK_MENU, false)]; + setLastError(0); + result.inputEventsInserted = Number(sendInput(events.length, events, koffi.sizeof(INPUT))) || 0; + if (result.inputEventsInserted !== events.length) { + result.inputErrorCode = Number(getLastError()) || 0; + if (result.inputEventsInserted === 1) { + result.releaseAccepted = sendInput( + 1, + [keyboardEvent(VK_MENU, false)], + koffi.sizeof(INPUT) + ) === 1; + } + return result; + } + result.setForegroundAccepted = Boolean(setForegroundWindow(hwnd)); + return result; + }, + flash(hwnd) { + const wasActiveBeforeRequest = Boolean(flashWindowEx({ + cbSize: koffi.sizeof(FLASHWINFO), + hwnd, + dwFlags: FLASHW_TRAY, + uCount: 3, + dwTimeout: 0 + })); + return { requested: true, wasActiveBeforeRequest }; + } + }; +} +async function defaultWindowsApi() { + if (!windowsApiPromise) windowsApiPromise = createWindowsApi(); + try { + return await windowsApiPromise; + } catch (error) { + windowsApiPromise = null; + throw error; + } +} +async function initializeWindowsFocusRuntime() { + await defaultWindowsApi(); + return true; +} +function validCachedWindow(api, processId) { + const hwnd = cachedWindows.get(processId); + const candidateValid = hwnd != null && typeof api.isWindowCandidate === "function" ? api.isWindowCandidate(hwnd, processId) : hwnd != null && api.isWindow(hwnd) && api.processIdForWindow(hwnd) === processId; + if (candidateValid) { + return hwnd; + } + cachedWindows.delete(processId); + return null; +} +async function verifyForegroundWithDelays(native, hwnd, wait, delays, onDiagnostic, stage) { + for (let index = 0; index < delays.length; index += 1) { + await wait(delays[index]); + const matchesTarget = sameWindow(native.getForegroundWindow(), hwnd); + emitDiagnostic(onDiagnostic, stage, { + outcome: "observed", + attempts: index + 1, + matchesTarget + }); + if (matchesTarget) return true; + } + return false; +} +async function activateWindowsProcess(processId, { + api, + wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + onDiagnostic = null, + forceFocus = false +} = {}) { + if (!Number.isInteger(processId) || processId <= 0) { + throw new Error("The connected Codex process identity is unavailable"); + } + const native = api ?? await defaultWindowsApi(); + const cacheHit = cachedWindows.has(processId); + const cachedWindow = validCachedWindow(native, processId); + const cacheValid = cachedWindow != null; + let hwnd = cachedWindow; + let candidateCount = cacheValid ? 1 : 0; + try { + if (hwnd == null) { + const candidates = typeof native.findWindowsForProcess === "function" ? native.findWindowsForProcess(processId) : [native.findWindowForProcess(processId)].filter((candidate) => candidate != null); + candidateCount = candidates.length; + hwnd = candidates[0] ?? null; + } + } catch (error) { + emitDiagnostic(onDiagnostic, "target-selection", { + outcome: "observed", + cacheHit, + cacheValid, + candidateCount, + source: cacheValid ? "cache" : "discovery", + targetFound: false + }); + throw error; + } + emitDiagnostic(onDiagnostic, "target-selection", { + outcome: "observed", + cacheHit, + cacheValid, + candidateCount, + primaryCandidate: hwnd != null && typeof native.isPrimaryWindowCandidate === "function" ? native.isPrimaryWindowCandidate(hwnd, processId) : true, + source: cacheValid ? "cache" : "discovery", + targetFound: hwnd != null + }); + if (hwnd == null) throw new Error("The connected Codex Desktop window was not found"); + if (typeof native.isPrimaryWindowCandidate !== "function" || native.isPrimaryWindowCandidate(hwnd, processId)) { + cachedWindows.set(processId, hwnd); + } else { + cachedWindows.delete(processId); + } + const alreadyForeground = sameWindow(native.getForegroundWindow(), hwnd); + const alreadyMaximized = Boolean(native.isZoomed(hwnd)); + const maximizeAccepted = alreadyMaximized ? null : Boolean(native.maximize(hwnd)); + emitDiagnostic(onDiagnostic, "maximize", { + outcome: alreadyMaximized ? "skipped" : "observed", + alreadyMaximized, + ...maximizeAccepted == null ? {} : { accepted: maximizeAccepted } + }); + if (maximizeAccepted === false) { + throw new Error("Windows did not accept the Codex maximize request"); + } + if (alreadyForeground) { + emitDiagnostic(onDiagnostic, "initial-foreground", { + outcome: "skipped", + alreadyForeground + }); + } else { + emitDiagnostic(onDiagnostic, "initial-foreground", { + outcome: "observed", + alreadyForeground, + accepted: Boolean(native.foreground(hwnd)) + }); + } + if (!alreadyMaximized) await wait(20); + let foreground = sameWindow(native.getForegroundWindow(), hwnd); + emitDiagnostic(onDiagnostic, "verify-foreground-immediate", { + outcome: "observed", + matchesTarget: foreground + }); + if (!foreground && !alreadyForeground) { + await wait(25); + foreground = sameWindow(native.getForegroundWindow(), hwnd); + emitDiagnostic(onDiagnostic, "verify-foreground-delayed", { + outcome: "observed", + matchesTarget: foreground + }); + } + let automaticAggressiveFallback = false; + if (!foreground && !alreadyForeground && typeof native.forceForeground === "function") { + let result; + try { + result = native.forceForeground(hwnd) || {}; + const foregroundAttachErrorCode = Number(result.foregroundAttachErrorCode) || 0; + const targetAttachErrorCode = Number(result.targetAttachErrorCode) || 0; + automaticAggressiveFallback = foregroundAttachErrorCode === ERROR_ACCESS_DENIED || targetAttachErrorCode === ERROR_ACCESS_DENIED; + emitDiagnostic(onDiagnostic, "force-foreground", { + outcome: "observed", + foregroundAttach: result.foregroundAttach || "unknown", + foregroundAttachErrorCode, + targetAttach: result.targetAttach || "unknown", + targetAttachErrorCode, + bringToTopAccepted: Boolean(result.bringToTopAccepted), + setForegroundAccepted: Boolean(result.setForegroundAccepted), + setFocusReturnedWindow: Boolean(result.setFocusReturnedWindow), + detachAccepted: result.detachAccepted !== false + }); + } catch (error) { + emitDiagnostic(onDiagnostic, "force-foreground", { outcome: "failed" }); + throw error; + } + foreground = await verifyForegroundWithDelays( + native, + hwnd, + wait, + automaticAggressiveFallback ? [FORCED_VERIFY_DELAYS_MS[0]] : FORCED_VERIFY_DELAYS_MS, + onDiagnostic, + "verify-foreground-forced" + ); + } + if (!foreground && !alreadyForeground && (forceFocus || automaticAggressiveFallback) && typeof native.aggressiveForeground === "function") { + try { + const result = native.aggressiveForeground(hwnd) || {}; + emitDiagnostic(onDiagnostic, "aggressive-foreground", { + outcome: "observed", + automatic: automaticAggressiveFallback, + forceFocus: Boolean(forceFocus), + modifierStateClear: Boolean(result.modifierStateClear), + inputEventsInserted: Number(result.inputEventsInserted) || 0, + inputErrorCode: Number(result.inputErrorCode) || 0, + releaseAccepted: result.releaseAccepted !== false, + setForegroundAccepted: Boolean(result.setForegroundAccepted) + }); + if (Number(result.inputEventsInserted) > 0) { + foreground = await verifyForegroundWithDelays( + native, + hwnd, + wait, + FORCED_VERIFY_DELAYS_MS, + onDiagnostic, + "verify-foreground-aggressive" + ); + } + } catch { + emitDiagnostic(onDiagnostic, "aggressive-foreground", { + outcome: "failed", + automatic: automaticAggressiveFallback, + forceFocus: Boolean(forceFocus) + }); + } + } + if (!foreground) { + let attentionRequested = false; + let wasActiveBeforeAttention = null; + if (typeof native.flash === "function") { + try { + const result = native.flash(hwnd); + attentionRequested = true; + wasActiveBeforeAttention = typeof result === "object" && result != null ? Boolean(result.wasActiveBeforeRequest) : Boolean(result); + } catch { + } + } + emitDiagnostic(onDiagnostic, "final-attention", { + outcome: "observed", + attentionRequested, + ...wasActiveBeforeAttention == null ? {} : { wasActiveBeforeAttention } + }); + emitDiagnostic(onDiagnostic, "final-denial", { + outcome: "observed", + matchesTarget: false, + attentionRequested + }); + const error = new Error("Windows did not allow Codex Desktop to receive focus"); + error.attentionRequested = attentionRequested; + throw error; + } + return { + processId, + alreadyForeground, + alreadyMaximized, + maximized: alreadyMaximized || Boolean(native.isZoomed(hwnd)) + }; +} +function clearWindowsFocusCache() { + cachedWindows.clear(); +} +var SW_MAXIMIZE, FLASHW_TRAY, INPUT_KEYBOARD, KEYEVENTF_KEYUP, VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN, GW_OWNER, MODIFIER_KEYS, ERROR_ACCESS_DENIED, FORCED_VERIFY_DELAYS_MS, cachedWindows, windowsApiPromise; +var init_windows_focus = __esm({ + "../../src/bridge/windows-focus.mjs"() { + SW_MAXIMIZE = 3; + FLASHW_TRAY = 2; + INPUT_KEYBOARD = 1; + KEYEVENTF_KEYUP = 2; + VK_SHIFT = 16; + VK_CONTROL = 17; + VK_MENU = 18; + VK_LWIN = 91; + VK_RWIN = 92; + GW_OWNER = 4; + MODIFIER_KEYS = [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN]; + ERROR_ACCESS_DENIED = 5; + FORCED_VERIFY_DELAYS_MS = [25, 75, 150]; + cachedWindows = /* @__PURE__ */ new Map(); + windowsApiPromise = null; + } +}); + // ../../node_modules/ws/lib/constants.js var require_constants = __commonJS({ "../../node_modules/ws/lib/constants.js"(exports, module) { @@ -308,7 +793,7 @@ var require_permessage_deflate = __commonJS({ acceptAsServer(offers) { const opts = this._options; const accepted = offers.find((params) => { - if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { return false; } return true; @@ -2270,7 +2755,7 @@ var require_websocket = __commonJS({ var http = __require("http"); var net = __require("net"); var tls = __require("tls"); - var { randomBytes, createHash } = __require("crypto"); + var { randomBytes, createHash: createHash2 } = __require("crypto"); var { Duplex, Readable } = __require("stream"); var { URL: URL2 } = __require("url"); var PerMessageDeflate2 = require_permessage_deflate(); @@ -2938,7 +3423,7 @@ var require_websocket = __commonJS({ abortHandshake(websocket, socket, "Invalid Upgrade header"); return; } - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); if (res.headers["sec-websocket-accept"] !== digest) { abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); return; @@ -3307,7 +3792,7 @@ var require_websocket_server = __commonJS({ var EventEmitter = __require("events"); var http = __require("http"); var { Duplex } = __require("stream"); - var { createHash } = __require("crypto"); + var { createHash: createHash2 } = __require("crypto"); var extension2 = require_extension(); var PerMessageDeflate2 = require_permessage_deflate(); var subprotocol2 = require_subprotocol(); @@ -3614,7 +4099,7 @@ var require_websocket_server = __commonJS({ ); } if (this._state > RUNNING) return abortHandshake(socket, 503); - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); const headers = [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", @@ -3703,12 +4188,207 @@ var require_websocket_server = __commonJS({ // ../../src/bridge/server.mjs import { createServer } from "node:http"; -import { execFile as execFile2 } from "node:child_process"; -import { promisify as promisify2 } from "node:util"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join as join2 } from "node:path"; +import { fileURLToPath } from "node:url"; -// ../../src/bridge/codex-cdp.mjs -import { execFile } from "node:child_process"; +// ../../src/bridge/platform.mjs +import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; +var execFileAsync = promisify(execFile); +var CDP_HOST = "127.0.0.1"; +var DEFAULT_CDP_PORT = 9222; +var CDP_ARGUMENTS = Object.freeze([ + `--remote-debugging-address=${CDP_HOST}`, + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, + `--remote-allow-origins=http://${CDP_HOST}:${DEFAULT_CDP_PORT}` +]); +var WINDOWS_PROCESS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$processNames = @('ChatGPT', 'ChatGPT (Beta)', 'Codex', 'Codex (Beta)') +$existing = @(Get-Process -Name $processNames -ErrorAction SilentlyContinue) +if ($existing.Count -eq 0) { '[]'; exit 0 } +$listenerOwners = @{} +try { + netstat.exe -ano -p tcp | ForEach-Object { + if ($_ -match '^\s*TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)') { + $listenerOwners[('{0}:{1}' -f $matches[1], $matches[2])] = $true + } + } +} catch { + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | + ForEach-Object { $listenerOwners[('{0}:{1}' -f $_.LocalPort, $_.OwningProcess)] = $true } +} +$rows = Get-CimInstance Win32_Process -Filter "Name LIKE 'ChatGPT%.exe' OR Name LIKE 'Codex%.exe'" | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | + ForEach-Object { + $portMatch = [regex]::Match([string]$_.CommandLine, '--remote-debugging-port(?:=|\s+)(\d+)') + $debugPort = if ($portMatch.Success) { [int]$portMatch.Groups[1].Value } else { 0 } + [pscustomobject]@{ + processId = [int]$_.ProcessId + executable = [string]$_.ExecutablePath + commandLine = [string]$_.CommandLine + ownsDebugPort = $debugPort -gt 0 -and $listenerOwners.ContainsKey(('{0}:{1}' -f $debugPort, $_.ProcessId)) + } + } +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_PACKAGE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { + $package = Get-AppxPackage -Name $name | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $package) { continue } + $app = Join-Path $package.InstallLocation 'app' + $names = if ($name -eq 'OpenAI.CodexBeta') { + @('ChatGPT (Beta).exe', 'Codex (Beta).exe', 'ChatGPT.exe') + } else { + @('ChatGPT.exe', 'Codex.exe') + } + foreach ($file in $names) { + $candidate = Join-Path $app $file + if (Test-Path -LiteralPath $candidate) { + [pscustomobject]@{ + channel = if ($name -eq 'OpenAI.CodexBeta') { 'beta' } else { 'stable' } + packageName = $name + packageFullName = $package.PackageFullName + executable = $candidate + } + break + } + } +} +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE +$processNames = @('ChatGPT', 'ChatGPT (Beta)', 'Codex', 'Codex (Beta)') +$existing = @(Get-Process -Name $processNames -ErrorAction SilentlyContinue) +if ($existing.Count -eq 0) { exit 0 } +$processes = @(Get-CimInstance Win32_Process -Filter "Name LIKE 'ChatGPT%.exe' OR Name LIKE 'Codex%.exe'" | + Where-Object { + $_.ExecutablePath -eq $target -and + $_.CommandLine -notlike '*--type=*' -and + $_.CommandLine -notlike '*crashpad-handler*' + }) +foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -ErrorAction Stop } +foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout 8 -ErrorAction SilentlyContinue } +`; +function powershellArgs(command, extra = []) { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command, + ...extra + ]; +} +function powershellOptions(options = {}) { + return { ...options, windowsHide: true }; +} +function processChannel(executable, commandLine) { + const identity = `${executable || ""} ${commandLine || ""}`.toLowerCase(); + return /codexbeta|chatgpt\s*\(beta\)|codex\s*\(beta\)/.test(identity) ? "beta" : "stable"; +} +function debugProcessesFromCommandLines(text) { + const source = String(text || "").trim(); + let rows = []; + if (source.startsWith("[") || source.startsWith("{")) { + try { + const parsed = JSON.parse(source); + rows = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + } + } + if (rows.length === 0) { + rows = source.split(/\r?\n/).filter(Boolean).map((commandLine) => ({ commandLine })); + } + return rows.flatMap((row) => { + const commandLine = String(row?.commandLine || ""); + if (!commandLine.includes("--remote-debugging-address=127.0.0.1")) return []; + if (commandLine.includes("--type=")) return []; + const port = Number(commandLine.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) return []; + const processId = Number(row?.processId); + const executable = typeof row?.executable === "string" ? row.executable : null; + return [{ + port, + processId: Number.isInteger(processId) && processId > 0 ? processId : null, + executable, + channel: processChannel(executable, commandLine), + ownsDebugPort: row?.ownsDebugPort === true + }]; + }); +} +async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); +} +async function processCommandLines(platform, execute) { + if (platform === "win32") { + return (await execute( + "powershell.exe", + powershellArgs(WINDOWS_PROCESS_COMMAND), + powershellOptions({ timeout: 4e3 }) + )).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4e3 })).stdout; + } + return ""; +} +async function discoverDebugEndpoint({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + let processes = []; + try { + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); + } catch { + } + const candidates = [preferredPort, ...processes.map((item) => item.port)]; + for (const port of [...new Set(candidates)]) { + try { + await fetchJson(`http://${CDP_HOST}:${port}/json/version`, 500, fetchImpl); + const process2 = processes.find((item) => item.port === port && item.ownsDebugPort) ?? processes.find((item) => item.port === port); + return { + port, + processId: process2?.processId ?? null, + executable: process2?.executable ?? null, + channel: process2?.channel ?? null + }; + } catch { + } + } + throw new Error("Codex is not running with the local debug bridge"); +} +async function focusCodex({ + platform = process.platform, + execute = execFileAsync, + processId = null, + activateWindows = null, + onDiagnostic = null, + forceFocus = false +} = {}) { + if (platform === "win32") { + const activate = activateWindows ?? (await Promise.resolve().then(() => (init_windows_focus(), windows_focus_exports))).activateWindowsProcess; + return activate(processId, { onDiagnostic, forceFocus }); + } + if (platform === "darwin") { + await execute("/usr/bin/open", ["-b", "com.openai.codex"], { timeout: 3e3 }); + return; + } + throw new Error(`Codex Desktop focus is not supported on ${platform}`); +} // ../../node_modules/ws/wrapper.mjs var import_stream = __toESM(require_stream(), 1); @@ -3747,8 +4427,215 @@ function localThreadKey(value) { return `local:${validateThreadId(value)}`; } +// ../../src/bridge/model-selection.mjs +var REASONING_EFFORTS = Object.freeze([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" +]); +var MODEL_LABELS = Object.freeze({ + "gpt-5.6-sol": "5.6 Sol", + "gpt-5.6-luna": "5.6 Luna", + "gpt-reserve": "Luna Reserve", + "gpt-6-astra": "GPT-6 Astra", + "gemini-3.8-flash-high": "Gemini 3.8 Flash High" +}); +var DEFAULT_MODEL_OPTIONS = Object.freeze([ + Object.freeze({ model: "gpt-6-astra", effort: "low", label: "ASTRA LOW" }), + Object.freeze({ model: "gpt-6-astra", effort: "medium", label: "ASTRA MED" }), + Object.freeze({ model: "gemini-3.8-flash-high", effort: "high", label: "GEMINI HIGH" }), + Object.freeze({ model: "gpt-5.6-sol", effort: "high", label: "SOL HIGH" }) +]); +var MODEL_DISPLAY_NAMES = Object.freeze({ + "gpt-6-astra": "ASTRA", + "gemini-3.8-flash-high": "GEMINI", + "gpt-5.6-sol": "SOL", + "gpt-5.6-luna": "LUNA", + "gpt-reserve": "RESERVE" +}); +var EFFORT_DISPLAY_NAMES = Object.freeze({ + none: "NONE", + minimal: "MIN", + low: "LOW", + medium: "MED", + high: "HIGH", + xhigh: "XHIGH", + max: "MAX", + ultra: "ULTRA" +}); +function resolveModelSelection(defaults, settings) { + if (settings === void 0) return defaults; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error("Invalid model shortcut settings"); + } + const { model, effort } = settings; + if (model !== void 0 && (typeof model !== "string" || model.length > 100 || !/^[\p{L}\p{N}\p{M} ._+()/:\-]*$/u.test(model))) { + throw new Error("Invalid model shortcut model"); + } + if (effort !== void 0 && (typeof effort !== "string" || effort !== "" && !REASONING_EFFORTS.includes(effort))) { + throw new Error("Invalid model shortcut reasoning effort"); + } + const requestedModel = model?.trim() || defaults.model; + const modelKey = (value) => value.toLowerCase().replace(/^gpt[- ]/, "").replace(/[- ]/g, ""); + const alias = Object.entries(MODEL_LABELS).find(([id, label]) => modelKey(id) === modelKey(requestedModel) || modelKey(label) === modelKey(requestedModel)); + const selectedModel = alias?.[0] || requestedModel; + return Object.freeze({ + model: selectedModel, + displayName: selectedModel === defaults.model ? defaults.displayName : MODEL_LABELS[selectedModel] || selectedModel, + effort: effort || defaults.effort + }); +} +function modelSelectionMatches(text, selection) { + const value = String(text ?? "").replace(/\s+/g, " ").trim(); + const key = (label) => String(label ?? "").toLowerCase().replace(/^gpt[- ]/, "").replace(/[- ]/g, ""); + const withoutEffort = value.replace(/\s+(?:none|minimal|low|medium|high|xhigh|max|ultra)$/i, ""); + return [selection.model, selection.displayName].filter(Boolean).some((label) => key(value) === key(label) || key(withoutEffort) === key(label)); +} + +// ../../src/bridge/model-picker.mjs +function inspectModelPicker(preset, kind) { + const visible = (element) => { + for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.getAttribute("aria-hidden") === "true" || ancestor.hasAttribute("inert") || ancestor.getAttribute("data-active") === "false") return false; + } + const rect = element?.getBoundingClientRect?.(); + return Boolean(element && element.getAttribute("aria-hidden") !== "true" && (element.offsetParent !== null || rect?.width > 0 && rect?.height > 0)); + }; + const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(visible); + if (triggers.length !== 1) return kind === "snapshot" ? { modern: false } : []; + const trigger = triggers[0]; + const open = (element) => visible(element) && element.getAttribute("data-state") !== "closed"; + const controlled = document.getElementById(trigger.getAttribute("aria-controls")); + const roots = controlled && open(controlled) ? [controlled] : [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => open(menu) && (menu.querySelector("[data-model-picker-view-toggle]") || menu.querySelector("[data-reasoning-slider]")) + ); + if (roots.length !== 1) return kind === "snapshot" ? { modern: false } : []; + const menus = new Set(roots); + for (const menu of menus) { + for (const owner of menu.querySelectorAll("[aria-controls]")) { + const child = document.getElementById(owner.getAttribute("aria-controls")); + if (child && open(child)) menus.add(child); + } + } + const selector = '[role="menuitem"],[role="menuitemradio"],[role="option"],[role="radio"],button'; + const items = [...new Set([...menus].flatMap((menu) => [...menu.querySelectorAll(selector)]))].filter((item) => visible(item) && !item.disabled && item.getAttribute("aria-disabled") !== "true" && !item.hasAttribute("data-disabled") && !item.hasAttribute("data-model-picker-view-toggle") && !item.hasAttribute("data-reasoning-slider")); + const normalize = (value) => String(value ?? "").replace(/\s+/g, " ").trim(); + const labels = (item) => [ + item.getAttribute("aria-label"), + item.getAttribute("data-model-id"), + item.getAttribute("data-model"), + item.getAttribute("data-reasoning-effort"), + item.getAttribute("data-value"), + item.getAttribute("value"), + item.textContent, + // A label and a description often occupy separate spans. + ...[...item.querySelectorAll("span")].filter((span) => !span.children.length).map((span) => span.textContent) + ].filter(Boolean).map(normalize); + const modelKey = (value) => normalize(value).toLowerCase().replace(/^gpt[- ]/, "").replace(/[- ]/g, ""); + const targets = new Set([preset.model, preset.displayName].map(modelKey)); + const innermost = (candidates) => candidates.filter((item) => !candidates.some((other) => other !== item && item.contains(other))); + const modelItems = innermost(items.filter((item) => ( + // A legacy row can display the current model while opening the model + // list, rather than selecting that model. Leave those rows to the legacy + // semantic probe unless they explicitly identify an actual model. + (item.getAttribute("aria-haspopup") !== "menu" || item.hasAttribute("data-model-id")) && labels(item).some((label) => targets.has(modelKey(label))) + ))); + const effortKey = (value) => { + const key = normalize(value).toLowerCase(); + return { + "extra high": "xhigh", + "extra-high": "xhigh", + "maximum": "max", + "\u65E0": "none", + "\u6700\u4F4E": "minimal", + "\u4F4E": "low", + "\u4E2D": "medium", + "\u9AD8": "high", + "\u6781\u9AD8": "xhigh", + "\u6975\u9AD8": "xhigh", + "\u6700\u9AD8": "max" + }[key] || key; + }; + const efforts = /* @__PURE__ */ new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); + const sliders = [...new Set([...menus].flatMap((menu) => [...menu.querySelectorAll("[data-reasoning-slider]")]))].filter(visible); + let slider = null; + if (sliders.length === 1) { + const element = sliders[0]; + const roots2 = Object.keys(element).filter((key) => key.startsWith("__reactProps$")); + const queue = roots2.map((key) => element[key]?.children); + const seen = /* @__PURE__ */ new Set(); + const candidates = []; + for (let count = 0; queue.length && count < 200; count += 1) { + const node = queue.shift(); + if (!node || typeof node !== "object" || seen.has(node)) continue; + seen.add(node); + if (Array.isArray(node)) { + queue.push(...node); + continue; + } + const props = node.props; + if (!props || typeof props !== "object") continue; + if (Array.isArray(props.options) && typeof props.selectedOptionId === "string") candidates.push(props); + queue.push(props.children); + } + if (!queue.length && candidates.length === 1) { + const props = candidates[0]; + const options = props.options.length <= 20 ? props.options.map((option) => { + if (typeof option?.id !== "string" || option.id.length > 160) return null; + const separator = option.id.lastIndexOf(":"); + const model = option.id.slice(0, separator); + const effort = option.id.slice(separator + 1); + return separator > 0 && efforts.has(effort) ? { id: option.id, model, effort, locked: option.isLocked === true } : null; + }) : []; + const index = options.findIndex((option) => option?.id === props.selectedOptionId); + if (options.length && options.every(Boolean) && new Set(options.map((option) => option.id)).size === options.length && index >= 0) { + const targetIndexes = options.flatMap((option, index2) => targets.has(modelKey(option.model)) && option.effort === preset.effort ? [index2] : []); + slider = { + options, + index, + current: options[index], + targetIndex: targetIndexes.length === 1 ? targetIndexes[0] : -1, + disabled: element.disabled || element.hasAttribute("data-disabled") || element.getAttribute("aria-disabled") === "true" || props.disabled === true || props.active === false + }; + } + } + } + if (kind?.type === "slider-step") { + if (!slider || slider.disabled || sliders.length !== 1) throw new Error("Reasoning slider unavailable"); + const ids = slider.options.map((option) => option.id); + const nextIndex = slider.index + (kind.key === "ArrowRight" ? 1 : kind.key === "ArrowLeft" ? -1 : 0); + const next = slider.options[nextIndex]; + if (JSON.stringify(ids) !== JSON.stringify(kind.ids) || slider.current.id !== kind.fromId || next?.id !== kind.toId || next.locked || next.model !== slider.current.model || !targets.has(modelKey(next.model))) throw new Error("Reasoning slider changed before input"); + sliders[0].focus({ preventScroll: true }); + sliders[0].dispatchEvent(new KeyboardEvent("keydown", { key: kind.key, code: kind.key, bubbles: true, cancelable: true })); + sliders[0].dispatchEvent(new KeyboardEvent("keyup", { key: kind.key, code: kind.key, bubbles: true, cancelable: true })); + return true; + } + const effortItems = innermost(items.filter((item) => item.getAttribute("aria-haspopup") !== "menu" && labels(item).some((label) => efforts.has(effortKey(label))))); + const targetEfforts = effortItems.filter((item) => labels(item).some((label) => effortKey(label) === preset.effort)); + if (kind === "model") return modelItems; + if (kind === "effort") return targetEfforts; + return { + open: true, + triggerEffort: trigger.getAttribute("data-selected-reasoning-effort"), + modern: modelItems.length > 0 || effortItems.length > 0 || sliders.length > 0, + sliderCount: sliders.length, + slider, + modelCount: modelItems.length, + effortCount: targetEfforts.length, + availableEffortCount: effortItems.length + }; +} +function modelPickerExpression(preset, kind = "snapshot") { + return `(${inspectModelPicker.toString()})(${JSON.stringify(preset)}, ${JSON.stringify(kind)})`; +} + // ../../src/bridge/codex-cdp.mjs -var execFileAsync = promisify(execFile); var USAGE_REFRESH_MS = Math.max( 15e3, Number(process.env.CODEX_KEYBOARD_USAGE_REFRESH_SECONDS || 600) * 1e3 @@ -3765,7 +4652,12 @@ var MICRO_ACTION_KEYS = Object.freeze({ mic: "ACT10", submit: "ACT12" }); -var RENDERER_ACTIONS = /* @__PURE__ */ new Set(["pin", "new"]); +var MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", displayName: "5.6 Luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "medium" }) +}); +var RENDERER_ACTIONS = /* @__PURE__ */ new Set(["pin", "new", ...Object.keys(MODEL_PRESETS)]); var PIN_ACTION_LABELS = Object.freeze([ "Pin chat", "Unpin chat", @@ -3833,6 +4725,11 @@ function rendererActionExpression(action) { return true; })()`; } +function modelPreset(action, settings = void 0) { + const preset = MODEL_PRESETS[action]; + if (!preset) throw new Error(`Unknown Codex model preset: ${action}`); + return resolveModelSelection(preset, settings); +} function composerSteerExpression() { return `(() => { const editor = [...document.querySelectorAll('[contenteditable="true"][role="textbox"]')] @@ -4089,49 +4986,146 @@ function selectMainTarget(targets) { } }) ?? pages.find((target) => !/avatar-overlay|composition-surface/i.test(target.url || "")); } -async function fetchJson(url, timeout = 1200) { - const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); - if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); - return response.json(); +function connectionIdentity(discovery) { + return Number.isInteger(discovery) ? { port: discovery, processId: null, executable: null, channel: null } : discovery; } -async function discoverDebugPort() { - const { stdout } = await execFileAsync("/bin/ps", ["-axo", "command="], { timeout: 4e3 }); - for (const line of stdout.split("\n")) { - if (!line.includes("--remote-debugging-address=127.0.0.1")) continue; - const port = Number(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); - if (!Number.isInteger(port)) continue; - try { - await fetchJson(`http://127.0.0.1:${port}/json/version`, 500); - return port; - } catch { - } +function threadActiveExpression(threadKey) { + return `(() => { + const key = ${JSON.stringify(threadKey)}; + const normalize = (value) => String(value ?? "").replace(/^local:/, ""); + const sidebarItems = [...document.querySelectorAll("[data-app-action-sidebar-thread-id]")]; + const target = sidebarItems.find((element) => + normalize(element.getAttribute("data-app-action-sidebar-thread-id")) === normalize(key) + ); + const active = sidebarItems.find((element) => + element.getAttribute("data-app-action-sidebar-thread-active") === "true" || + element.getAttribute("aria-current") === "page" + ); + const current = document.querySelector("[data-above-composer-conversation-id]") + ?.getAttribute("data-above-composer-conversation-id") + ?? active?.getAttribute("data-app-action-sidebar-thread-id") + ?? null; + return normalize(current) === normalize(key) || Boolean(target && active && target === active); + })()`; +} +function traceErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "cdp-operation"; +} +async function runTraceStage(trace, stage, operation, fields = {}) { + const startedAt = performance.now(); + trace?.record("cdp.stage", { stage, outcome: "started", ...fields }); + try { + const result = await operation(); + trace?.record("cdp.stage", { + stage, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + return result; + } catch (error) { + trace?.record("cdp.stage", { + stage, + outcome: "failed", + category: traceErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + throw error; } - throw new Error("Codex is not running with the local debug bridge"); } var CodexCdpClient = class { socket = null; + connectPromise = null; + connectionGeneration = 0; nextId = 0; pending = /* @__PURE__ */ new Map(); lastSnapshot = null; + connectionIdentity = null; + modelActionQueue = Promise.resolve(); + modelPickerLayout = null; + constructor({ + discoverPort = discoverDebugEndpoint, + fetchTargets = fetchJson, + createSocket = (url) => new wrapper_default(url) + } = {}) { + this.discoverPort = discoverPort; + this.fetchTargets = fetchTargets; + this.createSocket = createSocket; + } async connect() { if (this.socket?.readyState === wrapper_default.OPEN) return; - const port = await discoverDebugPort(); - const target = selectMainTarget(await fetchJson(`http://127.0.0.1:${port}/json/list`)); + if (this.connectPromise) return this.connectPromise; + const generation = ++this.connectionGeneration; + const operation = this.openConnection(generation); + this.connectPromise = operation; + try { + return await operation; + } finally { + if (this.connectPromise === operation) this.connectPromise = null; + } + } + async openConnection(generation) { + const discovery = await this.discoverPort(); + const identity = connectionIdentity(discovery); + const port = Number(identity?.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("Codex debug endpoint identity was invalid"); + } + const target = selectMainTarget(await this.fetchTargets(`http://127.0.0.1:${port}/json/list`)); if (!target?.webSocketDebuggerUrl) throw new Error("Codex main renderer was not found"); - const socket = new wrapper_default(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - const timer2 = setTimeout(() => reject(new Error("Timed out connecting to Codex")), 3e3); - socket.once("open", () => { - clearTimeout(timer2); - resolve(); - }); - socket.once("error", reject); - }); - socket.on("message", (raw) => this.handleMessage(String(raw))); - socket.on("close", () => this.disconnect()); - socket.on("error", () => this.disconnect()); + if (generation !== this.connectionGeneration) throw new Error("Codex bridge connection was cancelled"); + const socket = this.createSocket(target.webSocketDebuggerUrl); this.socket = socket; - await this.evaluate(ENABLE_EXPRESSION); + this.connectionIdentity = { + port, + processId: Number.isInteger(identity?.processId) ? identity.processId : null, + executable: typeof identity?.executable === "string" ? identity.executable : null, + channel: ["stable", "beta"].includes(identity?.channel) ? identity.channel : null + }; + try { + await new Promise((resolve, reject) => { + const timer2 = setTimeout(() => { + cleanup(); + reject(new Error("Timed out connecting to Codex")); + }, 3e3); + const cleanup = () => { + clearTimeout(timer2); + socket.off("open", onOpen); + socket.off("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + socket.once("open", onOpen); + socket.once("error", onError); + }); + if (generation !== this.connectionGeneration || this.socket !== socket) { + throw new Error("Codex bridge connection was cancelled"); + } + socket.on("message", (raw) => this.handleMessage(String(raw))); + socket.on("close", () => { + if (this.socket === socket) this.disconnect(); + }); + socket.on("error", () => { + if (this.socket === socket) this.disconnect(); + }); + await this.evaluate(ENABLE_EXPRESSION); + } catch (error) { + if (this.socket === socket) this.disconnect(); + throw error; + } } async snapshot() { await this.connect(); @@ -4150,22 +5144,71 @@ var CodexCdpClient = class { if (!agent?.threadKey) throw new Error(`Agent slot ${slot + 1} is empty`); return this.clickThreadKey(agent.threadKey, slot); } - async clickThread(threadId, slot = 0) { - await this.connect(); - return this.clickThreadKey(localThreadKey(threadId), slot); + async clickThread(threadId, slot = 0, trace = null) { + await runTraceStage(trace, "task.connect", () => this.connect(), { slot: slot + 1 }); + return this.clickThreadKey(localThreadKey(threadId), slot, trace); } - async clickThreadKey(threadKey, slot) { + async clickThreadKey(threadKey, slot, trace = null) { + if (await runTraceStage( + trace, + "task.verify-initial", + () => this.threadIsActive(threadKey), + { slot: slot + 1 } + )) return true; + let nativeError = null; + let pressed = false; try { - await this.dispatchAgent(slot, threadKey, 1); - void (async () => { - await new Promise((resolve) => setTimeout(resolve, 35)); - await this.dispatchAgent(slot, threadKey, 0); - await this.activateThread(threadKey); - })().catch(() => { + await runTraceStage( + trace, + "task.native-act1", + () => this.dispatchAgent(slot, threadKey, 1), + { slot: slot + 1 } + ); + pressed = true; + await new Promise((resolve) => setTimeout(resolve, 35)); + } catch (error) { + nativeError = error; + trace?.record("task.fallback", { + outcome: "started", + category: traceErrorCategory(error), + slot: slot + 1 }); - } catch { - await this.activateThread(threadKey); + } finally { + if (pressed) { + try { + await runTraceStage( + trace, + "task.native-act0", + () => this.dispatchAgent(slot, threadKey, 0), + { slot: slot + 1 } + ); + } catch (error) { + nativeError ??= error; + trace?.record("task.fallback", { + outcome: "started", + category: traceErrorCategory(error), + slot: slot + 1 + }); + } + } + } + await new Promise((resolve) => setTimeout(resolve, 50)); + const nativeActivated = nativeError === null && await runTraceStage( + trace, + "task.verify-native", + () => this.threadIsActive(threadKey), + { slot: slot + 1 } + ); + if (!nativeActivated) { + await runTraceStage( + trace, + "task.dom-activate", + () => this.activateThread(threadKey), + { slot: slot + 1 } + ); } + await this.waitForThreadActivation(threadKey, trace); + return true; } async dispatchAgent(slot, threadKey, act) { return this.dispatchMicroMessage({ @@ -4179,21 +5222,506 @@ var CodexCdpClient = class { event: { key, act, slot: null, threadKey: null } }, "codex-micro-hid-event"); } - async dispatchNamedAction(action, pressed) { + async dispatchNamedAction(action, pressed, trace = null, settings = void 0) { const key = MICRO_ACTION_KEYS[action]; if (key) return this.dispatchAction(key, pressed ? 1 : 0); if (!RENDERER_ACTIONS.has(action)) { throw new Error(`Unsupported Codex bridge action: ${action}`); } if (!pressed) return true; - return this.dispatchRendererAction(action); + return this.dispatchRendererAction(action, trace, settings); } - async dispatchRendererAction(action) { - await this.connect(); + async dispatchRendererAction(action, trace = null, settings = void 0) { + if (MODEL_PRESETS[action]) { + const preset = modelPreset(action, settings); + const operation = this.modelActionQueue.then(async () => { + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); + return this.dispatchModelPreset(action, trace, preset); + }); + this.modelActionQueue = operation.catch(() => { + }); + await operation; + return true; + } + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); const invoked = await this.evaluate(rendererActionExpression(action)); if (!invoked) throw new Error(`Codex ${action} action is not available`); return true; } + async dispatchModelPreset(action, trace = null, settings = void 0) { + const preset = modelPreset(action, settings); + const presetStartedAt = performance.now(); + trace?.record("model.preset", { + action, + stage: "start", + outcome: "started", + targetEffort: preset.effort + }); + const effortOrder = REASONING_EFFORTS; + const isSingleEffort = preset.model === "gemini-3.8-flash-high" || /gemini/i.test(preset.model); + let modernPicker = false; + let pendingModelSelection = false; + const pickerState = () => this.evaluate(modelPickerExpression(preset)); + const selectedExpression = `(() => { + const picker = ${modelPickerExpression(preset)}; + const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(element => { + const rect = element.getBoundingClientRect(); + return element.offsetParent !== null || (rect.width > 0 && rect.height > 0); + }); + if (triggers.length !== 1) return false; + const modelMatched = (${modelSelectionMatches.toString()})(picker.slider?.current?.model ?? triggers[0].textContent, ${JSON.stringify(preset)}); + const triggerEffort = triggers[0].getAttribute("data-selected-reasoning-effort"); + const effortMatched = triggerEffort === ${JSON.stringify(preset.effort)} || (!triggerEffort && ${Boolean(isSingleEffort)}); + return modelMatched && effortMatched; + })()`; + const chooseModernModel = async () => { + const state = await pickerState(); + if (state.modelCount !== 1) throw new Error("Expected one available model option in sequential picker"); + await this.clickRendererCandidates( + modelPickerExpression(preset, "model"), + "requested model option", + trace, + "model.select-model-option" + ); + pendingModelSelection = true; + await this.waitForRenderer( + `(() => { const picker = ${modelPickerExpression(preset)}; + return picker.sliderCount > 0 || picker.availableEffortCount > 0 || (${selectedExpression}); + })()`, + "Codex model reasoning choices", + trace, + "model.wait-reasoning-view" + ); + }; + const readState = async () => { + const state = await runTraceStage(trace, "model.read-state", () => this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(visible); + if (triggers.length !== 1) return { error: \`Expected one visible intelligence trigger, found \${triggers.length}\` }; + return { + text: String(triggers[0].textContent ?? "").replace(/\\s+/g, " ").trim(), + effort: triggers[0].getAttribute("data-selected-reasoning-effort"), + expanded: triggers[0].getAttribute("aria-expanded") === "true" + }; + })()`)); + if (state?.error) throw new Error(state.error); + const picker = await pickerState(); + if (picker?.slider?.current) state.text = picker.slider.current.model; + trace?.record("model.state", { + currentEffort: state.effort || "unknown", + effortMatched: state.effort === preset.effort, + modelMatched: modelSelectionMatches(state.text, preset) + }); + return state; + }; + const closeMenus = async () => { + let attempts = 0; + for (; attempts < 3 && (await readState()).expanded; attempts += 1) { + await this.pressRendererEscape(trace, "model.close-menu"); + } + if ((await readState()).expanded) throw new Error("Codex intelligence menu did not close"); + trace?.record("model.menu-close", { attempts, outcome: "succeeded" }); + }; + const openMain = async () => { + if (!(await readState()).expanded) { + await this.clickRendererCandidates( + '[...document.querySelectorAll("[data-codex-intelligence-trigger]")]', + "Codex intelligence trigger", + trace, + "model.open-trigger" + ); + } + await this.waitForRenderer(`(() => { + if ((${modelPickerExpression(preset)}).open) return true; + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + return [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => visible(menu) && ( + menu.querySelector("[data-model-picker-view-toggle]") || + menu.querySelector("[data-reasoning-slider]") + ) + ).length === 1; + })()`, "Codex intelligence menu", trace, "model.wait-main-menu"); + const toggleState = await runTraceStage(trace, "model.read-menu-shape", () => this.evaluate(`(() => { + const menus = [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => menu.querySelector("[data-model-picker-view-toggle]") || menu.querySelector("[data-reasoning-slider]") + ); + const toggles = menus.length === 1 + ? [...menus[0].querySelectorAll("[data-model-picker-view-toggle]")] + : []; + return { + count: toggles.length, + expanded: toggles[0]?.getAttribute("aria-expanded") === "true", + rowCount: menus[0]?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0 + }; + })()`)); + const direct = await pickerState(); + if (direct?.modern && (direct.modelCount > 0 || modelSelectionMatches((await readState()).text, preset))) { + modernPicker = true; + trace?.record("model.menu-shape", { outcome: "sequential", rowCount: direct.modelCount }); + return; + } + trace?.record("model.menu-shape", { + rowCount: toggleState.rowCount, + outcome: toggleState.expanded ? "expanded" : "collapsed" + }); + if (toggleState.count === 0) { + if (toggleState.rowCount < 1) throw new Error("Codex model picker has no submenu rows"); + return; + } + if (toggleState.count !== 1) { + throw new Error(`Expected one model picker view toggle, found ${toggleState.count}`); + } + if (!toggleState.expanded) { + await this.clickRendererCandidates( + `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu ? [...menu.querySelectorAll("[data-model-picker-view-toggle]")] : []; + })()`, + "model picker view toggle", + trace, + "model.open-picker-view" + ); + await this.waitForRenderer(`(() => { + if ((${modelPickerExpression(preset)}).modern) return true; + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return (menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0) > 0; + })()`, "expanded Codex model picker", trace, "model.wait-picker-view"); + } + const expandedPicker = await pickerState(); + modernPicker = Boolean(expandedPicker?.modern); + if (modernPicker) trace?.record("model.menu-shape", { outcome: "sequential", rowCount: expandedPicker.modelCount }); + }; + const rowExpression = (rowIndex) => `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + return rows[${rowIndex}] ? [rows[${rowIndex}]] : []; + })()`; + const submenuInfo = async (rowIndex) => this.waitForRenderer(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + if (!rows[${rowIndex}]) return null; + const submenu = document.getElementById(rows[${rowIndex}].getAttribute("aria-controls")); + if (!submenu || submenu.getAttribute("data-state") !== "open") return null; + return [...submenu.querySelectorAll('[role="menuitem"]')].map((item) => ({ + text: String(item.textContent ?? "").replace(/\\s+/g, " ").trim(), + checked: Boolean(item.querySelector("svg")) + })); + })()`, "Codex model picker submenu", trace, `model.wait-submenu-${rowIndex + 1}`); + const identifyRows = async () => { + if (this.modelPickerLayout?.connectionGeneration === this.connectionGeneration) { + trace?.record("model.rows-identified", { + rowCount: this.modelPickerLayout.rowCount, + source: "cache", + outcome: "succeeded" + }); + return this.modelPickerLayout; + } + this.modelPickerLayout = null; + const rowCount = await runTraceStage(trace, "model.read-row-count", () => this.evaluate(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0; + })()`)); + if (rowCount < 1) throw new Error("Codex model picker has no submenu rows"); + const current = await readState(); + if (!effortOrder.includes(current.effort)) { + throw new Error(`Unsupported Codex reasoning effort: ${current.effort}`); + } + const modelRowIndexes = []; + const effortRowIndexes = []; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + await openMain(); + await this.clickRendererCandidates( + rowExpression(rowIndex), + `model picker row ${rowIndex + 1}`, + trace, + `model.open-row-${rowIndex + 1}` + ); + const items = await submenuInfo(rowIndex); + if (items.filter((item) => item.text === preset.displayName).length === 1) { + modelRowIndexes.push(rowIndex); + } + const effortTexts = items.map((item) => item.text); + const checkedItems = items.filter((item) => item.checked); + if (items.length > 0 && effortTexts.every((text) => effortOrder.includes(text)) && new Set(effortTexts).size === items.length && checkedItems.length === 1 && checkedItems[0].text === current.effort) { + effortRowIndexes.push(rowIndex); + } + await this.pressRendererEscape(trace, `model.close-row-${rowIndex + 1}`); + } + if (modelRowIndexes.length !== 1) { + throw new Error(`Expected one Codex model row for ${preset.displayName}, found ${modelRowIndexes.length}`); + } + if (effortRowIndexes.length !== 1) { + throw new Error(`Expected one Codex reasoning effort row, found ${effortRowIndexes.length}`); + } + const modelRowIndex = modelRowIndexes[0]; + const effortRowIndex = effortRowIndexes[0]; + if (modelRowIndex === effortRowIndex) { + throw new Error("Codex model and reasoning effort rows are ambiguous"); + } + const result = { + connectionGeneration: this.connectionGeneration, + rowCount, + modelRowIndex, + effortRowIndex + }; + this.modelPickerLayout = result; + trace?.record("model.rows-identified", { + rowCount, + source: "probe", + outcome: "succeeded" + }); + return result; + }; + const selectSliderEffort = async () => { + for (let step = 0; step < 20; step += 1) { + const state = await pickerState(); + const slider = state?.slider; + trace?.record("model.menu-shape", { + stage: "model.read-slider", + rowCount: slider?.options.length ?? 0, + currentEffort: slider?.current.effort || "unknown", + targetEffort: preset.effort, + outcome: slider ? "slider" : "unsupported" + }); + if (state?.sliderCount !== 1 || !slider || slider.disabled) { + throw new Error("Reasoning slider metadata unavailable, ambiguous or disabled"); + } + if (slider.targetIndex < 0 || !modelSelectionMatches(slider.current.model, preset)) { + throw new Error("Requested reasoning effort is unavailable for the selected model"); + } + const from = Math.min(slider.index, slider.targetIndex); + const to = Math.max(slider.index, slider.targetIndex); + if (slider.options.slice(from, to + 1).some((option) => option.locked || option.model !== slider.current.model)) { + throw new Error("Reasoning slider path contains a locked option or another model"); + } + if (slider.index === slider.targetIndex) { + await this.waitForRenderer(selectedExpression, "requested model and reasoning effort", trace, "model.wait-effort-selected"); + return; + } + const key = slider.targetIndex > slider.index ? "ArrowRight" : "ArrowLeft"; + const next = slider.options[slider.index + (key === "ArrowRight" ? 1 : -1)]; + await runTraceStage(trace, "model.slider-step", () => this.evaluate(modelPickerExpression(preset, { + type: "slider-step", + key, + fromId: slider.current.id, + toId: next.id, + ids: slider.options.map((option) => option.id) + })), { targetEffort: next.effort }); + await this.waitForRenderer(`(() => { + const picker = ${modelPickerExpression(preset)}; + return picker.slider?.current.id === ${JSON.stringify(next.id)} + && picker.triggerEffort === ${JSON.stringify(next.effort)}; + })()`, "reasoning slider step", trace, "model.wait-slider-step"); + } + throw new Error("Reasoning slider exceeded bounded step count"); + }; + const selectEffort = async () => { + const current = await readState(); + const currentEffortMatched = current.effort === preset.effort || !current.effort && isSingleEffort; + if (currentEffortMatched && modelSelectionMatches(current.text, preset)) { + trace?.record("model.effort", { + currentEffort: current.effort || preset.effort, + targetEffort: preset.effort, + outcome: "skipped" + }); + return; + } + if (isSingleEffort) { + trace?.record("model.effort", { + currentEffort: preset.effort, + targetEffort: preset.effort, + outcome: "single-effort" + }); + return; + } + trace?.record("model.effort", { + currentEffort: current.effort || "unknown", + targetEffort: preset.effort, + outcome: "changing" + }); + if (!effortOrder.includes(preset.effort)) { + throw new Error(`Unsupported Codex reasoning effort transition: ${current.effort} -> ${preset.effort}`); + } + if (!pendingModelSelection) await openMain(); + if (modernPicker) { + let state = await pickerState(); + if (!state.sliderCount && state.availableEffortCount === 0 && state.modelCount > 0) { + await chooseModernModel(); + state = await pickerState(); + } + if (state.sliderCount > 0) { + await selectSliderEffort(); + trace?.record("model.effort", { targetEffort: preset.effort, outcome: "succeeded" }); + return; + } + if (state.effortCount !== 1) { + throw new Error("Requested reasoning effort is unavailable or ambiguous for this model"); + } + await this.clickRendererCandidates( + modelPickerExpression(preset, "effort"), + "requested reasoning effort", + trace, + "model.select-effort-option" + ); + await this.waitForRenderer(selectedExpression, "requested model and reasoning effort", trace, "model.wait-effort-selected"); + trace?.record("model.effort", { targetEffort: preset.effort, outcome: "succeeded" }); + return; + } + const { effortRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates( + rowExpression(effortRowIndex), + "reasoning effort row", + trace, + "model.open-effort-row" + ); + const items = await submenuInfo(effortRowIndex); + const effortTexts = items.map((item) => item.text); + const checkedItems = items.filter((item) => item.checked); + if (items.length === 0 || !effortTexts.every((text) => effortOrder.includes(text)) || new Set(effortTexts).size !== items.length || !effortTexts.includes(preset.effort) || checkedItems.length !== 1 || checkedItems[0].text !== current.effort) { + throw new Error("Codex reasoning effort order or selected state changed"); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${effortRowIndex}] + ? document.getElementById(rows[${effortRowIndex}].getAttribute("aria-controls")) + : null; + return submenu + ? [...submenu.querySelectorAll('[role="menuitem"]')].filter( + (item) => String(item.textContent ?? "").replace(/\\s+/g, " ").trim() === ${JSON.stringify(preset.effort)} + ) + : []; + })()`, `reasoning effort ${preset.effort}`, trace, "model.select-effort-option"); + await this.waitForRenderer( + `document.querySelector("[data-codex-intelligence-trigger]")?.getAttribute("data-selected-reasoning-effort") === ${JSON.stringify(preset.effort)}`, + `reasoning effort ${preset.effort}`, + trace, + "model.wait-effort-selected" + ); + trace?.record("model.effort", { + targetEffort: preset.effort, + outcome: "succeeded" + }); + }; + const selectModel = async () => { + if (modelSelectionMatches((await readState()).text, preset)) { + trace?.record("model.model", { modelMatched: true, outcome: "skipped" }); + return; + } + trace?.record("model.model", { modelMatched: false, outcome: "changing" }); + await openMain(); + if (modernPicker) { + if (modelSelectionMatches((await readState()).text, preset)) return; + await chooseModernModel(); + trace?.record("model.model", { outcome: "selected", modelMatched: modelSelectionMatches((await readState()).text, preset) }); + return; + } + const { modelRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates( + rowExpression(modelRowIndex), + "model row", + trace, + "model.open-model-row" + ); + const items = await submenuInfo(modelRowIndex); + if (items.filter((item) => item.text === preset.displayName).length !== 1) { + throw new Error(`Expected one available ${preset.displayName} model option`); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${modelRowIndex}] + ? document.getElementById(rows[${modelRowIndex}].getAttribute("aria-controls")) + : null; + return submenu + ? [...submenu.querySelectorAll('[role="menuitem"]')].filter( + (item) => String(item.textContent ?? "").replace(/\\s+/g, " ").trim() === ${JSON.stringify(preset.displayName)} + ) + : []; + })()`, `model ${preset.displayName}`, trace, "model.select-model-option"); + await this.waitForRenderer( + `String(document.querySelector("[data-codex-intelligence-trigger]")?.textContent ?? "").includes(${JSON.stringify(preset.displayName)})`, + `model ${preset.displayName}`, + trace, + "model.wait-model-selected" + ); + trace?.record("model.model", { modelMatched: true, outcome: "succeeded" }); + }; + try { + await runTraceStage(trace, "model.select-model", selectModel, { action }); + await runTraceStage(trace, "model.select-effort", selectEffort, { targetEffort: preset.effort }); + const selected = await runTraceStage(trace, "model.validate", readState, { action }); + const effortMatched = selected.effort === preset.effort || !selected.effort && isSingleEffort; + if (!modelSelectionMatches(selected.text, preset) || !effortMatched) { + throw new Error(`Codex did not select ${preset.displayName} / ${preset.effort}`); + } + await runTraceStage(trace, "model.close-menus", closeMenus, { action }); + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "succeeded", + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); + return { model: preset.model, effort: preset.effort }; + } catch (error) { + this.modelPickerLayout = null; + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "failed", + category: traceErrorCategory(error), + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); + try { + await runTraceStage(trace, "model.cleanup-menus", closeMenus, { action }); + } catch { + } + throw error; + } + } + async refreshConnectionIdentity() { + const identity = connectionIdentity(await this.discoverPort()); + const port = Number(identity?.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("Codex debug endpoint identity was invalid"); + } + if (this.socket?.readyState === wrapper_default.OPEN && this.connectionIdentity?.port !== port) { + this.disconnect(); + await this.connect(); + return this.connectionIdentity; + } + this.connectionIdentity = { + port, + processId: Number.isInteger(identity?.processId) ? identity.processId : null, + executable: typeof identity?.executable === "string" ? identity.executable : null, + channel: ["stable", "beta"].includes(identity?.channel) ? identity.channel : null + }; + return this.connectionIdentity; + } async dispatchComposerSteer() { await this.connect(); const clicked = await this.evaluate(composerSteerExpression()); @@ -4268,7 +5796,18 @@ var CodexCdpClient = class { return true; })()`); } - evaluate(expression) { + threadIsActive(threadKey) { + return this.evaluate(threadActiveExpression(threadKey)); + } + waitForThreadActivation(threadKey, trace = null) { + return this.waitForRenderer( + threadActiveExpression(threadKey), + "target Codex task activation", + trace, + "task.verify-final" + ); + } + sendCommand(method, params, returnValue = false) { if (!this.socket || this.socket.readyState !== wrapper_default.OPEN) { return Promise.reject(new Error("Codex bridge is disconnected")); } @@ -4278,14 +5817,77 @@ var CodexCdpClient = class { this.pending.delete(id); reject(new Error("Codex runtime response timed out")); }, 7e3); - this.pending.set(id, { resolve, reject, timer: timer2 }); + this.pending.set(id, { resolve, reject, timer: timer2, returnValue }); this.socket.send(JSON.stringify({ id, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true } + method, + params })); }); } + evaluate(expression) { + return this.sendCommand("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }, true); + } + async clickRendererCandidates(candidatesExpression, description, trace = null, stage = "renderer.click") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const candidates = [...(${candidatesExpression})].filter(visible); + if (candidates.length !== 1) { + throw new Error(\`Expected one ${description}, found \${candidates.length}\`); + } + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + const EventType = type.startsWith("pointer") ? PointerEvent : MouseEvent; + candidates[0].dispatchEvent(new EventType(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type.endsWith("down") ? 1 : 0, + view: window + })); + } + return true; + })()`)); + } + async pressRendererEscape(trace = null, stage = "renderer.escape") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", code: "Escape", bubbles: true, cancelable: true + })); + return true; + })()`)); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + async waitForRenderer(expression, description, trace = null, stage = "renderer.wait") { + const startedAt = performance.now(); + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await this.evaluate(expression); + if (result) { + trace?.record("renderer.poll", { + stage, + outcome: "succeeded", + attempts: attempt + 1, + durationMs: Math.round(performance.now() - startedAt) + }); + return result; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + trace?.record("renderer.poll", { + stage, + outcome: "failed", + category: "timeout", + attempts: 20, + durationMs: Math.round(performance.now() - startedAt) + }); + throw new Error(`Timed out waiting for ${description}`); + } handleMessage(raw) { let message; try { @@ -4303,12 +5905,24 @@ var CodexCdpClient = class { message.result.exceptionDetails.exception?.description ?? message.result.exceptionDetails.text ?? "Codex evaluation failed" )); } - pending.resolve(message.result?.result?.value); + pending.resolve( + pending.returnValue ? message.result?.result?.value : message.result + ); } disconnect() { + this.connectionGeneration += 1; + this.connectPromise = null; + this.modelPickerLayout = null; + this.connectionIdentity = null; const socket = this.socket; this.socket = null; - if (socket?.readyState === wrapper_default.OPEN) socket.close(); + if (socket && socket.readyState !== wrapper_default.CLOSED) { + try { + socket.close(); + } catch { + socket.terminate?.(); + } + } for (const { reject, timer: timer2 } of this.pending.values()) { clearTimeout(timer2); reject(new Error("Codex bridge disconnected")); @@ -4317,13 +5931,157 @@ var CodexCdpClient = class { } }; +// ../../src/bridge/auth.mjs +import { timingSafeEqual } from "node:crypto"; +function bridgeRequestAuthorized(token, authorizationHeader) { + if (!token) return false; + const candidate = String(authorizationHeader || "").replace(/^Bearer\s+/i, ""); + const actual = Buffer.from(token); + const supplied = Buffer.from(candidate); + return actual.length === supplied.length && timingSafeEqual(actual, supplied); +} + +// ../../src/bridge/diagnostics.mjs +var SAFE_TRACE_FIELD_KEYS = /* @__PURE__ */ new Set([ + "accepted", + "action", + "alreadyForeground", + "alreadyMaximized", + "attentionRequested", + "attempts", + "automatic", + "background", + "bringToTopAccepted", + "cacheHit", + "cacheValid", + "candidateCount", + "category", + "channel", + "complete", + "connection", + "currentEffort", + "detachAccepted", + "durationMs", + "effortMatched", + "focusOk", + "foregroundAttach", + "foregroundAttachErrorCode", + "forceFocus", + "inputErrorCode", + "inputEventsInserted", + "matchesTarget", + "modifierStateClear", + "modelMatched", + "outcome", + "path", + "phase", + "platform", + "primaryCandidate", + "reused", + "releaseAccepted", + "route", + "rowCount", + "setFocusReturnedWindow", + "setForegroundAccepted", + "slot", + "source", + "stage", + "switchRequested", + "targetAttach", + "targetAttachErrorCode", + "targetEffort", + "targetFound", + "wasActiveBeforeAttention" +]); +function safeTraceFields(fields) { + return Object.fromEntries(Object.entries(fields || {}).flatMap(([key, value]) => { + if (!SAFE_TRACE_FIELD_KEYS.has(key)) return []; + if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) { + return [[key, value]]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [[key, normalized]] : []; + })); +} + +// ../../src/bridge/navigation.mjs +async function navigateAndFocus(navigate, focus) { + const [navigation, activation] = await Promise.allSettled([navigate(), focus()]); + if (navigation.status === "rejected") throw navigation.reason; + if (activation.status === "rejected") throw activation.reason; + return { + focusOk: true, + attentionRequested: false + }; +} + +// ../../src/bridge/model-request.mjs +function readModelSettings(request, { timeoutMs = 2e3, maxBytes = 2048 } = {}) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + const timer2 = setTimeout(() => fail("Model settings request timed out"), timeoutMs); + const cleanup = () => { + clearTimeout(timer2); + request.off("data", onData); + request.off("end", onEnd); + request.off("error", onError); + request.off("aborted", onError); + }; + const fail = (message) => { + cleanup(); + request.resume(); + reject(Object.assign(new Error(message), { statusCode: 400 })); + }; + const onError = () => fail("Incomplete model settings request"); + const onData = (chunk) => { + size += Buffer.byteLength(chunk); + if (size > maxBytes) return fail("Model settings request too large"); + chunks.push(Buffer.from(chunk)); + }; + const onEnd = () => { + cleanup(); + if (size === 0) return resolve(void 0); + try { + const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "model" && key !== "effort")) { + throw new Error(); + } + resolve(value); + } catch { + reject(Object.assign(new Error("Invalid model settings request"), { statusCode: 400 })); + } + }; + request.on("data", onData); + request.on("end", onEnd); + request.on("error", onError); + request.on("aborted", onError); + }); +} + // ../../src/bridge/server.mjs -var execFileAsync2 = promisify2(execFile2); var HOST = "127.0.0.1"; var PORT = Number(process.env.CODEX_KEYBOARD_PORT || 17373); +var BRIDGE_VERSION = false ? null : "0.6.1"; +var RUNTIME_HASH = (() => { + try { + return createHash("sha256").update(readFileSync(fileURLToPath(import.meta.url))).digest("hex"); + } catch { + return null; + } +})(); +var NATIVE_RUNTIME_HASH = /^[a-f0-9]{64}$/.test(String(process.env.CODEX_BRIDGE_NATIVE_HASH || "")) ? process.env.CODEX_BRIDGE_NATIVE_HASH : null; var configuredRefreshMs = Number(process.env.CODEX_KEYBOARD_REFRESH_MS || 500); var REFRESH_MS = Number.isFinite(configuredRefreshMs) ? Math.max(250, configuredRefreshMs) : 500; var client = new CodexCdpClient(); +var dataRoot = process.env.CODEX_BRIDGE_DATA_ROOT || (process.platform === "win32" ? join2(process.env.LOCALAPPDATA || join2(homedir(), "AppData", "Local"), "OpenCodexMicro") : join2(homedir(), "Library", "Application Support", "OpenCodexMicro")); +var bridgeToken = process.env.CODEX_BRIDGE_TOKEN || (() => { + try { + return readFileSync(join2(dataRoot, "bridge-token"), "utf8").trim(); + } catch { + return ""; + } +})(); var cached = { connected: false, slots: Array.from({ length: 6 }, (_, id) => ({ @@ -4338,10 +6096,121 @@ var cached = { }; var refreshPromise = null; var nextReconnectAt = 0; -async function focusCodex() { - await execFileAsync2("/usr/bin/open", ["-b", "com.openai.codex"], { - timeout: 3e3 - }); +var nextIdentityRefreshAt = 0; +var hasRefreshed = false; +var traceBuffer = /* @__PURE__ */ new Map(); +var TRACE_TTL_MS = 3e4; +var TRACE_LIMIT = 32; +var TRACE_EVENT_LIMIT = 160; +function diagnosticErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "bridge-operation"; +} +function traceIdFrom(request) { + const value = String(request.headers["x-codex-trace-id"] || ""); + return /^[a-f0-9-]{36}$/.test(value) ? value : null; +} +function startTrace(request, route, fields = {}) { + const traceId = traceIdFrom(request); + if (!traceId) return null; + const startedAt = performance.now(); + const trace = { + traceId, + startedAt, + events: [], + pending: 0, + completionRequested: false, + complete: false, + record(event, details = {}) { + if (!/^[a-z0-9.-]{1,80}$/.test(event) || this.events.length >= TRACE_EVENT_LIMIT) return; + this.events.push({ + event, + offsetMs: Math.round(performance.now() - startedAt), + ...safeTraceFields(details) + }); + }, + defer() { + this.pending += 1; + let settled = false; + return () => { + if (settled) return; + settled = true; + this.pending = Math.max(0, this.pending - 1); + if (this.completionRequested && this.pending === 0) this.complete = true; + }; + }, + finish() { + this.completionRequested = true; + if (this.pending === 0) this.complete = true; + } + }; + trace.record("server.request", { route, ...fields }); + traceBuffer.set(traceId, trace); + while (traceBuffer.size > TRACE_LIMIT) traceBuffer.delete(traceBuffer.keys().next().value); + const cleanup = setTimeout(() => traceBuffer.delete(traceId), TRACE_TTL_MS); + cleanup.unref?.(); + return trace; +} +function traceSnapshot(traceId) { + const trace = traceBuffer.get(traceId); + if (!trace) return null; + return { + traceId, + complete: trace.complete, + events: trace.events.map((event) => ({ ...event })) + }; +} +async function focusCodexDesktop(trace = null, { forceFocus = false } = {}) { + const startedAt = performance.now(); + const path = process.platform === "win32" ? "win32-native" : "platform-adapter"; + trace?.record("focus.start", { platform: process.platform, path }); + try { + let identity = null; + if (process.platform === "win32") { + const connection = client.socket?.readyState === 1 ? "reused" : "open"; + await client.connect(); + identity = client.connectionIdentity; + trace?.record("focus.target", { + connection, + channel: identity?.channel || "unknown", + outcome: "succeeded" + }); + } + const result = await focusCodex({ + processId: identity?.processId ?? null, + forceFocus, + onDiagnostic: trace ? (stage, fields) => trace.record("focus.stage", { stage, ...fields }) : null + }); + trace?.record("focus.native", { + outcome: "succeeded", + channel: identity?.channel || "unknown", + reused: Boolean(result?.alreadyForeground && result?.alreadyMaximized) + }); + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt) + }); + console.log("Codex Desktop window maximized and focused through the native desktop adapter"); + } catch (error) { + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "failed", + category: diagnosticErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt) + }); + throw error; + } +} +function authorized(request) { + return bridgeRequestAuthorized(bridgeToken, request.headers.authorization); } async function refresh(force = false) { if (refreshPromise) return refreshPromise; @@ -4351,6 +6220,15 @@ async function refresh(force = false) { const snapshot = await client.snapshot(); cached = { connected: true, ...snapshot, error: null, updatedAt: Date.now() }; nextReconnectAt = 0; + const identity = client.connectionIdentity; + if (process.platform === "win32" && Date.now() >= nextIdentityRefreshAt && (!Number.isInteger(identity?.processId) || !identity?.channel)) { + try { + await client.refreshConnectionIdentity(); + nextIdentityRefreshAt = Date.now() + 2e3; + } catch { + nextIdentityRefreshAt = Date.now() + 2e3; + } + } } catch (error) { cached = { ...cached, connected: false, error: error.message, updatedAt: Date.now() }; nextReconnectAt = Date.now() + 2e3; @@ -4359,6 +6237,7 @@ async function refresh(force = false) { try { await refreshPromise; } finally { + hasRefreshed = true; refreshPromise = null; } } @@ -4373,16 +6252,36 @@ function json(response, status, body) { } var server = createServer(async (request, response) => { const url = new URL(request.url || "/", `http://${HOST}:${PORT}`); + if (request.method === "POST" && !authorized(request)) { + return json(response, 401, { ok: false, error: "Bridge authorization required" }); + } if (request.method === "GET" && url.pathname === "/health") { - await refresh(true); - return json(response, 200, { ok: true, codexConnected: cached.connected, updatedAt: cached.updatedAt }); + if (!hasRefreshed || Date.now() - cached.updatedAt >= REFRESH_MS * 2) { + await refresh(true); + } + return json(response, 200, { + ok: true, + bridgeVersion: BRIDGE_VERSION, + runtimeHash: RUNTIME_HASH, + nativeRuntimeHash: NATIVE_RUNTIME_HASH, + codexConnected: cached.connected, + updatedAt: cached.updatedAt + }); } if (request.method === "GET" && url.pathname === "/state") { + if (url.searchParams.get("refresh") === "1" || url.searchParams.get("force") === "1") { + await refresh(true); + } return json(response, 200, cached); } + if (request.method === "POST" && url.pathname === "/diagnostics/trace") { + const traceId = traceIdFrom(request); + const diagnostics = traceId ? traceSnapshot(traceId) : null; + return json(response, diagnostics ? 200 : 404, diagnostics ? { ok: true, diagnostics } : { ok: false, error: "Trace diagnostics are unavailable" }); + } if (request.method === "POST" && url.pathname === "/focus") { try { - await focusCodex(); + await focusCodexDesktop(); return json(response, 200, { ok: true }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); @@ -4391,11 +6290,11 @@ var server = createServer(async (request, response) => { const match = request.method === "POST" && url.pathname.match(/^\/agent\/([0-5])\/click$/); if (match) { try { - await Promise.all([ - client.clickAgent(Number(match[1])), - focusCodex() - ]); - return json(response, 200, { ok: true }); + const result = await navigateAndFocus( + () => client.clickAgent(Number(match[1])), + () => focusCodexDesktop() + ); + return json(response, 200, { ok: true, ...result }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); } @@ -4404,41 +6303,67 @@ var server = createServer(async (request, response) => { /^\/thread\/([^/]+)\/click$/ ); if (threadMatch) { + let trace = null; try { const threadId = decodeThreadPathSegment(threadMatch[1]); const slot = Number(url.searchParams.get("slot") || 0); + const forceFocus = url.searchParams.get("forceFocus") === "1"; if (!Number.isInteger(slot) || slot < 0 || slot > 5) { throw new Error("Invalid Codex Micro slot"); } - await Promise.all([ - client.clickThread(threadId, slot), - focusCodex() - ]); - return json(response, 200, { ok: true, bridge: true }); + trace = startTrace(request, "thread-click", { slot: slot + 1, forceFocus }); + const result = await navigateAndFocus( + () => client.clickThread(threadId, slot, trace), + () => focusCodexDesktop(trace, { forceFocus }) + ); + trace?.record("server.response", { + outcome: "succeeded", + route: "thread-click", + focusOk: result.focusOk, + attentionRequested: result.attentionRequested + }); + return json(response, 200, { ok: true, bridge: true, ...result }); } catch (error) { + trace?.record("server.response", { + outcome: "failed", + route: "thread-click", + category: diagnosticErrorCategory(error) + }); return json(response, 503, { ok: false, bridge: false, error: error.message }); + } finally { + trace?.finish(); } } const action = request.method === "POST" && url.pathname.match( - /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit)\/(down|up)$/ + /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit|model-sol-high|model-luna-max|model-sol-medium)\/(down|up)$/ ); if (action) { + const trace = startTrace(request, "action", { action: action[1], phase: action[2] }); try { if (action[1] === "steer") { if (action[2] === "down") { - await focusCodex(); + await focusCodexDesktop(); await client.dispatchComposerSteer(); } return json(response, 200, { ok: true }); } - await client.dispatchNamedAction(action[1], action[2] === "down"); + const settings = action[1].startsWith("model-") && action[2] === "down" ? await readModelSettings(request) : void 0; + await client.dispatchNamedAction(action[1], action[2] === "down", trace, settings); + trace?.record("server.response", { outcome: "succeeded", route: "action" }); return json(response, 200, { ok: true, bridge: true }); } catch (error) { - return json(response, 503, { ok: false, error: error.message }); + trace?.record("server.response", { + outcome: "failed", + route: "action", + category: diagnosticErrorCategory(error) + }); + return json(response, error.statusCode === 400 ? 400 : 503, { ok: false, error: error.message }); + } finally { + trace?.finish(); } } const joystick = request.method === "POST" && url.pathname.match( @@ -4455,7 +6380,7 @@ var server = createServer(async (request, response) => { return json(response, 404, { ok: false, error: "Not found" }); }); server.listen(PORT, HOST, () => { - console.log(`Codex Keyboard bridge listening on http://${HOST}:${PORT}`); + console.log(`Codex Keyboard bridge ${BRIDGE_VERSION || "unversioned"} listening on http://${HOST}:${PORT}`); void refresh(); }); var timer = setInterval(() => void refresh(), REFRESH_MS); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/elevated-launcher.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/elevated-launcher.mjs new file mode 100644 index 0000000..057f04d --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/elevated-launcher.mjs @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HASH_PATTERN = /^[a-f0-9]{64}$/; +const secureRoot = dirname(fileURLToPath(import.meta.url)); +const dataRoot = process.argv[2]; + +if (!dataRoot) throw new Error("Codex Bridge elevated launcher requires its data root."); + +const metadata = JSON.parse(readFileSync(join(dataRoot, "install.json"), "utf8")); +const runtimeHash = String(metadata.runtimeHash || ""); +const nativeRuntimeHash = String(metadata.nativeRuntimeHash || ""); + +if (!HASH_PATTERN.test(runtimeHash) || !HASH_PATTERN.test(nativeRuntimeHash)) { + throw new Error("Codex Bridge elevated runtime metadata is invalid."); +} + +const runtimePath = join(secureRoot, "runtimes", runtimeHash, "bridge.mjs"); +const nativeRoot = join(secureRoot, "native-runtimes", nativeRuntimeHash); +const installedRuntimeHash = createHash("sha256").update(readFileSync(runtimePath)).digest("hex"); +if (installedRuntimeHash !== runtimeHash) { + throw new Error("Codex Bridge protected runtime hash verification failed."); +} + +const nativeManifest = JSON.parse(readFileSync(join(nativeRoot, "native-runtime.json"), "utf8")); +if (nativeManifest?.runtimeHash !== nativeRuntimeHash || !Array.isArray(nativeManifest?.files)) { + throw new Error("Codex Bridge protected native runtime manifest is invalid."); +} +const verifiedNativeFiles = []; +for (const file of nativeManifest.files) { + const relative = String(file?.path || "").replaceAll("\\", "/"); + if (!relative || relative.startsWith("/") || relative.split("/").includes("..")) { + throw new Error("Codex Bridge protected native runtime contains an invalid path."); + } + const actual = createHash("sha256") + .update(readFileSync(join(nativeRoot, ...relative.split("/")))) + .digest("hex"); + if (actual !== file.sha256) { + throw new Error("Codex Bridge protected native runtime hash verification failed."); + } + verifiedNativeFiles.push({ path: relative, sha256: actual }); +} +const verifiedNativeHash = createHash("sha256") + .update(verifiedNativeFiles.map(file => `${file.path}:${file.sha256}`).join("\n")) + .digest("hex"); +if (verifiedNativeHash !== nativeRuntimeHash) { + throw new Error("Codex Bridge protected native runtime identity verification failed."); +} + +process.env.CODEX_BRIDGE_DATA_ROOT = dataRoot; +process.env.CODEX_BRIDGE_NATIVE_ROOT = nativeRoot; +process.env.CODEX_BRIDGE_NATIVE_HASH = nativeRuntimeHash; + +const pidPath = join(dataRoot, "bridge.pid"); +writeFileSync(pidPath, `${process.pid}\n`, { mode: 0o600 }); +process.on("exit", () => { + try { + if (Number(readFileSync(pidPath, "utf8").trim()) === process.pid) rmSync(pidPath, { force: true }); + } catch {} +}); + +await import(pathToFileURL(runtimePath).href); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json new file mode 100644 index 0000000..9576ccc --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "runtimeHash": "30b86d3469b290b7b0fa99a44d65c94e4ba91b0f95fa9789f594ef645f896785", + "platform": "win32", + "architecture": "x64", + "files": [ + { + "path": "node_modules/koffi/index.cjs", + "sha256": "a200e15ec168d00cb3155794808a98243051c61dd7f6989c23c57ee220c918b9" + }, + { + "path": "node_modules/koffi/src/koffi/index.cjs", + "sha256": "ac18369dc4c0b2401a32587ce1f04c07754403ad62253db53b356530dd9ecec6" + }, + { + "path": "node_modules/koffi/src/koffi/src/static.cjs", + "sha256": "25dff303c876cfdb531e2d9590ecbbb4928c8e60453c484faad66b2a3abd1556" + }, + { + "path": "node_modules/koffi/package.json", + "sha256": "d775f96fcc02d4417543c0344a5af26ff42284992f81e54400d451d07ec76aa4" + }, + { + "path": "node_modules/koffi/LICENSE.txt", + "sha256": "22bcbbbacd739b026a8dc484f62a31484ce4c4e31f3408b60b29b4e41d62b290" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/index.js", + "sha256": "f9991ec70a0f775487ffadc2461d69edd1555e3d9bf80de0bbdd2eec7d245c49" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/package.json", + "sha256": "5de9e2da3ead5a3f53bb68222b7f4b21abab0a437993bb48af066bbb91530647" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/README.md", + "sha256": "4fc87635ba2ce995d9f91401e96c68dd0600ca3db25dae419a9abc4e8e11fcd0" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node", + "sha256": "3f92d8794a051e2b873e8201bcbb66b0049e4b44d93a76e38ec9b1e588976ebe" + } + ] +} diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json index a3c82cd..5e2cdde 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Ulanzi Studio の Ulanzi D200 Series から Codex Desktop のタスクと操作を制御します。", - "Description": "Ulanzi Studio を通じて、Ulanzi D200 Series を Codex Desktop 専用のコントロールサーフェスにします。\n\n機能\n- 最近の 5 件の Codex タスクをリアルタイム状態付きで表示し、選択したタスクを正確に開きます。\n- Fast、Usage、Pin、New、Fork、Steer、Mic、Submit を操作できます。\n- Latest Task & Scroll Encoder で task1 を開き、Codex を上下にスクロールできます。\n- 各 Action の設定ページから Codex Bridge のインストール、修復、起動、確認ができます。\n- Codex Bridge.app とループバック限定エンドポイントを使用して通信をローカルに保ちます。\n\nインストール環境\n- macOS 13 以降\n- Codex Desktop\n- Ulanzi Studio 3.0.1 以降\n- Ulanzi Studio に接続された Ulanzi D200 Series\n- リポジトリから手動設定する場合のみ Node.js 20 以降\n\nLLM / AGENT インストール\nAgent に次のように入力します:\nUlanzi Studio プラグインをインストールしてください:[https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n手動インストール\n1. https://github.com/UlanziTechnology/OpenCodexMicro をクローンしてリポジトリを開きます。\n2. Ulanzi Studio を終了します。\n3. 実行:npm install\n4. 実行:npm run install:plugin\n5. 実行:npm run setup\n6. Ulanzi Studio を再度開き、Codex Desktop を終了して ~/Applications/Codex Bridge.app を起動します。\n7. Ulanzi Studio で Codex Micro actions をキーに追加します。\n8. 任意の Action を選択し、Codex Bridge 設定ページでインストール、サービス、CDP の状態を確認します。\n\n重要事項\nCodex Micro を使用する際は、必ず ~/Applications/Codex Bridge.app から Codex を起動してください。Codex Desktop を直接開かないでください。\n起動コマンド: open ~/Applications/Codex\\ Bridge.app", + "Description": "Ulanzi Studio と Ulanzi D200 Series から Codex Desktop を操作します。最近の 5 タスク、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit、3 つの GPT-5.6 モデルプリセット、Encoder ナビゲーションに対応します。Windows 10 以降と macOS 13 以降をサポートします。Windows Bridge は Stable または Beta を動的に検出し、Bridge の書き込み操作はローカル能力トークンで保護され、CDP はループバックだけを待ち受けます。リポジトリ設定では npm install、npm run install:plugin、npm run setup を実行します。", "Actions": [ { "Name": "Codex タスク 1", "Tooltip": "最近の Codex タスクスロット 1 を開き、リアルタイム状態を表示します。" }, { "Name": "Codex タスク 2", "Tooltip": "最近の Codex タスクスロット 2 を開き、リアルタイム状態を表示します。" }, @@ -16,12 +16,28 @@ { "Name": "Codex タスクをフォーク", "Tooltip": "アクティブな Codex タスクを新しいタスクとしてフォークします。" }, { "Name": "Codex を誘導", "Tooltip": "現在の入力欄のテキストを実行中タスクへの誘導入力として送信します。" }, { "Name": "Codex マイク", "Tooltip": "Codex のマイク操作を切り替えます。" }, - { "Name": "Codex に送信", "Tooltip": "現在の Codex 入力欄のテキストを送信またはキューに追加します。" } + { "Name": "Codex に送信", "Tooltip": "現在の Codex 入力欄のテキストを送信またはキューに追加します。" }, + { "Name": "Codex Sol High", "Tooltip": "表示中の Codex タスクを GPT-5.6 Sol、高推論強度に切り替えます。" }, + { "Name": "Codex Luna Max", "Tooltip": "表示中の Codex タスクを GPT-5.6 Luna、最大推論強度に切り替えます。" }, + { "Name": "Codex Sol Medium", "Tooltip": "表示中の Codex タスクを GPT-5.6 Sol、中推論強度に切り替えます。" } ], "Localization": { + "ModelSettingsTitle": "モデルショートカット(このキーのみ)", + "PresetLabel": "デフォルトオプション", + "PresetHelp": "クイック切り替えのデフォルトオプションを選択するか、以下でカスタム設定してください。", + "PresetCustom": "デフォルト / カスタム", + "ModelLabel": "モデル", + "EffortLabel": "推論強度", + "ModelHelp": "Codex メニューの正確なモデル名、または既知の公式 ID 別名を入力します。カスタム表示名に対応。空欄はこのキーの既定モデルです。変更は自動保存されます。", + "EffortHelp": "Codex でこのモデルが実際に対応する強度のみ選択してください。非対応の場合は失敗し、代替しません。空欄はこのキーの既定強度です。", + "EffortDefault": "既定", + "ModelDefaults": "既定:{model}({id})/ {effort}", + "ModelInvalid": "100 文字以内。文字、数字、空白と . _ + ( ) / : - のみ使用できます。制御文字やコード/HTML の記号は使用できません。", + "EffortInvalid": "一覧から推論強度を選択してください。", + "ModelLoading": "このキーの設定を復元中…", "BridgeSetup": "Codex Bridge 設定", "BridgeSetupDescription": "ローカルの Codex 接続をインストール、起動、確認します。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge サービス", "CDPConnection": "CDP 接続", "InstallRepair": "インストール / 修復", @@ -30,19 +46,21 @@ "UninstallingBridge": "アンインストール中…", "UninstallBridgeDone": "Codex Bridge をアンインストールしました。Ulanzi プラグインと Codex Desktop は保持されます。", "UninstallBridgeConfirm": "Codex Bridge とバックグラウンドサービスをアンインストールしますか?Ulanzi プラグインと Codex Desktop は削除されません。", - "BridgeAppInfoTitle": "Codex Bridge.app とは?", - "BridgeAppDescription": "Codex Bridge.app はローカルランチャーです。Codex が実行中の場合は正常終了を要求し、ループバック専用の CDP 起動引数で公式 Codex Desktop 実行ファイルを起動します。", - "BridgeAppSafety": "このランチャーは Codex Desktop、アカウント、プロジェクト、ユーザーファイルを変更せず、CDP をこの Mac の外部に公開しません。その他の権限昇格や破壊的操作も行いません。別の Bridge サービスがローカル状態と Action 通信を処理します。", + "BridgeAppInfoTitle": "Codex Bridge はどのように Codex を起動しますか?", + "BridgeAppDescription": "Windows では Stable または Beta を動的に検出し、ループバック専用の CDP 引数で公式プログラムを起動します。macOS ではローカルラッパーアプリを使用します。", + "BridgeAppSafety": "ランチャーは Codex Desktop、アカウント、プロジェクト、ユーザーファイルを変更しません。CDP はローカル限定で、Bridge の書き込み操作にはローカル能力トークンが必要です。", "BackgroundActivityTitle": "バックグラウンド動作と Node.js", - "LaunchAgentDescription": "LaunchAgent はログイン時にローカル Codex Bridge サービスを起動し、終了した場合は再起動します。サービスはループバックアドレスでこの Mac 内だけを待ち受けます。", + "LaunchAgentDescription": "Windows では現在のユーザー専用のオンデマンド最高権限タスクを使用し、通常の起動では UAC を表示しません。macOS ではユーザー LaunchAgent を使用します。", "NodeSelectionStrategy": "インストール / 修復では、ログイン環境、PATH、Homebrew、一般的なパスから Node.js 20 以降を優先し、見つからない場合のみ Ulanzi Studio 内蔵 Node を使用します。", "NodeInstallRecommendation": "推奨:Terminal で最新の Node.js をインストールし、インストール / 修復を再実行してください:", - "UlanziNodeSignatureNotice": "Ulanzi Studio Node にフォールバックすると、macOS のバックグラウンド項目に Ulanzi Studio の署名付きアクティビティが表示されることがあります。これは Codex Bridge サービスが Ulanzi Studio の Node 実行ファイルで動作しているためです。", + "UlanziNodeSignatureNotice": "Windows はハッシュ検証済みの保護された Bridge コードだけを実行します。インストール、更新、削除には UAC が必要で、タスクに能力トークンは保存されません。", "CurrentNodeRuntime": "現在のランタイム:{runtime} {version}", "SystemNode": "システム Node.js", "UlanziNode": "Ulanzi Studio Node", "UnknownNode": "未インストールまたは記録なし", "CheckAgain": "再確認", + "ForceFocus": "強制フォーカス(Windows)", + "ForceFocusDescription": "Windows でアクセス拒否となったフォーカスには、保護された Alt フォールバックを自動で 1 回実行します。有効にすると他のフォーカス拒否でも同じ処理を行います。待機中のフック、ポーリング、補助プロセスは追加しません。", "InstallationGuide": "インストールガイド" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json index 2cbe818..a9b4881 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Ulanzi Studio의 Ulanzi D200 Series에서 Codex Desktop 작업과 동작을 제어합니다.", - "Description": "Ulanzi Studio를 통해 Ulanzi D200 Series를 Codex Desktop 전용 컨트롤 표면으로 사용합니다.\n\n기능\n- 최근 Codex 작업 5개의 실시간 상태를 표시하고 선택한 작업을 정확히 엽니다.\n- Fast, Usage, Pin, New, Fork, Steer, Mic, Submit 동작을 제공합니다.\n- Latest Task & Scroll Encoder로 task1을 열고 Codex를 위아래로 스크롤합니다.\n- 각 Action 설정 페이지에서 Codex Bridge를 설치, 복구, 실행하고 상태를 확인합니다.\n- Codex Bridge.app과 루프백 전용 엔드포인트를 통해 통신을 로컬로 유지합니다.\n\n설치 환경\n- macOS 13 이상\n- Codex Desktop\n- Ulanzi Studio 3.0.1 이상\n- Ulanzi Studio에 연결된 Ulanzi D200 Series\n- 저장소에서 수동 설정할 때만 Node.js 20 이상 필요\n\nLLM / AGENT 설치\nAgent에 다음과 같이 입력하세요:\nUlanzi Studio 플러그인을 설치해 주세요: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n수동 설치\n1. https://github.com/UlanziTechnology/OpenCodexMicro 저장소를 복제하고 디렉터리를 엽니다.\n2. Ulanzi Studio를 종료합니다.\n3. 실행: npm install\n4. 실행: npm run install:plugin\n5. 실행: npm run setup\n6. Ulanzi Studio를 다시 열고 Codex Desktop을 종료한 다음 ~/Applications/Codex Bridge.app을 실행합니다.\n7. Ulanzi Studio에서 Codex Micro actions를 원하는 키에 추가합니다.\n8. Action을 선택하고 Codex Bridge 설정 페이지에서 설치, 서비스, CDP 상태를 확인합니다.\n\n중요 사항\nCodex Micro를 사용할 때는 항상 ~/Applications/Codex Bridge.app을 통해 Codex를 실행하세요. Codex Desktop을 직접 열지 마세요.\n실행 명령: open ~/Applications/Codex\\ Bridge.app", + "Description": "Ulanzi Studio의 Ulanzi D200 Series에서 Codex Desktop을 제어합니다. 최근 작업 5개, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit, GPT-5.6 모델 프리셋 3개 및 Encoder 탐색을 Windows 10 이상과 macOS 13 이상에서 지원합니다. Windows Bridge는 Stable 또는 Beta를 동적으로 찾고, 쓰기 작업은 로컬 기능 토큰으로 보호되며 CDP는 루프백에서만 수신합니다. 저장소 설정은 npm install, npm run install:plugin, npm run setup을 실행하세요.", "Actions": [ { "Name": "Codex 작업 1", "Tooltip": "최근 Codex 작업 슬롯 1을 열고 실시간 상태를 표시합니다." }, { "Name": "Codex 작업 2", "Tooltip": "최근 Codex 작업 슬롯 2를 열고 실시간 상태를 표시합니다." }, @@ -16,12 +16,28 @@ { "Name": "Codex 작업 포크", "Tooltip": "활성 Codex 작업을 새 작업으로 포크합니다." }, { "Name": "Codex 조정", "Tooltip": "현재 입력창 텍스트를 실행 중인 작업에 조정 입력으로 보냅니다." }, { "Name": "Codex 마이크", "Tooltip": "Codex 마이크 동작을 전환합니다." }, - { "Name": "Codex에 제출", "Tooltip": "현재 Codex 입력창 텍스트를 제출하거나 대기열에 추가합니다." } + { "Name": "Codex에 제출", "Tooltip": "현재 Codex 입력창 텍스트를 제출하거나 대기열에 추가합니다." }, + { "Name": "Codex Sol 높음", "Tooltip": "현재 보이는 Codex 작업을 GPT-5.6 Sol과 높은 추론 강도로 전환합니다." }, + { "Name": "Codex Luna 최대", "Tooltip": "현재 보이는 Codex 작업을 GPT-5.6 Luna와 최대 추론 강도로 전환합니다." }, + { "Name": "Codex Sol 중간", "Tooltip": "현재 보이는 Codex 작업을 GPT-5.6 Sol과 중간 추론 강도로 전환합니다." } ], "Localization": { + "ModelSettingsTitle": "모델 바로가기 (이 키만)", + "PresetLabel": "기본 옵션", + "PresetHelp": "빠른 전환 기본 옵션을 선택하거나 아래에서 사용자 지정하세요.", + "PresetCustom": "기본 / 사용자 지정", + "ModelLabel": "모델", + "EffortLabel": "추론 강도", + "ModelHelp": "Codex 메뉴의 정확한 모델 이름 또는 알려진 공식 ID 별칭을 입력하세요. 사용자 지정 표시 이름을 지원합니다. 비워 두면 이 키의 기본 모델을 사용합니다. 변경 시 자동 저장됩니다.", + "EffortHelp": "Codex에서 해당 모델이 실제 지원하는 강도만 선택하세요. 지원하지 않으면 대체 없이 실패합니다. 비워 두면 이 키의 기본 강도를 사용합니다.", + "EffortDefault": "기본값", + "ModelDefaults": "기본값: {model} ({id}) / {effort}", + "ModelInvalid": "최대 100자: 문자, 숫자, 공백 및 . _ + ( ) / : - 만 허용됩니다. 제어 문자와 코드/HTML 문장 부호는 허용되지 않습니다.", + "EffortInvalid": "목록에서 추론 강도를 선택하세요.", + "ModelLoading": "이 키의 설정을 복원하는 중…", "BridgeSetup": "Codex Bridge 설정", "BridgeSetupDescription": "로컬 Codex 연결을 설치하고 실행한 뒤 확인합니다.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 서비스", "CDPConnection": "CDP 연결", "InstallRepair": "설치 / 복구", @@ -30,19 +46,21 @@ "UninstallingBridge": "제거 중…", "UninstallBridgeDone": "Codex Bridge가 제거되었습니다. Ulanzi 플러그인과 Codex Desktop은 유지됩니다.", "UninstallBridgeConfirm": "Codex Bridge와 백그라운드 서비스를 제거할까요? Ulanzi 플러그인과 Codex Desktop은 삭제되지 않습니다.", - "BridgeAppInfoTitle": "Codex Bridge.app이란?", - "BridgeAppDescription": "Codex Bridge.app은 로컬 실행기입니다. Codex가 이미 실행 중이면 정상 종료를 요청한 뒤, 로컬 루프백 전용 CDP 실행 인자로 공식 Codex Desktop 실행 파일을 시작합니다.", - "BridgeAppSafety": "이 실행기는 Codex Desktop, 계정, 프로젝트 또는 사용자 파일을 수정하지 않으며 CDP를 이 Mac 외부에 노출하지 않습니다. 그 밖의 권한 상승이나 파괴적 작업도 수행하지 않습니다. 별도의 Bridge 서비스가 로컬 상태와 Action 통신을 처리합니다.", + "BridgeAppInfoTitle": "Codex Bridge는 Codex를 어떻게 실행하나요?", + "BridgeAppDescription": "Windows에서는 Codex Bridge가 Stable 또는 Beta를 동적으로 찾아 루프백 전용 CDP 인자로 공식 앱을 실행하고, macOS에서는 로컬 래퍼 앱을 사용합니다.", + "BridgeAppSafety": "실행기는 Codex Desktop, 계정, 프로젝트 또는 사용자 파일을 수정하지 않습니다. CDP는 로컬로 유지되며 Bridge 쓰기 작업에는 로컬 기능 토큰이 필요합니다.", "BackgroundActivityTitle": "백그라운드 활동 및 Node.js", - "LaunchAgentDescription": "LaunchAgent는 로그인할 때 로컬 Codex Bridge 서비스를 시작하고 종료되면 다시 시작합니다. 서비스는 루프백 주소를 통해 이 Mac에서만 수신합니다.", + "LaunchAgentDescription": "Windows에서는 현재 사용자만 필요할 때 실행하는 최고 권한 작업을 사용하며 일반 실행은 UAC 없이 조용히 처리됩니다. macOS에서는 사용자 LaunchAgent를 사용합니다.", "NodeSelectionStrategy": "설치 / 복구는 로그인 환경, PATH, Homebrew 및 일반 설치 경로의 Node.js 20 이상을 우선 선택하고, 적합한 버전이 없을 때만 Ulanzi Studio 내장 Node를 사용합니다.", "NodeInstallRecommendation": "권장: Terminal에서 최신 Node.js를 설치한 다음 설치 / 복구를 다시 실행하세요:", - "UlanziNodeSignatureNotice": "Ulanzi Studio Node로 대체하면 macOS 백그라운드 항목에 Ulanzi Studio 서명이 포함된 활동이 표시될 수 있습니다. 이는 Codex Bridge 서비스가 Ulanzi Studio의 Node 실행 파일로 동작하기 때문입니다.", + "UlanziNodeSignatureNotice": "Windows는 해시로 검증된 보호 Bridge 코드만 실행합니다. 설치, 업데이트 및 제거에는 UAC가 필요하며 작업에는 기능 토큰을 저장하지 않습니다.", "CurrentNodeRuntime": "현재 런타임: {runtime} {version}", "SystemNode": "시스템 Node.js", "UlanziNode": "Ulanzi Studio Node", "UnknownNode": "설치되지 않았거나 기록 없음", "CheckAgain": "다시 확인", + "ForceFocus": "강제 포커스(Windows)", + "ForceFocusDescription": "Windows가 액세스 거부로 포커스를 차단하면 보호된 Alt 대체 동작을 자동으로 한 번 실행합니다. 이 옵션을 켜면 다른 포커스 거부에도 같은 동작을 사용합니다. 유휴 상태에는 후크, 폴링 또는 보조 프로세스를 추가하지 않습니다.", "InstallationGuide": "설치 안내" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json index 74a06af..3b8204d 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json @@ -2,7 +2,7 @@ "Author": "OpenCodexMicro", "Name": "Codex Micro", "Overview": "Control Codex Desktop tasks and actions from Ulanzi D200 Series in Ulanzi Studio.", - "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep communication local through Codex Bridge.app and loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- macOS 13 or later\n- Codex Desktop\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nLLM / AGENT INSTALLATION\nIn your Agent, enter:\nInstall the Ulanzi Studio plugin for me: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUAL INSTALLATION\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro and open the repository directory.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio, quit Codex Desktop, and launch ~/Applications/Codex Bridge.app.\n7. Add the Codex Micro actions to your keys in Ulanzi Studio.\n8. Select any Action to check Bridge installation, service, and CDP status.\n\nIMPORTANT\nWhen using Codex Micro, always launch Codex through ~/Applications/Codex Bridge.app. Do not open Codex Desktop directly.\nLaunch command: open ~/Applications/Codex\\ Bridge.app", + "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Switch the visible Codex task to Sol High, Luna Max, or Sol Medium.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep Bridge and CDP communication on authenticated loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- Windows 10 or later, or macOS 13 or later\n- Codex Desktop Stable or Beta\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nMANUAL INSTALLATION\n1. Clone the repository and open plugins/codex-console.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio and select any Action to check Bridge, service, and CDP status.\n\nIMPORTANT\nOn Windows, Codex Bridge dynamically locates the installed Stable or Beta Appx package and starts Codex with loopback-only CDP arguments. It does not use a version-pinned WindowsApps path.", "Icon": "assets/icons/plugin.png", "Category": "AI", "CategoryIcon": "assets/icons/plugin.png", @@ -11,7 +11,7 @@ "assets/banners/codex-micro-banner-2.png", "assets/banners/codex-micro-banner-3.png" ], - "Version": "0.4.0", + "Version": "0.6.1", "UUID": "com.ulanzi.ulanzistudio.codexmicro", "Type": "JavaScript", "CodePath": "dist/app.js", @@ -22,58 +22,62 @@ { "Platform": "mac", "MinimumVersion": "13.0" + }, + { + "Platform": "windows", + "MinimumVersion": "10.0" } ], "Actions": [ { "Name": "Codex Task 1", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task1", "Tooltip": "Open recent Codex task slot 1 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 2", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task2", "Tooltip": "Open recent Codex task slot 2 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 3", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task3", "Tooltip": "Open recent Codex task slot 3 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 4", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task4", "Tooltip": "Open recent Codex task slot 4 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 5", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task5", "Tooltip": "Open recent Codex task slot 5 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Fast", @@ -117,14 +121,14 @@ }, { "Name": "Latest Task & Scroll", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.navigate", "Tooltip": "Press to open the latest Codex task. Turn left to scroll up and right to scroll down.", "Controllers": ["Encoder"], "Encoder": { "layout": "$UA1" }, "DisableAutomaticStates": true, - "States": [{ "Name": "Latest Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Latest Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Fork Codex Task", @@ -165,6 +169,36 @@ "Controllers": ["Keypad"], "DisableAutomaticStates": true, "States": [{ "Name": "Submit", "Image": "assets/icons/submit.png" }] + }, + { + "Name": "Codex Sol High", + "Icon": "assets/icons/model-sol-high.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-sol-high", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with high reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Sol High", "Image": "assets/icons/model-sol-high.svg" }] + }, + { + "Name": "Codex Luna Max", + "Icon": "assets/icons/model-luna-max.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-luna-max", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Luna with maximum reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Luna Max", "Image": "assets/icons/model-luna-max.svg" }] + }, + { + "Name": "Codex Sol Medium", + "Icon": "assets/icons/model-sol-medium.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-sol-medium", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with medium reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Sol Medium", "Image": "assets/icons/model-sol-medium.svg" }] } ] } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json index e0c483d..bc63827 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.6.1", "dependencies": { "ws": "^8.18.0" }, diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json index c2b4613..af95b71 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json @@ -1,11 +1,11 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.6.1", "private": true, "type": "module", "scripts": { "build": "node build.mjs", - "check": "node --check plugin/app.js && node --check plugin/bridge-installer.js", + "check": "node --check plugin/app.js && node --check plugin/bridge-installer.js && node --check plugin/windows-elevated-task.js", "smoke": "node test/smoke.mjs" }, "dependencies": { diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js index 7c93e76..1209d1c 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js @@ -1,7 +1,9 @@ import WebSocket from "ws"; +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { createBridgeInstaller } from "./bridge-installer.js"; +import { resolveModelSelection, modelDisplayLabel } from "../../../src/bridge/model-selection.mjs"; const PLUGIN_UUID = "com.ulanzi.ulanzistudio.codexmicro"; const BRIDGE_URL = process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373"; @@ -15,6 +17,53 @@ const bridgeSetup = createBridgeInstaller({ bridgeUrl: BRIDGE_URL, version: MANIFEST.Version }); +let bridgeReconcilePromise = null; +let bridgeReconcileRetryTimer = null; +let bridgeReconcileFailures = 0; + +function scheduleBridgeReconcileRetry() { + if (bridgeReconcileRetryTimer || bridgeReconcileFailures >= 3) return; + const delay = Math.min(10000, 2000 * (2 ** bridgeReconcileFailures)); + bridgeReconcileFailures += 1; + bridgeReconcileRetryTimer = setTimeout(() => { + bridgeReconcileRetryTimer = null; + void ensureBundledBridgeCurrent().catch(error => { + console.error(`[Codex Micro] Bridge reconciliation retry failed: ${error.message}`); + }); + }, delay); + bridgeReconcileRetryTimer.unref?.(); +} + +function ensureBundledBridgeCurrent() { + if (!bridgeReconcilePromise) { + const running = (async () => { + const before = await bridgeSetup.status(); + if (!before.installationDetected) return before; + if (before.needsUpdate) { + console.log(`[Codex Micro] Updating the installed Bridge to bundled version ${before.bundledVersion}.`); + } + if (before.administratorApprovalRequired) { + console.log("[Codex Micro] Bridge update requires one administrator confirmation from Install / Repair; automatic background reconciliation was deferred."); + return before; + } + const after = await bridgeSetup.ensureCurrent(); + if (before.needsUpdate) { + console.log(`[Codex Micro] Bridge ${after.installedVersion} restarted with the bundled runtime.`); + } + return after; + })(); + bridgeReconcilePromise = running; + void running.then(() => { + bridgeReconcileFailures = 0; + if (bridgeReconcileRetryTimer) clearTimeout(bridgeReconcileRetryTimer); + bridgeReconcileRetryTimer = null; + }, () => { + if (bridgeReconcilePromise === running) bridgeReconcilePromise = null; + scheduleBridgeReconcileRetry(); + }); + } + return bridgeReconcilePromise; +} const USAGE_BASE64 = readFileSync( resolve(PLUGIN_ROOT, "assets/icons/usage-base.png") ).toString("base64"); @@ -27,22 +76,41 @@ const ACTION_LABELS = Object.freeze({ fork: "FORK", steer: "STEER", mic: "MIC", - submit: "SUBMIT" + submit: "SUBMIT", + "model-sol-high": "SOL HIGH", + "model-luna-max": "LUNA MAX", + "model-sol-medium": "SOL MED" +}); +const DEBUG_MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", effort: "medium" }) }); -const TASK_ICON_PATHS = Object.freeze({ - idle: "assets/icons/task-idle.png", - working: "assets/icons/task-working.png", - complete: "assets/icons/task-complete.png", - attention: "assets/icons/task-attention.png", - error: "assets/icons/task-error.png" +const TASK_STATUS_PALETTES = Object.freeze({ + idle: { frame: "#475467", accent: "#98a2b3" }, + working: { frame: "#0b5fcc", accent: "#2589f5" }, + complete: { frame: "#087443", accent: "#28b875" }, + attention: { frame: "#9a6700", accent: "#ed9f20" }, + error: { frame: "#b42318", accent: "#e34d62" } }); +const TASK_COMPLETE_FLASH_PALETTE = Object.freeze({ frame: "#12b76a", accent: "#6ce9a6" }); +const TASK_TITLE_MAX_UNITS = 10; +const TASK_TITLE_MAX_LINES = 4; +const TASK_TITLE_SEGMENTER = typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; let socket; let reconnectTimer; let pollTimer; let pollInFlight = false; let latestState = null; +let desktopReadyPromise = null; let setupOperation = null; +let completeFlashOn = false; +let shortcutInvocationSequence = 0; +let globalSettings = Object.freeze({ forceFocus: false }); +const forwardedTraceEvents = new Map(); function contextOf(message) { return String(message.actionid || `${message.uuid}___${message.key}`); @@ -188,18 +256,191 @@ async function handleBridgeSetupMessage(message) { await sendBridgeSetupStatus(message, { result, error: failure }); } -function taskIconPath(status) { +function taskStatusKind(status) { const value = String(status || "").toLowerCase(); - if (["working", "thinking", "running", "in_progress"].includes(value)) return TASK_ICON_PATHS.working; - if (["unread", "complete", "completed", "done", "success"].includes(value)) return TASK_ICON_PATHS.complete; - if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return TASK_ICON_PATHS.attention; - if (["error", "failed", "failure"].includes(value)) return TASK_ICON_PATHS.error; - return TASK_ICON_PATHS.idle; + if (["working", "thinking", "running", "in_progress"].includes(value)) return "working"; + if (["unread", "complete", "completed", "done", "success"].includes(value)) return "complete"; + if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return "attention"; + if (["error", "failed", "failure"].includes(value)) return "error"; + return "idle"; +} + +function debugShortcut(uuid, settings = {}) { + const slot = taskSlot(uuid); + if (slot !== null) { + return Object.freeze({ name: `task-${slot + 1}`, kind: "task", target: `slot-${slot + 1}` }); + } + const action = actionName(uuid); + const preset = DEBUG_MODEL_PRESETS[action]; + if (!preset) return null; + return Object.freeze({ + name: action, + kind: "model-preset", + target: settings.model || settings.effort + ? "custom-model-selection" + : `model-${preset.model} effort-${preset.effort}` + }); +} + +function shortcutErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timeout")) return "bridge-timeout"; + if (message.includes("is empty")) return "empty-task-slot"; + if (message.includes("fetch failed") || message.includes("econnrefused")) return "bridge-unavailable"; + if (message.includes("authorization") || message.includes("unauthorized") || message.includes("forbidden")) { + return "bridge-rejected"; + } + return "bridge-error"; +} + +function logShortcut(instance, shortcut, level, event, fields = []) { + const message = `[Codex Micro] shortcut event=${event} name=${shortcut.name} kind=${shortcut.kind} ${fields.join(" ")}`.trim(); + if (level === "error") console.error(message); + else if (level === "warn") console.warn(message); + else console.log(message); + send({ + cmd: "logMessage", + uuid: PLUGIN_UUID, + actionid: "", + key: "", + level, + message + }); +} + +function safeDiagnosticFields(event) { + const allowed = new Set([ + "accepted", "action", "alreadyForeground", "alreadyMaximized", "attentionRequested", "attempts", "automatic", "background", + "bringToTopAccepted", "cacheHit", "cacheValid", "candidateCount", "category", "channel", "complete", + "connection", "currentEffort", "detachAccepted", "durationMs", "effortMatched", "focusOk", + "foregroundAttach", "foregroundAttachErrorCode", "forceFocus", "inputErrorCode", "inputEventsInserted", + "matchesTarget", "modelMatched", "modifierStateClear", "outcome", "path", "phase", "platform", "primaryCandidate", + "releaseAccepted", "reused", "route", "rowCount", "setFocusReturnedWindow", "setForegroundAccepted", "slot", + "source", "stage", "switchRequested", "targetAttach", "targetAttachErrorCode", "targetEffort", "targetFound", + "wasActiveBeforeAttention" + ]); + return Object.entries(event || {}).flatMap(([key, value]) => { + if (!allowed.has(key)) return []; + if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) { + return [`${key}=${value}`]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [`${key}=${normalized}`] : []; + }); +} + +async function forwardBridgeDiagnostics(instance, shortcut, traceId) { + try { + if (forwardedTraceEvents.get(traceId) === Infinity) return true; + const payload = await bridgeRequest("/diagnostics/trace", "POST", traceId); + const diagnostics = payload?.diagnostics; + if (!diagnostics || diagnostics.traceId !== traceId || !Array.isArray(diagnostics.events)) return false; + const seen = forwardedTraceEvents.get(traceId) || 0; + for (const event of diagnostics.events.slice(seen, 160)) { + const eventName = String(event?.event || ""); + if (!/^[a-z0-9.-]{1,80}$/.test(eventName)) continue; + logShortcut(instance, shortcut, event.outcome === "failed" ? "error" : "debug", "bridge-trace", [ + `trace=${traceId}`, + `bridgeEvent=${eventName}`, + `offsetMs=${Math.max(0, Math.round(Number(event.offsetMs) || 0))}`, + ...safeDiagnosticFields(event) + ]); + } + forwardedTraceEvents.set(traceId, diagnostics.events.length); + if (diagnostics.complete) forwardedTraceEvents.set(traceId, Infinity); + return Boolean(diagnostics.complete); + } catch { + return false; + } +} + +function collectBridgeDiagnostics(instance, shortcut, traceId) { + const delays = [0, 400, 1200, 2600, 5200]; + for (const delay of delays) { + const timer = setTimeout(() => void forwardBridgeDiagnostics(instance, shortcut, traceId), delay); + timer.unref?.(); + } + const cleanup = setTimeout(() => forwardedTraceEvents.delete(traceId), 10000); + cleanup.unref?.(); +} + +function titleGraphemes(value) { + const title = String(value || "Untitled").replace(/\s+/g, " ").trim() || "Untitled"; + if (!TASK_TITLE_SEGMENTER) return Array.from(title); + return Array.from(TASK_TITLE_SEGMENTER.segment(title), item => item.segment); +} + +function graphemeUnits(value) { + if (/^\s$/u.test(value)) return 0.55; + if (/^\p{Mark}+$/u.test(value)) return 0; + if (/\p{Extended_Pictographic}/u.test(value) || /[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/u.test(value)) return 2; + if (/^[ilI1.,'`:;|!\[\](){}]$/u.test(value)) return 0.55; + if (/^[mwMW@#%&]$/u.test(value)) return 1.35; + return 1; +} + +function lineUnits(values) { + return values.reduce((total, value) => total + graphemeUnits(value), 0); +} + +function trimLine(values) { + const result = [...values]; + while (result[0] === " ") result.shift(); + while (result.at(-1) === " ") result.pop(); + return result; } -function shortTitle(value) { - const title = String(value || "Untitled").replace(/\s+/g, " ").trim(); - return title.length > 18 ? `${title.slice(0, 17)}…` : title; +function taskTitleLines(value) { + let graphemes = titleGraphemes(value); + const maxTotalUnits = TASK_TITLE_MAX_UNITS * TASK_TITLE_MAX_LINES; + if (lineUnits(graphemes) > maxTotalUnits) { + const ellipsisUnits = graphemeUnits("…"); + const clipped = []; + let used = 0; + for (const grapheme of graphemes) { + const units = graphemeUnits(grapheme); + if (used + units + ellipsisUnits > maxTotalUnits) break; + clipped.push(grapheme); + used += units; + } + graphemes = trimLine(clipped); + graphemes.push("…"); + } + + const lines = []; + while (graphemes.length && lines.length < TASK_TITLE_MAX_LINES) { + const line = []; + while (graphemes.length && lineUnits([...line, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { + line.push(graphemes.shift()); + } + if (!line.length) line.push(graphemes.shift()); + const trimmed = trimLine(line); + if (trimmed.length) lines.push(trimmed.join("")); + while (graphemes[0] === " ") graphemes.shift(); + } + if (graphemes.length && lines.length === TASK_TITLE_MAX_LINES && !lines.at(-1).endsWith("…")) { + const lastLine = titleGraphemes(lines.at(-1)); + while (lastLine.length && lineUnits([...lastLine, "…"]) > TASK_TITLE_MAX_UNITS) lastLine.pop(); + lines[lines.length - 1] = `${trimLine(lastLine).join("")}…`; + } + return lines; +} + +function taskIconData(kind, flashOn = false) { + const palette = kind === "complete" && flashOn + ? TASK_COMPLETE_FLASH_PALETTE + : TASK_STATUS_PALETTES[kind]; + const svg = ` + + + + + + + + `; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } function setDisplay(instance, state, text) { @@ -222,8 +463,12 @@ function setDisplay(instance, state, text) { }); } -function setTaskDisplay(instance, path, text) { - const digest = `path:${path}:${text}`; +function setTaskDisplay(instance, status, title) { + const kind = taskStatusKind(status); + const lines = taskTitleLines(title); + const displayTitle = lines.join("\n"); + const flashOn = kind === "complete" && completeFlashOn; + const digest = `task:${kind}:${flashOn}:${displayTitle}`; if (!instance.active || instance.lastDisplay === digest) return; instance.lastDisplay = digest; send({ @@ -233,10 +478,11 @@ function setTaskDisplay(instance, path, text) { uuid: instance.uuid, actionid: instance.actionid, key: instance.key, - type: 2, - path, + type: 1, + data: taskIconData(kind, flashOn), showtext: true, - textdata: text + textData: displayTitle, + textdata: displayTitle }] } }); @@ -269,7 +515,7 @@ function renderInstance(instance) { const action = actionName(instance.uuid); if (!latestState?.connected) { if (action === "navigate") { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); } else { setDisplay(instance, 0, "Bridge Offline"); } @@ -282,35 +528,51 @@ function renderInstance(instance) { if (action === "navigate") { const task = latestState.slots?.[0]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Latest Task"); + setTaskDisplay(instance, "idle", "Latest Task"); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); + return; + } + if (DEBUG_MODEL_PRESETS[action]) { + let label = ACTION_LABELS[action] || "CODEX"; + try { + const resolved = resolveModelSelection(DEBUG_MODEL_PRESETS[action], instance.settings || {}); + label = modelDisplayLabel(resolved); + } catch { + label = "CUSTOM MODEL"; + } + setDisplay(instance, 0, label); return; } setDisplay(instance, 0, ACTION_LABELS[action] || "CODEX"); return; } if (!latestState?.connected) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); return; } const task = latestState.slots?.[slot]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, `Task ${slot + 1}`); + setTaskDisplay(instance, "idle", `Task ${slot + 1}`); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); } function renderAll() { for (const instance of instances.values()) renderInstance(instance); } -async function bridgeRequest(path, method = "GET") { +async function bridgeRequest(path, method = "GET", traceId = null, timeoutMs = 1200, body = undefined) { + const headers = { ...await bridgeSetup.authorizationHeaders() }; + if (traceId) headers["X-Codex-Trace-Id"] = traceId; + if (body !== undefined) headers["Content-Type"] = "application/json"; const response = await fetch(`${BRIDGE_URL}${path}`, { method, - signal: AbortSignal.timeout(1200) + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(timeoutMs) }); const payload = await response.json(); if (!response.ok || payload.ok === false) { @@ -319,10 +581,46 @@ async function bridgeRequest(path, method = "GET") { return payload; } -async function openTaskSlot(slot) { +function ensureCodexDesktopReady() { + if (latestState?.connected) return Promise.resolve(latestState); + if (desktopReadyPromise) return desktopReadyPromise; + + const running = (async () => { + console.log("[Codex Micro] ChatGPT is unavailable; starting the current installed app build with the local debug bridge."); + await bridgeSetup.launch(); + let lastError = null; + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 200)); + try { + latestState = await bridgeRequest("/state?refresh=1", "GET", null, 1600); + renderAll(); + if (latestState.connected) return latestState; + lastError = new Error(latestState.error || "ChatGPT is not connected"); + } catch (error) { + lastError = error; + } + } + throw new Error(`ChatGPT did not become ready after launch: ${lastError?.message || "unknown error"}`); + })(); + desktopReadyPromise = running; + void running.finally(() => { + if (desktopReadyPromise === running) desktopReadyPromise = null; + }).catch(() => {}); + return running; +} + +async function openTaskSlot(slot, traceId, forceFocus = false) { + await ensureCodexDesktopReady(); const task = latestState?.slots?.[slot]; if (!task?.threadKey) throw new Error(`Codex task slot ${slot + 1} is empty`); - await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST"); + const query = new URLSearchParams({ slot: String(slot) }); + if (forceFocus) query.set("forceFocus", "1"); + return bridgeRequest( + `/thread/${encodeURIComponent(task.threadKey)}/click?${query}`, + "POST", + traceId, + 1800 + ); } async function pollBridge() { @@ -334,16 +632,56 @@ async function pollBridge() { latestState = { connected: false, error: error.message, slots: [] }; } finally { pollInFlight = false; + completeFlashOn = !completeFlashOn; renderAll(); } } async function invoke(instance, pressed) { const slot = taskSlot(instance.uuid); + const shortcut = debugShortcut(instance.uuid, instance.settings); + const invocation = shortcut ? ++shortcutInvocationSequence : null; + const traceId = shortcut ? randomUUID() : null; + const phase = pressed ? "down" : "up"; + const startedAt = Date.now(); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "received", [ + `invocation=${invocation}`, + `trace=${traceId}`, + `phase=${phase}`, + `target=${shortcut.target}` + ]); + } try { if (slot !== null) { - if (!pressed) return; - await openTaskSlot(slot); + if (!pressed) { + if (shortcut) { + logShortcut(instance, shortcut, "debug", "ignored", [ + `invocation=${invocation}`, + `phase=${phase}`, + "reason=keydown-only" + ]); + } + return; + } + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http", + `forceFocus=${globalSettings.forceFocus}` + ]); + } + const result = await openTaskSlot(slot, traceId, globalSettings.forceFocus); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `focusOk=${result?.focusOk === true}`, + `attentionRequested=${Boolean(result?.attentionRequested)}` + ]); + } return; } const action = actionName(instance.uuid); @@ -352,10 +690,50 @@ async function invoke(instance, pressed) { if (pressed) await bridgeRequest("/focus", "POST"); return; } - await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST"); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + let selection; + if (DEBUG_MODEL_PRESETS[action] && pressed) { + const resolved = resolveModelSelection(DEBUG_MODEL_PRESETS[action], instance.settings || {}); + selection = { model: resolved.model, effort: resolved.effort }; + } + await bridgeRequest( + `/action/${action}/${pressed ? "down" : "up"}`, "POST", traceId, + DEBUG_MODEL_PRESETS[action] ? 15000 : 1200, selection + ); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } } catch (error) { - send({ cmd: "logMessage", uuid: instance.uuid, actionid: instance.actionid, key: instance.key, level: "error", message: error.message }); + if (shortcut) { + logShortcut(instance, shortcut, "error", "failed", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `category=${shortcutErrorCategory(error)}` + ]); + } else { + send({ + cmd: "logMessage", + uuid: instance.uuid, + actionid: instance.actionid, + key: instance.key, + level: "error", + message: `[Codex Micro] action failed category=${shortcutErrorCategory(error)}` + }); + } send({ cmd: "showAlert", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } finally { + if (shortcut && pressed) collectBridgeDiagnostics(instance, shortcut, traceId); } } @@ -390,7 +768,16 @@ function addInstance(message) { lastDisplay: null }; instance.active = true; + const settings = message.settings ?? message.param; + if (settings && typeof settings === "object" && !Array.isArray(settings)) { + instance.settings = { ...settings }; + } else if (!existing) { + instance.settings = {}; + } instances.set(context, instance); + if (!existing && DEBUG_MODEL_PRESETS[actionName(instance.uuid)]) { + send({ cmd: "getSettings", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } renderInstance(instance); return instance; } @@ -429,6 +816,19 @@ function handleMessage(raw) { } return; } + if (message.cmd === "didReceiveGlobalSettings") { + globalSettings = Object.freeze({ forceFocus: message.settings?.forceFocus === true }); + return; + } + if (message.cmd === "didReceiveSettings" || message.cmd === "paramfromplugin") { + const instance = instances.get(contextOf(message)); + const settings = message.settings ?? message.param; + if (instance && settings && typeof settings === "object" && !Array.isArray(settings)) { + instance.settings = { ...settings }; + renderInstance(instance); + } + return; + } if (message.cmd === "run") { ack(message); return; @@ -451,6 +851,12 @@ function connect() { socket = new WebSocket(HOST_URL); socket.on("open", () => { send({ code: 0, cmd: "connected", uuid: PLUGIN_UUID }); + send({ cmd: "getGlobalSettings", uuid: PLUGIN_UUID, actionid: "", key: "" }); + if (process.env.CODEX_BRIDGE_AUTOSTART !== "0") { + void ensureBundledBridgeCurrent().catch(error => { + console.error(`[Codex Micro] Automatic Bridge reconciliation failed: ${error.message}`); + }); + } clearInterval(pollTimer); pollTimer = setInterval(() => void pollBridge(), 500); pollTimer.unref(); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 801203a..d083474 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -2,17 +2,29 @@ import { constants as fsConstants } from "node:fs"; import { access, chmod, + cp, copyFile, mkdir, readFile, realpath, + rename, rm, + stat, writeFile } from "node:fs/promises"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; import { promisify } from "node:util"; import { homedir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { + discoverDebugEndpoint, + launchWindowsCodex +} from "../../../src/bridge/platform.mjs"; +import { + createWindowsElevatedTaskManager, + WINDOWS_BRIDGE_TASK_NAME +} from "./windows-elevated-task.js"; const execFileAsync = promisify(execFile); @@ -42,6 +54,93 @@ async function readJson(path) { } } +async function fileSha256(path) { + try { + return createHash("sha256").update(await readFile(path)).digest("hex"); + } catch { + return null; + } +} + +async function verifiedNativeRuntimeHash(root) { + const manifest = await readJson(join(root, "native-runtime.json")); + if ( + manifest?.version !== 1 || + !/^[a-f0-9]{64}$/.test(String(manifest?.runtimeHash || "")) || + !Array.isArray(manifest?.files) + ) { + return null; + } + const verified = []; + for (const file of manifest.files) { + const relative = String(file?.path || "").replaceAll("\\", "/"); + if (!relative || relative.startsWith("/") || relative.split("/").includes("..")) return null; + const sha256 = await fileSha256(join(root, ...relative.split("/"))); + if (!sha256 || sha256 !== file.sha256) return null; + verified.push({ path: relative, sha256 }); + } + const computed = createHash("sha256") + .update(verified.map(file => `${file.path}:${file.sha256}`).join("\n")) + .digest("hex"); + return computed === manifest.runtimeHash ? computed : null; +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export async function acquireFilesystemLock({ + lockPath, + timeoutMs = 15000, + staleMs = 300000, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) +}) { + const ownerToken = randomBytes(24).toString("base64url"); + await mkdir(dirname(lockPath), { recursive: true }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await mkdir(lockPath, { mode: 0o700 }); + await writeFile(join(lockPath, "owner.json"), `${JSON.stringify({ + pid: process.pid, + ownerToken, + createdAt: new Date().toISOString() + })}\n`); + return async () => { + const owner = await readJson(join(lockPath, "owner.json")); + if (owner?.ownerToken === ownerToken) { + await rm(lockPath, { recursive: true, force: true }); + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const [lockInfo, owner] = await Promise.all([ + stat(lockPath).catch(() => null), + readJson(join(lockPath, "owner.json")) + ]); + if (lockInfo && Date.now() - lockInfo.mtimeMs > staleMs && !processIsAlive(Number(owner?.pid))) { + const abandonedLock = `${lockPath}.abandoned-${process.pid}-${ownerToken}`; + try { + await rename(lockPath, abandonedLock); + await rm(abandonedLock, { recursive: true, force: true }); + } catch (recoveryError) { + if (!await exists(lockPath)) continue; + if (!["ENOENT", "EEXIST", "EPERM", "EACCES"].includes(recoveryError?.code)) throw recoveryError; + } + continue; + } + await wait(100); + } + } + throw new Error("Another lifecycle operation is still running."); +} + async function nodeVersion(executable, execute) { try { const { stdout = "" } = await execute(executable, ["--version"]); @@ -53,6 +152,17 @@ async function nodeVersion(executable, execute) { } } +export function bridgeDataRoot({ + platform = process.platform, + home = homedir(), + localAppData = process.env.LOCALAPPDATA +} = {}) { + if (platform === "win32") { + return join(localAppData || join(home, "AppData", "Local"), "OpenCodexMicro"); + } + return join(home, "Library", "Application Support", "OpenCodexMicro"); +} + export async function selectBridgeNodeRuntime({ home = homedir(), fallbackNodeExecutable = process.execPath, @@ -66,44 +176,47 @@ export async function selectBridgeNodeRuntime({ const { stdout = "" } = await execute("/bin/zsh", ["-lic", "node -p process.execPath"]); const discovered = String(stdout) .split(/\r?\n/) - .map(line => line.trim()) - .find(line => line.startsWith("/") && line.split("/").at(-1) === "node"); + .map((line) => line.trim()) + .find((line) => line.startsWith("/") && line.split("/").at(-1) === "node"); if (discovered) candidates.push(await realpath(discovered)); } catch { - // Login-shell discovery is best effort; fixed paths and fallback remain. + // Login-shell discovery is best effort; PATH and fallback remain. } } + const nodeName = platform === "win32" ? "node.exe" : "node"; for (const directory of environmentPath.split(delimiter).filter(Boolean)) { - candidates.push(join(directory, "node")); + candidates.push(join(directory, nodeName)); + } + if (platform === "darwin") { + candidates.push( + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node", + join(home, ".local", "bin", "node") + ); } - candidates.push( - "/opt/homebrew/bin/node", - "/usr/local/bin/node", - "/usr/bin/node", - join(home, ".local", "bin", "node") - ); let resolvedFallback = fallbackNodeExecutable; try { resolvedFallback = await realpath(fallbackNodeExecutable); } catch { - // The fallback availability check below will provide the final result. + // The fallback availability check below provides the final result. } for (const executable of [...new Set(candidates)]) { - if (!executable.startsWith("/")) continue; - if (!await exists(executable, fsConstants.X_OK)) continue; + if (!isAbsolute(executable)) continue; + if (!await exists(executable, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)) continue; const resolvedExecutable = await realpath(executable); if (resolvedExecutable === resolvedFallback) continue; - const version = await nodeVersion(resolvedExecutable, execute); - if (version?.major >= 20) { - return { executable: resolvedExecutable, version: version.text, source: "system" }; + const candidateVersion = await nodeVersion(resolvedExecutable, execute); + if (candidateVersion?.major >= 20) { + return { executable: resolvedExecutable, version: candidateVersion.text, source: "system" }; } } - if (await exists(resolvedFallback, fsConstants.X_OK)) { - const version = await nodeVersion(resolvedFallback, execute); - if (version?.major >= 20) { - return { executable: resolvedFallback, version: version.text, source: "ulanzi" }; + if (await exists(resolvedFallback, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)) { + const fallbackVersion = await nodeVersion(resolvedFallback, execute); + if (fallbackVersion?.major >= 20) { + return { executable: resolvedFallback, version: fallbackVersion.text, source: "ulanzi" }; } } throw new Error("No compatible Node.js 20 or later runtime was found."); @@ -114,13 +227,51 @@ export function createBridgeInstaller({ bridgeUrl, version, home = homedir(), + localAppData = process.env.LOCALAPPDATA, + programData = process.env.PROGRAMDATA || "C:\\ProgramData", uid = process.getuid?.(), platform = process.platform, nodeExecutable = process.execPath, environmentPath = process.env.PATH || "", - execute = execFileAsync + codexChannel = process.env.CODEX_DESKTOP_CHANNEL || "stable", + execute = execFileAsync, + spawnProcess = spawn, + fetchImpl = fetch, + windowsTaskManager = null, + serviceStartTimeoutMs = 8000, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) }) { - const appRoot = join(home, "Library", "Application Support", "OpenCodexMicro"); + const appRoot = bridgeDataRoot({ platform, home, localAppData }); + const bridgeRuntime = join(appRoot, "bridge.mjs"); + const tokenPath = join(appRoot, "bridge-token"); + const pidPath = join(appRoot, "bridge.pid"); + const installMetadata = join(appRoot, "install.json"); + const lifecycleLock = join(dirname(appRoot), ".OpenCodexMicro.lifecycle.lock"); + const installerRoot = resolve(pluginRoot, "installer"); + const bundledRuntime = join(installerRoot, "bridge.mjs"); + const bundledElevatedLauncher = join(installerRoot, "elevated-launcher.mjs"); + const bundledIcon = join(installerRoot, "CodexBridge.png"); + const bundledNativeRuntime = join(installerRoot, "native-runtime"); + const nativeRuntimesRoot = join(appRoot, "native-runtimes"); + const runtimeBackup = join(appRoot, ".bridge.mjs.previous"); + const metadataBackup = join(appRoot, ".install.json.previous"); + const bridgePort = (() => { + try { return Number(new URL(bridgeUrl).port || 17373); } + catch { return 17373; } + })(); + const elevatedRoot = join(programData, "OpenCodexMicro", "Bridge"); + const elevatedNode = join(elevatedRoot, "node.exe"); + const elevatedLauncher = join(elevatedRoot, "elevated-launcher.mjs"); + const windowsTask = platform === "win32" + ? windowsTaskManager || createWindowsElevatedTaskManager({ + execute, + dataRoot: appRoot, + secureRoot: elevatedRoot, + bridgePort, + taskName: WINDOWS_BRIDGE_TASK_NAME + }) + : null; + const userApplications = join(home, "Applications"); const bridgeApp = join(userApplications, "Codex Bridge.app"); const bridgeContents = join(bridgeApp, "Contents"); @@ -129,72 +280,192 @@ export function createBridgeInstaller({ const bridgeLicenses = join(bridgeResources, "licenses"); const bridgeExecutable = join(bridgeMacOS, "Codex Bridge"); const bridgeIcon = join(bridgeResources, "CodexBridge.icns"); - const bridgeRuntime = join(appRoot, "bridge.mjs"); - const installMetadata = join(appRoot, "install.json"); const agentsRoot = join(home, "Library", "LaunchAgents"); const bridgeAgent = join(agentsRoot, "io.opencodexmicro.bridge.plist"); - const installerRoot = resolve(pluginRoot, "installer"); - const bundledRuntime = join(installerRoot, "bridge.mjs"); - const bundledIcon = join(installerRoot, "CodexBridge.png"); + let lastWindowsServiceStart = 0; + let authorizationCache = null; + let authorizationPromise = null; + let authorizationGeneration = 0; + let lifecycleOperation = null; + + function resetAuthorizationCache() { + authorizationGeneration += 1; + authorizationCache = null; + authorizationPromise = null; + } + + async function authorizationHeaders() { + if (authorizationCache) return authorizationCache; + if (authorizationPromise) return authorizationPromise; + const generation = authorizationGeneration; + const operation = (async () => { + try { + const token = (await readFile(tokenPath, "utf8")).trim(); + const headers = token ? Object.freeze({ Authorization: `Bearer ${token}` }) : {}; + if (token && generation === authorizationGeneration) authorizationCache = headers; + return headers; + } catch { + return {}; + } + })(); + authorizationPromise = operation; + try { + return await operation; + } finally { + if (authorizationPromise === operation) authorizationPromise = null; + } + } async function probeBridge() { try { - const response = await fetch(`${bridgeUrl}/health`, { + const response = await fetchImpl(`${bridgeUrl}/health`, { + headers: await authorizationHeaders(), signal: AbortSignal.timeout(1200) }); const payload = await response.json(); if (!response.ok || payload.ok === false) throw new Error(payload.error || `Bridge HTTP ${response.status}`); - return { serviceOnline: true, cdpConnected: Boolean(payload.codexConnected), serviceError: null }; + return { + serviceOnline: true, + cdpConnected: Boolean(payload.codexConnected), + serviceVersion: typeof payload.bridgeVersion === "string" ? payload.bridgeVersion : null, + serviceRuntimeHash: typeof payload.runtimeHash === "string" ? payload.runtimeHash : null, + serviceNativeRuntimeHash: typeof payload.nativeRuntimeHash === "string" ? payload.nativeRuntimeHash : null, + serviceError: null + }; } catch (error) { - return { serviceOnline: false, cdpConnected: false, serviceError: error.message }; + return { + serviceOnline: false, + cdpConnected: false, + serviceVersion: null, + serviceRuntimeHash: null, + serviceNativeRuntimeHash: null, + serviceError: error.message + }; } } async function status() { - const [appInstalled, runtimeInstalled, agentInstalled, metadata, probe] = await Promise.all([ - exists(bridgeExecutable, fsConstants.X_OK), + const [ + runtimeInstalled, + tokenInstalled, + metadata, + probe, + bundledRuntimeHash, + installedRuntimeHash, + bundledLauncherHash, + installedLauncherHash, + taskStatus + ] = await Promise.all([ exists(bridgeRuntime), - exists(bridgeAgent), + exists(tokenPath), readJson(installMetadata), - probeBridge() + probeBridge(), + fileSha256(bundledRuntime), + fileSha256(bridgeRuntime), + platform === "win32" ? fileSha256(bundledElevatedLauncher) : null, + platform === "win32" ? fileSha256(elevatedLauncher) : null, + platform === "win32" ? windowsTask.status() : { installed: true, runLevel: null } ]); - const installed = appInstalled && runtimeInstalled && agentInstalled; + const bundledNativeRuntimeHash = platform === "win32" + ? await verifiedNativeRuntimeHash(bundledNativeRuntime) + : null; + const installedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash + ? await verifiedNativeRuntimeHash(join(nativeRuntimesRoot, metadata.nativeRuntimeHash)) + : null; + const nativeRuntimeInstalled = platform !== "win32" || Boolean( + bundledNativeRuntimeHash && + installedNativeRuntimeHash === bundledNativeRuntimeHash && + metadata?.nativeRuntimeHash === bundledNativeRuntimeHash + ); + const protectedRuntimeHash = platform === "win32" && metadata?.runtimeHash + ? await fileSha256(join(elevatedRoot, "runtimes", metadata.runtimeHash, "bridge.mjs")) + : null; + const protectedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash + ? await verifiedNativeRuntimeHash(join(elevatedRoot, "native-runtimes", metadata.nativeRuntimeHash)) + : null; + const sourceNodeHash = platform === "win32" && metadata?.nodeExecutable + ? await fileSha256(metadata.nodeExecutable) + : null; + const protectedNodeHash = platform === "win32" ? await fileSha256(elevatedNode) : null; + const macAppInstalled = platform === "darwin" + ? await exists(bridgeExecutable, fsConstants.X_OK) + : runtimeInstalled; + const agentInstalled = platform === "darwin" + ? await exists(bridgeAgent) + : platform === "win32" ? taskStatus.installed : true; + const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled && nativeRuntimeInstalled; + const installationDetected = Boolean( + runtimeInstalled || tokenInstalled || metadata || probe.serviceOnline || + (platform === "darwin" && (macAppInstalled || agentInstalled)) || + (platform === "win32" && taskStatus.installed) + ); + const metadataMatchesBundle = Boolean( + bundledRuntimeHash && + metadata?.version === version && + metadata?.runtimeHash === bundledRuntimeHash && + installedRuntimeHash === bundledRuntimeHash + && nativeRuntimeInstalled && ( + platform !== "win32" || Boolean( + metadata?.elevatedTask === true && + metadata?.taskName === WINDOWS_BRIDGE_TASK_NAME && + metadata?.launcherHash === bundledLauncherHash && + installedLauncherHash === bundledLauncherHash && + protectedRuntimeHash === bundledRuntimeHash && + protectedNativeRuntimeHash === bundledNativeRuntimeHash && + metadata?.nodeHash && + sourceNodeHash === metadata.nodeHash && + protectedNodeHash === metadata.nodeHash && + taskStatus.runLevel === "highest" + ) + ) + ); + const runningMatchesBundle = !probe.serviceOnline || Boolean( + bundledRuntimeHash && + probe.serviceVersion === version && + probe.serviceRuntimeHash === bundledRuntimeHash && + (platform !== "win32" || probe.serviceNativeRuntimeHash === bundledNativeRuntimeHash) + ); return { - supported: platform === "darwin" && Number.isInteger(uid), + supported: platform === "win32" || (platform === "darwin" && Number.isInteger(uid)), + platform, installed, - appInstalled, - serviceInstalled: runtimeInstalled && agentInstalled, + installationDetected, + appInstalled: macAppInstalled, + serviceInstalled: runtimeInstalled && tokenInstalled && agentInstalled, installedVersion: metadata?.version || null, bundledVersion: version, - needsUpdate: !installed || metadata?.version !== version, - appPath: bridgeApp, + bundledRuntimeHash, + installedRuntimeHash, + bundledNativeRuntimeHash, + installedNativeRuntimeHash, + protectedRuntimeHash, + protectedNativeRuntimeHash, + taskInstalled: platform === "win32" ? taskStatus.installed : null, + serviceRunLevel: platform === "win32" ? taskStatus.runLevel : null, + administratorApprovalRequired: platform === "win32" && installationDetected && (!installed || !metadataMatchesBundle), + needsUpdate: !installed || !metadataMatchesBundle || !runningMatchesBundle, + appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, nodeVersion: metadata?.nodeVersion || null, nodeSource: metadata?.nodeSource || null, + codexChannel: metadata?.codexChannel || codexChannel, ...probe }; } - async function buildIcon() { + async function buildMacIcon() { const iconset = join(appRoot, "CodexBridge.iconset"); await rm(iconset, { recursive: true, force: true }); await mkdir(iconset, { recursive: true }); try { for (const [name, size] of [ - ["icon_16x16.png", 16], - ["icon_16x16@2x.png", 32], - ["icon_32x32.png", 32], - ["icon_32x32@2x.png", 64], - ["icon_128x128.png", 128], - ["icon_128x128@2x.png", 256], - ["icon_256x256.png", 256], - ["icon_256x256@2x.png", 512], - ["icon_512x512.png", 512], - ["icon_512x512@2x.png", 1024] + ["icon_16x16.png", 16], ["icon_16x16@2x.png", 32], + ["icon_32x32.png", 32], ["icon_32x32@2x.png", 64], + ["icon_128x128.png", 128], ["icon_128x128@2x.png", 256], + ["icon_256x256.png", 256], ["icon_256x256@2x.png", 512], + ["icon_512x512.png", 512], ["icon_512x512@2x.png", 1024] ]) { - await execute("/usr/bin/sips", [ - "-z", String(size), String(size), bundledIcon, "--out", join(iconset, name) - ]); + await execute("/usr/bin/sips", ["-z", String(size), String(size), bundledIcon, "--out", join(iconset, name)]); } await execute("/usr/bin/iconutil", ["-c", "icns", iconset, "-o", bridgeIcon]); } finally { @@ -202,38 +473,17 @@ export function createBridgeInstaller({ } } - async function install() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge installation is supported on macOS only."); - } - if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { - throw new Error("The plugin does not contain the Codex Bridge installation resources."); - } - const nodeRuntime = await selectBridgeNodeRuntime({ - home, - fallbackNodeExecutable: nodeExecutable, - environmentPath, - platform, - execute - }); - - await mkdir(appRoot, { recursive: true, mode: 0o700 }); - await chmod(appRoot, 0o700); + async function installMac(nodeRuntime, appVersion = version) { await mkdir(userApplications, { recursive: true }); await mkdir(agentsRoot, { recursive: true }); - await copyFile(bundledRuntime, bridgeRuntime); - await rm(bridgeApp, { recursive: true, force: true }); await mkdir(bridgeMacOS, { recursive: true }); await mkdir(bridgeLicenses, { recursive: true }); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - const source = join(installerRoot, notice); - await copyFile(source, join(bridgeLicenses, notice)); - await copyFile(source, join(appRoot, notice)); + await copyFile(join(installerRoot, notice), join(bridgeLicenses, notice)); } - await buildIcon(); - - const info = ` + await buildMacIcon(); + await writeFile(join(bridgeContents, "Info.plist"), ` CFBundleDisplayNameCodex Bridge @@ -242,120 +492,572 @@ export function createBridgeInstaller({ CFBundleIdentifierio.opencodexmicro.bridge CFBundleNameCodex Bridge CFBundlePackageTypeAPPL - CFBundleShortVersionString${xml(version)} - CFBundleVersion${xml(version)} + CFBundleShortVersionString${xml(appVersion)} + CFBundleVersion${xml(appVersion)} LSMinimumSystemVersion13.0 LSUIElement - NSHighResolutionCapable -`; - await writeFile(join(bridgeContents, "Info.plist"), info); - - const launcher = `#!/bin/zsh +`); + await writeFile(bridgeExecutable, `#!/bin/zsh set -u -unsetopt BG_NICE - codex_binary="/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" -bridge_log="$HOME/Library/Logs/OpenCodexMicro-codex-bridge.log" - -if [[ ! -x "$codex_binary" ]]; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex was not found at /Applications/ChatGPT.app." as critical' - exit 1 -fi - +if [[ ! -x "$codex_binary" ]]; then exit 1; fi if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' - for attempt in {1..80}; do - /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break - /bin/sleep 0.1 - done -fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex did not quit, so bridge parameters could not be applied. Quit Codex and try again." as critical' - exit 1 + for attempt in {1..80}; do /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break; /bin/sleep 0.1; done fi - -/usr/bin/nohup "$codex_binary" \\ - --remote-debugging-address=127.0.0.1 \\ - --remote-debugging-port=9222 \\ - --remote-allow-origins=http://127.0.0.1:9222 \\ - >>"$bridge_log" 2>&1 & - -for attempt in {1..300}; do - if /usr/bin/curl --noproxy '*' --silent --fail --max-time 0.2 \\ - http://127.0.0.1:9222/json/version >/dev/null 2>&1; then - exit 0 - fi - if ! /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - break - fi - /bin/sleep 0.1 -done - -/usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex started, but the bridge endpoint is unavailable. Quit Codex and launch Codex Bridge again." as critical' -exit 1 -`; - await writeFile(bridgeExecutable, launcher, { mode: 0o755 }); +if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi +/usr/bin/nohup "$codex_binary" ${ + ["--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--remote-allow-origins=http://127.0.0.1:9222"].join(" ") +} >/dev/null 2>&1 & +`, { mode: 0o755 }); await chmod(bridgeExecutable, 0o755); await execute("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", bridgeApp]); - - const plist = ` + await writeFile(bridgeAgent, ` Labelio.opencodexmicro.bridge - ProgramArguments - ${xml(nodeRuntime.executable)} - ${xml(bridgeRuntime)} - - RunAtLoad - KeepAlive - ProcessTypeBackground - ThrottleInterval2 + ProgramArguments${xml(nodeRuntime.executable)}${xml(bridgeRuntime)} + EnvironmentVariables + CODEX_BRIDGE_DATA_ROOT${xml(appRoot)} + + RunAtLoadKeepAlive + ProcessTypeBackgroundThrottleInterval2 StandardOutPath${xml(join(appRoot, "bridge.log"))} StandardErrorPath${xml(join(appRoot, "bridge-error.log"))} -`; - await writeFile(bridgeAgent, plist, { mode: 0o644 }); - await writeFile(installMetadata, `${JSON.stringify({ - version, - nodeExecutable: nodeRuntime.executable, - nodeVersion: nodeRuntime.version, - nodeSource: nodeRuntime.source, - installedAt: new Date().toISOString() - }, null, 2)}\n`, { mode: 0o600 }); +`, { mode: 0o644 }); + await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + + async function acquireLifecycleLock() { + await mkdir(appRoot, { recursive: true, mode: 0o700 }); + return acquireFilesystemLock({ lockPath: lifecycleLock, wait }); + } + + function serializeLifecycle(operation) { + if (lifecycleOperation) return lifecycleOperation; + const running = (async () => { + const release = await acquireLifecycleLock(); + try { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + } + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + } + return await operation(); + } finally { + await release(); + } + })(); + lifecycleOperation = running; + return running.finally(() => { + if (lifecycleOperation === running) lifecycleOperation = null; + }); + } + + async function stopWindowsService() { + await windowsTask.stop(); + const recordedPid = Number((await readFile(pidPath, "utf8").catch(() => "")).trim()); + const stopScript = "$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; $targetPid=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PID, [ref]$targetPid); $targetPort=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PORT, [ref]$targetPort); $processes=@(); if ($targetPid -gt 0) { $proc=Get-Process -Id $targetPid -ErrorAction SilentlyContinue; if ($proc -and $proc.ProcessName -like 'node*') { $processes += $proc } }; if ($processes.Count -eq 0 -and $targetPort -gt 0) { $portPids=@(Get-NetTCPConnection -LocalPort $targetPort -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique); foreach ($p in $portPids) { $proc=Get-Process -Id $p -ErrorAction SilentlyContinue; if ($proc -and $proc.ProcessName -like 'node*') { $processes += $proc } } }; if ($processes.Count -eq 0) { $candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -and ($_.CommandLine.TrimEnd().EndsWith($target, [System.StringComparison]::OrdinalIgnoreCase) -or $_.CommandLine.TrimEnd().EndsWith(('\"' + $target + '\"'), [System.StringComparison]::OrdinalIgnoreCase)) }); $processes=$candidates }; $processes | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $processes | ForEach-Object { try { Wait-Process -Id $_.ProcessId -Timeout 5 -ErrorAction Stop } catch {} }"; + await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", stopScript], { + timeout: 7000, + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime, + CODEX_BRIDGE_TARGET_PID: Number.isInteger(recordedPid) && recordedPid > 0 ? String(recordedPid) : "", + CODEX_BRIDGE_TARGET_PORT: String(bridgePort) + } + }); + await rm(pidPath, { force: true }); + lastWindowsServiceStart = 0; + } + + async function waitForServiceOffline() { + const deadline = Date.now() + Math.min(serviceStartTimeoutMs, 5000); + do { + if (!(await probeBridge()).serviceOnline) return; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The managed Codex Bridge process did not stop before replacement."); + } + + async function stopService() { + const wasOnline = (await probeBridge()).serviceOnline; + if (platform === "darwin") { + if (await exists(bridgeAgent)) { + try { + await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); + } catch (error) { + if (wasOnline) throw new Error(`Codex Bridge LaunchAgent could not be stopped: ${error.message}`); + } + } + if (wasOnline) await waitForServiceOffline(); + return; + } + if (platform === "win32") { + await stopWindowsService(); + if (wasOnline) await waitForServiceOffline(); + } + } + async function commitRuntime(stagedRuntime) { + await rm(runtimeBackup, { force: true }); + if (await exists(bridgeRuntime)) await rename(bridgeRuntime, runtimeBackup); try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { - // The service may not be loaded yet. + await rename(stagedRuntime, bridgeRuntime); + } catch (error) { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + } + throw error; } - await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + + async function restorePreviousRuntime(previousRuntime) { + await rm(bridgeRuntime, { force: true }); + if (await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + return; + } + if (previousRuntime) { + const rollbackRuntime = join(appRoot, `.bridge.mjs.rollback-${process.pid}`); + await writeFile(rollbackRuntime, previousRuntime); + await rename(rollbackRuntime, bridgeRuntime); + } + } + + async function commitMetadata(metadataText) { + const stagedMetadata = join(appRoot, `.install.json.installing-${process.pid}`); + await writeFile(stagedMetadata, metadataText, { mode: 0o600 }); + await rm(metadataBackup, { force: true }); + if (await exists(installMetadata)) await rename(installMetadata, metadataBackup); + try { + await rename(stagedMetadata, installMetadata); + } catch (error) { + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + } + throw error; + } + } + + async function restorePreviousMetadata(previousMetadataText, useBackup = true) { + await rm(installMetadata, { force: true }); + if (useBackup && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + return; + } + await rm(metadataBackup, { force: true }); + if (previousMetadataText) { + await writeFile(installMetadata, previousMetadataText, { mode: 0o600 }); + } + } + + async function discardTransactionBackups() { + await Promise.all([ + rm(runtimeBackup, { force: true }), + rm(metadataBackup, { force: true }) + ]); + } + + async function startInstalledService(metadata) { + if (!metadata?.nodeExecutable) { + throw new Error("Codex Bridge runtime metadata is missing its Node.js executable."); + } + if (platform === "darwin") { + await installMac({ + executable: metadata.nodeExecutable, + version: metadata.nodeVersion || "unknown", + source: metadata.nodeSource || "unknown" + }, metadata.version || "0.0.0"); + } + if (platform === "win32") await ensureServiceUnlocked(); + } + + async function waitForAnyService() { + const deadline = Date.now() + serviceStartTimeoutMs; + do { + const probe = await probeBridge(); + if (probe.serviceOnline) return probe; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The restored Codex Bridge service did not become reachable."); + } + + async function rollbackInterruptedUpdate() { + const hasRuntimeBackup = await exists(runtimeBackup); + const hasMetadataBackup = await exists(metadataBackup); + if (!hasRuntimeBackup && !hasMetadataBackup) return false; + await stopService(); + if (hasRuntimeBackup) { + await rm(bridgeRuntime, { force: true }); + await rename(runtimeBackup, bridgeRuntime); + } + if (hasMetadataBackup) { + await rm(installMetadata, { force: true }); + await rename(metadataBackup, installMetadata); + } + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + await startInstalledService(restoredMetadata); + await waitForAnyService(); + return true; + } + + async function waitForExpectedService( + expectedRuntimeHash, + expectedVersion = version, + expectedNativeRuntimeHash = null + ) { + const deadline = Date.now() + serviceStartTimeoutMs; + let lastProbe = null; + do { + lastProbe = await probeBridge(); + if ( + lastProbe.serviceOnline && + lastProbe.serviceVersion === expectedVersion && + lastProbe.serviceRuntimeHash === expectedRuntimeHash && + (platform !== "win32" || lastProbe.serviceNativeRuntimeHash === expectedNativeRuntimeHash) + ) { + return lastProbe; + } + await wait(100); + } while (Date.now() < deadline); + const detail = lastProbe?.serviceOnline + ? "the running process reported a different build" + : "the restarted service did not become reachable"; + throw new Error(`Codex Bridge ${expectedVersion} restart failed: ${detail}.`); + } + + async function installUnlocked() { + if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { + throw new Error("Codex Bridge installation is supported on Windows and macOS only."); + } + if ( + !await exists(bundledRuntime) || + (platform === "win32" && !await exists(bundledElevatedLauncher)) || + !await exists(bundledIcon) || + (platform === "win32" && !await verifiedNativeRuntimeHash(bundledNativeRuntime)) + ) { + throw new Error("The plugin does not contain the Codex Bridge installation resources."); + } + const nodeRuntime = await selectBridgeNodeRuntime({ + home, + fallbackNodeExecutable: nodeExecutable, + environmentPath, + platform, + execute + }); + const nodeHash = platform === "win32" ? await fileSha256(nodeRuntime.executable) : null; + const launcherHash = platform === "win32" ? await fileSha256(bundledElevatedLauncher) : null; + if (platform === "win32" && (!nodeHash || !launcherHash)) { + throw new Error("The Windows elevated Bridge launch resources could not be verified."); + } + const previousStatus = await status(); + const previousRuntime = await readFile(bridgeRuntime).catch(() => null); + const previousRuntimeHash = previousRuntime + ? createHash("sha256").update(previousRuntime).digest("hex") + : null; + const previousMetadataText = await readFile(installMetadata, "utf8").catch(() => null); + const previousMetadata = await readJson(installMetadata); + const nativeRuntimeHash = platform === "win32" ? previousStatus.bundledNativeRuntimeHash : null; + const nativeRuntime = nativeRuntimeHash ? join(nativeRuntimesRoot, nativeRuntimeHash) : null; + let createdNativeRuntime = false; + if (nativeRuntimeHash && await verifiedNativeRuntimeHash(nativeRuntime) !== nativeRuntimeHash) { + const stagedNativeRuntime = join(appRoot, `.native-runtime.installing-${process.pid}`); + await rm(stagedNativeRuntime, { recursive: true, force: true }); + await cp(bundledNativeRuntime, stagedNativeRuntime, { recursive: true }); + if (await verifiedNativeRuntimeHash(stagedNativeRuntime) !== nativeRuntimeHash) { + await rm(stagedNativeRuntime, { recursive: true, force: true }); + throw new Error("The bundled native focus runtime could not be verified."); + } + await mkdir(nativeRuntimesRoot, { recursive: true }); + await rm(nativeRuntime, { recursive: true, force: true }); + await rename(stagedNativeRuntime, nativeRuntime); + createdNativeRuntime = true; + } + const stagedRuntime = join(appRoot, `.bridge.mjs.installing-${process.pid}`); + await mkdir(appRoot, { recursive: true, mode: 0o700 }); + if (platform !== "win32") await chmod(appRoot, 0o700); + await rm(stagedRuntime, { force: true }); + await copyFile(bundledRuntime, stagedRuntime); + const runtimeHash = await fileSha256(stagedRuntime); + if (!runtimeHash) throw new Error("The bundled Codex Bridge runtime could not be verified."); + + console.log(`[Codex Bridge] ${previousStatus.installed ? "Updating" : "Installing"} ${version}; stopping only the managed Bridge process.`); + let elevatedTakeoverRequired = false; + try { + try { + await stopService(); + } catch (stopError) { + if (platform === "win32" && previousStatus.serviceOnline && !previousStatus.taskInstalled) { + elevatedTakeoverRequired = true; + console.log("[Codex Bridge] Existing higher-privilege Bridge will be stopped inside the approved task-registration transaction."); + } else { + throw stopError; + } + } + await commitRuntime(stagedRuntime); + for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { + await copyFile(join(installerRoot, notice), join(appRoot, notice)); + } + if (!await exists(tokenPath)) { + await writeFile(tokenPath, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); + } + resetAuthorizationCache(); + if (platform === "win32") { + const nativeManifest = await readJson(join(bundledNativeRuntime, "native-runtime.json")); + await windowsTask.deploy({ + nodeSource: nodeRuntime.executable, + nodeHash, + launcherSource: bundledElevatedLauncher, + launcherHash, + runtimeSource: bridgeRuntime, + runtimeHash, + nativeSource: bundledNativeRuntime, + nativeRuntimeHash, + nativeFiles: nativeManifest.files, + takeOverExistingBridge: elevatedTakeoverRequired + }); + if (elevatedTakeoverRequired) await waitForServiceOffline(); + } + await commitMetadata(`${JSON.stringify({ + version, + runtimeHash, + nativeRuntimeHash, + platform, + codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + ...(platform === "win32" ? { + elevatedTask: true, + taskName: WINDOWS_BRIDGE_TASK_NAME, + secureRoot: elevatedRoot, + launcherHash, + nodeHash + } : {}), + installedAt: new Date().toISOString() + }, null, 2)}\n`); + if (platform === "darwin") await installMac(nodeRuntime); + if (platform === "win32") await ensureServiceUnlocked(); + await waitForExpectedService(runtimeHash, version, nativeRuntimeHash); + const installed = await status(); + if (installed.needsUpdate) throw new Error("The restarted Codex Bridge did not match the bundled runtime."); + await discardTransactionBackups(); + console.log(`[Codex Bridge] ${version} is installed and the restarted process reported the expected build.`); + return installed; + } catch (error) { + console.error(`[Codex Bridge] ${version} update failed; restoring the previous managed runtime.`); + let rollbackError = null; + try { + await stopService(); + if (previousRuntime) { + await restorePreviousRuntime(previousRuntime); + const recoveryVersion = previousMetadata?.version || previousStatus.serviceVersion || "0.0.0"; + const recoveryMetadataText = previousMetadata ? previousMetadataText : `${JSON.stringify({ + version: recoveryVersion, + runtimeHash: previousRuntimeHash, + nativeRuntimeHash: previousMetadata?.nativeRuntimeHash || null, + platform, + codexChannel: previousStatus.codexChannel || codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: new Date().toISOString(), + recovered: true + }, null, 2)}\n`; + await restorePreviousMetadata(recoveryMetadataText, Boolean(previousMetadata)); + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + if (restoredMetadata?.nodeExecutable) { + const previousNodeRuntime = { + executable: restoredMetadata.nodeExecutable, + version: restoredMetadata.nodeVersion || "unknown", + source: restoredMetadata.nodeSource || "unknown" + }; + if (platform === "darwin") await installMac(previousNodeRuntime, restoredMetadata.version || "0.0.0"); + if (platform === "win32") await ensureServiceUnlocked(); + } + } else { + if (platform === "darwin") { + await rm(bridgeAgent, { force: true }); + await rm(bridgeApp, { recursive: true, force: true }); + } + if (platform === "win32") await windowsTask.uninstall(); + await rm(appRoot, { recursive: true, force: true }); + resetAuthorizationCache(); + } + if ( + createdNativeRuntime && + nativeRuntime && + previousMetadata?.nativeRuntimeHash !== nativeRuntimeHash + ) { + await rm(nativeRuntime, { recursive: true, force: true }); + } + } catch (rollbackFailure) { + rollbackError = rollbackFailure; + } + const rollbackDetail = rollbackError ? ` Rollback also failed: ${rollbackError.message}` : " Previous runtime restored."; + throw new Error(`Codex Bridge installation failed: ${error.message}${rollbackDetail}`); + } finally { + await rm(stagedRuntime, { force: true }); + } + } + + async function install() { + return serializeLifecycle(installUnlocked); + } + + async function ensureServiceUnlocked() { + if (platform !== "win32") return status(); + const probe = await probeBridge(); + if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2000) return { ...await status(), ...probe }; + const metadata = await readJson(installMetadata); + const nativeRuntimeValid = !metadata?.nativeRuntimeHash || + await verifiedNativeRuntimeHash(join(nativeRuntimesRoot, metadata.nativeRuntimeHash)) === metadata.nativeRuntimeHash; + if ( + !metadata?.nodeExecutable || + !await exists(bridgeRuntime) || + !await exists(tokenPath) || + !nativeRuntimeValid + ) { + throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + } + if (metadata.elevatedTask === true) { + const taskStatus = await windowsTask.status(); + if (!taskStatus.installed || taskStatus.runLevel !== "highest") { + throw new Error("Codex Bridge elevated task is missing or invalid. Use Install / Repair first."); + } + await windowsTask.start(); + lastWindowsServiceStart = Date.now(); + return status(); + } + const token = (await readFile(tokenPath, "utf8")).trim(); + const child = spawnProcess(metadata.nodeExecutable, [bridgeRuntime], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_DATA_ROOT: appRoot, + CODEX_BRIDGE_TOKEN: token, + ...(metadata.nativeRuntimeHash ? { + CODEX_BRIDGE_NATIVE_ROOT: join(nativeRuntimesRoot, metadata.nativeRuntimeHash), + CODEX_BRIDGE_NATIVE_HASH: metadata.nativeRuntimeHash + } : {}) + } + }); + if (Number.isInteger(child.pid) && child.pid > 0) { + await writeFile(pidPath, `${child.pid}\n`, { mode: 0o600 }); + } else { + await rm(pidPath, { force: true }); + } + child.unref?.(); + lastWindowsServiceStart = Date.now(); return status(); } + async function ensureService() { + return serializeLifecycle(ensureServiceUnlocked); + } + + async function ensureCurrent() { + return serializeLifecycle(async () => { + const current = await status(); + if (!current.installationDetected) return current; + const interruptedUpdate = await exists(runtimeBackup) || await exists(metadataBackup); + if (interruptedUpdate && current.needsUpdate) { + await rollbackInterruptedUpdate(); + console.log("[Codex Bridge] Interrupted update rolled back; retrying the bundled update in the same lifecycle operation."); + return installUnlocked(); + } + if (!current.installed || current.needsUpdate) { + console.log(`[Codex Bridge] Installed or running build differs from bundled ${version}; starting automatic update.`); + return installUnlocked(); + } + if (!current.serviceOnline) { + try { + await startInstalledService(await readJson(installMetadata)); + await waitForExpectedService( + current.bundledRuntimeHash, + version, + current.bundledNativeRuntimeHash + ); + const ready = await status(); + await discardTransactionBackups(); + return ready; + } catch (error) { + if (interruptedUpdate && await rollbackInterruptedUpdate()) { + console.log("[Codex Bridge] Interrupted restart rolled back; retrying the bundled update once."); + return installUnlocked(); + } + throw error; + } + } + await discardTransactionBackups(); + return current; + }); + } + async function launch() { - if (!await exists(bridgeExecutable, fsConstants.X_OK)) { - throw new Error("Codex Bridge.app is not installed."); + const current = await ensureCurrent(); + if (!current.installed) throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + if (platform === "darwin") { + await execute("/usr/bin/open", [bridgeApp]); + return status(); } - await execute("/usr/bin/open", [bridgeApp]); - return status(); + if (platform === "win32") { + if (!current.serviceOnline) await ensureService(); + try { + await discoverDebugEndpoint({ platform, execute, fetchImpl }); + const response = await fetchImpl(`${bridgeUrl}/focus`, { + method: "POST", + headers: await authorizationHeaders(), + signal: AbortSignal.timeout(1200) + }); + const payload = await response.json(); + if (!response.ok || payload?.ok !== true) { + throw new Error(payload?.error || "Codex focus request failed"); + } + } catch (error) { + if (!/local debug bridge/.test(error.message)) throw error; + await launchWindowsCodex({ channel: codexChannel, execute, spawnProcess }); + } + return current; + } + throw new Error(`Codex Bridge launch is not supported on ${platform}.`); } - async function uninstall() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge uninstallation is supported on macOS only."); + async function uninstallUnlocked() { + if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { + throw new Error("Codex Bridge uninstallation is supported on Windows and macOS only."); } - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { - // The service may already be stopped or absent. + if (platform === "darwin") { + await stopService(); + await rm(bridgeAgent, { force: true }); + await rm(bridgeApp, { recursive: true, force: true }); + } else { + await stopService(); + const taskStatus = await windowsTask.status(); + if (taskStatus.installed || (await readJson(installMetadata))?.elevatedTask === true) { + await windowsTask.uninstall(); + if ((await windowsTask.status()).installed) { + throw new Error("The elevated Codex Bridge scheduled task could not be removed."); + } + } } - await rm(bridgeAgent, { force: true }); await rm(appRoot, { recursive: true, force: true }); - await rm(bridgeApp, { recursive: true, force: true }); + resetAuthorizationCache(); return status(); } - return { status, install, launch, uninstall }; + async function uninstall() { + return serializeLifecycle(uninstallUnlocked); + } + + return { status, install, launch, uninstall, ensureService, ensureCurrent, authorizationHeaders }; } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/windows-elevated-task.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/windows-elevated-task.js new file mode 100644 index 0000000..d2a4b9b --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/windows-elevated-task.js @@ -0,0 +1,195 @@ +import { Buffer } from "node:buffer"; +import { join } from "node:path"; + +export const WINDOWS_BRIDGE_TASK_NAME = "OpenCodexMicro Codex Bridge"; + +function psLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function nativeFileAssertions(rootExpression, files) { + return files.map(file => { + const relative = String(file.path).replaceAll("/", "\\"); + return `Assert-FileHash (Join-Path ${rootExpression} ${psLiteral(relative)}) ${psLiteral(file.sha256)}`; + }).join("\n"); +} + +export function createWindowsElevatedTaskManager({ + execute, + dataRoot, + secureRoot, + bridgePort = 17373, + taskName = WINDOWS_BRIDGE_TASK_NAME +}) { + const secureNode = join(secureRoot, "node.exe"); + const secureLauncher = join(secureRoot, "elevated-launcher.mjs"); + const expectedArguments = `"${secureLauncher}" "${dataRoot}"`; + + async function runElevated(script) { + const systemModuleBootstrap = "$env:PSModulePath=[Environment]::GetEnvironmentVariable('PSModulePath','Machine'); Import-Module Microsoft.PowerShell.Security -ErrorAction Stop"; + const encoded = Buffer.from(`${systemModuleBootstrap}\n${script}`, "utf16le").toString("base64"); + const bootstrap = "$isAdmin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); if ($isAdmin) { & powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand $env:CODEX_BRIDGE_ELEVATED_SCRIPT; exit $LASTEXITCODE }; $p=Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$env:CODEX_BRIDGE_ELEVATED_SCRIPT); if ($p.ExitCode -ne 0) { exit $p.ExitCode }"; + try { + await execute("powershell.exe", [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", bootstrap + ], { + timeout: 120000, + windowsHide: true, + env: { ...process.env, CODEX_BRIDGE_ELEVATED_SCRIPT: encoded } + }); + } catch (error) { + throw new Error(`Administrator approval or elevated Codex Bridge operation failed: ${error.message}`); + } + } + + async function status() { + try { + const { stdout = "" } = await execute("schtasks.exe", [ + "/Query", "/TN", taskName, "/XML" + ], { + timeout: 3000, + windowsHide: true + }); + const output = String(stdout); + const commandMatch = output.match(/([\s\S]*?)<\/Command>/i); + const argumentsMatch = output.match(/([\s\S]*?)<\/Arguments>/i); + const runLevelMatch = output.match(/([\s\S]*?)<\/RunLevel>/i); + if (commandMatch && argumentsMatch && runLevelMatch) { + const command = commandMatch[1].trim(); + const args = argumentsMatch[1].trim(); + const runLevel = runLevelMatch[1].trim(); + const isHighest = /highest/i.test(runLevel); + return { + installed: isHighest && + command.toLowerCase() === secureNode.toLowerCase() && + args.toLowerCase() === expectedArguments.toLowerCase(), + runLevel: isHighest ? "highest" : null + }; + } + } catch { + // Fall back to PowerShell inspection if schtasks XML query is unsupported or fails + } + try { + const statusScript = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $task = Get-ScheduledTask -TaskName ${psLiteral(taskName)} -ErrorAction Stop; $action = @($task.Actions)[0]; @{ runLevel = [string]$task.Principal.RunLevel; execute = [string]$action.Execute; arguments = [string]$action.Arguments } | ConvertTo-Json -Compress`; + const { stdout = "" } = await execute("powershell.exe", [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", statusScript + ], { + timeout: 5000, + windowsHide: true + }); + const definition = JSON.parse(String(stdout).trim()); + return { + installed: definition.runLevel === "Highest" && + String(definition.execute).toLowerCase() === secureNode.toLowerCase() && + String(definition.arguments).toLowerCase() === expectedArguments.toLowerCase(), + runLevel: definition.runLevel === "Highest" ? "highest" : null + }; + } catch { + return { installed: false, runLevel: null }; + } + } + + async function deploy({ + nodeSource, + nodeHash, + launcherSource, + launcherHash, + runtimeSource, + runtimeHash, + nativeSource, + nativeRuntimeHash, + nativeFiles, + takeOverExistingBridge = false + }) { + const runtimeTarget = join(secureRoot, "runtimes", runtimeHash); + const nativeTarget = join(secureRoot, "native-runtimes", nativeRuntimeHash); + const script = `$ErrorActionPreference = 'Stop' +$secureRoot = ${psLiteral(secureRoot)} +$nodeSource = ${psLiteral(nodeSource)} +$launcherSource = ${psLiteral(launcherSource)} +$runtimeSource = ${psLiteral(runtimeSource)} +$nativeSource = ${psLiteral(nativeSource)} +$runtimeTarget = ${psLiteral(runtimeTarget)} +$nativeTarget = ${psLiteral(nativeTarget)} +$nodeTarget = ${psLiteral(secureNode)} +$launcherTarget = ${psLiteral(secureLauncher)} +${takeOverExistingBridge ? `$listenerPids = @(Get-NetTCPConnection -LocalPort ${Number(bridgePort)} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) +foreach ($listenerPid in $listenerPids) { + $listenerProcess = Get-Process -Id $listenerPid -ErrorAction Stop + if ($listenerProcess.ProcessName -notlike 'node*') { throw 'The Codex Bridge port is owned by an unexpected process.' } + Stop-Process -Id $listenerPid -Force -ErrorAction Stop + Wait-Process -Id $listenerPid -Timeout 5 -ErrorAction SilentlyContinue +} +$remainingListener = @(Get-NetTCPConnection -LocalPort ${Number(bridgePort)} -State Listen -ErrorAction SilentlyContinue) +if ($remainingListener.Count -gt 0) { throw 'The existing Codex Bridge listener could not be stopped.' }` : ""} +function Assert-FileHash([string]$Path, [string]$Expected) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing protected Bridge source file." } + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected) { throw "Protected Bridge source hash verification failed." } +} +Assert-FileHash $nodeSource ${psLiteral(nodeHash)} +Assert-FileHash $launcherSource ${psLiteral(launcherHash)} +Assert-FileHash $runtimeSource ${psLiteral(runtimeHash)} +${nativeFileAssertions("$nativeSource", nativeFiles)} +New-Item -ItemType Directory -Path $runtimeTarget -Force | Out-Null +New-Item -ItemType Directory -Path $nativeTarget -Force | Out-Null +Copy-Item -LiteralPath $nodeSource -Destination $nodeTarget -Force +Copy-Item -LiteralPath $launcherSource -Destination $launcherTarget -Force +Copy-Item -LiteralPath $runtimeSource -Destination (Join-Path $runtimeTarget 'bridge.mjs') -Force +Copy-Item -Path (Join-Path $nativeSource '*') -Destination $nativeTarget -Recurse -Force +Assert-FileHash $nodeTarget ${psLiteral(nodeHash)} +Assert-FileHash $launcherTarget ${psLiteral(launcherHash)} +Assert-FileHash (Join-Path $runtimeTarget 'bridge.mjs') ${psLiteral(runtimeHash)} +${nativeFileAssertions("$nativeTarget", nativeFiles)} +$nodeVersion = (& $nodeTarget --version 2>$null | Select-Object -First 1) +if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^v?(2[0-9]|[3-9][0-9])\\.') { throw 'The protected Node.js runtime is not compatible.' } +$administrators = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +function Protect-BridgeItem([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force + $isDirectory = $item.PSIsContainer + $acl = if ($isDirectory) { [System.Security.AccessControl.DirectorySecurity]::new() } else { [System.Security.AccessControl.FileSecurity]::new() } + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administrators) + $inherit = if ($isDirectory) { [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' } else { [System.Security.AccessControl.InheritanceFlags]::None } + $propagation = [System.Security.AccessControl.PropagationFlags]::None + $allow = [System.Security.AccessControl.AccessControlType]::Allow + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($administrators, 'FullControl', $inherit, $propagation, $allow)) + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($system, 'FullControl', $inherit, $propagation, $allow)) + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($currentUser, 'ReadAndExecute', $inherit, $propagation, $allow)) + Set-Acl -LiteralPath $Path -AclObject $acl +} +Protect-BridgeItem $secureRoot +Get-ChildItem -LiteralPath $secureRoot -Recurse -Force | ForEach-Object { Protect-BridgeItem $_.FullName } +$action = New-ScheduledTaskAction -Execute $nodeTarget -Argument (${psLiteral(`"${secureLauncher}" "${dataRoot}"`)}) -WorkingDirectory $secureRoot +$principal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName ${psLiteral(taskName)} -Action $action -Principal $principal -Settings $settings -Force | Out-Null +`; + await runElevated(script); + } + + async function start() { + await execute("schtasks.exe", ["/Run", "/TN", taskName], { timeout: 5000, windowsHide: true }); + } + + async function stop() { + try { + await execute("schtasks.exe", ["/End", "/TN", taskName], { timeout: 7000, windowsHide: true }); + } catch { + // A stopped or absent task is handled by the caller's loopback health verification. + } + } + + async function uninstall() { + const script = `$ErrorActionPreference = 'Stop' +$task = Get-ScheduledTask -TaskName ${psLiteral(taskName)} -ErrorAction SilentlyContinue +if ($task) { Stop-ScheduledTask -InputObject $task -ErrorAction SilentlyContinue; Unregister-ScheduledTask -InputObject $task -Confirm:$false } +if (Test-Path -LiteralPath ${psLiteral(secureRoot)}) { Remove-Item -LiteralPath ${psLiteral(secureRoot)} -Recurse -Force } +`; + await runElevated(script); + } + + return { status, deploy, start, stop, uninstall, taskName, secureRoot, secureNode, secureLauncher }; +} diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html b/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html index 038564d..4904a0f 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html @@ -9,37 +9,34 @@ html, body { margin: 0; background: transparent; color: #e8e8e8; font-size: 13px; } * { box-sizing: border-box; } main { padding: 12px 14px 10px; } - h1 { margin: 0 0 5px; font-size: 15px; font-weight: 650; } - .intro { margin: 0 0 10px; color: #aeb3ba; line-height: 1.35; } - .panel { border: 1px solid rgba(255,255,255,.12); border-radius: 8px; overflow: hidden; background: rgba(255,255,255,.035); } - .row { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; min-height: 37px; padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,.08); } + h1 { margin: 0 0 5px; font-size: 15px; } + .intro, .message, .info-body { color: #aeb3ba; line-height: 1.4; } + .intro { margin: 0 0 10px; } + .panel, details { border: 1px solid rgba(255,255,255,.12); border-radius: 8px; background: rgba(255,255,255,.035); } + .row { display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.08); } .row:last-child { border-bottom: 0; } - .value { display: inline-flex; align-items: center; gap: 6px; color: #aeb3ba; text-align: right; } - .dot { width: 8px; height: 8px; border-radius: 50%; background: #72777f; box-shadow: 0 0 0 2px rgba(114,119,127,.15); } - .value.good { color: #75d6a3; } - .value.good .dot { background: #42c985; box-shadow: 0 0 0 2px rgba(66,201,133,.15); } - .value.warn { color: #f0bc68; } - .value.warn .dot { background: #e6a84b; box-shadow: 0 0 0 2px rgba(230,168,75,.15); } - .value.bad { color: #ef8585; } - .value.bad .dot { background: #e45f5f; box-shadow: 0 0 0 2px rgba(228,95,95,.15); } - .message { min-height: 18px; margin: 8px 1px 4px; color: #aeb3ba; line-height: 1.35; } - .message.error { color: #ef8585; } - .message.success { color: #75d6a3; } + .value { display: inline-flex; align-items: center; gap: 6px; text-align: right; } + .dot { width: 8px; height: 8px; border-radius: 50%; background: #72777f; } + .good { color: #75d6a3; } .good .dot { background: #42c985; } + .warn { color: #f0bc68; } .warn .dot { background: #e6a84b; } + .bad, .error { color: #ef8585; } .bad .dot { background: #e45f5f; } + .success { color: #75d6a3; } + .message { min-height: 18px; margin: 8px 1px 4px; } .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 8px; } button { min-height: 33px; border: 1px solid rgba(255,255,255,.14); border-radius: 6px; color: #f3f3f3; background: rgba(255,255,255,.08); cursor: pointer; } - button:hover:not(:disabled) { background: rgba(255,255,255,.13); } - button.primary { border-color: #3f78bd; background: #2867ad; } - button.primary:hover:not(:disabled) { background: #3276bf; } - button.danger { grid-column: 1 / -1; color: #ef9a9a; border-color: rgba(228,95,95,.35); background: rgba(228,95,95,.08); } - button.danger:hover:not(:disabled) { background: rgba(228,95,95,.16); } - button:disabled { cursor: default; opacity: .45; } - details.info { margin-top: 10px; border: 1px solid rgba(255,255,255,.1); border-radius: 7px; background: rgba(255,255,255,.025); } - details.info summary { padding: 9px 10px; color: #d5d8dc; cursor: pointer; font-weight: 600; } - .info-body { padding: 0 10px 10px; color: #9fa5ad; line-height: 1.45; } - .info-body p { margin: 7px 0; } - .info-body code { display: block; margin: 7px 0; padding: 7px 8px; overflow-wrap: anywhere; border-radius: 5px; color: #dce7f6; background: rgba(0,0,0,.28); user-select: text; } - .runtime { color: #c7d3e2; } - .signature-note { color: #e3b971; } + button.primary { background: #2867ad; } button.danger { grid-column: 1 / -1; color: #ef9a9a; } + button:disabled { opacity: .45; cursor: default; } + .setting { margin-top: 10px; padding: 9px 10px; } + .setting label { display: grid; grid-template-columns: auto 1fr; gap: 9px; align-items: start; cursor: pointer; } + .setting input { margin-top: 3px; } + .setting strong, .setting small { display: block; } + .setting small { margin-top: 3px; color: #aeb3ba; line-height: 1.35; } + .model-setting label { display: block; margin: 8px 0 4px; } + .model-setting input, .model-setting select { width: 100%; padding: 6px; color: inherit; background: #25272b; border: 1px solid #60656d; border-radius: 4px; } + .model-setting :disabled { opacity: .45; } + details { margin-top: 10px; } summary { padding: 9px 10px; cursor: pointer; font-weight: 600; } + .info-body { padding: 0 10px 10px; } + .info-body code { display: block; margin: 7px 0; padding: 7px 8px; border-radius: 5px; background: rgba(0,0,0,.28); user-select: text; } .version { margin-top: 7px; color: #777e87; font-size: 11px; text-align: right; } @@ -47,6 +44,38 @@

+
@@ -54,13 +83,19 @@

- +
-
+
+ +
+

@@ -68,153 +103,260 @@

-
+

-

- brew install node -

-

+

+

diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json index d62042b..5199c74 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Controle tarefas e ações do Codex Desktop com a Ulanzi D200 Series no Ulanzi Studio.", - "Description": "Transforme a Ulanzi D200 Series numa superfície de controlo dedicada ao Codex Desktop através do Ulanzi Studio.\n\nFUNCIONALIDADES\n- Mostra cinco tarefas recentes do Codex com estado em tempo real e abre exatamente a tarefa selecionada.\n- Disponibiliza as ações Fast, Usage, Pin, New, Fork, Steer, Mic e Submit.\n- Use o Encoder Latest Task & Scroll para abrir a task1 e deslocar o Codex.\n- Instale, repare, inicie e verifique o Codex Bridge na página de configuração de cada Action.\n- Mantém a comunicação local através da Codex Bridge.app e de endpoints apenas de loopback.\n\nAMBIENTE DE INSTALAÇÃO\n- macOS 13 ou posterior\n- Codex Desktop\n- Ulanzi Studio 3.0.1 ou posterior\n- Ulanzi D200 Series ligada no Ulanzi Studio\n- Node.js 20 ou posterior apenas para configuração manual pelo repositório\n\nINSTALAÇÃO COM LLM / AGENT\nIntroduza no seu Agent:\nInstala o plugin do Ulanzi Studio por mim: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nINSTALAÇÃO MANUAL\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro e abra a pasta do repositório.\n2. Feche o Ulanzi Studio.\n3. Execute: npm install\n4. Execute: npm run install:plugin\n5. Execute: npm run setup\n6. Reabra o Ulanzi Studio, feche o Codex Desktop e inicie ~/Applications/Codex Bridge.app.\n7. Adicione as ações Codex Micro às teclas pretendidas no Ulanzi Studio.\n8. Selecione uma Action e verifique a instalação, o serviço e o CDP na página do Codex Bridge.\n\nIMPORTANTE\nAo usar o Codex Micro, inicie sempre o Codex através de ~/Applications/Codex Bridge.app. Não abra diretamente o Codex Desktop.\nComando de início: open ~/Applications/Codex\\ Bridge.app", + "Description": "Controle o Codex Desktop com a Ulanzi D200 Series no Ulanzi Studio. Suporta cinco tarefas recentes, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit, três predefinições de modelo GPT-5.6 e navegação por Encoder no Windows 10 ou posterior e macOS 13 ou posterior. No Windows, a Bridge encontra Stable ou Beta dinamicamente; as ações de escrita usam um token local e o CDP permanece em loopback. Para configurar pelo repositório execute npm install, npm run install:plugin e npm run setup.", "Actions": [ { "Name": "Tarefa Codex 1", "Tooltip": "Abre a posição 1 das tarefas recentes do Codex e mostra o estado em tempo real." }, { "Name": "Tarefa Codex 2", "Tooltip": "Abre a posição 2 das tarefas recentes do Codex e mostra o estado em tempo real." }, @@ -16,12 +16,28 @@ { "Name": "Bifurcar tarefa Codex", "Tooltip": "Bifurca a tarefa ativa do Codex numa nova tarefa." }, { "Name": "Orientar Codex", "Tooltip": "Envia o texto atual do editor como orientação para a tarefa em execução." }, { "Name": "Microfone Codex", "Tooltip": "Alterna a ação de microfone do Codex." }, - { "Name": "Enviar para o Codex", "Tooltip": "Envia ou coloca em fila o texto atual do editor do Codex." } + { "Name": "Enviar para o Codex", "Tooltip": "Envia ou coloca em fila o texto atual do editor do Codex." }, + { "Name": "Codex Sol Alto", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Sol com raciocínio alto." }, + { "Name": "Codex Luna Máximo", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Luna com raciocínio máximo." }, + { "Name": "Codex Sol Médio", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Sol com raciocínio médio." } ], "Localization": { + "ModelSettingsTitle": "Atalho de modelo (apenas esta tecla)", + "PresetLabel": "Opções padrão", + "PresetHelp": "Selecione uma opção padrão ou personalize abaixo.", + "PresetCustom": "Padrão / Personalizado", + "ModelLabel": "Modelo", + "EffortLabel": "Nível de raciocínio", + "ModelHelp": "Introduza o nome exato do modelo no menu Codex ou um alias de ID oficial conhecido. Aceita nomes de apresentação personalizados. Vazio usa o modelo predefinido desta tecla. As alterações são guardadas automaticamente.", + "EffortHelp": "Selecione apenas um nível realmente disponível para este modelo no Codex. Opções não suportadas falham sem alternativa. Vazio usa o nível predefinido desta tecla.", + "EffortDefault": "Predefinição", + "ModelDefaults": "Predefinições: {model} ({id}) / {effort}", + "ModelInvalid": "Máximo de 100 caracteres: letras, números, espaços ou . _ + ( ) / : -. Não são permitidos caracteres de controlo nem pontuação de código/HTML.", + "EffortInvalid": "Selecione um nível de raciocínio da lista.", + "ModelLoading": "A restaurar as definições desta tecla…", "BridgeSetup": "Configuração do Codex Bridge", "BridgeSetupDescription": "Instale, inicie e verifique a ligação local ao Codex.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Serviço Bridge", "CDPConnection": "Ligação CDP", "InstallRepair": "Instalar / Reparar", @@ -30,19 +46,21 @@ "UninstallingBridge": "A desinstalar…", "UninstallBridgeDone": "O Codex Bridge foi desinstalado. O plugin Ulanzi e o Codex Desktop foram mantidos.", "UninstallBridgeConfirm": "Desinstalar o Codex Bridge e o respetivo serviço em segundo plano? O plugin Ulanzi e o Codex Desktop não serão removidos.", - "BridgeAppInfoTitle": "O que é a Codex Bridge.app?", - "BridgeAppDescription": "A Codex Bridge.app é um iniciador local. Se o Codex já estiver aberto, pede-lhe que termine normalmente e inicia o executável oficial do Codex Desktop com argumentos CDP limitados ao endereço de loopback.", - "BridgeAppSafety": "O iniciador não altera o Codex Desktop, contas, projetos ou ficheiros do utilizador e não expõe o CDP fora deste Mac. Não executa outras operações privilegiadas ou destrutivas. O serviço Bridge separado trata do estado local e da comunicação com as Actions.", + "BridgeAppInfoTitle": "Como é que o Codex Bridge inicia o Codex?", + "BridgeAppDescription": "No Windows, o Codex Bridge encontra Stable ou Beta dinamicamente e inicia a aplicação oficial com argumentos CDP de loopback; no macOS usa a aplicação wrapper local.", + "BridgeAppSafety": "O iniciador não altera o Codex Desktop, contas, projetos ou ficheiros do utilizador. O CDP permanece local e as ações de escrita da Bridge exigem um token local.", "BackgroundActivityTitle": "Atividade em segundo plano e Node.js", - "LaunchAgentDescription": "O LaunchAgent inicia o serviço local Codex Bridge ao iniciar sessão e reinicia-o se terminar. O serviço escuta apenas neste Mac através de um endereço de loopback.", + "LaunchAgentDescription": "O Windows usa uma tarefa a pedido, exclusiva do utilizador atual e com privilégios máximos; os arranques normais são silenciosos. O macOS usa um LaunchAgent do utilizador.", "NodeSelectionStrategy": "Instalar / Reparar dá prioridade ao Node.js 20 ou posterior do ambiente de início de sessão, PATH, Homebrew ou caminhos comuns. Só usa o Node incluído no Ulanzi Studio como alternativa.", "NodeInstallRecommendation": "Recomendado: instale a versão mais recente do Node.js no Terminal e execute novamente Instalar / Reparar:", - "UlanziNodeSignatureNotice": "Ao usar o Node do Ulanzi Studio como alternativa, os Itens em segundo plano do macOS podem mostrar uma atividade com a assinatura do Ulanzi Studio. É o serviço Codex Bridge a usar o executável Node do Ulanzi Studio.", + "UlanziNodeSignatureNotice": "O Windows executa apenas código Bridge protegido e verificado por hash. Instalar, atualizar e desinstalar requerem UAC; a tarefa nunca guarda o token local.", "CurrentNodeRuntime": "Runtime atual: {runtime} {version}", "SystemNode": "Node.js do sistema", "UlanziNode": "Node do Ulanzi Studio", "UnknownNode": "Não instalado ou não registado", "CheckAgain": "Verificar novamente", + "ForceFocus": "Forçar foco (Windows)", + "ForceFocusDescription": "As recusas de foco do Windows por acesso negado já recebem uma simulação automática e protegida de Alt. Ative esta opção para a usar também noutras recusas de foco. Não adiciona hooks, sondagens ou processos auxiliares em inatividade.", "InstallationGuide": "Guia de instalação" } } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs index d0e09e2..dd7d036 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -1,27 +1,78 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { WebSocketServer } from "ws"; const packageRootUrl = new URL("..", import.meta.url); const manifest = JSON.parse(await readFile(new URL("manifest.json", packageRootUrl))); +const pluginBundleSource = await readFile(new URL("dist/app.js", packageRootUrl), "utf8"); +const bundledBridgeSource = await readFile(new URL("installer/bridge.mjs", packageRootUrl)); +const bundledBridgeHash = createHash("sha256") + .update(bundledBridgeSource) + .digest("hex"); +const nativeRuntimeManifest = JSON.parse(await readFile( + new URL("installer/native-runtime/native-runtime.json", packageRootUrl), + "utf8" +)); const navigateAction = manifest.Actions.find(action => action.UUID === "com.ulanzi.ulanzistudio.codexmicro.navigate" ); assert.deepEqual(navigateAction?.Controllers, ["Encoder"]); assert.equal(navigateAction?.Encoder?.layout, "$UA1"); -assert.equal(manifest.Version, "0.4.0"); +assert.equal(navigateAction?.Icon, "assets/icons/task-idle.svg"); +assert.equal(navigateAction?.States?.[0]?.Image, "assets/icons/task-idle.svg"); +assert.equal(manifest.Version, "0.6.1"); +assert.match(String(bundledBridgeSource), /BRIDGE_VERSION = .*"0\.6\.1"/); +assert.doesNotMatch(String(bundledBridgeSource), /process\.env\.CODEX_BRIDGE_VERSION/); +assert.match(pluginBundleSource, /scheduleBridgeReconcileRetry\(\);/); +assert.match(pluginBundleSource, /bridgeReconcilePromise === running\) bridgeReconcilePromise = null/); +assert.match(pluginBundleSource, /ChatGPT is unavailable; starting the current installed app build/); +assert.match(pluginBundleSource, /await ensureCodexDesktopReady\(\);/); assert.equal(manifest.Software?.MinVersion, "3.0.1"); assert.equal(manifest.OS?.find(item => item.Platform === "mac")?.MinimumVersion, "13.0"); +assert.equal(manifest.OS?.find(item => item.Platform === "windows")?.MinimumVersion, "10.0"); +const taskActions = manifest.Actions.filter(action => /\.task[1-5]$/.test(action.UUID)); +assert.ok(taskActions.every(action => action.Icon === "assets/icons/task-idle.svg")); +assert.ok(taskActions.every(action => action.States?.[0]?.Image === "assets/icons/task-idle.svg")); +assert.ok((await readFile(new URL("assets/icons/task-idle.svg", packageRootUrl))).length > 0); assert.ok( manifest.Actions.every(action => action.PropertyInspectorPath === "property-inspector/setup.html"), "every action must expose the shared Bridge setup inspector" ); +const modelActions = manifest.Actions.slice(-3); +assert.deepEqual( + modelActions.map(action => action.UUID), + [ + "com.ulanzi.ulanzistudio.codexmicro.model-sol-high", + "com.ulanzi.ulanzistudio.codexmicro.model-luna-max", + "com.ulanzi.ulanzistudio.codexmicro.model-sol-medium" + ] +); +for (const action of modelActions) { + assert.deepEqual(action.Controllers, ["Keypad"]); + assert.equal(action.DisableAutomaticStates, true); + assert.equal(action.States?.[0]?.Image, action.Icon); + assert.ok((await readFile(new URL(action.Icon, packageRootUrl))).length > 0, `${action.Icon} must exist`); +} const setupInspector = await readFile(new URL("property-inspector/setup.html", packageRootUrl), "utf8"); assert.match(setupInspector, /Codex Bridge Setup/); assert.match(setupInspector, /Install \/ Repair/); assert.match(setupInspector, /bridgeSetup/); +assert.match(setupInspector, /id="forceFocus"/); +assert.match(setupInspector, /getGlobalSettings/); +assert.match(setupInspector, /setGlobalSettings/); +assert.equal( + setupInspector.match(/\bsetInterval\s*\(/g)?.length, + 1, + "force focus must not add an Inspector idle timer beyond the existing Bridge status poll" +); +assert.doesNotMatch( + String(bundledBridgeSource), + /SetWindowsHookEx|RegisterHotKey/, + "force focus must stay request-scoped and must not install a Windows hook or global hotkey" +); const setupScript = setupInspector.match(/