diff --git a/.gitignore b/.gitignore index f05e5e7..3c8b156 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ .env.* !.env.example *.tgz + +.loader-*/ +module-data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c890aa8..a1bf0b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add independently packable Codex Plugin Loader with desktop/CLI entry, per-module service processes, isolated renderer globals, scoped RPC/events and bounded cleanup. +- Run Tags through the Loader SDK with local settings, catalog and search services. +- Verify service crash containment, cancellation, multiple windows, reload and independent package consumption. + +## Unreleased + - Scope sidebar tag counts to mounted rows so counts match the available sidebar filter; retain the complete catalog in the dashboard. - Filter every mounted sidebar row, including tasks absent from the local catalog and duplicate appearances. diff --git a/README.md b/README.md index f81c94a..4b312f9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ npx @c0sc0s/codex-tags@latest Open **Codex → Plugins → Codex Tags** and review/trust **SessionStart**, **UserPromptSubmit**, and **SessionEnd**. -**Next time:** open `~/Applications/Codex Tags.app` and pin it to the Dock. It launches the official app, not a second Codex installation. No automatic restart or launch supervisor. Naming is agent-assisted, not a guaranteed title rewrite. +**Next time:** open `~/Applications/Codex Tags.app` and pin it to the Dock. This is the Codex Plugin Loader entry: it starts the official app and loads configured modules, including Tags. The existing app path is retained for Dock compatibility. No automatic restart or launch supervisor. Naming is agent-assisted, not a guaranteed title rewrite.
Develop or install from source @@ -85,3 +85,5 @@ The fast loop requires an already activated, debug-enabled app. No HMR server is - [Roadmap](docs/roadmap.md) · [Changelog](CHANGELOG.md) Not affiliated with or endorsed by OpenAI. No open-source license is currently granted (`UNLICENSED`). + +See [Codex Plugin Loader](docs/plugin-loader.md) for the independent package and module SDK: Loader manages CDP, isolated service processes and RPC/events; modules implement business behavior. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4898def..67eafed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@ npx @c0sc0s/codex-tags@latest 打开 **Codex → Plugins → Codex Tags**,检查并信任/启用 **SessionStart、UserPromptSubmit、SessionEnd**。 -**下次启动:** 使用 `~/Applications/Codex Tags.app`,可拖到 Dock 固定。它启动的是官方 App,不是第二套 Codex;不会自动重启或安装启动守护进程。命名由 Agent 辅助完成,不保证每次确定性改名。 +**下次启动:** 使用 `~/Applications/Codex Tags.app`,可拖到 Dock 固定。它是 Codex Plugin Loader 的入口:启动官方 App 后,由 Loader 加载 Tags 等已配置模块,保留原路径以兼容 Dock;不会自动重启或安装启动守护进程。命名由 Agent 辅助完成,不保证每次确定性改名。
从源码开发或安装 @@ -85,3 +85,5 @@ npm run test:package - [后续规划](docs/roadmap.md) · [更新记录](CHANGELOG.md) 本项目独立开发,不隶属于 OpenAI,也未经其背书。目前没有开放源代码许可授权(`UNLICENSED`)。 + +独立基础包与模块开发接口见 [Codex Plugin Loader](docs/plugin-loader.md):Loader 管理 CDP、独立服务进程和 RPC/事件,业务模块通过 SDK 实现功能。 diff --git a/docs/architecture.md b/docs/architecture.md index d27407c..737dac2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,56 +1,32 @@ # Architecture -[Development](development.md) · [Protocol](protocol.md) · [Roadmap](roadmap.md) +[Development](development.md) · [Protocol](protocol.md) · [Loader SDK](plugin-loader.md) -Codex Tags is a reversible enhancement, not a Codex fork. +The desktop launcher starts Codex Plugin Loader. Loader loads configured modules into the official Codex app without modifying its signed bundle. -## Ownership - -| Layer | Owns | Must not own | -| --- | --- | --- | -| CLI / manager | Installation, activation, removal, diagnostics | Session naming or UI behavior | -| Dedicated launcher | Explicitly start the official app with loopback debugging | Monitoring launches or restarting a running app | -| Controller services | CDP targets, settings, catalog, search index | DOM selectors or UI state | -| Injected UI | Presentation, interactions, reversible decoration | Filesystem access or durable settings | -| Host adapter | Codex selectors and native row bindings | Classification policy | -| Hooks / skills | Live classification guidance for the agent | Direct transcript/title database writes | - -## Data flow +| Layer | Responsibility | +| --- | --- | +| Tags CLI / installer | Package installation, module configuration and diagnostics | +| Desktop launcher | Invoke the standalone Loader CLI | +| Loader | Owned CDP endpoint, isolated renderer world, per-plugin service processes, RPC/events, lifecycle and cleanup | +| Tags service | Local settings, session catalog, search index and navigation validation | +| Tags renderer | UI, interactions and reversible DOM decoration | +| Codex DOM adapter | All private host selectors and native row bindings | +| Hooks / skills | Agent naming guidance using the saved tag definitions | ```text -Codex state database ──read-only──▶ SessionCatalog ──metadata──┐ -Codex session JSONL ──read-only──▶ SQLite FTS5 ──snippets──────┤ - ▼ -settings.json ◀── SettingsRepository ◀── ControllerRouter ⇄ injected UI - │ │ - └── hook / naming skills → Codex agent └── host adapter +Desktop entry → Loader → Tags renderer ⇄ RPC/events ⇄ Tags service + │ ├─ settings.json + Codex DOM adapter ├─ read-only session catalog + └─ local SQLite search index ``` -The active local catalog is independent of sidebar expansion. Remote-only sessions remain best-effort DOM discovery. Schema mismatch reports an incomplete catalog and falls back to visible/cached rows. Conversation text stays in the local index; only bounded matching snippets cross the bridge. - -## Resource boundaries - -- **SettingsRepository:** normalization, migration, serialized atomic writes. Renderer storage is only a cache; concurrent windows currently use last-writer-wins. -- **SessionCatalog:** read-only schema-checked metadata, changed snapshots about every 5 seconds. Excludes subagents and internal guardian reviews using `thread_source` and legacy `source` provenance; standalone agent-created tasks remain visible. Filtered snapshots prune cached local entries, keeping counts and search scope consistent. -- **SessionRegistry:** joins metadata and temporary native bindings using canonical local IDs. -- **SessionSearchIndex:** incremental FTS5 indexing, with discovery/refresh about every 30 seconds and bounded text extraction. -- **CodexProcess / TargetRegistry:** process ownership, target discovery, versioned injection and client cleanup. -- **HostLifecycle:** coalesced native changes and pointer/input-safe refresh. -- **DashboardView:** modal controls and interaction state; Preact result rows. Background updates preserve IME, menus, drafts and scroll. -- **ControllerRouter:** validated intent-shaped messages. Navigation requires a UUID present in the current catalog. - -## Stack and evolution - -Browser: strict TypeScript, Preact result components, bundled Motion, esbuild IIFE. Controller/CLI: Node ESM and better-sqlite3. No remotely loaded runtime scripts. - -The dashboard mixes imperative controls and Preact rows. Migrate to a single Preact root when interaction complexity justifies it; do not introduce a general framework solely for uniformity. - -## Safety +`runtime/src/plugin-loader` is independently packable and has no Tags or SQLite dependency. Its public module contexts expose business RPC, events and resource lifecycle; business modules never construct CDP commands or injection expressions. Services run in separate Node processes. Renderer globals live in a named isolated world, sharing the app's DOM and renderer thread. See [Loader architecture](plugin-loader.md) for contracts and failure behavior. -Native title DOM and listeners have restoration paths. Missing host capabilities should disable the enhancement without damaging native navigation. The signed bundle, session records and authentication data remain untouched. +Tags registers `runtime/dist/injected.js` and `tags-service.mjs` in `loader.json`. On activation the renderer requests its configuration; after readiness the service sends settings/catalog snapshots. Each connected window has an instance identity, so replacement and reload cannot receive another instance's outstanding replies. -`Codex Tags.app` explicitly launches the official app with loopback debugging. If a non-debuggable Codex is already open, activation stops with instructions to quit it manually. No launch supervisor is installed; upgrades unload and remove the legacy LaunchAgent. Updates stop old code before replacing files and are retryable, not automatically rolled back. +`SettingsRepository` normalizes and atomically serializes writes. Windows use last-writer-wins; localStorage is a cache. Hooks read the same settings file. `SessionCatalog` reads schema-checked local metadata independently of sidebar expansion; schema mismatch reports incompleteness. `SessionRegistry` joins that metadata with temporary DOM bindings. `SessionSearchIndex` refreshes about every 30 seconds; catalog snapshots refresh about every five seconds. Only metadata and bounded matching snippets enter the renderer, and conversation content stays local. -Private DOM/schema/CDP dependencies cannot be guaranteed across future Codex releases. Keep them at adapter/process/catalog boundaries and verify [compatibility](compatibility.md). +`HostLifecycle` coalesces native changes. `DashboardView` combines imperative controls and Preact result rows, preserving input composition, drafts, menus and scroll during updates. `ControllerRouter` validates Tags message envelopes; navigation requires a UUID in the current catalog. Private selectors stay in `injected/codex-dom-adapter.ts`. -For every new capability, identify its owner, command/message, failure isolation, cleanup and tests. Reuse shared normalization; never add another settings store, selectors outside the adapter, or complete-transcript transfer. +Browser code uses strict TypeScript, Preact, bundled Motion and an esbuild IIFE. Node services use ESM and better-sqlite3. Modules register cleanup as they acquire resources. The Loader contains service-process failures; renderer plugins remain trusted code sharing DOM and CPU. No remote runtime assets are loaded. Signed app files, sessions and authentication data remain untouched. diff --git a/docs/development.md b/docs/development.md index c95dc40..3e15628 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,10 +24,11 @@ Edit this checkout, never installed files in Application Support or the plugin c npm run dev:apply ``` -This runs build → repository `install` → `apply` against an already debug-enabled app. No watcher/HMR is provided. Unlike public CLI installation, repository `node scripts/manage.mjs install` only refreshes files and the dedicated launcher and removes the legacy supervisor; it does not register/enable the plugin or activate the UI. +This runs build → repository `install` → `apply` against an already debug-enabled app. No watcher/HMR is provided. Unlike public CLI installation, repository `node scripts/manage.mjs install` stops loaded code, refreshes files and the Loader launcher, and removes the legacy supervisor; it does not register/enable the plugin or activate the UI. -- Browser changes: bump `RUNTIME_VERSION` in `runtime/src/inject-expression.mjs`, then build/install/apply. -- Controller changes: install/apply so the controller uses the updated installed modules. +- Browser changes: bump `RUNTIME_VERSION` in `runtime/src/tags-plugin.mjs`, then build/install/apply. +- Loader kernel changes: bump `LOADER_VERSION` and its package version so the isolated-world bootstrap is replaced after unloading. +- Service/Loader changes: install/apply to stop old modules before replacing files and start the updated Loader. - Hook/skill changes: refresh the plugin cachebuster and use the public installer; renewed hook review may be required. - Never edit `runtime/dist/injected.js` manually; commit the generated bundle with source changes. @@ -37,8 +38,10 @@ This runs build → repository `install` → `apply` against an already debug-en | --- | --- | | CLI and installation | `bin/codex-tags.mjs`, `scripts/{cli-options,manager-core}.mjs` | | Readiness / mutation lock | `scripts/{health,lifecycle-lock}.mjs` | -| App lifecycle / CDP | `runtime/src/{codex-process,cdp-client,runtime-target-registry}.mjs` | -| Controller / bridge | `runtime/src/{controller,controller-router,protocol}.mjs` | +| Independent loader / CDP | `runtime/src/plugin-loader/` (standalone npm package) | +| Tags module metadata | `runtime/src/tags-plugin.mjs` | +| Tags services / bridge | `runtime/src/{tags-service,controller-router,protocol}.mjs` | +| Tags CLI compatibility | `runtime/src/controller.mjs` | | Catalog / search | `runtime/src/{session-catalog,content-index,search-index}.mjs` | | Saved definitions | `runtime/src/{settings-repository,tag-settings}.mjs` | | Host selectors | `runtime/src/injected/codex-dom-adapter.ts` | @@ -66,9 +69,9 @@ App QA requires an already injected app. It checks IME, search, menu persistence Start with `node bin/codex-tags.mjs doctor --json`. -Logs under `~/Library/Application Support/Codex Sidebar Tags/`: `controller.log`, `launcher.log`. Installation metadata is in `install.json`. Never share credentials or conversation text in diagnostics. +Logs under `~/Library/Application Support/Codex Sidebar Tags/`: `.loader-/loader.log`, `launcher.log`. Installation metadata is in `install.json`. Never share credentials or conversation text in diagnostics. -Renderer diagnostics: `window.__codexSidebarTags.status()`, `debug()`, `dispose()`. +Renderer diagnostics in the `codex-plugin-loader` isolated world: `window.__codexSidebarTags.status()`, `debug()`, `dispose()`. | Symptom | Check | | --- | --- | @@ -81,4 +84,8 @@ Renderer diagnostics: `window.__codexSidebarTags.status()`, `debug()`, `dispose( Testing overrides: `CODEX_TAGS_INSTALL_DIR` (runtime), `CODEX_TAGS_APPLICATIONS_DIR` (launcher), `CODEX_TAGS_CDP_PORT` (default 9341), `CODEX_HOME` (Codex data), `CODEX_TAGS_SETTINGS_PATH` (hook settings), `CODEX_TAGS_STATE_DIR` (hook markers). They do not isolate every macOS/plugin side effect; unit tests use injected fake process runners. -Use `node bin/codex-tags.mjs off` to disable all components while keeping settings. Uninstall only with explicit user permission. +Use `node bin/codex-tags.mjs off` to disable Tags while keeping settings and other Loader modules. Uninstall only with explicit user permission. + +## Loader development + +See [Loader architecture and entry](plugin-loader.md). The package under `runtime/src/plugin-loader` has no Tags or SQLite dependency. Changes there are copied by the repository installer and included in package smoke. Its Node tests run in `npm test`. diff --git a/docs/distribution.md b/docs/distribution.md index 50c30e1..c94731a 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -4,7 +4,7 @@ ## Contract -The npm package `@c0sc0s/codex-tags` carries the CLI, prebuilt UI bundle, local controller, naming hooks and three English skills. Its production dependency is native SQLite. Users need macOS, Node.js 22+, and the official Codex app with plugin support. +The npm package `@c0sc0s/codex-tags` carries the CLI, prebuilt UI bundle, independent Loader and Tags service module, naming hooks and three English skills. Its production dependency is native SQLite. Users need macOS, Node.js 22+, and the official Codex app with plugin support. Installation: @@ -13,7 +13,7 @@ Installation: The CLI uses official plugin commands, never private trust records or authorization bypasses. Installing only the plugin does not provide the native runtime needed for UI injection. -For future launches, open `~/Applications/Codex Tags.app` (pin it to the Dock). This small launcher starts the official app with loopback debugging; it never monitors or restarts a running app. If a non-debuggable Codex is open, it shows a prompt to quit it manually. The official entry is unmodified. The controller only maintains UI injection while the explicitly activated app runs; it does not relaunch Codex. Keep the activation Node installation available; rerun the CLI after replacing Node versions. +For future launches, open `~/Applications/Codex Tags.app` (pin it to the Dock). This small launcher invokes Loader with `loader.json`; Loader starts the official app with loopback debugging; it never monitors or restarts a running app. If a non-debuggable Codex is open, it shows a prompt to quit it manually. The official entry is unmodified. The Loader only maintains configured modules while the explicitly activated app runs; it does not relaunch Codex. Keep the activation Node installation available; rerun the CLI after replacing Node versions. ## Installed files @@ -21,7 +21,7 @@ For future launches, open `~/Applications/Codex Tags.app` (pin it to the Dock). | --- | --- | | `~/Library/Application Support/Codex Sidebar Tags/` | Runtime, UI bundle, SQLite dependency, settings, index, logs | | Its `plugin-marketplace/` directory | CLI-owned plugin snapshot and marketplace | -| `~/Applications/Codex Tags.app` | Small shell launcher, not a second Codex app | +| `~/Applications/Codex Tags.app` | Loader desktop entry; original path/identity retained for Dock compatibility | | Codex plugin cache/data | Registered plugin payload and hook markers | The signed app, authentication data and transcript files are never patched. Search/catalog reads stay local; only bounded snippets enter the injected UI. CDP remains a powerful trusted-local-machine capability. @@ -30,8 +30,8 @@ The signed app, authentication data and transcript files are never patched. Sear - **install / on / enable:** preflight → remove legacy supervisor → stop old controller → copy runtime/plugin → register → activate → verify. - **update:** same flow using the invoked package version. Use `npx …@latest update` to fetch the newest; an old globally installed CLI cannot self-upgrade. -- **off / disable / restore:** stop controller and remove any legacy supervisor, restore UI and remove naming plugin; retain settings/index. -- **uninstall:** remove owned runtime, plugin registration, launcher, legacy supervisor, index and logs; retain settings. +- **off / disable / restore:** disable the Tags service/renderer module, remove any legacy supervisor and naming plugin; preserve other Loader modules; retain settings/index. +- **uninstall:** refuse when other modules share the installation; otherwise stop Loader and remove owned runtime, plugin registration, launcher, legacy supervisor, index and logs; retain settings. - **uninstall --purge:** also remove settings, owned hook data and reachable renderer caches. Never deletes or renames Codex sessions. - **status / doctor:** read-only. Doctor exits nonzero when not ready; a closed app or disabled installation is expected to be non-ready. @@ -63,3 +63,7 @@ The first public release is an early release with pending manual acceptance expl `prepublishOnly` runs source verification and package smoke. macOS CI checks Node 22/24 and bundle drift; it cannot replace GUI/hook acceptance. Use a configured trusted CI publisher if provenance is needed. No open-source license is currently granted (`UNLICENSED`). Public distribution alone does not grant one; the owner must choose a license if open-source distribution is intended. + +## Independent Loader package + +`runtime/src/plugin-loader` is also a self-contained npm package, `@c0sc0s/codex-plugin-loader`, with no production dependencies. Run `npm pack ./runtime/src/plugin-loader` to produce its tarball. Its CLI is the explicit standalone startup entry; it does not install naming hooks, create Tags data or require the Tags controller. The Tags distribution embeds the same source, configures its renderer/service module and delegates the existing desktop entry directly to Loader. See [Loader contract](plugin-loader.md). The package exports the standalone desktop-launcher builder. Publication remains separate release work. diff --git a/docs/plugin-loader.md b/docs/plugin-loader.md new file mode 100644 index 0000000..c6bb51f --- /dev/null +++ b/docs/plugin-loader.md @@ -0,0 +1,104 @@ +# Codex Plugin Loader + +Codex Plugin Loader is a dependency-free Node.js package at `runtime/src/plugin-loader`. It owns desktop startup, the local CDP connection, plugin services and renderer lifecycle. Tags uses its public SDK; the Loader never imports Tags, SQLite or private Codex DOM selectors. + +## Architecture + +```text +Desktop launcher / Loader CLI → loader.json → Loader daemon + │ + ┌────────────────────────┴──────────────────┐ + ▼ ▼ + Service process per plugin Owned loopback CDP + Node activate(context) │ + ▲ Named isolated world + └──────── RPC / events ─────────── Renderer activate(context) + Shared Codex DOM +``` + +The microkernel owns transport and lifecycle. Business modules own their data, validation and UI. The service process boundary contains process crashes and blocking Node code. The renderer uses a named isolated JavaScript world in the main frame, keeping its globals separate from Codex while sharing the DOM. Neither mechanism is a security sandbox for untrusted plugins: services have the user's Node privileges, and renderer plugins share a renderer thread and DOM. A synchronous renderer loop can still block Codex. + +The desktop launcher directly invokes `codex-plugin-loader start --config …`. `createLauncher` creates a standalone macOS entry with caller-supplied paths. The Tags distribution places this entry at `~/Applications/Codex Tags.app`, with display name **Codex Plugin Loader**. + +## Manifest and SDK + +Paths are relative to the configuration directory; entries and symlinks must resolve inside it. IDs are unique lowercase names, up to 64 characters. `enabled` defaults to true. Each renderer bundle defines `CodexPlugin.activate`; `service` optionally names an ESM module exporting `activate`. + +```json +{ + "apiVersion": 1, + "plugins": [{ + "id": "example", + "version": "1.0.0", + "entry": "renderer.js", + "service": "service.mjs", + "config": { "label": "Example" } + }] +} +``` + +The package ships TypeScript `RendererContext`, `RendererPlugin` and `ServiceContext` contracts. Both contexts provide `id`, `config`, an abort `signal` and `onDispose(callback)`. Bundle renderer dependencies locally with esbuild `format: "iife", globalName: "CodexPlugin"`. + +```js +// service.mjs +export function activate({ rpc, events, clients }) { + rpc.handle("greeting", () => "Hello"); + clients.onConnect(id => events.publish("ready", {}, id)); +} +``` + +```js +// Renderer source, bundled into renderer.js +export async function activate({ rpc, events, onDispose }) { + const label = document.createElement("div"); + label.textContent = await rpc.call("greeting"); + onDispose(() => label.remove()); + document.body.append(label); + events.subscribe("ready", () => { label.dataset.ready = "true"; }); +} +``` + +| API | Contract | +| --- | --- | +| Renderer `rpc.call(method, payload?, {signal?, timeoutMs?}?)` | JSON request; resolves a JSON result or rejects | +| Renderer `events.subscribe(topic, handler)` | Module-local subscription; returns unsubscribe; cleared on unload | +| Service `rpc.handle(method, handler)` | Unique method; handler receives payload and `{clientId, signal}` | +| Service `events.publish(topic, payload, clientId?)` | Send to one connected client or all clients of this module | +| Service `clients.onConnect / onDisconnect` | Subscribe to renderer readiness/removal; client IDs change after replacement/reload | +| Service `stateDirectory` | Persistent `module-data/` directory; module owns its storage format | + +Renderer activation can call RPC before readiness. `onConnect` runs after activation, allowing the service to send initial snapshots after event subscriptions exist. Services should tolerate a repeated connect notification following a transient delivery failure. No raw CDP client, binding name or injection expression is exposed through either business context. Low-level package exports support infrastructure embedding and diagnostics. + +## Lifecycle and limits + +Each config/port has one background owner with a token-checked process lease. Renderer IDs also have a configuration owner. Foreign ports and conflicting owners are rejected; the Loader never kills an unrelated process or patches the official app bundle. A stale crash lease requires checking the named PID and removing the reported lease file. + +The daemon reconciles configuration and windows. Active matching renderer versions are retained; changing a module's manifest replaces it. Source-only changes require a version bump or explicit stop/start. Removal aborts renderer work, removes bindings, cancels in-flight calls and closes the service. Disposers run in reverse registration order and must be registered immediately when resources are acquired. + +- Renderer activation/individual cleanup has a five-second deadline. A timeout or failed cleanup blocks replacement until that renderer reloads. +- Each service has its own process. Service startup failure/crash is reported for that module; healthy modules continue. There is no automatic crash restart loop: disable/re-enable the module or restart Loader after fixing it. +- RPC defaults to ten seconds and accepts at most thirty seconds. Cancellation reaches the service handler's signal. Requests are limited to 64 KiB on the renderer bridge; service replies/events to 1 MiB. Each transport/service caps in-flight requests at 128. +- Shutdown gives service cleanup a bounded grace period, then kills only the owned child if it is blocked. A daemon disconnect terminates its service children. +- Connection tokens and CDP context generations prevent stale messages from entering a replacement renderer. Topics and RPC handlers are scoped per module; they are not an authorization boundary against malicious local code. + +Diagnostics report module/window and failure stage without recording request bodies or arbitrary plugin exceptions. Service stdout/stderr are suppressed. Renderer diagnostic `status()` implementations must avoid secrets and conversation content. + +## Commands and packaging + +```bash +npm pack ./runtime/src/plugin-loader +node runtime/src/plugin-loader/cli.mjs start --config ./loader.json --attach +node runtime/src/plugin-loader/cli.mjs status --config ./loader.json +node runtime/src/plugin-loader/cli.mjs remove --config ./loader.json --plugin example +node runtime/src/plugin-loader/cli.mjs stop --config ./loader.json +``` + +The packed executable is `codex-plugin-loader`. `start` starts a closed official Codex with local debugging, or reuses a compatible running app; `--attach` requires it already running with the owned endpoint. `watch` runs in the foreground. `apply` loads renderer-only bundles once. `remove --plugin` persists `enabled: false` and preserves peers. `stop` leaves Codex running. `status` imports no plugins. Commands use port 9341 unless `--port` is supplied. A running non-debuggable Codex must be quit manually. + +Tags embeds these same package sources. Its installer writes a renderer/service manifest and preserves the existing settings directory through module configuration. Disabling Tags retains other modules; uninstall refuses to remove shared infrastructure while other modules are configured. Package publication is separate from source delivery. + +Tests exercise real service processes, crash/blocking containment, cancellation, message limits, multiple modules/windows, stale replies, reload, scoped removal, ownership and independent packed consumption. Real-app QA checks the Tags UI and local RPC path; [compatibility checks](compatibility.md) cover release acceptance. + +## Design references + +The separation follows the [VS Code extension-host model](https://code.visualstudio.com/api/advanced-topics/extension-host) and [Electron's narrow IPC interface guidance](https://www.electronjs.org/docs/latest/tutorial/context-isolation). Transport uses [CDP isolated worlds](https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-createIsolatedWorld), [named-context bindings](https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#method-addBinding) and [Node child-process IPC](https://nodejs.org/api/child_process.html). These are design references; the implementation does not use VS Code or Electron extension APIs. diff --git a/docs/protocol.md b/docs/protocol.md index f546cf0..a776966 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -14,7 +14,7 @@ New names contain one ASCII-bracketed tag and the title, with no date/time metad The plugin bundles `SessionStart`, `UserPromptSubmit`, and `SessionEnd` lifecycle hooks. A `startup` event arms one session ID, and the first prompt for that ID consumes the marker and receives a compact developer-context naming policy. Resumed sessions and later prompts receive no context. `SessionEnd` removes an unused marker. -The hook never edits a transcript or session file. It instructs the Codex agent to use Codex's own task naming capability and select exactly one configured tag. The controller owns the versioned local `settings.json` file; renderers send updates through the local bridge and receive normalized snapshots. The hook reads that same file and falls back to the built-in definitions when it is unavailable. +The hook never edits a transcript or session file. It instructs the Codex agent to use Codex's own task naming capability and select exactly one configured tag. The Tags service owns the versioned local `settings.json` file; renderers send updates through the local bridge and receive normalized snapshots. The hook reads that same file and falls back to the built-in definitions when it is unavailable. ```json { @@ -63,7 +63,7 @@ interface RuntimeMessage { Current message families are: - `search.request` / `search.result`: asynchronous bounded local content search -- `settings.get` / `settings.snapshot` / `settings.update`: controller-owned tag settings +- `settings.get` / `settings.snapshot` / `settings.update`: service-owned tag settings - `settings.error`: failed persistence; the preceding snapshot restores saved settings - `catalog.snapshot`: active local metadata, completeness flag and bounded error message - Optional catalog pin/project metadata may be unavailable; `null` must not erase known native-row metadata. @@ -82,8 +82,8 @@ The injected runtime exposes `window.__codexSidebarTags` as a deliberately small - `debug()`: return the bounded local interaction trace - `dispose()`: restore native DOM and remove injected UI and listeners -The runtime sends serialized envelopes through one CDP `Runtime.addBinding` bridge. `ControllerRouter` validates and dispatches them to settings or search services, then returns envelopes through a bounded evaluated expression. Only matching snippets and normalized settings are transferred; full conversation bodies remain outside the renderer. The browser bundle is loaded from the installed runtime directory, and no remote script is fetched. +The renderer obtains its initial configuration through Loader RPC `bootstrap`. Tags intents use RPC `dispatch`; snapshots/results use the module-local `message` event. `ControllerRouter` validates the Tags protocol independently of Loader's transport. Only metadata, matching snippets and normalized settings are transferred. `window.__codexSidebarTags` lives in Loader's isolated JavaScript world. No remote script is fetched. Loader API and Tags protocol versions are independent. -`catalog.delta` remains planned; the current catalog uses changed snapshots. Protocol version and injected runtime version are independent: a protocol major changes only for an incompatible wire contract, while injected UI changes bump `RUNTIME_VERSION` so hot apply cannot retain old browser code. +The catalog uses changed snapshots. Protocol version and injected runtime version are independent: a protocol major changes only for an incompatible wire contract, while injected UI changes bump `RUNTIME_VERSION` so hot apply cannot retain old browser code. `status()` also exposes `sidebarFilter` and `activeTag` so installation checks and real-app QA can verify the compact sidebar filter without inspecting private state. diff --git a/docs/roadmap.md b/docs/roadmap.md index f9c6844..15a8f63 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -The current design separates installation, lifecycle, local services, host adaptation and UI. Extend those boundaries without introducing a general extension framework. +The current design separates the reusable Codex Plugin Loader from product services and UI. Loader provides desktop/CLI entry, isolated service processes and RPC/events and renderer-plugin lifecycle; Tags is its first configured renderer/service module. See [the loader contract](plugin-loader.md) for ownership and extension boundaries. ## Release blockers @@ -20,7 +20,6 @@ Passing unit tests does not replace these gates. See [compatibility](compatibili | P1 | Versioned recoverable installation | Interrupted updates retain a known-good artifact with documented rollback | | P1 | Multi-window settings revisions | Conflicts detected without silent lost edits | | P2 | Single Preact dashboard root | IME, menus, drafts and scroll persist without imperative remount guards | -| P2 | Isolated search scheduler | Cancellation and large-history failures are independently tested | | P2 | Compatibility manifest | Tested builds, hashes, schemas and per-feature state are recorded | | P2 | Search completeness/performance | Text caps, refresh delays and query latency are measured and visible | diff --git a/package.json b/package.json index d441c69..6e66e65 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "CHANGELOG.md", "LICENSE", "README.md", - "README.zh-CN.md" + "README.zh-CN.md", + "runtime/src/plugin-loader/" ], "publishConfig": { "access": "public", @@ -40,12 +41,12 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check.mjs", - "test:package": "node scripts/test-package.mjs", + "test:package": "node scripts/test-package.mjs && node scripts/test-loader-package.mjs", "prepublishOnly": "npm run verify && npm run test:package", "dev:apply": "npm run build && node scripts/manage.mjs install && node scripts/manage.mjs apply", "qa:app": "node runtime/qa-runtime.mjs", "test": "node --test runtime/test/*.test.mjs && vitest run runtime/test/*.test.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.loader.json", "verify": "npm run build && npm run check && npm run typecheck && npm test" }, "engines": { diff --git a/runtime/dist/injected.js b/runtime/dist/injected.js index 2feab0a..3d4d60a 100644 --- a/runtime/dist/injected.js +++ b/runtime/dist/injected.js @@ -1,5 +1,5 @@ "use strict"; -var CodexTagsInjected = (() => { +var CodexPlugin = (() => { var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; @@ -21,6 +21,7 @@ var CodexTagsInjected = (() => { // runtime/src/injected/entry.ts var entry_exports = {}; __export(entry_exports, { + activate: () => activate, installRuntime: () => installRuntime }); @@ -2113,25 +2114,20 @@ var CodexTagsInjected = (() => { // runtime/src/injected/runtime-client.ts var RuntimeClient = class { - constructor(bindingName, onMessage, onRejectedMessage, resolveBinding = (name) => window[name]) { - this.bindingName = bindingName; + constructor(transport, onMessage, onRejectedMessage, onTransportError) { + this.transport = transport; this.onMessage = onMessage; this.onRejectedMessage = onRejectedMessage; - this.resolveBinding = resolveBinding; + this.onTransportError = onTransportError; } - bindingName; + transport; onMessage; onRejectedMessage; - resolveBinding; + onTransportError; protocolVersion = RUNTIME_PROTOCOL_VERSION; - get connected() { - return typeof this.resolveBinding(this.bindingName) === "function"; - } send(type, payload, requestId) { - const binding = this.resolveBinding(this.bindingName); - if (typeof binding !== "function") return false; - binding(JSON.stringify(createRuntimeMessage(type, payload, requestId))); - return true; + const message = createRuntimeMessage(type, payload, requestId); + void Promise.resolve().then(() => this.transport(message)).catch(() => this.onTransportError(message)); } handle(value) { const parsed = parseRuntimeMessage(value); @@ -2153,7 +2149,6 @@ var CodexTagsInjected = (() => { if (typeof value.version !== "string" || !value.version.trim()) throw new Error("Invalid Codex Tags runtime version"); if (value.protocolVersion !== RUNTIME_PROTOCOL_VERSION) throw new Error(`Unsupported Codex Tags protocol ${String(value.protocolVersion)}`); if (value.settingsSource !== "repository" && value.settingsSource !== "defaults") throw new Error("Invalid Codex Tags settings source"); - if (typeof value.requestBinding !== "string" || !/^__[A-Za-z0-9]+$/u.test(value.requestBinding)) throw new Error("Invalid Codex Tags runtime binding"); if (!Array.isArray(value.tagDefinitions)) throw new Error("Invalid Codex Tags tag definitions"); const colorPresets = Array.isArray(value.colorPresets) ? value.colorPresets.flatMap((item) => { if (!isRecord(item) || typeof item.name !== "string" || typeof item.color !== "string" || !HEX_COLOR.test(item.color)) return []; @@ -2171,8 +2166,7 @@ var CodexTagsInjected = (() => { tagDefinitions: normalizeTagDefinitions(value.tagDefinitions, []), settingsSource: value.settingsSource, colorPresets, - legacyToneColors, - requestBinding: value.requestBinding + legacyToneColors }; } @@ -2785,9 +2779,9 @@ var CodexTagsInjected = (() => { }; // runtime/src/injected/runtime.ts - function installRuntime(input) { + function installRuntime(input, send) { const config = parseRuntimeConfig(input); - const { version, colorPresets, legacyToneColors, requestBinding } = config; + const { version, colorPresets, legacyToneColors } = config; const STYLE_ID = "codex-sidebar-tags-style"; const TOOLBAR_ID = "codex-sidebar-tags-toolbar"; const FILTER_BAR_ID = "codex-sidebar-tags-filter-bar"; @@ -2840,9 +2834,17 @@ var CodexTagsInjected = (() => { }; let receiveRuntimeMessage = () => false; const runtimeClient = new RuntimeClient( - requestBinding, + send, (message) => receiveRuntimeMessage(message), - (reason) => trace("protocol-rejected", { reason }) + (reason) => trace("protocol-rejected", { reason }), + (request) => { + if (request.type === RuntimeMessageType.searchRequest) receiveRuntimeMessage(createRuntimeMessage(RuntimeMessageType.searchResult, { + query: request.payload.query, + items: [], + error: i18n.t("searchUnavailable") + }, request.requestId)); + if (request.type === RuntimeMessageType.settingsUpdate) receiveRuntimeMessage(createRuntimeMessage(RuntimeMessageType.settingsError)); + } ); const parse = (value) => { const raw = typeof value === "string" ? value.trim() : ""; @@ -3038,12 +3040,6 @@ var CodexTagsInjected = (() => { const requestId = activeSearchRequestId; searchRequestTimer = setTimeout(() => { searchRequestTimer = null; - if (!runtimeClient.connected) { - searchLoading = false; - searchError = i18n.t("searchUnavailable"); - renderToolbar(entriesFrom(titleNodes()), "search-unavailable"); - return; - } runtimeClient.send(RuntimeMessageType.searchRequest, { query, threadIds: entries.map(({ threadId }) => threadId).filter((threadId) => Boolean(threadId)), @@ -3204,6 +3200,7 @@ var CodexTagsInjected = (() => { }), debug: () => debugEvents.slice(), dispose: () => { + receiveRuntimeMessage = () => false; clearPendingSearch(); stopLocaleObserver(); hostLifecycle.dispose(); @@ -3222,5 +3219,29 @@ var CodexTagsInjected = (() => { window.__codexSidebarTags = runtime; return runtime.status(); } + + // runtime/src/injected/entry.ts + async function activate(context) { + let tags = null; + try { + tags = JSON.parse(localStorage.getItem("codex-sidebar-tags-config-v1") ?? "null"); + } catch { + } + const config = await context.rpc.call("bootstrap", { tags }); + let runtime; + context.onDispose(() => { + runtime?.dispose?.(); + }); + context.events.subscribe("message", (message) => { + runtime?.handleMessage(message); + }); + installRuntime(config, (message) => context.rpc.call("dispatch", message)); + runtime = window.__codexSidebarTags; + return { + isActive: () => window.__codexSidebarTags === runtime, + status: () => runtime?.status(), + handleMessage: (message) => runtime?.handleMessage(message) + }; + } return __toCommonJS(entry_exports); })(); diff --git a/runtime/qa-runtime.mjs b/runtime/qa-runtime.mjs index 4d90b05..d1de9ea 100644 --- a/runtime/qa-runtime.mjs +++ b/runtime/qa-runtime.mjs @@ -6,10 +6,10 @@ import { RuntimeTargetRegistry } from "./src/runtime-target-registry.mjs"; const processOwner = new CodexProcess(); assert.ok(await processOwner.ownsCdpEndpoint(), "The owned Codex endpoint is required; QA never starts or restarts Codex."); -const registry = new RuntimeTargetRegistry({ port: 9341 }); +const registry = new RuntimeTargetRegistry({ port: 9341, ownsEndpoint: () => processOwner.ownsCdpEndpoint() }); let client; for (const target of await registry.discover()) { - const candidate = await CdpClient.connect(target); + const candidate = await registry.client(target); if (await candidate.evaluate("Boolean(document.querySelector('.codex-sidebar-dashboard-launcher'))")) { client = candidate; break; } candidate.close(); } diff --git a/runtime/src/cdp-client.mjs b/runtime/src/cdp-client.mjs index 3e2b27b..772e9e4 100644 --- a/runtime/src/cdp-client.mjs +++ b/runtime/src/cdp-client.mjs @@ -1,100 +1 @@ -export class CdpClient { - static async connect(target) { - const socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - socket.close(); - reject(new Error(`CDP websocket open timed out for target ${target.id}`)); - }, 3000); - socket.addEventListener("open", () => { - clearTimeout(timer); - resolve(); - }, { once: true }); - socket.addEventListener("error", () => { - clearTimeout(timer); - reject(new Error(`CDP websocket open failed for target ${target.id}`)); - }, { once: true }); - }); - return new CdpClient(target, socket); - } - - constructor(target, socket) { - this.target = target; - this.socket = socket; - this.nextCommandId = 1; - this.pendingCommands = new Map(); - this.bindingHandlers = new Map(); - socket.addEventListener("message", (event) => this.#handleMessage(event.data)); - socket.addEventListener("close", () => this.#rejectPending(new Error(`CDP target ${target.id} disconnected`))); - socket.addEventListener("error", () => this.#rejectPending(new Error(`CDP target ${target.id} failed`))); - } - - get connected() { - return this.socket.readyState === WebSocket.OPEN; - } - - async command(method, params = {}, timeoutMs = 15_000) { - if (!this.connected) throw new Error(`CDP target ${this.target.id} is not connected`); - const id = this.nextCommandId; - this.nextCommandId += 1; - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pendingCommands.delete(id); - reject(new Error(`${method} timed out for target ${this.target.id}`)); - }, timeoutMs); - this.pendingCommands.set(id, { resolve, reject, timer, method }); - this.socket.send(JSON.stringify({ id, method, params })); - }); - } - - async evaluate(expression, executionContextId) { - const params = { expression, awaitPromise: true, returnByValue: true }; - if (Number.isSafeInteger(executionContextId)) params.contextId = executionContextId; - const response = await this.command("Runtime.evaluate", params); - if (response?.exceptionDetails) { - const description = response.exceptionDetails.exception?.description ?? response.exceptionDetails.text; - throw new Error(description ?? `Runtime.evaluate failed for target ${this.target.id}`); - } - return response?.result?.value; - } - - async addBinding(name, handler) { - this.bindingHandlers.set(name, handler); - await this.command("Runtime.enable"); - await this.command("Runtime.addBinding", { name }); - } - - close() { - this.socket.close(); - } - - #handleMessage(rawMessage) { - let message; - try { - message = JSON.parse(rawMessage); - } catch { - return; - } - if (message.id !== undefined) { - const pending = this.pendingCommands.get(message.id); - if (!pending) return; - this.pendingCommands.delete(message.id); - clearTimeout(pending.timer); - if (message.error) pending.reject(new Error(`${pending.method} failed: ${message.error.message ?? "unknown CDP error"}`)); - else pending.resolve(message.result); - return; - } - if (message.method !== "Runtime.bindingCalled") return; - const handler = this.bindingHandlers.get(message.params?.name); - if (!handler) return; - Promise.resolve(handler(message.params)).catch(() => {}); - } - - #rejectPending(error) { - for (const { reject, timer } of this.pendingCommands.values()) { - clearTimeout(timer); - reject(error); - } - this.pendingCommands.clear(); - } -} +export { CdpClient } from "./plugin-loader/cdp-client.mjs"; diff --git a/runtime/src/codex-process.mjs b/runtime/src/codex-process.mjs index e506a82..914047f 100644 --- a/runtime/src/codex-process.mjs +++ b/runtime/src/codex-process.mjs @@ -1,115 +1 @@ -import { execFile, execFileSync, spawn } from "node:child_process"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; - -const defaultRun = promisify(execFile); -const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); - -export function findCodexApp() { - for (const appPath of ["/Applications/Codex.app", join(homedir(), "Applications", "Codex.app"), "/Applications/ChatGPT.app"]) { - try { - const plist = join(appPath, "Contents", "Info.plist"); - const read = (key) => execFileSync("/usr/bin/plutil", ["-extract", key, "raw", plist], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 2000 }).trim(); - if (read("CFBundleIdentifier") !== "com.openai.codex") continue; - const executable = read("CFBundleExecutable"); - if (!/^[A-Za-z0-9_-]+$/u.test(executable)) continue; - return { appPath, executable: join(appPath, "Contents", "MacOS", executable) }; - } catch { /* Uninstalled or unrelated applications are not candidates. */ } - } - return null; -} - -export class CodexProcess { - constructor(options = {}) { - this.port = options.port ?? 9341; - const detected = options.appPath ? null : findCodexApp(); - this.appPath = options.appPath ?? detected?.appPath ?? "/Applications/Codex.app"; - this.executable = options.executable ?? detected?.executable ?? join(this.appPath, "Contents", "MacOS", "ChatGPT"); - this.run = options.run ?? defaultRun; - this.spawn = options.spawn ?? spawn; - } - - async isRunning() { - const { stdout } = await this.run("/bin/ps", ["-axo", "command="]); - return stdout.split("\n").some((command) => { - const value = command.trim(); - return value === this.executable || value.startsWith(`${this.executable} `); - }); - } - - async hasCdpLaunchArguments() { - const { stdout } = await this.run("/bin/ps", ["-axo", "command="]); - return stdout.split("\n").some((command) => { - const value = command.trim(); - const isCodex = value === this.executable || value.startsWith(`${this.executable} `); - return isCodex && value.includes(`--remote-debugging-port=${this.port}`); - }); - } - - async ownsCdpEndpoint() { - let stdout; - try { - ({ stdout } = await this.run("/usr/sbin/lsof", [ - "-nP", `-iTCP:${this.port}`, "-sTCP:LISTEN", "-t", - ])); - } catch (error) { - if (error.code === 1) return false; - throw error; - } - - for (const value of stdout.trim().split(/\s+/u)) { - let pid = Number(value); - for (let depth = 0; Number.isSafeInteger(pid) && pid > 0 && depth < 8; depth += 1) { - try { - const [{ stdout: command }, { stdout: parent }] = await Promise.all([ - this.run("/bin/ps", ["-p", String(pid), "-o", "command="]), - this.run("/bin/ps", ["-p", String(pid), "-o", "ppid="]), - ]); - if (command.trim() === this.executable || command.trim().startsWith(`${this.executable} `)) return true; - pid = Number(parent.trim()); - } catch { - break; - } - } - } - return false; - } - - async portIsListening() { - try { - const { stdout } = await this.run("/usr/sbin/lsof", ["-nP", `-iTCP:${this.port}`, "-sTCP:LISTEN", "-t"]); - return stdout.trim().length > 0; - } catch (error) { - if (error.code === 1) return false; - throw error; - } - } - - async cdpIsReady(discoverTargets) { - try { - return await this.ownsCdpEndpoint() && (await discoverTargets()).length > 0; - } catch { - return false; - } - } - - async waitForCdp(discoverTargets, timeoutMs = 30_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await this.cdpIsReady(discoverTargets)) return; - await wait(250); - } - throw new Error("等待 Codex CDP 端口超时"); - } - - async launchWithCdp(discoverTargets) { - if (await this.isRunning()) throw new Error("Codex is already running without Tags. Quit Codex completely, then open Codex Tags.app. The running app was not restarted."); - const child = this.spawn(this.executable, [ - "--remote-debugging-address=127.0.0.1", - `--remote-debugging-port=${this.port}`, - ], { detached: true, stdio: "ignore" }); - child.unref(); - await this.waitForCdp(discoverTargets); - } -} +export { CodexProcess, findCodexApp } from "./plugin-loader/codex-process.mjs"; diff --git a/runtime/src/controller-router.mjs b/runtime/src/controller-router.mjs index 38fc824..6f9d362 100644 --- a/runtime/src/controller-router.mjs +++ b/runtime/src/controller-router.mjs @@ -22,12 +22,12 @@ export class ControllerRouter { ]); } - async handle(client, params) { - const parsed = parseRuntimeMessage(params.payload); + async handle(client, message) { + const parsed = parseRuntimeMessage(message); if (!parsed.ok) return false; const handler = this.handlers.get(parsed.message.type); if (!handler) return false; - await handler(client, parsed.message, params.executionContextId); + await handler(client, parsed.message); return true; } @@ -35,32 +35,31 @@ export class ControllerRouter { this.latestSearchRequestIds.delete(targetId); } - async sendSettingsSnapshot(client, executionContextId) { + async sendSettingsSnapshot(client) { await this.send( client, createRuntimeMessage(RuntimeMessageType.settingsSnapshot, { settings: this.getSettings() }), - executionContextId, ); } - async handleSettingsGet(client, _request, executionContextId) { - await this.sendSettingsSnapshot(client, executionContextId); + async handleSettingsGet(client, _request) { + await this.sendSettingsSnapshot(client); } - async handleSettingsUpdate(client, request, executionContextId) { + async handleSettingsUpdate(client, request) { try { if (!Array.isArray(request.payload.tags) || request.payload.tags.length > 32) throw new Error("Invalid tag definitions"); const result = await this.settingsRepository.write(request.payload.tags); await this.onSettingsChanged(result.settings); } catch { - await this.sendSettingsSnapshot(client, executionContextId); - await this.send(client, createRuntimeMessage(RuntimeMessageType.settingsError, {}), executionContextId); + await this.sendSettingsSnapshot(client); + await this.send(client, createRuntimeMessage(RuntimeMessageType.settingsError, {})); } } - async handleSearchRequest(client, request, executionContextId) { + async handleSearchRequest(client, request) { if (!Number.isSafeInteger(request.requestId)) return; - const targetId = client.target.id; + const targetId = client.id; this.latestSearchRequestIds.set(targetId, request.requestId); await this.waitForIndex(); if (this.latestSearchRequestIds.get(targetId) !== request.requestId) return; @@ -71,14 +70,14 @@ export class ControllerRouter { query: request.payload.query, items, indexStatus: this.getIndexStatus(), - }, request.requestId), executionContextId); + }, request.requestId)); } catch (error) { await this.send(client, createRuntimeMessage(RuntimeMessageType.searchResult, { query: request.payload.query, items: [], indexStatus: this.getIndexStatus(), error: error instanceof Error ? error.message : "Search failed", - }, request.requestId), executionContextId).catch(() => {}); + }, request.requestId)).catch(() => {}); } } } diff --git a/runtime/src/controller.mjs b/runtime/src/controller.mjs index 54f9216..1d45d70 100644 --- a/runtime/src/controller.mjs +++ b/runtime/src/controller.mjs @@ -1,261 +1,68 @@ #!/usr/bin/env node -import { execFile, spawn } from "node:child_process"; -import { closeSync, openSync } from "node:fs"; -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { readFile, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; - -import { CdpClient } from "./cdp-client.mjs"; -import { CodexProcess, findCodexApp } from "./codex-process.mjs"; -import { ControllerRouter } from "./controller-router.mjs"; -import { buildRuntimeMessageExpression, RUNTIME_BINDING, RUNTIME_VERSION } from "./inject-expression.mjs"; +import { CodexProcess } from "./codex-process.mjs"; +import { RUNTIME_VERSION, buildRemovalExpression } from "./inject-expression.mjs"; +import { PluginTargetRegistry } from "./plugin-loader/index.mjs"; +import { setPluginEnabled } from "./plugin-loader/manifest.mjs"; +import { daemonPaths, daemonStatus } from "./plugin-loader/daemon.mjs"; import { isOwnedControllerCommand, parseControllerPid } from "./controller-state.mjs"; -import { createRuntimeMessage, RuntimeMessageType } from "./protocol.mjs"; import { SessionSearchIndex } from "./search-index.mjs"; import { SessionCatalog } from "./session-catalog.mjs"; import { SettingsRepository } from "./settings-repository.mjs"; -import { RuntimeTargetRegistry } from "./runtime-target-registry.mjs"; - -process.umask(0o077); const run = promisify(execFile); -const configuredPort = Number(process.env.CODEX_TAGS_CDP_PORT ?? 9341); -if (!Number.isSafeInteger(configuredPort) || configuredPort < 1024 || configuredPort > 65535) throw new Error("CODEX_TAGS_CDP_PORT must be an integer between 1024 and 65535"); -const PORT = configuredPort; const SCRIPT_PATH = fileURLToPath(import.meta.url); const STATE_DIR = process.env.CODEX_TAGS_STATE_DIR ?? dirname(SCRIPT_PATH); -const PID_PATH = join(STATE_DIR, "controller.pid"); -const LOG_PATH = join(STATE_DIR, "controller.log"); -const SEARCH_DATABASE_PATH = join(STATE_DIR, "search.sqlite"); -const SETTINGS_PATH = join(STATE_DIR, "settings.json"); -const INDEX_REFRESH_INTERVAL_MS = 30_000; -const settingsRepository = new SettingsRepository(SETTINGS_PATH); +const PORT = Number(process.env.CODEX_TAGS_CDP_PORT ?? 9341); +const configPath = join(STATE_DIR, "loader.json"); +const loaderCli = join(dirname(SCRIPT_PATH), "plugin-loader", "cli.mjs"); +const paths = daemonPaths(configPath, PORT); const codexProcess = new CodexProcess({ port: PORT }); -const targetRegistry = new RuntimeTargetRegistry({ - port: PORT, - ownsEndpoint: () => codexProcess.ownsCdpEndpoint(), - settingsRepository, -}); - -const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); - -async function apply() { - const { results } = await targetRegistry.ensureInjected(await settingsRepository.read()); - return results; -} - -async function readControllerPid() { - try { - return parseControllerPid(await readFile(PID_PATH, "utf8")); - } catch { return null; } -} - -async function isOwnedController(pid) { - try { - const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "command="]); - return isOwnedControllerCommand(stdout, SCRIPT_PATH); - } catch { - return false; - } -} - -async function stopController() { - const pid = await readControllerPid(); - if (pid !== null && await isOwnedController(pid)) { - try { process.kill(pid, "SIGTERM"); } catch {} - const deadline = Date.now() + 3000; - while (Date.now() < deadline && await isOwnedController(pid)) await wait(50); - if (await isOwnedController(pid)) throw new Error("The controller did not stop. No files were removed; retry after it exits."); - } - await rm(PID_PATH, { force: true }); -} - -async function spawnController() { - await stopController(); - const output = openSync(LOG_PATH, "a"); - const child = spawn(process.execPath, [SCRIPT_PATH, "watch"], { - detached: true, - stdio: ["ignore", output, output], - }); - child.unref(); - closeSync(output); -} - -async function start() { - if (!findCodexApp()) throw new Error("Install the official Codex desktop app before activating Tags."); - await mkdir(STATE_DIR, { recursive: true }); - await stopController(); - if (!(await codexProcess.cdpIsReady(() => targetRegistry.discover()))) { - if (await codexProcess.ownsCdpEndpoint() || await codexProcess.hasCdpLaunchArguments()) { - await codexProcess.waitForCdp(() => targetRegistry.discover()); - } else { - if (await codexProcess.portIsListening()) throw new Error(`端口 ${PORT} 已被其他进程占用;未退出或修改 Codex`); - await codexProcess.launchWithCdp(() => targetRegistry.discover()); - } - } - const results = await apply(); - await spawnController(); - console.log(`Codex Sidebar Tags ${RUNTIME_VERSION} 已启用`, results); -} +const targetRegistry = new PluginTargetRegistry({ port: PORT, ownsEndpoint: () => codexProcess.ownsCdpEndpoint() }); +const settingsRepository = new SettingsRepository(join(STATE_DIR, "settings.json")); +const SEARCH_DATABASE_PATH = join(STATE_DIR, "search.sqlite"); -async function hotApply() { - await mkdir(STATE_DIR, { recursive: true }); - await stopController(); - const results = await apply(); - await spawnController(); - return results; +async function runLoader(command, ...args) { + const result = await run(process.execPath, [loaderCli, command, "--config", configPath, "--port", String(PORT), ...args], { maxBuffer: 8 * 1024 * 1024 }); + if (result.stdout.trim()) console.log(result.stdout.trim()); } -async function watch() { - await mkdir(STATE_DIR, { recursive: true }); - const nextPidPath = `${PID_PATH}.next-${process.pid}`; - await writeFile(nextPidPath, `${JSON.stringify({ pid: process.pid, scriptPath: SCRIPT_PATH, startedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 }); - await rename(nextPidPath, PID_PATH); - console.log(`${new Date().toISOString()} watching ${RUNTIME_VERSION}`); - let misses = 0; - const searchIndex = new SessionSearchIndex(SEARCH_DATABASE_PATH); - const catalog = new SessionCatalog(); - let catalogState = await catalog.read(); - let catalogSignature = JSON.stringify(catalogState); - let nextCatalogRefreshAt = 0; - const runtimeClients = new Map(); - let indexStatus = { phase: "indexing", completed: 0, total: 0, changed: 0 }; - let indexRefresh = Promise.resolve(); - let nextIndexRefreshAt = 0; - let settingsState = await settingsRepository.read(); - if (settingsState.error) console.error(`${new Date().toISOString()} ${settingsState.error}`); - const syncTagSettings = async (target) => { - if (settingsState.exists || settingsState.error) return; - const definitions = await targetRegistry.evaluate(target, "window.__codexSidebarTags?.tagDefinitions?.() ?? null"); - if (!Array.isArray(definitions)) return; - const result = await settingsRepository.write(definitions); - settingsState = { settings: result.settings, exists: true, error: null }; +async function stopLegacyController() { + const path = join(STATE_DIR, "controller.pid"); + let pid; + try { pid = parseControllerPid(await readFile(path, "utf8")); } + catch (error) { if (error.code === "ENOENT") return; throw error; } + const owned = async () => { + if (!pid) return false; + try { + const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "command="]); + return isOwnedControllerCommand(stdout, SCRIPT_PATH); + } catch { return false; } }; - const scheduleIndexRefresh = () => { - indexRefresh = indexRefresh - .then(() => searchIndex.refresh((status) => { indexStatus = status; })) - .catch((error) => { - indexStatus = { phase: "error", message: error.message }; - console.error(`${new Date().toISOString()} search indexing failed: ${error.message}`); - }); - nextIndexRefreshAt = Date.now() + INDEX_REFRESH_INTERVAL_MS; - }; - scheduleIndexRefresh(); - - const sendRuntimeMessage = (client, message, executionContextId) => client.evaluate( - buildRuntimeMessageExpression(message), - executionContextId, - ); - - const broadcastSettings = async (settings) => { - const message = createRuntimeMessage(RuntimeMessageType.settingsSnapshot, { settings }); - await Promise.allSettled([...runtimeClients.values()].map((client) => sendRuntimeMessage(client, message))); - }; - - const router = new ControllerRouter({ - searchIndex, - settingsRepository, - getSettings: () => settingsState.settings, - onSettingsChanged: async (settings) => { - settingsState = { settings, exists: true, error: null }; - await broadcastSettings(settings); - }, - waitForIndex: () => indexRefresh, - getIndexStatus: () => indexStatus, - send: sendRuntimeMessage, - openSession: async (threadId) => { - if (catalogState.items.some((item) => item.threadId === threadId)) await run("/usr/bin/open", [`codex://threads/${threadId}`]); - }, - }); - - const ensureRuntimeClient = async (target) => { - const existing = runtimeClients.get(target.id); - if (existing?.connected) return existing; - existing?.close(); - const client = await CdpClient.connect(target); - await client.addBinding(RUNTIME_BINDING, (params) => router.handle(client, params)); - runtimeClients.set(target.id, client); - await router.sendSettingsSnapshot(client); - await sendRuntimeMessage(client, createRuntimeMessage(RuntimeMessageType.catalogSnapshot, catalogState)); - return client; - }; - - let cleanupPromise = null; - const clean = () => { - cleanupPromise ??= (async () => { - for (const client of runtimeClients.values()) client.close(); - runtimeClients.clear(); - searchIndex.close(); - if ((await readControllerPid()) === process.pid) await rm(PID_PATH, { force: true }); - })(); - return cleanupPromise; - }; - process.once("SIGTERM", () => { clean().finally(() => process.exit(0)); }); - process.once("SIGINT", () => { clean().finally(() => process.exit(0)); }); - - try { - while (true) { - try { - const { targets } = await targetRegistry.ensureInjected(settingsState); - const currentTargetIds = new Set(targets.map(({ id }) => id)); - for (const [targetId, client] of runtimeClients) { - if (currentTargetIds.has(targetId)) continue; - client.close(); - runtimeClients.delete(targetId); - router.forgetTarget(targetId); - } - for (const target of targets) { - await ensureRuntimeClient(target); - await syncTagSettings(target); - } - if (Date.now() >= nextIndexRefreshAt) scheduleIndexRefresh(); - if (Date.now() >= nextCatalogRefreshAt) { - catalogState = await catalog.read(); - const signature = JSON.stringify(catalogState); - if (signature !== catalogSignature) { - catalogSignature = signature; - await Promise.allSettled([...runtimeClients.values()].map((client) => sendRuntimeMessage(client, createRuntimeMessage(RuntimeMessageType.catalogSnapshot, catalogState)))); - } - nextCatalogRefreshAt = Date.now() + 5000; - } - misses = 0; - } catch (error) { - misses += 1; - if (misses === 1 || misses % 10 === 0) console.error(`${new Date().toISOString()} sync failed (${misses}): ${error.message}`); - if (misses >= 10 && !(await codexProcess.isRunning())) return; - } - await wait(1000); + if (await owned()) { + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 5000; + while (await owned()) { + if (Date.now() >= deadline) throw new Error("Legacy Tags controller did not stop"); + await new Promise((resolve) => setTimeout(resolve, 50)); } - } finally { - await clean(); } + await rm(path, { force: true }); } -async function restore() { - await stopController(); - if (await codexProcess.cdpIsReady(() => targetRegistry.discover())) await targetRegistry.removeInjection(); - console.log("侧栏标题增强已移除;Codex 安装包没有被修改。"); -} - -async function purge() { - await stopController(); - if (await codexProcess.cdpIsReady(() => targetRegistry.discover())) { - for (const target of await targetRegistry.discover()) { - await targetRegistry.evaluate(target, ` - localStorage.removeItem("codex-sidebar-tags-config-v1"); - localStorage.removeItem("codex-sidebar-tags-index-v1"); - `); - } - await targetRegistry.removeInjection(); +async function removeTags() { + if (await codexProcess.ownsCdpEndpoint()) { + for (const target of await targetRegistry.discover()) await targetRegistry.evaluate(target, buildRemovalExpression()); } - console.log("侧栏增强及其浏览器缓存已移除;Codex 会话没有被修改。"); } async function status() { const ready = await codexProcess.cdpIsReady(() => targetRegistry.discover()); - const storedPid = await readControllerPid(); - const controllerPid = storedPid !== null && await isOwnedController(storedPid) ? storedPid : null; + const controllerPid = (await daemonStatus(paths)).pid; const codexRunning = await codexProcess.isRunning(); const activeVersions = []; const activeWindows = []; @@ -280,11 +87,23 @@ async function status() { console.log(JSON.stringify({ codexRunning, cdp: ready, controllerPid, sourceVersion: RUNTIME_VERSION, activeVersions, activeWindows, searchIndex, catalog, tagSettings, tagSettingsError: tagSettingsState.error }, null, 2)); } -const command = process.argv[2] ?? "start"; -if (command === "start") await start(); -else if (command === "apply") console.log(await hotApply()); -else if (command === "restore") await restore(); -else if (command === "purge") await purge(); -else if (command === "status") await status(); -else if (command === "watch") await watch(); -else throw new Error(`未知命令:${command}`); +const command = process.argv[2] ?? "status"; +try { + if (command === "status") await status(); + else if (["start", "apply", "restore", "purge"].includes(command)) { + await stopLegacyController(); + if (command === "apply") await runLoader("stop"); + if (command === "restore" || command === "purge") await runLoader("remove", "--plugin", "codex-tags"); + await removeTags(); + if (command === "start" || command === "apply") { + await setPluginEnabled(configPath, "codex-tags", true); + await runLoader("start", ...(command === "apply" ? ["--attach"] : [])); + } + if (command === "purge" && await codexProcess.ownsCdpEndpoint()) { + for (const target of await targetRegistry.discover()) await targetRegistry.evaluate(target, ` + localStorage.removeItem("codex-sidebar-tags-config-v1"); + localStorage.removeItem("codex-sidebar-tags-index-v1"); + `); + } + } else throw new Error(`Unknown Tags adapter command: ${command}`); +} finally { targetRegistry.close(); } diff --git a/runtime/src/inject-expression.mjs b/runtime/src/inject-expression.mjs index d52b461..afac70a 100644 --- a/runtime/src/inject-expression.mjs +++ b/runtime/src/inject-expression.mjs @@ -1,49 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { DEFAULT_TAG_DEFINITIONS, LEGACY_TONE_COLORS, TAG_COLOR_PRESETS } from "./tag-settings.mjs"; -import { createRuntimeMessage, RUNTIME_PROTOCOL_VERSION, RuntimeMessageType } from "./protocol.mjs"; - -export const RUNTIME_VERSION = "6.0.15"; -export const RUNTIME_BINDING = "__codexTagsRequest"; -export const SEARCH_BINDING = RUNTIME_BINDING; - -const moduleDirectory = dirname(fileURLToPath(import.meta.url)); -const bundleCandidates = [ - join(moduleDirectory, "dist", "injected.js"), - join(moduleDirectory, "..", "dist", "injected.js"), -]; -const bundlePath = bundleCandidates.find(existsSync); - -if (!bundlePath) { - throw new Error("Codex Tags runtime bundle is missing. Run `npm run build` before installing."); -} - -const injectedBundle = readFileSync(bundlePath, "utf8"); - -export function buildInjectionExpression(options = {}) { - const config = { - version: RUNTIME_VERSION, - protocolVersion: RUNTIME_PROTOCOL_VERSION, - tagDefinitions: options.tagDefinitions ?? DEFAULT_TAG_DEFINITIONS, - settingsSource: options.settingsSource ?? "defaults", - colorPresets: TAG_COLOR_PRESETS, - legacyToneColors: LEGACY_TONE_COLORS, - requestBinding: RUNTIME_BINDING, - }; - return `(() => { ${injectedBundle}\nreturn CodexTagsInjected.installRuntime(${JSON.stringify(config)}); })()`; -} - -export function buildRuntimeMessageExpression(message) { - return `window.__codexSidebarTags?.handleMessage?.(${JSON.stringify(message)}) ?? false`; -} - -export function buildSearchResultExpression(result) { - const { type: _legacyType, requestId, ...payload } = result; - return buildRuntimeMessageExpression(createRuntimeMessage(RuntimeMessageType.searchResult, payload, requestId)); -} +import { TAGS_PLUGIN_ID } from "./tags-plugin.mjs"; +export { RUNTIME_VERSION } from "./tags-plugin.mjs"; export function buildRemovalExpression() { - return "(() => { try { return window.__codexSidebarTags?.dispose?.() ?? false; } catch { return false; } })()"; + return `window.__codexPluginLoader?.unload(${JSON.stringify(TAGS_PLUGIN_ID)}) ?? false`; } diff --git a/runtime/src/injected/entry.ts b/runtime/src/injected/entry.ts index e04c5fd..c3b8fa9 100644 --- a/runtime/src/injected/entry.ts +++ b/runtime/src/injected/entry.ts @@ -1 +1,19 @@ -export { installRuntime } from "./runtime"; +import { installRuntime } from "./runtime"; +import type { RendererContext } from "../plugin-loader/index.mjs"; + +export { installRuntime }; +export async function activate(context: RendererContext) { + let tags: unknown = null; + try { tags = JSON.parse(localStorage.getItem("codex-sidebar-tags-config-v1") ?? "null"); } catch {} + const config = await context.rpc.call("bootstrap", { tags }); + let runtime: typeof window.__codexSidebarTags; + context.onDispose(() => { runtime?.dispose?.(); }); + context.events.subscribe("message", (message) => { runtime?.handleMessage(message); }); + installRuntime(config, (message) => context.rpc.call("dispatch", message)); + runtime = window.__codexSidebarTags; + return { + isActive: () => window.__codexSidebarTags === runtime, + status: () => runtime?.status(), + handleMessage: (message: unknown) => runtime?.handleMessage(message), + }; +} diff --git a/runtime/src/injected/global.d.ts b/runtime/src/injected/global.d.ts index 799786b..e79408b 100644 --- a/runtime/src/injected/global.d.ts +++ b/runtime/src/injected/global.d.ts @@ -2,6 +2,7 @@ interface CodexTagsRuntimeApi { version: string; status(): unknown; dispose?(): boolean; + handleMessage(value: unknown): boolean; } interface Window { diff --git a/runtime/src/injected/runtime-client.ts b/runtime/src/injected/runtime-client.ts index 77c1928..88ed0c4 100644 --- a/runtime/src/injected/runtime-client.ts +++ b/runtime/src/injected/runtime-client.ts @@ -5,27 +5,20 @@ import { type RuntimeMessage, } from "../protocol.mjs"; -type RuntimeBinding = (serializedMessage: string) => void; export class RuntimeClient { readonly protocolVersion = RUNTIME_PROTOCOL_VERSION; constructor( - private readonly bindingName: string, + private readonly transport: (message: RuntimeMessage) => Promise, private readonly onMessage: (message: RuntimeMessage) => boolean, private readonly onRejectedMessage: (reason: string) => void, - private readonly resolveBinding: (name: string) => unknown = (name) => (window as unknown as Record)[name], + private readonly onTransportError: (message: RuntimeMessage) => void, ) {} - get connected(): boolean { - return typeof this.resolveBinding(this.bindingName) === "function"; - } - - send>(type: string, payload: TPayload, requestId?: number): boolean { - const binding = this.resolveBinding(this.bindingName); - if (typeof binding !== "function") return false; - (binding as RuntimeBinding)(JSON.stringify(createRuntimeMessage(type, payload, requestId))); - return true; + send>(type: string, payload: TPayload, requestId?: number): void { + const message = createRuntimeMessage(type, payload, requestId); + void Promise.resolve().then(() => this.transport(message)).catch(() => this.onTransportError(message)); } handle(value: unknown): boolean { diff --git a/runtime/src/injected/runtime-config.ts b/runtime/src/injected/runtime-config.ts index 5ffe91b..b6dbd16 100644 --- a/runtime/src/injected/runtime-config.ts +++ b/runtime/src/injected/runtime-config.ts @@ -8,7 +8,6 @@ export interface RuntimeConfig { settingsSource: "repository" | "defaults"; colorPresets: Array<{ name: string; color: string }>; legacyToneColors: Record & { neutral: string; blue: string }; - requestBinding: string; } const HEX_COLOR = /^#[0-9a-f]{6}$/iu; @@ -22,7 +21,6 @@ export function parseRuntimeConfig(value: unknown): RuntimeConfig { if (typeof value.version !== "string" || !value.version.trim()) throw new Error("Invalid Codex Tags runtime version"); if (value.protocolVersion !== RUNTIME_PROTOCOL_VERSION) throw new Error(`Unsupported Codex Tags protocol ${String(value.protocolVersion)}`); if (value.settingsSource !== "repository" && value.settingsSource !== "defaults") throw new Error("Invalid Codex Tags settings source"); - if (typeof value.requestBinding !== "string" || !/^__[A-Za-z0-9]+$/u.test(value.requestBinding)) throw new Error("Invalid Codex Tags runtime binding"); if (!Array.isArray(value.tagDefinitions)) throw new Error("Invalid Codex Tags tag definitions"); const colorPresets = Array.isArray(value.colorPresets) @@ -45,6 +43,5 @@ export function parseRuntimeConfig(value: unknown): RuntimeConfig { settingsSource: value.settingsSource, colorPresets, legacyToneColors: legacyToneColors as RuntimeConfig["legacyToneColors"], - requestBinding: value.requestBinding, }; } diff --git a/runtime/src/injected/runtime.ts b/runtime/src/injected/runtime.ts index 3651775..70d5d46 100644 --- a/runtime/src/injected/runtime.ts +++ b/runtime/src/injected/runtime.ts @@ -1,6 +1,6 @@ import { normalizeTagDefinitions } from "../tag-settings.mjs"; import { parseTitleMetadata } from "../title-format.mjs"; -import { RUNTIME_PROTOCOL_VERSION, RuntimeMessageType } from "../protocol.mjs"; +import { createRuntimeMessage, RUNTIME_PROTOCOL_VERSION, RuntimeMessageType } from "../protocol.mjs"; import type { RuntimeMessage } from "../protocol.mjs"; import { detectCodexCapabilities, @@ -37,9 +37,9 @@ import { RuntimeStore } from "./store"; import { buildRuntimeStyles } from "./styles"; import { TitleDecorator } from "./title-decorator"; -export function installRuntime(input: unknown) { +export function installRuntime(input: unknown, send: (message: RuntimeMessage) => Promise) { const config = parseRuntimeConfig(input); - const { version, colorPresets, legacyToneColors, requestBinding } = config; + const { version, colorPresets, legacyToneColors } = config; const STYLE_ID = "codex-sidebar-tags-style"; const TOOLBAR_ID = "codex-sidebar-tags-toolbar"; const FILTER_BAR_ID = "codex-sidebar-tags-filter-bar"; @@ -90,9 +90,15 @@ export function installRuntime(input: unknown) { }; let receiveRuntimeMessage: (message: RuntimeMessage) => boolean = () => false; const runtimeClient = new RuntimeClient( - requestBinding, + send, (message) => receiveRuntimeMessage(message), (reason) => trace("protocol-rejected", { reason }), + (request) => { + if (request.type === RuntimeMessageType.searchRequest) receiveRuntimeMessage(createRuntimeMessage(RuntimeMessageType.searchResult, { + query: request.payload.query, items: [], error: i18n.t("searchUnavailable"), + }, request.requestId)); + if (request.type === RuntimeMessageType.settingsUpdate) receiveRuntimeMessage(createRuntimeMessage(RuntimeMessageType.settingsError)); + }, ); const parse = (value: unknown): ParsedSessionTitle | null => { @@ -306,12 +312,6 @@ export function installRuntime(input: unknown) { const requestId = activeSearchRequestId; searchRequestTimer = setTimeout(() => { searchRequestTimer = null; - if (!runtimeClient.connected) { - searchLoading = false; - searchError = i18n.t("searchUnavailable"); - renderToolbar(entriesFrom(titleNodes()), "search-unavailable"); - return; - } runtimeClient.send(RuntimeMessageType.searchRequest, { query, threadIds: entries.map(({ threadId }) => threadId).filter((threadId): threadId is string => Boolean(threadId)), @@ -476,6 +476,7 @@ export function installRuntime(input: unknown) { }), debug: () => debugEvents.slice(), dispose: () => { + receiveRuntimeMessage = () => false; clearPendingSearch(); stopLocaleObserver(); hostLifecycle.dispose(); diff --git a/runtime/src/plugin-loader/.npmignore b/runtime/src/plugin-loader/.npmignore new file mode 100644 index 0000000..19cf5d2 --- /dev/null +++ b/runtime/src/plugin-loader/.npmignore @@ -0,0 +1,2 @@ +.loader-*/ +module-data/ diff --git a/runtime/src/plugin-loader/README.md b/runtime/src/plugin-loader/README.md new file mode 100644 index 0000000..5ee98a1 --- /dev/null +++ b/runtime/src/plugin-loader/README.md @@ -0,0 +1,18 @@ +# Codex Plugin Loader + +A dependency-free Node.js 22+ toolkit for adding trusted local modules to Codex desktop on macOS. Loader owns desktop startup, loopback CDP, isolated renderer globals, per-plugin service processes, RPC/events and cleanup. It does not depend on Tags. + +```bash +codex-plugin-loader start --config ./loader.json +codex-plugin-loader status --config ./loader.json +codex-plugin-loader remove --config ./loader.json --plugin hello +codex-plugin-loader stop --config ./loader.json +``` + +A manifest has `apiVersion: 1` and `plugins: [{id, version, entry, service?, config?, enabled?}]`. `entry` is a local IIFE defining `CodexPlugin.activate(context)`; optional `service` is a Node ESM file exporting `activate(context)`. Paths must stay inside the manifest directory. `examples/loader.json` provides a renderer-only module; `examples/service-loader.json` adds RPC. + +Both contexts provide `id`, `config`, `signal` and `onDispose`. Renderer code uses `rpc.call(method, payload, options?)` and `events.subscribe(topic, handler)`. Services use `rpc.handle(method, handler)`, `events.publish(topic, payload, clientId?)`, `clients.onConnect/onDisconnect`, and a persistent `stateDirectory`. TypeScript contracts ship in `index.d.mts`. Low-level exports and `createLauncher` support custom infrastructure entry points. + +Use `--attach` to require an already debug-enabled app. `watch` runs in the foreground; `apply` injects renderer-only bundles once. Increment the module version after code changes. Cleanup runs in reverse registration order. RPC has timeouts, cancellation, payload and concurrency limits. Service crashes do not stop peer services. Fix and disable/re-enable a failed module; no automatic crash restart is performed. + +Plugins are trusted local code. Services run with Node privileges; renderers share the DOM/thread despite isolated globals. Renderer blocking code can block Codex. Do not put conversation bodies or secrets in diagnostics. Bundle all renderer assets locally. The official Codex bundle is never modified. diff --git a/runtime/src/plugin-loader/cdp-client.mjs b/runtime/src/plugin-loader/cdp-client.mjs new file mode 100644 index 0000000..c30bd9b --- /dev/null +++ b/runtime/src/plugin-loader/cdp-client.mjs @@ -0,0 +1,133 @@ +export class CdpClient { + static async connect(target, { createSocket = (url) => new WebSocket(url) } = {}) { + const socket = createSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + const finish = (error) => { + clearTimeout(timer); + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + if (error) { socket.close(); reject(error); } else resolve(); + }; + const onOpen = () => finish(); + const onError = () => finish(new Error(`CDP websocket open failed for target ${target.id}`)); + const onClose = () => finish(new Error(`CDP websocket closed before opening for target ${target.id}`)); + const timer = setTimeout(() => finish(new Error(`CDP websocket open timed out for target ${target.id}`)), 3000); + socket.addEventListener("open", onOpen); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + }); + return new CdpClient(target, socket); + } + + constructor(target, socket) { + this.target = target; + this.socket = socket; + this.nextCommandId = 1; + this.pendingCommands = new Map(); + this.bindingHandlers = new Map(); + this.generation = 0; + this.closed = false; + socket.addEventListener("message", (event) => this.#handleMessage(event.data)); + socket.addEventListener("close", () => this.#rejectPending(new Error(`CDP target ${target.id} disconnected`))); + socket.addEventListener("error", () => this.#rejectPending(new Error(`CDP target ${target.id} failed`))); + } + + get connected() { + return !this.closed && this.socket.readyState === 1; + } + + async command(method, params = {}, timeoutMs = 15_000) { + if (!this.connected) throw new Error(`CDP target ${this.target.id} is not connected`); + const id = this.nextCommandId; + this.nextCommandId += 1; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingCommands.delete(id); + reject(new Error(`${method} timed out for target ${this.target.id}`)); + }, timeoutMs); + this.pendingCommands.set(id, { resolve, reject, timer, method }); + try { this.socket.send(JSON.stringify({ id, method, params })); } + catch (error) { clearTimeout(timer); this.pendingCommands.delete(id); reject(error); } + }); + } + + async useIsolatedWorld() { + if (this.worldContextId !== undefined) return; + this.worldPending ??= (async () => { + const generation = this.generation; + await this.command("Runtime.enable"); + const { frameTree } = await this.command("Page.getFrameTree"); + const { executionContextId } = await this.command("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, worldName: "codex-plugin-loader", grantUniveralAccess: false, + }); + if (this.generation !== generation) throw new Error("Renderer changed during world creation"); + this.worldContextId = executionContextId; + })().finally(() => { this.worldPending = null; }); + await this.worldPending; + } + + async evaluate(expression, executionContextId = this.worldContextId) { + const params = { expression, awaitPromise: true, returnByValue: true }; + if (Number.isSafeInteger(executionContextId)) params.contextId = executionContextId; + const response = await this.command("Runtime.evaluate", params); + if (response?.exceptionDetails) { + const description = response.exceptionDetails.exception?.description ?? response.exceptionDetails.text; + throw new Error(description ?? `Runtime.evaluate failed for target ${this.target.id}`); + } + return response?.result?.value; + } + + async addBinding(name, handler) { + await this.command("Runtime.enable"); + await this.command("Runtime.addBinding", { name, ...(this.worldContextId === undefined ? {} : { executionContextName: "codex-plugin-loader" }) }); + this.bindingHandlers.set(name, handler); + } + + async removeBinding(name) { + this.bindingHandlers.delete(name); + if (this.connected) await this.command("Runtime.removeBinding", { name }); + } + + close() { + this.closed = true; + this.bindingHandlers.clear(); + this.#rejectPending(new Error(`CDP target ${this.target.id} closed`)); + this.socket.close(); + } + + #handleMessage(rawMessage) { + let message; + try { + message = JSON.parse(rawMessage); + } catch { + return; + } + if (!message || typeof message !== "object") return; + if (message.id !== undefined) { + const pending = this.pendingCommands.get(message.id); + if (!pending) return; + this.pendingCommands.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(`${pending.method} failed: ${message.error.message ?? "unknown CDP error"}`)); + else pending.resolve(message.result); + return; + } + if (message.method === "Runtime.executionContextsCleared" || (message.method === "Runtime.executionContextDestroyed" && message.params?.executionContextId === this.worldContextId)) { + this.generation += 1; + this.worldContextId = undefined; + } + if (message.method !== "Runtime.bindingCalled") return; + const handler = this.bindingHandlers.get(message.params?.name); + if (!handler) return; + Promise.resolve().then(() => handler(message.params)).catch(() => {}); + } + + #rejectPending(error) { + for (const { reject, timer } of this.pendingCommands.values()) { + clearTimeout(timer); + reject(error); + } + this.pendingCommands.clear(); + } +} diff --git a/runtime/src/plugin-loader/cli.mjs b/runtime/src/plugin-loader/cli.mjs new file mode 100644 index 0000000..042d90f --- /dev/null +++ b/runtime/src/plugin-loader/cli.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { realpath } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { CodexProcess, findCodexApp } from "./codex-process.mjs"; +import { PluginTargetRegistry } from "./target-registry.mjs"; +import { PluginHost } from "./host.mjs"; +import { readManifest, readPlugins, setPluginEnabled } from "./manifest.mjs"; +import { acquireOwner, claimOwner, daemonPaths, daemonStatus, releaseOwner, startDaemon, stopDaemon, readHostStatus, writeHostStatus } from "./daemon.mjs"; + +process.umask(0o077); + +const [command = "--help", ...args] = process.argv.slice(2); +if (command === "--help") { + console.log("Usage: codex-plugin-loader --config [--port <9341>] [--attach]\nstart runs the Loader in the background; --attach prevents app launch. watch attaches in the foreground. apply injects renderer-only bundles once. stop/remove unload configured modules. status never imports modules."); +} else { + let registry; + try { + if (!["start", "apply", "watch", "status", "remove", "stop"].includes(command)) throw new Error("Unknown loader command"); + const options = {}; + for (let i = 0; i < args.length; i += 1) { + const key = args[i]; + if (!["--config", "--port", "--token", "--attach", "--plugin"].includes(key) || options[key] !== undefined) throw new Error("Invalid loader options"); + if (key === "--attach") options[key] = true; + else { + if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`Missing ${key} value`); + options[key] = args[++i]; + } + } + if (!options["--config"] || (options["--token"] && command !== "watch") || (options["--attach"] && command !== "start") || (options["--plugin"] && command !== "remove")) throw new Error("Invalid options for Loader command; --config is required"); + const manifest = ["status", "stop"].includes(command) + ? { path: await realpath(options["--config"]), plugins: [] } + : await readManifest(options["--config"]); + const port = Number(options["--port"] ?? 9341); + const app = new CodexProcess({ port }); + const paths = daemonPaths(manifest.path, port); + registry = new PluginTargetRegistry({ port, ownsEndpoint: () => app.ownsCdpEndpoint(), owner: paths.owner }); + if (command === "start") { + if (!findCodexApp()) throw new Error("Install the official Codex desktop app first"); + if (!(await app.cdpIsReady(() => registry.discover()))) { + if (await app.ownsCdpEndpoint() || await app.hasCdpLaunchArguments()) await app.waitForCdp(() => registry.discover()); + else { + if (options["--attach"]) throw new Error("An already debug-enabled Codex is required"); + if (await app.portIsListening()) throw new Error("The CDP port is already occupied"); + await app.launchWithCdp(() => registry.discover()); + } + } + const result = await startDaemon({ configPath: manifest.path, port, paths }); + console.log(JSON.stringify(result)); + if (result.results?.some((item) => item.error)) process.exitCode = 1; + } else if (command === "status") { + const windows = []; + if (await app.ownsCdpEndpoint()) { + for (const target of await registry.discover()) windows.push({ targetId: target.id, plugins: await registry.evaluate(target, "window.__codexPluginLoader?.status() ?? []") }); + } + console.log(JSON.stringify({ daemon: await daemonStatus(paths), modules: await readHostStatus(paths), windows })); + } else if (command === "remove" || command === "stop") { + const selectedId = options["--plugin"]; + if (selectedId) { + await setPluginEnabled(manifest.path, selectedId, false); + if ((await daemonStatus(paths)).running) { + const deadline = Date.now() + 15_000; + while (true) { + const modules = await readHostStatus(paths); + const state = modules?.find((module) => module.id === selectedId); + if (modules && !state) break; + if (state?.state === "cleanup-failed") throw new Error("Module cleanup failed; other modules remain active"); + if (!(await daemonStatus(paths)).running) break; + if (Date.now() >= deadline) throw new Error("Module removal did not complete; configuration is disabled"); + await delay(100); + } + } else if (await app.ownsCdpEndpoint()) await registry.removePlugins([selectedId]); + } else { + await stopDaemon(paths); + if (await app.ownsCdpEndpoint()) await registry.removePlugins(manifest.plugins.map(({ id }) => id)); + } + } else if (command === "apply") { + const { results } = await registry.ensurePlugins(await readPlugins(manifest.path, paths.owner)); + console.log(JSON.stringify(results)); + if (results.some((result) => result.error)) process.exitCode = 1; + } else { + const token = options["--token"] ?? await acquireOwner(paths); + await claimOwner(paths, token); + const stop = new AbortController(); + const onStop = () => stop.abort(); + process.once("SIGINT", onStop); + process.once("SIGTERM", onStop); + const host = new PluginHost({ registry, manifest }); + let previous = ""; + let publishedModules = ""; + const publish = async () => { + const modules = host.status(); + const signature = JSON.stringify(modules); + if (signature === publishedModules) return; + await writeHostStatus(paths, token, modules); + publishedModules = signature; + }; + try { + await host.start(); + const initial = await host.sync(); + await publish(); + await claimOwner(paths, token, "running"); + process.send?.({ type: "ready", result: initial }); + while (!stop.signal.aborted) { + let results; + try { results = await host.sync(); } + catch { + if (!(await app.isRunning())) break; + results = [{ stage: "discovery", error: "Waiting for an owned Codex renderer" }]; + } + await publish(); + const state = JSON.stringify(results.map(({ targetId, pluginId, stage, error }) => ({ targetId, pluginId, stage, error }))); + if (state !== previous) { console.log(state); previous = state; } + await delay(1000, undefined, { signal: stop.signal }).catch(() => {}); + } + } finally { + const failures = await host.close(); + if (failures.length && await app.ownsCdpEndpoint()) console.error(JSON.stringify({ cleanup: failures })); + await releaseOwner(paths, token); + process.removeListener("SIGINT", onStop); + process.removeListener("SIGTERM", onStop); + } + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } finally { registry?.close(); } +} diff --git a/runtime/src/plugin-loader/codex-process.mjs b/runtime/src/plugin-loader/codex-process.mjs new file mode 100644 index 0000000..0514cbf --- /dev/null +++ b/runtime/src/plugin-loader/codex-process.mjs @@ -0,0 +1,115 @@ +import { execFile, execFileSync, spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const defaultRun = promisify(execFile); +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export function findCodexApp() { + for (const appPath of ["/Applications/Codex.app", join(homedir(), "Applications", "Codex.app"), "/Applications/ChatGPT.app"]) { + try { + const plist = join(appPath, "Contents", "Info.plist"); + const read = (key) => execFileSync("/usr/bin/plutil", ["-extract", key, "raw", plist], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 2000 }).trim(); + if (read("CFBundleIdentifier") !== "com.openai.codex") continue; + const executable = read("CFBundleExecutable"); + if (!/^[A-Za-z0-9_-]+$/u.test(executable)) continue; + return { appPath, executable: join(appPath, "Contents", "MacOS", executable) }; + } catch { /* Uninstalled or unrelated applications are not candidates. */ } + } + return null; +} + +export class CodexProcess { + constructor(options = {}) { + this.port = options.port ?? 9341; + const detected = options.appPath ? null : findCodexApp(); + this.appPath = options.appPath ?? detected?.appPath ?? "/Applications/Codex.app"; + this.executable = options.executable ?? detected?.executable ?? join(this.appPath, "Contents", "MacOS", "ChatGPT"); + this.run = options.run ?? defaultRun; + this.spawn = options.spawn ?? spawn; + } + + async isRunning() { + const { stdout } = await this.run("/bin/ps", ["-axo", "command="]); + return stdout.split("\n").some((command) => { + const value = command.trim(); + return value === this.executable || value.startsWith(`${this.executable} `); + }); + } + + async hasCdpLaunchArguments() { + const { stdout } = await this.run("/bin/ps", ["-axo", "command="]); + return stdout.split("\n").some((command) => { + const value = command.trim(); + const isCodex = value === this.executable || value.startsWith(`${this.executable} `); + return isCodex && value.includes(`--remote-debugging-port=${this.port}`); + }); + } + + async ownsCdpEndpoint() { + let stdout; + try { + ({ stdout } = await this.run("/usr/sbin/lsof", [ + "-nP", `-iTCP:${this.port}`, "-sTCP:LISTEN", "-t", + ])); + } catch (error) { + if (error.code === 1) return false; + throw error; + } + + for (const value of stdout.trim().split(/\s+/u)) { + let pid = Number(value); + for (let depth = 0; Number.isSafeInteger(pid) && pid > 0 && depth < 8; depth += 1) { + try { + const [{ stdout: command }, { stdout: parent }] = await Promise.all([ + this.run("/bin/ps", ["-p", String(pid), "-o", "command="]), + this.run("/bin/ps", ["-p", String(pid), "-o", "ppid="]), + ]); + if (command.trim() === this.executable || command.trim().startsWith(`${this.executable} `)) return true; + pid = Number(parent.trim()); + } catch { + break; + } + } + } + return false; + } + + async portIsListening() { + try { + const { stdout } = await this.run("/usr/sbin/lsof", ["-nP", `-iTCP:${this.port}`, "-sTCP:LISTEN", "-t"]); + return stdout.trim().length > 0; + } catch (error) { + if (error.code === 1) return false; + throw error; + } + } + + async cdpIsReady(discoverTargets) { + try { + return await this.ownsCdpEndpoint() && (await discoverTargets()).length > 0; + } catch { + return false; + } + } + + async waitForCdp(discoverTargets, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await this.cdpIsReady(discoverTargets)) return; + await wait(250); + } + throw new Error("等待 Codex CDP 端口超时"); + } + + async launchWithCdp(discoverTargets) { + if (await this.isRunning()) throw new Error("Codex is already running without debugging. Quit Codex completely, then use the explicit loader entry again. The running app was not restarted."); + const child = this.spawn(this.executable, [ + "--remote-debugging-address=127.0.0.1", + `--remote-debugging-port=${this.port}`, + ], { detached: true, stdio: "ignore" }); + child.unref(); + await this.waitForCdp(discoverTargets); + } +} diff --git a/runtime/src/plugin-loader/daemon.mjs b/runtime/src/plugin-loader/daemon.mjs new file mode 100644 index 0000000..6ffcc50 --- /dev/null +++ b/runtime/src/plugin-loader/daemon.mjs @@ -0,0 +1,148 @@ +import { execFile, fork } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { setTimeout as delay } from "node:timers/promises"; + +const run = promisify(execFile); +const cliPath = fileURLToPath(new URL("cli.mjs", import.meta.url)); + +export function daemonPaths(configPath, port) { + const owner = createHash("sha256").update(`${resolve(configPath)}:${port}`).digest("hex").slice(0, 24); + const root = join(dirname(configPath), `.loader-${owner}`); + return { owner, root, lease: join(root, "owner.json"), log: join(root, "loader.log"), status: join(root, "status.json") }; +} + +export async function readOwner(paths) { + try { + if ((await lstat(paths.lease)).isSymbolicLink()) throw new Error("Loader ownership file must not be a symlink"); + const record = JSON.parse(await readFile(paths.lease, "utf8")); + if (!Number.isSafeInteger(record?.pid) || record.pid <= 0 || typeof record.token !== "string") throw new Error("Invalid Loader ownership record"); + return record; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +function isAlive(pid) { + try { process.kill(pid, 0); return true; } + catch (error) { if (error.code === "ESRCH") return false; throw error; } +} + +export async function daemonStatus(paths) { + const record = await readOwner(paths); + if (!record || !isAlive(record.pid)) return { running: false, pid: null }; + let stdout; + try { ({ stdout } = await run("/bin/ps", ["-p", String(record.pid), "-o", "command="])); } + catch (error) { if (!isAlive(record.pid)) return { running: false, pid: null }; throw error; } + const owned = record.scriptPath === cliPath && typeof record.command === "string" + && record.command.startsWith(`${cliPath} `) && stdout.trim().endsWith(record.command); + return { running: owned, pid: owned ? record.pid : null, conflict: !owned, phase: record.phase }; +} + +/** Exclusive ownership refuses live conflicts and reports stale leases without racing their replacement. */ +export async function acquireOwner(paths) { + await mkdir(paths.root, { recursive: true, mode: 0o700 }); + if ((await lstat(paths.root)).isSymbolicLink()) throw new Error("Loader state directory must not be a symlink"); + const token = randomUUID(); + let handle; + try { handle = await open(paths.lease, "wx", 0o600); } + catch (error) { + if (error.code !== "EEXIST") throw error; + const previous = await readOwner(paths); + if (!previous || isAlive(previous.pid)) throw new Error("A Loader for this configuration is already running or starting"); + throw new Error(`Stale Loader ownership file: ${paths.lease}. After confirming that process ${previous.pid} has exited, remove only that file and retry.`); + } + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, token, phase: "starting", scriptPath: cliPath, command: process.argv.slice(1).join(" ") })); + } finally { await handle.close(); } + return token; +} + +export async function claimOwner(paths, token, phase = "starting") { + if ((await readOwner(paths))?.token !== token) throw new Error("Loader startup ownership changed"); + const next = `${paths.lease}.${token}`; + await writeFile(next, JSON.stringify({ pid: process.pid, token, phase, scriptPath: cliPath, command: process.argv.slice(1).join(" ") }), { mode: 0o600 }); + await rename(next, paths.lease); +} + +export async function releaseOwner(paths, token) { + if ((await readOwner(paths))?.token === token) await rm(paths.lease, { force: true }); +} + +export async function startDaemon({ configPath, port, paths }) { + const status = await daemonStatus(paths); + if (status.running && status.phase === "running") return { status: "already-running", pid: status.pid }; + const token = await acquireOwner(paths); + let log; + let child; + try { + log = await open(paths.log, "a", 0o600); + child = fork(cliPath, ["watch", "--config", configPath, "--port", String(port), "--token", token], { + detached: true, stdio: ["ignore", log.fd, log.fd, "ipc"], + }); + const result = await new Promise((resolveReady, reject) => { + const timer = setTimeout(() => done(new Error("Loader startup timed out; see loader.log")), 30_000); + const done = (error, value) => { + clearTimeout(timer); + child.removeListener("error", onError); + child.removeListener("exit", onExit); + child.removeListener("message", onMessage); + if (error) reject(error); else resolveReady(value); + }; + const onError = () => done(new Error("Loader process could not start")); + const onExit = () => done(new Error("Loader exited before readiness; see loader.log")); + const onMessage = (message) => { if (message?.type === "ready") done(null, message.result); }; + child.once("error", onError); + child.once("exit", onExit); + child.on("message", onMessage); + }); + child.disconnect(); + child.unref(); + return { status: "running", pid: child.pid, results: result }; + } catch (error) { + if (child?.pid) { + child.kill("SIGTERM"); + // Keep ownership until the child exits, so a retry cannot overlap its cleanup. + if (child.exitCode === null && child.signalCode === null) await new Promise((resolveExit) => { + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + child.once("exit", () => { clearTimeout(timer); resolveExit(); }); + }); + } + await releaseOwner(paths, token); + throw error; + } finally { await log?.close(); } +} + +export async function stopDaemon(paths) { + const status = await daemonStatus(paths); + if (status.conflict) throw new Error("Loader ownership conflicts with another live process; it was not stopped"); + if (!status.running) return; + process.kill(status.pid, "SIGTERM"); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (!(await daemonStatus(paths)).running) return; + await delay(50); + } + throw new Error("Loader did not stop; no files were replaced"); +} + + +export async function writeHostStatus(paths, token, modules) { + if ((await readOwner(paths))?.token !== token) throw new Error("Loader status ownership changed"); + const next = `${paths.status}.${token}`; + await writeFile(next, JSON.stringify({ token, modules }), { mode: 0o600 }); + await rename(next, paths.status); +} + +export async function readHostStatus(paths) { + const owner = await readOwner(paths); + if (!owner) return null; + try { + const status = JSON.parse(await readFile(paths.status, "utf8")); + return status.token === owner.token ? status.modules : null; + } catch (error) { if (error.code === "ENOENT") return null; throw error; } +} diff --git a/runtime/src/plugin-loader/examples/hello.js b/runtime/src/plugin-loader/examples/hello.js new file mode 100644 index 0000000..9764174 --- /dev/null +++ b/runtime/src/plugin-loader/examples/hello.js @@ -0,0 +1,10 @@ +var CodexPlugin = { + activate({ config, onDispose }) { + const badge = document.createElement("div"); + badge.textContent = config.text ?? "Codex Plugin Loader"; + Object.assign(badge.style, { position: "fixed", bottom: "12px", right: "12px", zIndex: "2147483647", padding: "8px 12px", borderRadius: "8px", background: "#173f35", color: "white", pointerEvents: "none" }); + document.body.append(badge); + onDispose(() => badge.remove()); + return { status: () => ({ mounted: badge.isConnected }) }; + }, +}; diff --git a/runtime/src/plugin-loader/examples/loader.json b/runtime/src/plugin-loader/examples/loader.json new file mode 100644 index 0000000..e3ade7f --- /dev/null +++ b/runtime/src/plugin-loader/examples/loader.json @@ -0,0 +1,4 @@ +{ + "apiVersion": 1, + "plugins": [{ "id": "hello", "version": "1.0.0", "entry": "hello.js", "config": { "text": "Codex Plugin Loader" } }] +} diff --git a/runtime/src/plugin-loader/examples/service-loader.json b/runtime/src/plugin-loader/examples/service-loader.json new file mode 100644 index 0000000..c41ae28 --- /dev/null +++ b/runtime/src/plugin-loader/examples/service-loader.json @@ -0,0 +1,4 @@ +{ + "apiVersion": 1, + "plugins": [{ "id": "example-service", "version": "1.0.0", "entry": "service.js", "service": "service.mjs" }] +} diff --git a/runtime/src/plugin-loader/examples/service.js b/runtime/src/plugin-loader/examples/service.js new file mode 100644 index 0000000..6376fe0 --- /dev/null +++ b/runtime/src/plugin-loader/examples/service.js @@ -0,0 +1,10 @@ +var CodexPlugin = { + async activate({ rpc, onDispose }) { + const badge = document.createElement("div"); + badge.textContent = await rpc.call("greeting"); + Object.assign(badge.style, { position: "fixed", bottom: "12px", right: "12px", padding: "8px", background: "#173f35", color: "white", zIndex: "2147483647" }); + onDispose(() => badge.remove()); + document.body.append(badge); + return { status: () => ({ mounted: badge.isConnected }) }; + }, +}; diff --git a/runtime/src/plugin-loader/examples/service.mjs b/runtime/src/plugin-loader/examples/service.mjs new file mode 100644 index 0000000..1425d90 --- /dev/null +++ b/runtime/src/plugin-loader/examples/service.mjs @@ -0,0 +1,3 @@ +export function activate({ id, rpc }) { + rpc.handle("greeting", () => `Hello from ${id}`); +} diff --git a/runtime/src/plugin-loader/expressions.mjs b/runtime/src/plugin-loader/expressions.mjs new file mode 100644 index 0000000..b621203 --- /dev/null +++ b/runtime/src/plugin-loader/expressions.mjs @@ -0,0 +1,30 @@ +import { createRendererTransport } from "./renderer-transport.mjs"; +import { installLoader } from "./renderer.mjs"; +import { createResourceScope } from "./lifecycle.mjs"; + +export const LOADER_VERSION = "0.3.1"; + +/** Bootstrap only infrastructure; it never loads a product module. */ +export function buildLoaderExpression() { + return `(${installLoader.toString()})(${createResourceScope.toString()}, ${JSON.stringify(LOADER_VERSION)}, ${createRendererTransport.toString()})`; +} + +export function buildPluginExpression({ id, version, apiVersion = 1, source, config = {}, owner = null, endpoint = null }) { + if (apiVersion !== 1 || typeof id !== "string" || !/^[a-z][a-z0-9.-]{0,63}$/.test(id) + || typeof version !== "string" || !version || typeof source !== "string") throw new Error("Invalid plugin descriptor"); + return `(async () => { + const loader = ${buildLoaderExpression()}; + return loader.load(${JSON.stringify({ id, version, apiVersion, owner, endpoint, instanceId: endpoint?.token })}, async (context) => { + ${source}\n; + return CodexPlugin.activate(context); + }, ${JSON.stringify(config)}); + })()`; +} + +export function buildPluginRemovalExpression(id) { + return `window.__codexPluginLoader?.unload(${JSON.stringify(id)}) ?? false`; +} + +export function buildPluginMessageExpression(id, message) { + return `window.__codexPluginLoader?.dispatch(${JSON.stringify(id)}, ${JSON.stringify(message)}) ?? false`; +} diff --git a/runtime/src/plugin-loader/host.mjs b/runtime/src/plugin-loader/host.mjs new file mode 100644 index 0000000..d170f00 --- /dev/null +++ b/runtime/src/plugin-loader/host.mjs @@ -0,0 +1,162 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createServiceModule } from "./service-module.mjs"; +import { createResourceScope } from "./lifecycle.mjs"; +import { readManifest } from "./manifest.mjs"; + +/** Runs configured host modules and their renderer plugins without product-specific branches. */ +export class PluginHost { + constructor({ registry, manifest, createModule = createServiceModule }) { + this.registry = registry; + this.manifest = manifest; + this.createModule = createModule; + this.modules = []; + this.syncing = null; + this.closing = null; + } + + async start() { + if (this.modules.length || this.closing) throw new Error("Plugin host has already started"); + for (const entry of this.manifest.plugins.filter((item) => item.enabled !== false)) await this.add(entry); + } + + async add(entry) { + const scope = createResourceScope(); + const record = { id: entry.id, scope, module: null, clients: new Map(), pending: new Set(), error: null, signature: JSON.stringify(entry) }; + this.modules.push(record); + try { + const stateDirectory = join(this.manifest.root, "module-data", entry.id); + await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); + const activation = this.createModule(entry, { root: this.manifest.root, stateDirectory, owner: this.registry.owner, onDispose: scope.onDispose }) + .then((module) => { if (typeof module?.dispose === "function") scope.onDispose(() => module.dispose()); return module; }); + record.module = await scope.bounded(() => activation, "Service activation"); + if (typeof record.module?.renderer !== "function") throw new Error("Expected renderer descriptor provider"); + for (const binding of record.module.bindings ?? []) { + if (typeof binding.name !== "string" || !/^__[A-Za-z][A-Za-z0-9_]*$/.test(binding.name) || typeof binding.handle !== "function") throw new Error("Invalid module binding"); + } + } catch { + record.error = "host-activation"; + try { await scope.close(); } catch { record.error = "host-cleanup"; } + record.module = null; + } + } + + sync() { + if (this.closing) return Promise.reject(new Error("Plugin host is closing")); + this.syncing ??= this.synchronize().finally(() => { this.syncing = null; }); + return this.syncing; + } + + async synchronize() { + const latest = await readManifest(this.manifest.path); + const entries = latest.plugins.filter((item) => item.enabled !== false); + for (const record of [...this.modules]) { + if (!record.retiring && !entries.some((entry) => entry.id === record.id && JSON.stringify(entry) === record.signature)) { + const failures = await this.removeRecord(record); + if (failures.length) { record.retiring = true; record.error = "module-cleanup"; record.module = null; continue; } + this.modules = this.modules.filter((item) => item !== record); + } + } + for (const entry of entries) if (!this.modules.some((record) => record.id === entry.id)) await this.add(entry); + const targets = await this.registry.discover(); + this.registry.prune(targets); + if (!targets.length) throw new Error("No Codex renderer found"); + const targetIds = new Set(targets.map(({ id }) => id)); + const results = []; + for (const record of this.modules) { + if (!record.module || record.scope.signal.aborted) { results.push({ pluginId: record.id, stage: record.error, error: "Host module unavailable" }); continue; } + for (const [id] of record.clients) { + if (!targetIds.has(id)) { + record.clients.delete(id); + try { await record.scope.bounded(() => record.module.onTargetRemoved?.(id), "Target cleanup"); } catch { record.error = "target-cleanup"; } + } + } + try { + await record.scope.bounded(() => record.module.tick?.(), "Host tick"); + for (const target of targets) { + if (record.scope.signal.aborted) break; + let stage = "connect"; + try { + await this.registry.claimPlugin(target, record.id); + const client = await this.registry.client(target); + const previous = record.clients.get(target.id); + const changed = !previous?.bound || previous.client !== client || previous.generation !== client.generation; + if (changed) { + await record.scope.bounded(() => record.module.onTargetRemoved?.(target.id), "Target cleanup"); + if (previous?.client === client) for (const name of previous.bindings) await client.removeBinding(name); + const state = { client, generation: client.generation, ready: false, bound: false, bindings: [] }; + record.clients.set(target.id, state); + for (const binding of record.module.bindings ?? []) { + if (client.bindingHandlers.has(binding.name)) throw new Error("Duplicate module binding"); + await client.addBinding(binding.name, (params) => { + if (record.scope.signal.aborted) return; + const pending = Promise.resolve().then(() => binding.handle(client, params)); + record.pending.add(pending); + return pending.catch(() => { record.error = "binding"; }).finally(() => record.pending.delete(pending)); + }); + state.bindings.push(binding.name); + } + state.bound = true; + } + stage = "renderer"; + const descriptor = await record.scope.bounded(() => record.module.renderer(client), "Renderer descriptor"); + if (descriptor?.id !== record.id || typeof descriptor.version !== "string" || typeof descriptor.expression !== "string") throw new Error("Invalid module renderer descriptor"); + const result = await this.registry.ensurePlugins([descriptor], [target]); + results.push(...result.results); + if (result.results.some((item) => item.error)) continue; + const state = record.clients.get(target.id); + if (!state.ready || result.injectedTargetIds.has(target.id)) { + stage = "ready"; + await record.scope.bounded(() => record.module.onReady?.(client), "Module ready"); + state.ready = true; + } + } catch (error) { + if (error?.message === "Module ready timed out" || error?.message === "Target cleanup timed out") { record.scope.abort(); record.error = stage; } + results.push({ targetId: target.id, pluginId: record.id, stage, error: `Module ${stage} failed` }); + } + } + } catch { + record.scope.abort(); record.error = "host-tick"; + results.push({ pluginId: record.id, stage: "host-tick", error: "Host module synchronization failed" }); + } + } + if (!this.modules.length && targets.length) await this.registry.ensurePlugins([], targets); + return results; + } + + async removeRecord(record) { + record.scope.abort(); + const failures = []; + try { record.module?.cancelPending?.(); } catch { failures.push({ pluginId: record.id, stage: "request-cancellation" }); } + try { await this.registry.removePlugins([record.id]); } catch { failures.push({ pluginId: record.id, stage: "renderer-cleanup" }); } + for (const { client, bindings } of record.clients.values()) { + for (const name of bindings) { + try { await client.removeBinding(name); } catch { failures.push({ pluginId: record.id, stage: "binding-cleanup" }); } + } + } + record.clients.clear(); + try { await record.scope.bounded(() => Promise.allSettled([...record.pending]), "Pending messages"); } + catch { failures.push({ pluginId: record.id, stage: "pending-messages" }); } + try { await record.scope.close(); } catch { failures.push({ pluginId: record.id, stage: "host-cleanup" }); } + return failures; + } + + status() { + return this.modules.map((record) => ({ id: record.id, + state: record.retiring ? "cleanup-failed" : record.module && !record.scope.signal.aborted ? "active" : "failed", + })); + } + + close() { + if (this.closing) return this.closing; + for (const record of this.modules) record.scope.abort(); + this.closing = (async () => { + await this.syncing?.catch(() => {}); + const failures = []; + for (const record of [...this.modules].reverse()) failures.push(...await this.removeRecord(record)); + this.registry.close(); + return failures; + })(); + return this.closing; + } +} diff --git a/runtime/src/plugin-loader/index.d.mts b/runtime/src/plugin-loader/index.d.mts new file mode 100644 index 0000000..ff44d25 --- /dev/null +++ b/runtime/src/plugin-loader/index.d.mts @@ -0,0 +1,80 @@ +export type MaybePromise = T | Promise; +export interface PluginDescriptor { id: string; version: string; expression: string; instanceId?: string } +export interface PluginSource { id: string; version: string; source: string; apiVersion?: 1; config?: Record; owner?: string | null } +export interface PluginEntry { id: string; version: string; entry: string; service?: string; enabled?: boolean; config?: Record } +export interface LoaderManifest { path: string; root: string; plugins: PluginEntry[] } +export interface Target { id: string; type?: string; url?: string; webSocketDebuggerUrl: string } +export interface BindingCall { name: string; payload: string; executionContextId: number } +export interface ResourceContext { + id: string; config: Record; signal: AbortSignal; + onDispose(dispose: () => MaybePromise): void; +} +export interface RendererContext extends ResourceContext { + rpc: { call(method: string, payload?: unknown, options?: { signal?: AbortSignal; timeoutMs?: number }): Promise }; + events: { subscribe(topic: string, handler: (payload: unknown) => MaybePromise): () => void }; +} +export interface RendererPlugin { + dispose?(): MaybePromise; + isActive?(): boolean; + status?(): unknown; + handleMessage?(message: unknown): MaybePromise; +} +export interface ServiceContext extends ResourceContext { + stateDirectory: string; + rpc: { handle(method: string, handler: (payload: unknown, request: { clientId: string; signal: AbortSignal }) => MaybePromise): void }; + events: { publish(topic: string, payload: unknown, clientId?: string): void }; + clients: { + onConnect(handler: (clientId: string) => MaybePromise): () => void; + onDisconnect(handler: (clientId: string) => MaybePromise): () => void; + }; +} +export interface PluginResult { targetId?: string; pluginId: string | null; status?: unknown; injected?: boolean; stage?: string; error?: string } +export interface CleanupFailure { pluginId: string; stage: string } +export declare const LOADER_VERSION: string; +export declare function buildLoaderExpression(): string; +export declare function buildPluginExpression(plugin: PluginSource): string; +export declare function buildPluginMessageExpression(id: string, message: unknown): string; +export declare function buildPluginRemovalExpression(id: string): string; +export declare function isCodexRendererTarget(target: unknown): boolean; +export declare function findCodexApp(): { appPath: string; executable: string } | null; +export declare class CdpClient { + static connect(target: Target, options?: { createSocket?: (url: string) => WebSocket }): Promise; + constructor(target: Target, socket: WebSocket); + readonly target: Target; + readonly connected: boolean; + readonly generation: number; + command(method: string, params?: Record, timeoutMs?: number): Promise; + evaluate(expression: string, executionContextId?: number): Promise; + addBinding(name: string, handler: (params: BindingCall) => MaybePromise): Promise; + removeBinding(name: string): Promise; + close(): void; +} +export declare class PluginTargetRegistry { + constructor(options: { port?: number; owner?: string | null; ownsEndpoint(): Promise; connect?: (target: Target) => Promise }); + readonly owner: string | null; + discover(): Promise; + client(target: Target): Promise; + evaluate(target: Target, expression: string): Promise; + prune(targets: Target[]): void; + ensurePlugins(plugins: PluginDescriptor[], targets?: Target[]): Promise<{ targets: Target[]; results: PluginResult[]; injectedTargetIds: Set }>; + removePlugins(ids: string[]): Promise; + close(): void; +} +export declare class PluginHost { + constructor(options: { registry: PluginTargetRegistry; manifest: LoaderManifest }); + start(): Promise; + sync(): Promise; + status(): { id: string; state: "active" | "failed" | "cleanup-failed" }[]; + close(): Promise; +} +export declare class CodexProcess { + constructor(options?: { port?: number; appPath?: string; executable?: string }); + isRunning(): Promise; + hasCdpLaunchArguments(): Promise; + ownsCdpEndpoint(): Promise; + portIsListening(): Promise; + cdpIsReady(discover: () => Promise): Promise; + waitForCdp(discover: () => Promise, timeoutMs?: number): Promise; + launchWithCdp(discover: () => Promise): Promise; +} +export declare function createLauncher(options: { launcherPath: string; nodePath: string; cliPath: string; configPath: string; port?: number; logPath: string; iconPath?: string; bundleId?: string }): Promise; diff --git a/runtime/src/plugin-loader/index.mjs b/runtime/src/plugin-loader/index.mjs new file mode 100644 index 0000000..0927cdc --- /dev/null +++ b/runtime/src/plugin-loader/index.mjs @@ -0,0 +1,6 @@ +export { CodexProcess, findCodexApp } from "./codex-process.mjs"; +export { CdpClient } from "./cdp-client.mjs"; +export { PluginTargetRegistry, isCodexRendererTarget } from "./target-registry.mjs"; +export { LOADER_VERSION, buildLoaderExpression, buildPluginExpression, buildPluginRemovalExpression, buildPluginMessageExpression } from "./expressions.mjs"; +export { PluginHost } from "./host.mjs"; +export { createLauncher } from "./launcher.mjs"; diff --git a/runtime/src/plugin-loader/ipc-protocol.mjs b/runtime/src/plugin-loader/ipc-protocol.mjs new file mode 100644 index 0000000..102c85b --- /dev/null +++ b/runtime/src/plugin-loader/ipc-protocol.mjs @@ -0,0 +1,3 @@ +export function assertMessageSize(value) { + if (Buffer.byteLength(JSON.stringify(value) ?? "null") > 1024 * 1024) throw new Error("Service message exceeds 1 MiB"); +} diff --git a/runtime/src/plugin-loader/launcher.mjs b/runtime/src/plugin-loader/launcher.mjs new file mode 100644 index 0000000..24d6f77 --- /dev/null +++ b/runtime/src/plugin-loader/launcher.mjs @@ -0,0 +1,37 @@ +import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +const quoteShell = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; +const escapeXml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); + +/** Creates the explicit Loader app entry; no product controller is referenced. */ +export async function createLauncher({ launcherPath, nodePath, cliPath, configPath, port = 9341, logPath, iconPath, bundleId = "io.github.c0sc0s.codex-plugin-loader" }) { + try { + if ((await lstat(launcherPath)).isSymbolicLink()) throw new Error("Launcher must not be a symlink"); + const plist = await readFile(`${launcherPath}/Contents/Info.plist`, "utf8"); + if (!plist.includes(`${escapeXml(bundleId)}`)) throw new Error("Another application owns the launcher path"); + } catch (error) { if (error.code !== "ENOENT") throw error; } + const next = `${launcherPath}.next-${process.pid}`; + const executable = `${next}/Contents/MacOS/codex-plugin-loader`; + try { + await mkdir(dirname(executable), { recursive: true }); + await mkdir(`${next}/Contents/Resources`, { recursive: true }); + if (iconPath) await copyFile(iconPath, `${next}/Contents/Resources/icon.icns`); + const script = `#!/bin/sh\nif ! ${[nodePath, cliPath, "start", "--config", configPath, "--port", String(port)].map(quoteShell).join(" ")} >> ${quoteShell(logPath)} 2>&1 \n +CFBundleDisplayNameCodex Plugin Loader +CFBundleExecutablecodex-plugin-loader +CFBundleIdentifier${escapeXml(bundleId)} +CFBundleNameCodex Plugin Loader +${iconPath ? 'CFBundleIconFileicon.icns' : ''} +CFBundlePackageTypeAPPL +CFBundleShortVersionString1.0 +LSUIElement +\n`); + await rm(launcherPath, { recursive: true, force: true }); + await rename(next, launcherPath); + } finally { await rm(next, { recursive: true, force: true }); } + return launcherPath; +} diff --git a/runtime/src/plugin-loader/lifecycle.mjs b/runtime/src/plugin-loader/lifecycle.mjs new file mode 100644 index 0000000..89d1f58 --- /dev/null +++ b/runtime/src/plugin-loader/lifecycle.mjs @@ -0,0 +1,30 @@ +/** Tracks plugin resources and bounds asynchronous cleanup. Safe to serialize into a renderer. */ +export function createResourceScope(timeoutMs = 5000) { + const abort = new AbortController(); + const disposers = []; + let closing = null; + const bounded = (operation, label) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + Promise.resolve().then(operation).then(resolve, reject).finally(() => clearTimeout(timer)); + }); + const onDispose = (dispose) => { + if (typeof dispose !== 'function') throw new Error('Expected a cleanup function'); + if (closing) { + // An activation may finish after its deadline; do not retain its late resources. + void bounded(dispose, 'Late cleanup').catch(() => {}); + } else disposers.push(dispose); + }; + const close = () => { + if (closing) return closing; + abort.abort(); + closing = Promise.resolve().then(async () => { + let failed = false; + for (const dispose of disposers.splice(0).reverse()) { + try { await bounded(dispose, 'Cleanup'); } catch { failed = true; } + } + if (failed) throw new Error('Plugin cleanup failed'); + }); + return closing; + }; + return { signal: abort.signal, abort: () => abort.abort(), onDispose, close, bounded }; +} diff --git a/runtime/src/plugin-loader/manifest.mjs b/runtime/src/plugin-loader/manifest.mjs new file mode 100644 index 0000000..2c89ee3 --- /dev/null +++ b/runtime/src/plugin-loader/manifest.mjs @@ -0,0 +1,56 @@ +import { readFile, realpath, open, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { buildPluginExpression } from "./expressions.mjs"; + +/** Reads identities without touching executable entries, so status/removal survives a missing bundle. */ +export async function readManifest(configPath) { + const path = await realpath(configPath); + const manifest = JSON.parse(await readFile(path, "utf8")); + if (manifest?.apiVersion !== 1 || !Array.isArray(manifest.plugins)) throw new Error("Expected loader config apiVersion 1 and plugins array"); + const ids = new Set(); + for (const plugin of manifest.plugins) { + if (!plugin || typeof plugin.id !== "string" || !/^[a-z][a-z0-9.-]{0,63}$/.test(plugin.id) || ids.has(plugin.id)) throw new Error("Invalid or duplicate plugin ID"); + const entry = plugin.entry; + if (typeof entry !== "string" || !entry || isAbsolute(entry) || plugin.host !== undefined) throw new Error("Specify a local renderer entry"); + if ((typeof plugin.version !== "string" || !plugin.version)) throw new Error("Renderer version is required"); + if (plugin.service !== undefined && (typeof plugin.service !== "string" || !plugin.service || isAbsolute(plugin.service))) throw new Error("Invalid local service entry"); + if (plugin.config !== undefined && (!plugin.config || typeof plugin.config !== "object" || Array.isArray(plugin.config))) throw new Error("config must be an object"); + if (plugin.apiVersion !== undefined && plugin.apiVersion !== 1) throw new Error("Unsupported plugin apiVersion"); + if (plugin.enabled !== undefined && typeof plugin.enabled !== "boolean") throw new Error("enabled must be a boolean"); + ids.add(plugin.id); + } + return { path, root: dirname(path), plugins: manifest.plugins }; +} + +export async function resolvePluginEntry(root, entry) { + const path = await realpath(resolve(root, entry)); + const local = relative(root, path); + if (local === ".." || local.startsWith("../") || isAbsolute(local)) throw new Error("Plugin entry must stay inside the config directory"); + return path; +} + +export async function readPlugins(configPath, owner = null) { + const manifest = await readManifest(configPath); + return Promise.all(manifest.plugins.filter((plugin) => plugin.enabled !== false).map(async (plugin) => { + if (plugin.service) throw new Error("Service modules require start/watch; apply only supports renderer bundles"); + const source = await readFile(await resolvePluginEntry(manifest.root, plugin.entry), "utf8"); + return { id: plugin.id, version: plugin.version, expression: buildPluginExpression({ ...plugin, source, owner }) }; + })); +} + +/** Changes only the selected module, serializing writers and preserving other configuration. */ +export async function setPluginEnabled(configPath, id, enabled) { + const path = await realpath(configPath); + const lockPath = `${path}.lock`; + const lock = await open(lockPath, "wx", 0o600); + const next = `${path}.next-${process.pid}`; + try { + await readManifest(path); + const config = JSON.parse(await readFile(path, "utf8")); + const plugin = config.plugins.find((entry) => entry.id === id); + if (!plugin) throw new Error(`Unknown module ${id}`); + plugin.enabled = enabled; + await writeFile(next, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await rename(next, path); + } finally { await lock.close(); await rm(lockPath, { force: true }); await rm(next, { force: true }); } +} diff --git a/runtime/src/plugin-loader/package.json b/runtime/src/plugin-loader/package.json new file mode 100644 index 0000000..95facc4 --- /dev/null +++ b/runtime/src/plugin-loader/package.json @@ -0,0 +1,24 @@ +{ + "name": "@c0sc0s/codex-plugin-loader", + "version": "0.3.1", + "description": "Local renderer plugin lifecycle and explicit CDP entry for Codex desktop.", + "type": "module", + "exports": { + "types": "./index.d.mts", + "import": "./index.mjs" + }, + "bin": { + "codex-plugin-loader": "./cli.mjs" + }, + "files": [ + "*.mjs", + "*.d.mts", + "examples", + "README.md" + ], + "engines": { + "node": ">=22" + }, + "license": "UNLICENSED", + "types": "./index.d.mts" +} diff --git a/runtime/src/plugin-loader/renderer-transport.mjs b/runtime/src/plugin-loader/renderer-transport.mjs new file mode 100644 index 0000000..b6ae722 --- /dev/null +++ b/runtime/src/plugin-loader/renderer-transport.mjs @@ -0,0 +1,66 @@ +// Serialized with the Loader; no module-scope dependencies are available in the renderer. +export function createRendererTransport(endpoint, scope) { + const pending = new Map(); + const subscribers = new Map(); + let sequence = 0; + const send = (message) => { + const encoded = JSON.stringify({ ...message, token: endpoint.token }); + if (new TextEncoder().encode(encoded).byteLength > 65_536) throw new Error("RPC request exceeds 64 KiB"); + const binding = window[endpoint.binding]; + if (typeof binding !== "function") throw new Error("Plugin service is unavailable"); + binding(encoded); + }; + const transport = { + rpc: Object.freeze({ call(method, payload = null, options = {}) { + if (scope.signal.aborted || options.signal?.aborted) return Promise.reject(new Error("RPC cancelled")); + if (typeof method !== "string" || !method || method.length > 128) return Promise.reject(new Error("Invalid RPC method")); + if (pending.size >= 128) return Promise.reject(new Error("Too many pending RPC requests")); + const timeoutMs = options.timeoutMs ?? 10_000; + if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) return Promise.reject(new Error("RPC timeout must be between 1 and 30000 ms")); + const id = ++sequence; + return new Promise((resolve, reject) => { + const finish = (error, value) => { + if (!pending.delete(id)) return; + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + error ? reject(error) : resolve(value); + }; + const cancel = (reason) => { + try { send({ type: "cancel", id }); } catch {} + finish(new Error(reason)); + }; + const abort = () => cancel("RPC cancelled"); + const timer = setTimeout(() => cancel("RPC timed out"), timeoutMs); + pending.set(id, { finish, cancel }); + options.signal?.addEventListener("abort", abort, { once: true }); + try { send({ type: "call", id, method, payload, timeoutMs }); } catch (error) { finish(error); } + }); + } }), + events: Object.freeze({ subscribe(topic, handler) { + if (scope.signal.aborted) throw new Error("Plugin is closing"); + if (typeof topic !== "string" || !topic || typeof handler !== "function") throw new Error("Invalid event subscription"); + const handlers = subscribers.get(topic) ?? new Set(); + subscribers.set(topic, handlers); + handlers.add(handler); + return () => { handlers.delete(handler); if (!handlers.size) subscribers.delete(topic); }; + } }), + deliver(message) { + if (scope.signal.aborted || message?.token !== endpoint.token) return false; + if (message.type === "reply") { + const request = pending.get(message.id); + request?.finish(message.error ? new Error(message.error) : null, message.value); + return Boolean(request); + } + if (message.type === "event") { + for (const handler of subscribers.get(message.topic) ?? []) Promise.resolve().then(() => { if (!scope.signal.aborted) return handler(message.payload); }).catch(() => {}); + return true; + } + return false; + }, + }; + scope.onDispose(() => { + for (const request of [...pending.values()]) request.cancel("Plugin unloaded"); + subscribers.clear(); + }); + return transport; +} diff --git a/runtime/src/plugin-loader/renderer.mjs b/runtime/src/plugin-loader/renderer.mjs new file mode 100644 index 0000000..87eeec8 --- /dev/null +++ b/runtime/src/plugin-loader/renderer.mjs @@ -0,0 +1,103 @@ +// This function is serialized into the renderer; keep it independent of module scope. +export function installLoader(createResourceScope, version, createRendererTransport) { + const existing = window.__codexPluginLoader; + if (existing) { + if (existing.apiVersion === 1 && existing.version === version) return existing; + if (existing.apiVersion !== 1 || existing.status().length) throw new Error("Incompatible Codex Plugin Loader is active; unload its plugins before upgrading"); + } + const plugins = new Map(); + const owners = new Map(); + const queues = new Map(); + const serialize = (id, operation) => { + const next = (queues.get(id) ?? Promise.resolve()).catch(() => {}).then(operation); + queues.set(id, next); + next.finally(() => { if (queues.get(id) === next) queues.delete(id); }).catch(() => {}); + return next; + }; + const isActive = (record) => { + try { return record.instance?.isActive?.() !== false; } + catch { return false; } + }; + const summary = (record) => record ? { + id: record.id, version: record.version, + ...(record.instanceId ? { instanceId: record.instanceId } : {}), + state: record.state === "active" && !isActive(record) ? "inactive" : record.state, + } : null; + const clean = async (record) => { + record.state = "unloading"; + try { await record.scope.close(); } + catch { record.state = "cleanup-failed"; throw new Error(`Plugin ${record.id} cleanup failed`); } + }; + const loader = { + apiVersion: 1, + version, + claim(id, owner) { + if (owners.has(id) && owners.get(id) !== owner) return false; + owners.set(id, owner); + return true; + }, + async release(id, owner) { + if (owners.has(id) && owners.get(id) !== owner) throw new Error("Plugin belongs to another Loader configuration"); + const result = await loader.unload(id); + owners.delete(id); + return result; + }, + status: (id) => id === undefined ? [...plugins.values()].map(summary) : summary(plugins.get(id)), + load: (manifest, activate, config = {}) => { + if (!manifest || manifest.apiVersion !== 1 || typeof manifest.id !== "string" || !/^[a-z][a-z0-9.-]{0,63}$/.test(manifest.id) + || typeof manifest.version !== "string" || !manifest.version || typeof activate !== "function") { + return Promise.reject(new Error("Invalid plugin contract")); + } + return serialize(manifest.id, async () => { + if (manifest.owner && owners.get(manifest.id) !== manifest.owner) throw new Error("Plugin ownership changed"); + const previous = plugins.get(manifest.id); + if (summary(previous)?.state === "active" && previous.version === manifest.version && previous.instanceId === manifest.instanceId) return summary(previous); + if (previous?.state === "cleanup-failed") throw new Error(`Plugin ${manifest.id} requires a renderer reload after failed cleanup`); + if (previous) { await clean(previous); plugins.delete(manifest.id); } + const record = { id: manifest.id, version: manifest.version, instanceId: manifest.instanceId, state: "loading", scope: createResourceScope(), instance: null }; + plugins.set(manifest.id, record); + record.transport = createRendererTransport(manifest.endpoint ?? { binding: null, token: null }, record.scope); + try { + const activation = Promise.resolve().then(() => activate(Object.freeze({ + id: manifest.id, config, signal: record.scope.signal, onDispose: record.scope.onDispose, + rpc: record.transport?.rpc, events: record.transport?.events, + }))).then((instance) => { + if (typeof instance?.dispose === "function") record.scope.onDispose(() => instance.dispose()); + return instance; + }); + record.instance = await record.scope.bounded(() => activation, "Activation"); + if (record.scope.signal.aborted) throw new Error("Activation cancelled"); + record.state = "active"; + return summary(record); + } catch (error) { + await clean(record); + record.state = error?.message === "Activation timed out" ? "cleanup-failed" : "failed"; + throw new Error(`Plugin ${manifest.id} activation failed`); + } + }); + }, + unload: (id) => { + plugins.get(id)?.scope.abort(); + return serialize(id, async () => { + const record = plugins.get(id); + if (!record) return false; + if (record.state === "cleanup-failed") throw new Error(`Plugin ${id} requires a renderer reload after failed cleanup`); + await clean(record); + plugins.delete(id); + return true; + }); + }, + deliver: (id, message) => plugins.get(id)?.transport?.deliver(message) ?? false, + dispatch: (id, message) => { + const record = plugins.get(id); + if (summary(record)?.state !== "active") return false; + return record.instance?.handleMessage?.(message) ?? false; + }, + inspect: (id) => { + const record = plugins.get(id); + return record?.state === "active" ? record.instance?.status?.() ?? summary(record) : summary(record); + }, + }; + window.__codexPluginLoader = Object.freeze(loader); + return loader; +} diff --git a/runtime/src/plugin-loader/service-module.mjs b/runtime/src/plugin-loader/service-module.mjs new file mode 100644 index 0000000..5bf9b9d --- /dev/null +++ b/runtime/src/plugin-loader/service-module.mjs @@ -0,0 +1,68 @@ +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { buildPluginExpression } from "./expressions.mjs"; +import { resolvePluginEntry } from "./manifest.mjs"; +import { ServiceProcess } from "./service-process.mjs"; + +/** Adapts the business SDK to the host's private target lifecycle. */ +export async function createServiceModule(entry, { root, stateDirectory, owner, onDispose }) { + const source = await readFile(await resolvePluginEntry(root, entry.entry), "utf8"); + const clients = new Map(); + const binding = `__codexPlugin_${entry.id.replaceAll(/[^a-z0-9]/g, "_")}_${randomBytes(6).toString("hex")}`; + const deliver = async (state, message) => { + if (!state.client.connected || state.client.generation !== state.generation || clients.get(state.client.target.id) !== state) return; + return state.client.evaluate(`window.__codexPluginLoader?.deliver(${JSON.stringify(entry.id)}, ${JSON.stringify({ ...message, token: state.token })})`, state.contextId); + }; + const service = entry.service ? new ServiceProcess({ + entry: await resolvePluginEntry(root, entry.service), id: entry.id, config: entry.config ?? {}, stateDirectory, + onEvent: (message) => Promise.allSettled([...clients.values()].filter((state) => message.clientId === undefined || state.token === message.clientId).map((state) => deliver(state, message))), + }) : null; + onDispose(() => service?.close()); + await service?.ready; + const disconnect = async (id) => { + const state = clients.get(id); + if (!state) return; + clients.delete(id); + for (const request of state.pending.values()) request.abort(); + await service?.request("disconnect", { clientId: state.token }, { timeoutMs: 1000 }).catch(() => {}); + }; + return { + cancelPending() { for (const state of clients.values()) for (const controller of state.pending.values()) controller.abort(); }, + tick() { if (service?.closed) throw new Error("Plugin service exited"); }, + async renderer(client) { + if (service?.closed) throw new Error("Plugin service exited; restart the module"); + const status = await client.evaluate(`window.__codexPluginLoader?.status(${JSON.stringify(entry.id)})`); + if (clients.has(client.target.id) && status?.state !== "active") await disconnect(client.target.id); + let state = clients.get(client.target.id); + if (!state) { + state = { client, token: randomBytes(24).toString("hex"), generation: client.generation, contextId: client.worldContextId, pending: new Map() }; + clients.set(client.target.id, state); + } + return state.descriptor ??= { id: entry.id, version: entry.version, instanceId: state.token, expression: buildPluginExpression({ + ...entry, source, owner, endpoint: { binding, token: state.token }, + }) }; + }, + bindings: service ? [{ name: binding, async handle(client, params) { + const state = clients.get(client.target.id); + if (!state || state.client !== client || state.generation !== client.generation || typeof params.payload !== "string" || Buffer.byteLength(params.payload) > 65_536) return; + let request; + try { request = JSON.parse(params.payload); } catch { return; } + if (request?.token !== state.token || !Number.isSafeInteger(request.id) || request.id < 1) return; + if (state.contextId !== undefined && state.contextId !== params.executionContextId) return; + state.contextId = params.executionContextId; + if (request.type === "cancel") { state.pending.get(request.id)?.abort(); return; } + if (request.type !== "call" || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > 30_000 || typeof request.method !== "string" || request.method.length > 128 || state.pending.has(request.id)) return; + if (state.pending.size >= 128) { await deliver(state, { type: "reply", id: request.id, error: "Too many pending RPC requests" }); return; } + const controller = new AbortController(); + state.pending.set(request.id, controller); + try { + const value = await service.request("call", { method: request.method, payload: request.payload, clientId: state.token }, { signal: controller.signal, timeoutMs: request.timeoutMs }); + await deliver(state, { type: "reply", id: request.id, value }); + } catch { await deliver(state, { type: "reply", id: request.id, error: "Plugin service request failed" }); } + finally { state.pending.delete(request.id); } + } }] : [], + onReady: (client) => service?.request("connect", { clientId: clients.get(client.target.id).token }), + onTargetRemoved: disconnect, + async dispose() { for (const id of [...clients.keys()]) await disconnect(id); }, + }; +} diff --git a/runtime/src/plugin-loader/service-process.mjs b/runtime/src/plugin-loader/service-process.mjs new file mode 100644 index 0000000..95e0a38 --- /dev/null +++ b/runtime/src/plugin-loader/service-process.mjs @@ -0,0 +1,70 @@ +import { fork } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { assertMessageSize } from "./ipc-protocol.mjs"; + +/** One process per service bounds failures even when plugin code blocks the event loop. */ +export class ServiceProcess { + constructor({ entry, id, config = {}, stateDirectory, timeoutMs = 10_000, shutdownMs = 1500, onEvent = () => {} }) { + this.timeoutMs = timeoutMs; + this.shutdownMs = shutdownMs; + this.pending = new Map(); + this.sequence = 0; + this.closed = false; + this.child = fork(fileURLToPath(new URL("./service-worker.mjs", import.meta.url)), [], { + stdio: ["ignore", "ignore", "ignore", "ipc"], execArgv: [], serialization: "json", + }); + this.exited = new Promise((resolve) => this.child.once("exit", resolve)); + this.child.on("error", () => this.fail()); + this.child.on("exit", () => this.fail()); + this.child.on("message", (message) => { + if (this.closed || !message || typeof message !== "object") return; + if (message.type === "event") { Promise.resolve().then(() => onEvent(message)).catch(() => {}); return; } + const pending = this.pending.get(message.id); + if (!pending) return; + message.error ? pending.finish(new Error(message.error)) : pending.finish(null, message.value); + }); + this.ready = this.request("activate", { entry, pluginId: id, config, stateDirectory }); + } + + fail() { + this.closed = true; + for (const pending of [...this.pending.values()]) pending.finish(new Error("Plugin service unavailable")); + } + + request(type, payload = {}, { signal, timeoutMs = this.timeoutMs } = {}) { + if (this.closed || !this.child.connected) return Promise.reject(new Error("Plugin service unavailable")); + if (signal?.aborted) return Promise.reject(new Error("Service request cancelled")); + if (this.pending.size >= 128) return Promise.reject(new Error("Too many pending service requests")); + const id = ++this.sequence; + const message = { ...payload, type, id }; + try { assertMessageSize(message); } catch (error) { return Promise.reject(error); } + return new Promise((resolve, reject) => { + const finish = (error, value) => { + if (!this.pending.delete(id)) return; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + error ? reject(error) : resolve(value); + }; + const cancel = (reason) => { + if (this.child.connected) this.child.send({ type: "cancel", requestId: id }, () => {}); + finish(new Error(reason)); + }; + const abort = () => cancel("Service request cancelled"); + const timer = setTimeout(() => cancel("Service request timed out"), Math.min(30_000, Math.max(1, timeoutMs))); + this.pending.set(id, { finish }); + signal?.addEventListener("abort", abort, { once: true }); + this.child.send(message, (error) => { if (error) finish(new Error("Plugin service disconnected")); }); + }); + } + + close() { + this.closing ??= (async () => { + if (!this.closed) await this.request("dispose", {}, { timeoutMs: this.shutdownMs }).catch(() => {}); + this.fail(); + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL"); + await this.exited; + })(); + return this.closing; + } +} diff --git a/runtime/src/plugin-loader/service-worker.mjs b/runtime/src/plugin-loader/service-worker.mjs new file mode 100644 index 0000000..db41de0 --- /dev/null +++ b/runtime/src/plugin-loader/service-worker.mjs @@ -0,0 +1,73 @@ +import { pathToFileURL } from "node:url"; +import { createResourceScope } from "./lifecycle.mjs"; +import { assertMessageSize } from "./ipc-protocol.mjs"; + +const scope = createResourceScope(1000); +const handlers = new Map(); +const requests = new Map(); +const clients = new Set(); +const connectHandlers = new Set(); +const disconnectHandlers = new Set(); +let activated = false; +const send = (message) => { + assertMessageSize(message); + if (process.connected) process.send(message, () => {}); +}; +const subscribe = (set, callback) => { + if (typeof callback !== "function") throw new Error("Expected callback"); + set.add(callback); + const remove = () => { set.delete(callback); }; + scope.onDispose(remove); + return remove; +}; +async function handle(message) { + const { type, id } = message; + if (type === "cancel") { requests.get(message.requestId)?.controller.abort(); return; } + if (type === "activate") { + if (activated) throw new Error("Service already activated"); + activated = true; + const imported = await import(pathToFileURL(message.entry).href); + if (typeof imported.activate !== "function") throw new Error("Expected service activate export"); + const instance = await imported.activate(Object.freeze({ + id: message.pluginId, config: message.config, stateDirectory: message.stateDirectory, + signal: scope.signal, onDispose: scope.onDispose, + rpc: Object.freeze({ handle(method, callback) { + if (typeof method !== "string" || !method || method.length > 128 || handlers.has(method) || typeof callback !== "function") throw new Error("Invalid or duplicate RPC method"); + handlers.set(method, callback); + scope.onDispose(() => { handlers.delete(method); }); + } }), + events: Object.freeze({ publish(topic, payload, clientId) { + if (scope.signal.aborted) return; + if (typeof topic !== "string" || !topic || topic.length > 128) throw new Error("Invalid event topic"); + if (clientId !== undefined && !clients.has(clientId)) return; + send({ type: "event", topic, payload, clientId }); + } }), + clients: Object.freeze({ onConnect: (callback) => subscribe(connectHandlers, callback), onDisconnect: (callback) => subscribe(disconnectHandlers, callback) }), + })); + if (typeof instance?.dispose === "function") scope.onDispose(() => instance.dispose()); + return null; + } + if (type === "dispose") { scope.abort(); for (const { controller } of requests.values()) controller.abort(); await scope.close(); return null; } + if (scope.signal.aborted) throw new Error("Service is closing"); + if (type === "connect") { clients.add(message.clientId); for (const callback of connectHandlers) await callback(message.clientId); return null; } + if (type === "disconnect") { clients.delete(message.clientId); for (const request of requests.values()) if (request.clientId === message.clientId) request.controller.abort(); for (const callback of disconnectHandlers) await callback(message.clientId); return null; } + if (type !== "call" || !handlers.has(message.method)) throw new Error("Unknown service method"); + if (requests.size >= 128) throw new Error("Too many service requests"); + const controller = new AbortController(); + requests.set(id, { controller, clientId: message.clientId }); + try { return await handlers.get(message.method)(message.payload, { clientId: message.clientId, signal: controller.signal }); } + finally { requests.delete(id); } +} +process.on("message", (message) => { + if (!message || typeof message !== "object") return; + Promise.resolve().then(() => handle(message)).then( + (value) => { + if (message.type === "cancel") return; + try { send({ id: message.id, value: value ?? null }); } + catch { send({ id: message.id, error: "Invalid or oversized service reply" }); } + }, + () => { send({ id: message.id, error: "Plugin service request failed" }); }, + ).catch(() => {}); +}); +// An abruptly terminated daemon must not leave services or open databases behind. +process.on("disconnect", () => { process.exit(0); }); diff --git a/runtime/src/plugin-loader/target-registry.mjs b/runtime/src/plugin-loader/target-registry.mjs new file mode 100644 index 0000000..de16bee --- /dev/null +++ b/runtime/src/plugin-loader/target-registry.mjs @@ -0,0 +1,116 @@ +import { CdpClient } from "./cdp-client.mjs"; +import { buildLoaderExpression, buildPluginRemovalExpression } from "./expressions.mjs"; + +export function isCodexRendererTarget(target) { + return target?.type === "page" && typeof target.url === "string" && target.url.startsWith("app://-") + && typeof target.webSocketDebuggerUrl === "string"; +} + +/** Owns reusable CDP connections and rejects unowned/non-loopback targets. */ +export class PluginTargetRegistry { + constructor({ port = 9341, ownsEndpoint, connect = CdpClient.connect, owner = null }) { + if (!Number.isSafeInteger(port) || port < 1024 || port > 65535 || typeof ownsEndpoint !== "function") throw new Error("Invalid CDP endpoint configuration"); + this.port = port; + this.owner = owner; + this.ownsEndpoint = ownsEndpoint; + this.connect = connect; + this.clients = new Map(); + } + + async assertOwnership() { + if (!(await this.ownsEndpoint())) { + this.close(); + throw new Error(`CDP endpoint 127.0.0.1:${this.port} is not owned by Codex`); + } + } + + async discover() { + await this.assertOwnership(); + const response = await fetch(`http://127.0.0.1:${this.port}/json/list`, { + redirect: "error", signal: AbortSignal.timeout(800), + }); + if (!response.ok) throw new Error(`CDP discovery failed: HTTP ${response.status}`); + const targets = await response.json(); + if (!Array.isArray(targets)) throw new Error("Invalid CDP target list"); + return targets.filter(isCodexRendererTarget); + } + + async client(target) { + const url = new URL(target.webSocketDebuggerUrl); + if (url.protocol !== "ws:" || url.hostname !== "127.0.0.1" || Number(url.port) !== this.port + || url.username || url.password) throw new Error("CDP target must use the owned loopback endpoint"); + await this.assertOwnership(); + const previous = this.clients.get(target.id); + if (previous?.connected && previous.target.webSocketDebuggerUrl === target.webSocketDebuggerUrl) { await previous.useIsolatedWorld?.(); return previous; } + previous?.close(); + const client = await this.connect(target); + this.clients.set(target.id, client); + await client.useIsolatedWorld?.(); + return client; + } + + async evaluate(target, expression) { + return (await this.client(target)).evaluate(expression); + } + + prune(targets) { + const ids = new Set(targets.map(({ id }) => id)); + for (const [id, client] of this.clients) { + if (!ids.has(id)) { client.close(); this.clients.delete(id); } + } + } + + close() { + for (const client of this.clients.values()) client.close(); + this.clients.clear(); + } + + async claimPlugin(target, id) { + await this.evaluate(target, `${buildLoaderExpression()}; true`); + if (this.owner && !(await this.evaluate(target, `window.__codexPluginLoader.claim(${JSON.stringify(id)}, ${JSON.stringify(this.owner)})`))) throw new Error("Plugin owner conflict"); + } + + async ensurePlugins(plugins, targets) { + await this.assertOwnership(); + targets ??= await this.discover(); + if (!targets.length) throw new Error("No Codex renderer found"); + const results = []; + const injectedTargetIds = new Set(); + await Promise.all(targets.map(async (target) => { + try { await this.evaluate(target, `${buildLoaderExpression()}; true`); } + catch { results.push({ targetId: target.id, pluginId: null, stage: "bootstrap", error: "Loader bootstrap failed" }); return; } + for (const plugin of plugins) { + let stage = "inspect"; + try { + if (this.owner && !(await this.evaluate(target, `window.__codexPluginLoader.claim(${JSON.stringify(plugin.id)}, ${JSON.stringify(this.owner)})`))) throw new Error("Plugin owner conflict"); + const state = await this.evaluate(target, `window.__codexPluginLoader.status(${JSON.stringify(plugin.id)})`); + let injected = false; + if (state?.state !== "active" || state.version !== plugin.version || (plugin.instanceId && state.instanceId !== plugin.instanceId)) { + stage = "activate"; + await this.evaluate(target, plugin.expression); + injectedTargetIds.add(target.id); + injected = true; + } + stage = "status"; + const status = await this.evaluate(target, `window.__codexPluginLoader.inspect(${JSON.stringify(plugin.id)})`); + results.push({ targetId: target.id, pluginId: plugin.id, status, injected }); + } catch { + results.push({ targetId: target.id, pluginId: plugin.id, error: `Plugin ${stage} failed`, stage }); + } + } + })); + return { targets, results, injectedTargetIds }; + } + + async removePlugins(ids) { + const targets = await this.discover(); + const failures = []; + await Promise.all(targets.map(async (target) => { + for (const id of ids) { + try { await this.evaluate(target, this.owner ? `window.__codexPluginLoader?.release ? window.__codexPluginLoader.release(${JSON.stringify(id)}, ${JSON.stringify(this.owner)}) : (${buildPluginRemovalExpression(id)})` : buildPluginRemovalExpression(id)); } + catch { failures.push(`${target.id}/${id}`); } + } + })); + if (failures.length) throw new Error(`Plugin removal failed: ${failures.join(", ")}`); + } +} diff --git a/runtime/src/runtime-target-registry.mjs b/runtime/src/runtime-target-registry.mjs index 8450dc6..3804907 100644 --- a/runtime/src/runtime-target-registry.mjs +++ b/runtime/src/runtime-target-registry.mjs @@ -1,92 +1 @@ -import { buildInjectionExpression, buildRemovalExpression, RUNTIME_VERSION } from "./inject-expression.mjs"; - -export function isCodexRendererTarget(target) { - return target?.type === "page" - && typeof target.url === "string" - && target.url.startsWith("app://-") - && typeof target.webSocketDebuggerUrl === "string"; -} - -export class RuntimeTargetRegistry { - constructor(options) { - this.port = options.port; - this.ownsEndpoint = options.ownsEndpoint; - this.settingsRepository = options.settingsRepository; - } - - async discover() { - const response = await fetch(`http://127.0.0.1:${this.port}/json/list`, { - redirect: "error", - signal: AbortSignal.timeout(800), - }); - if (!response.ok) throw new Error(`CDP discovery failed: HTTP ${response.status}`); - const targets = await response.json(); - return targets.filter(isCodexRendererTarget); - } - - async evaluate(target, expression) { - const socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - socket.close(); - reject(new Error(`CDP websocket open timed out for target ${target.id}`)); - }, 3000); - socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); - socket.addEventListener("error", () => { clearTimeout(timer); reject(new Error(`CDP websocket open failed for target ${target.id}`)); }, { once: true }); - }); - - try { - return await new Promise((resolve, reject) => { - const id = 1; - const timer = setTimeout(() => reject(new Error(`Runtime.evaluate timed out for target ${target.id}`)), 15_000); - socket.addEventListener("message", (event) => { - let message; - try { - message = JSON.parse(event.data); - } catch { - return; - } - if (message.id !== id) return; - clearTimeout(timer); - if (message.error) reject(new Error(message.error.message ?? "Runtime.evaluate failed")); - else if (message.result?.exceptionDetails) reject(new Error(message.result.exceptionDetails.text ?? "injected script failed")); - else resolve(message.result?.result?.value); - }); - socket.send(JSON.stringify({ - id, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true }, - })); - }); - } finally { - socket.close(); - } - } - - async ensureInjected(settingsState) { - if (!(await this.ownsEndpoint())) throw new Error(`拒绝连接:127.0.0.1:${this.port} 不属于 Codex`); - const resolvedSettings = settingsState ?? await this.settingsRepository.read(); - const targets = await this.discover(); - if (targets.length === 0) throw new Error("未找到 Codex 主窗口 renderer"); - const results = []; - const injectedTargetIds = new Set(); - for (const target of targets) { - const activeVersion = await this.evaluate(target, "window.__codexSidebarTags?.version ?? null"); - if (activeVersion === RUNTIME_VERSION) { - results.push(await this.evaluate(target, "window.__codexSidebarTags.status()")); - continue; - } - results.push(await this.evaluate(target, buildInjectionExpression({ - tagDefinitions: resolvedSettings.settings.tags, - settingsSource: resolvedSettings.exists ? "repository" : "defaults", - }))); - injectedTargetIds.add(target.id); - } - return { targets, results, injectedTargetIds }; - } - - async removeInjection() { - if (!(await this.ownsEndpoint())) throw new Error(`拒绝连接:127.0.0.1:${this.port} 不属于 Codex`); - for (const target of await this.discover()) await this.evaluate(target, buildRemovalExpression()); - } -} +export { PluginTargetRegistry as RuntimeTargetRegistry, isCodexRendererTarget } from "./plugin-loader/target-registry.mjs"; diff --git a/runtime/src/tags-plugin.mjs b/runtime/src/tags-plugin.mjs new file mode 100644 index 0000000..2c36e48 --- /dev/null +++ b/runtime/src/tags-plugin.mjs @@ -0,0 +1,2 @@ +export const TAGS_PLUGIN_ID = "codex-tags"; +export const RUNTIME_VERSION = "6.3.0"; diff --git a/runtime/src/tags-service.mjs b/runtime/src/tags-service.mjs new file mode 100644 index 0000000..dbf4aa5 --- /dev/null +++ b/runtime/src/tags-service.mjs @@ -0,0 +1,86 @@ +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { ControllerRouter } from "./controller-router.mjs"; +import { createRuntimeMessage, RUNTIME_PROTOCOL_VERSION, RuntimeMessageType } from "./protocol.mjs"; +import { TAG_COLOR_PRESETS, LEGACY_TONE_COLORS } from "./tag-settings.mjs"; +import { RUNTIME_VERSION } from "./tags-plugin.mjs"; +import { SessionSearchIndex } from "./search-index.mjs"; +import { SessionCatalog } from "./session-catalog.mjs"; +import { SettingsRepository } from "./settings-repository.mjs"; + +const run = promisify(execFile); + +/** Local search and tag settings depend only on the Loader service SDK. */ +export async function activate({ stateDirectory, signal, onDispose, config, rpc, events, clients }) { + const dataDirectory = typeof config.dataDirectory === "string" ? config.dataDirectory : stateDirectory; + const settingsRepository = new SettingsRepository(join(dataDirectory, "settings.json")); + const searchIndex = new SessionSearchIndex(join(dataDirectory, "search.sqlite")); + let indexRefresh = Promise.resolve(); + onDispose(async () => { try { await indexRefresh; } finally { searchIndex.close(); } }); + const catalog = new SessionCatalog(); + let catalogState = await catalog.read(); + let catalogSignature = JSON.stringify(catalogState); + let indexStatus = { phase: "indexing", completed: 0, total: 0, changed: 0 }; + let settingsState = await settingsRepository.read(); + const send = (client, message) => { events.publish("message", message, client.id); return Promise.resolve(); }; + const router = new ControllerRouter({ + searchIndex, settingsRepository, + getSettings: () => settingsState.settings, + onSettingsChanged: async (settings) => { + settingsState = { settings, exists: true, error: null }; + events.publish("message", createRuntimeMessage(RuntimeMessageType.settingsSnapshot, { settings })); + }, + waitForIndex: () => indexRefresh, getIndexStatus: () => indexStatus, send, + openSession: async (threadId) => { + if (catalogState.items.some((item) => item.threadId === threadId)) await run("/usr/bin/open", [`codex://threads/${threadId}`]); + }, + }); + // The first connected window may supply a locally cached configuration before a settings file exists. + let bootstrap = Promise.resolve(); + rpc.handle("bootstrap", (payload) => { + bootstrap = bootstrap.catch(() => {}).then(async () => { + if (!settingsState.exists && !settingsState.error && Array.isArray(payload?.tags)) { + const result = await settingsRepository.write(payload.tags); + settingsState = { settings: result.settings, exists: true, error: null }; + } + return { + version: RUNTIME_VERSION, protocolVersion: RUNTIME_PROTOCOL_VERSION, + tagDefinitions: settingsState.settings.tags, settingsSource: settingsState.exists ? "repository" : "defaults", + colorPresets: TAG_COLOR_PRESETS, legacyToneColors: LEGACY_TONE_COLORS, + }; + }); + return bootstrap; + }); + rpc.handle("dispatch", (message, { clientId }) => router.handle({ id: clientId }, message)); + clients.onConnect(async (id) => { + await router.sendSettingsSnapshot({ id }); + await send({ id }, createRuntimeMessage(RuntimeMessageType.catalogSnapshot, catalogState)); + }); + clients.onDisconnect((id) => router.forgetTarget(id)); + + const refreshIndex = () => { + indexRefresh = searchIndex.refresh((status) => { indexStatus = status; }).catch(() => { + indexStatus = { phase: "error", message: "Local search refresh failed" }; + }); + return indexRefresh; + }; + const repeat = (callback, interval) => { + let timer; + const tick = async () => { + try { await callback(); } catch { /* The last valid snapshot remains available. */ } + if (!signal.aborted) timer = setTimeout(tick, interval); + }; + onDispose(() => clearTimeout(timer)); + void tick(); + }; + repeat(refreshIndex, 30_000); + repeat(async () => { + const next = await catalog.read(); + const signature = JSON.stringify(next); + if (signature !== catalogSignature) { + catalogState = next; catalogSignature = signature; + events.publish("message", createRuntimeMessage(RuntimeMessageType.catalogSnapshot, catalogState)); + } + }, 5000); +} diff --git a/runtime/test/controller-router.test.mjs b/runtime/test/controller-router.test.mjs index 3f2a3c8..5032b17 100644 --- a/runtime/test/controller-router.test.mjs +++ b/runtime/test/controller-router.test.mjs @@ -4,21 +4,21 @@ import test from "node:test"; import { ControllerRouter } from "../src/controller-router.mjs"; import { createRuntimeMessage, RuntimeMessageType } from "../src/protocol.mjs"; -const client = { target: { id: "target-1" } }; +const client = { id: "target-1" }; test("navigation normalizes local IDs and rejects remote or malformed targets", async () => { const opened = []; const { router } = createRouter({ openSession: async (id) => opened.push(id) }); const id = "12345678-1234-1234-1234-123456789abc"; for (const threadId of [id, `local:${id}`, `remote:${id}`, "-".repeat(36), "file:///tmp/session"]) { - await router.handle(client, { payload: JSON.stringify(createRuntimeMessage(RuntimeMessageType.navigationOpen, { threadId })) }); + await router.handle(client, createRuntimeMessage(RuntimeMessageType.navigationOpen, { threadId })); } assert.deepEqual(opened, [id, id]); }); test("failed settings writes return saved configuration and an explicit error", async () => { const { router, sent } = createRouter({ settingsRepository: { write: async () => { throw new Error("disk full"); } } }); - await router.handle(client, { payload: JSON.stringify(createRuntimeMessage(RuntimeMessageType.settingsUpdate, { tags: [] })) }); + await router.handle(client, createRuntimeMessage(RuntimeMessageType.settingsUpdate, { tags: [] })); assert.deepEqual(sent.map(({ message }) => message.type), [RuntimeMessageType.settingsSnapshot, RuntimeMessageType.settingsError]); }); @@ -41,24 +41,23 @@ function createRouter(overrides = {}) { test("routes settings snapshots and updates through one versioned boundary", async () => { const { router, sent, getSettings } = createRouter(); const getRequest = createRuntimeMessage(RuntimeMessageType.settingsGet, {}); - assert.equal(await router.handle(client, { payload: JSON.stringify(getRequest), executionContextId: 7 }), true); + assert.equal(await router.handle(client, getRequest), true); assert.equal(sent[0].message.type, RuntimeMessageType.settingsSnapshot); - assert.equal(sent[0].executionContextId, 7); const tags = [{ name: "Review", color: "#123456", description: "复核" }]; const updateRequest = createRuntimeMessage(RuntimeMessageType.settingsUpdate, { tags }); - assert.equal(await router.handle(client, { payload: JSON.stringify(updateRequest) }), true); + assert.equal(await router.handle(client, updateRequest), true); assert.deepEqual(getSettings().tags, tags); }); test("returns bounded search results and ignores unknown messages", async () => { const { router, sent } = createRouter(); const request = createRuntimeMessage(RuntimeMessageType.searchRequest, { query: "match", threadIds: ["thread-1"], limit: 10 }, 4); - assert.equal(await router.handle(client, { payload: JSON.stringify(request), executionContextId: 3 }), true); + assert.equal(await router.handle(client, request), true); assert.equal(sent[0].message.type, RuntimeMessageType.searchResult); assert.equal(sent[0].message.requestId, 4); assert.equal(sent[0].message.payload.items.length, 1); const unknown = createRuntimeMessage("future.message", {}); - assert.equal(await router.handle(client, { payload: JSON.stringify(unknown) }), false); + assert.equal(await router.handle(client, unknown), false); }); diff --git a/runtime/test/inject-expression.test.mjs b/runtime/test/inject-expression.test.mjs deleted file mode 100644 index 59ecc6b..0000000 --- a/runtime/test/inject-expression.test.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { buildInjectionExpression, buildRemovalExpression, buildRuntimeMessageExpression, buildSearchResultExpression } from "../src/inject-expression.mjs"; - -test("builds valid standalone JavaScript expressions", () => { - assert.doesNotThrow(() => new Function(`return ${buildInjectionExpression()}`)); - assert.doesNotThrow(() => new Function(`return ${buildRemovalExpression()}`)); - assert.doesNotThrow(() => new Function(`return ${buildRuntimeMessageExpression({ protocolVersion: 1, type: "hello", payload: {} })}`)); - assert.doesNotThrow(() => new Function(`return ${buildSearchResultExpression({ type: "searchResult", requestId: 1, query: "test", items: [] })}`)); -}); - -test("injects authoritative settings through the runtime config", () => { - const expression = buildInjectionExpression({ - settingsSource: "repository", - tagDefinitions: [{ name: "Review", color: "#123456", description: "人工复核" }], - }); - assert.match(expression, /"settingsSource":"repository"/u); - assert.match(expression, /"name":"Review"/u); -}); - -test("targets only Codex thread title nodes", () => { - const expression = buildInjectionExpression(); - assert.match(expression, /data-thread-title/); - assert.match(expression, /codex-sidebar-tag-chip/); - assert.match(expression, /codex-sidebar-tags-toolbar/); - assert.match(expression, /codex-sidebar-tags-filter-bar/); - assert.match(expression, /codex-sidebar-quick-filter/); - assert.match(expression, /data-codex-sidebar-tags-filtered/); - assert.match(expression, /codex-sidebar-dashboard-dialog/); - assert.match(expression, /Tags/); - assert.doesNotMatch(expression, /Kanban/); - assert.match(expression, /contentMatches/); - assert.match(expression, /codex-sidebar-search-input/); - assert.match(expression, /codex-sidebar-tags-config-v1/); - assert.match(expression, /codex-sidebar-sort-control/); - assert.match(expression, /codex-sidebar-sort-menu/); - assert.match(expression, /codex-sidebar-search-mark/); - assert.match(expression, /codex-sidebar-tag-description/); - assert.match(expression, /codex-sidebar-tag-color-preset/); - assert.match(expression, /codex-sidebar-tag-color-custom/); - assert.match(expression, /renderResultsList/); - assert.match(expression, /TitleDecorator/); - assert.match(expression, /searchRequest/); - assert.match(expression, /setSearchResult/); - assert.doesNotMatch(expression, /setContentIndex|contentByThread/); - assert.match(expression, /role", "listbox/); - assert.match(expression, /sortOptions/); - assert.match(expression, /compositionstart/); - assert.match(expression, /compositionend/); - assert.match(expression, /SessionRegistry/); - assert.match(expression, /data-app-action-sidebar-thread-id/); - assert.match(expression, /codex-sidebar-tags-index-v1/); - assert.match(expression, /renderedIndexSignature/); - assert.match(expression, /isRelevantMutation/); - assert.match(expression, /render-deferred/); - assert.match(expression, /overflow-anchor/); - assert.doesNotMatch(expression, /codex-sidebar-tag-time/); - assert.doesNotMatch(expression, /codex-sidebar-result-time/); -}); diff --git a/runtime/test/loader-api.types.ts b/runtime/test/loader-api.types.ts new file mode 100644 index 0000000..19672c1 --- /dev/null +++ b/runtime/test/loader-api.types.ts @@ -0,0 +1,16 @@ +import type { RendererContext, RendererPlugin, ServiceContext, PluginEntry } from "../src/plugin-loader/index.mjs"; +export const entry: PluginEntry = { id: "example", entry: "renderer.js", service: "service.mjs", version: "1" }; +export async function activate(context: RendererContext): Promise { + const value = await context.rpc.call("read", null, { signal: context.signal }); + context.events.subscribe("updated", () => {}); + context.onDispose(() => {}); + // @ts-expect-error Business code has no CDP transport. + context.evaluate("window"); + return { status: () => value }; +} +export function service(context: ServiceContext) { + context.rpc.handle("read", (_payload, request) => request.clientId); + context.clients.onConnect((id) => context.events.publish("updated", {}, id)); + // @ts-expect-error Services cannot build renderer expressions. + context.renderer(); +} diff --git a/runtime/test/loader-infrastructure.test.mjs b/runtime/test/loader-infrastructure.test.mjs new file mode 100644 index 0000000..f43be2c --- /dev/null +++ b/runtime/test/loader-infrastructure.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CdpClient } from "../src/plugin-loader/cdp-client.mjs"; +import { createResourceScope } from "../src/plugin-loader/lifecycle.mjs"; +import { acquireOwner, daemonPaths, readOwner, releaseOwner } from "../src/plugin-loader/daemon.mjs"; +import { createLauncher } from "../src/plugin-loader/launcher.mjs"; +import { readManifest } from "../src/plugin-loader/manifest.mjs"; + +class Socket extends EventTarget { + readyState = 1; + sent = []; + send(value) { this.sent.push(JSON.parse(value)); } + close() { this.readyState = 3; this.dispatchEvent(new Event("close")); } + message(data) { this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(data) })); } +} + +test("CDP handles synchronous binding failures without uncaught errors and drains commands on close", async () => { + const socket = new Socket(); const client = new CdpClient({ id: "a" }, socket); + client.bindingHandlers.set("__example", () => { throw new Error("handler failure"); }); + socket.message({ method: "Runtime.bindingCalled", params: { name: "__example" } }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const pending = client.command("Runtime.evaluate"); + client.close(); await assert.rejects(pending, /closed/); + assert.equal(client.pendingCommands.size, 0); +}); + +test("CDP cleans pending state when send throws and rejects a socket closed during connection", async () => { + const socket = new Socket(); const client = new CdpClient({ id: "a" }, socket); + socket.send = () => { throw new Error("send failure"); }; + await assert.rejects(client.command("test"), /send failure/); + assert.equal(client.pendingCommands.size, 0); + const connecting = new Socket(); connecting.readyState = 0; + const connection = CdpClient.connect({ id: "a", webSocketDebuggerUrl: "ws://127.0.0.1" }, { createSocket: () => connecting }); + connecting.close(); await assert.rejects(connection, /closed before opening/); +}); + +test("resource deadlines bound stalled cleanup, run remaining disposers and clean late resources", async () => { + const scope = createResourceScope(15); const events = []; + scope.onDispose(() => events.push("remaining")); + scope.onDispose(() => new Promise(() => {})); + await assert.rejects(scope.close(), /cleanup failed/); + scope.onDispose(() => events.push("late")); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(events, ["remaining", "late"]); + assert.equal(scope.signal.aborted, true); +}); + +test("daemon ownership rejects concurrent starts and token mismatches cannot release another owner", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loader-owner-")); t.after(() => rm(root, { recursive: true, force: true })); + const paths = daemonPaths(join(root, "loader.json"), 9341); + const results = await Promise.allSettled([acquireOwner(paths), acquireOwner(paths)]); + assert.equal(results.filter((item) => item.status === "fulfilled").length, 1); + const token = results.find((item) => item.status === "fulfilled").value; + await releaseOwner(paths, "wrong-token"); assert.equal((await readOwner(paths)).token, token); + await releaseOwner(paths, token); assert.equal(await readOwner(paths), null); +}); + +test("standalone launcher invokes Loader with shell-safe paths and no product entry", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loader-launcher-")); t.after(() => rm(root, { recursive: true, force: true })); + const launcherPath = join(root, "Loader.app"); + const options = { launcherPath, nodePath: "/a path/node", cliPath: "/a path/cli.mjs", configPath: "/configs/a'b.json", logPath: join(root, "log") }; + await createLauncher(options); await createLauncher(options); + const source = await readFile(join(launcherPath, "Contents/MacOS/codex-plugin-loader"), "utf8"); + assert.match(source, /'\/a path\/cli\.mjs' 'start' '--config' '\/configs\/a'"'"'b\.json'/); + assert.doesNotMatch(source, /app\.mjs|tags|controller/); + await assert.rejects(createLauncher({ ...options, bundleId: "unrelated" }), /owns/); +}); + +test("diagnostics can read module IDs when their executable files are missing", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loader-manifest-")); t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "loader.json"); + await writeFile(path, JSON.stringify({ apiVersion: 1, plugins: [{ id: "missing", entry: "absent.js", version: "1" }] })); + assert.equal((await readManifest(path)).plugins[0].id, "missing"); +}); + +test("CDP confines evaluation and bindings to the main frame world and recreates it after destruction", async () => { + const socket = new Socket(); const client = new CdpClient({ id: "a" }, socket); + let contextId = 17; + socket.send = (encoded) => { + const message = JSON.parse(encoded); socket.sent.push(message); + const result = message.method === "Page.getFrameTree" ? { frameTree: { frame: { id: "main" } } } + : message.method === "Page.createIsolatedWorld" ? { executionContextId: contextId } + : message.method === "Runtime.evaluate" ? { result: { value: true } } : {}; + queueMicrotask(() => socket.message({ id: message.id, result })); + }; + await Promise.all([client.useIsolatedWorld(), client.useIsolatedWorld()]); + await client.addBinding("__test", () => {}); + await client.evaluate("true"); + assert.deepEqual(socket.sent.find(item => item.method === "Page.createIsolatedWorld").params, { + frameId: "main", worldName: "codex-plugin-loader", grantUniveralAccess: false, + }); + assert.equal(socket.sent.filter(item => item.method === "Page.createIsolatedWorld").length, 1); + assert.equal(socket.sent.find(item => item.method === "Runtime.addBinding").params.executionContextName, "codex-plugin-loader"); + assert.equal(socket.sent.find(item => item.method === "Runtime.evaluate").params.contextId, 17); + socket.message({ method: "Runtime.executionContextDestroyed", params: { executionContextId: 17 } }); + contextId = 19; + await client.useIsolatedWorld(); await client.evaluate("true"); + assert.equal(client.generation, 1); + assert.equal(socket.sent.at(-1).params.contextId, 19); + client.close(); +}); diff --git a/runtime/test/loader-service.test.mjs b/runtime/test/loader-service.test.mjs new file mode 100644 index 0000000..1361bae --- /dev/null +++ b/runtime/test/loader-service.test.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { ServiceProcess } from "../src/plugin-loader/service-process.mjs"; +import { PluginHost } from "../src/plugin-loader/host.mjs"; +import { PluginTargetRegistry } from "../src/plugin-loader/target-registry.mjs"; +import { readManifest, setPluginEnabled } from "../src/plugin-loader/manifest.mjs"; + +async function directory(t) { + const root = await mkdtemp(join(tmpdir(), "loader-service-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} +async function service(t, source, options = {}) { + const root = await directory(t); + const entry = join(root, "service.mjs"); + await writeFile(entry, source); + const child = new ServiceProcess({ entry, id: "test", stateDirectory: root, timeoutMs: 1000, shutdownMs: 100, ...options }); + t.after(() => child.close()); + await child.ready; + return child; +} +async function until(predicate) { + for (let i = 0; i < 100; i++) { if (await predicate()) return; await delay(10); } + assert.fail("condition did not become true"); +} + +test("service crashes reject in-flight RPC without stopping another plugin", async (t) => { + const source = `export function activate({id, rpc}) { + rpc.handle('identity', () => id); + rpc.handle('crash', () => process.exit(12)); + }`; + const broken = await service(t, source); + const healthy = await service(t, source); + assert.equal(await healthy.request("call", { method: "identity" }), "test"); + await assert.rejects(broken.request("call", { method: "crash" }), /unavailable/); + assert.equal(await healthy.request("call", { method: "identity" }), "test"); +}); + +test("cancellation reaches the handler and timed-out requests release the parent queue", async (t) => { + const child = await service(t, `export function activate({rpc}) { + let cancelled = 0; + rpc.handle('wait', (_, {signal}) => new Promise(resolve => signal.addEventListener('abort', () => { cancelled++; resolve(); }, {once:true}))); + rpc.handle('count', () => cancelled); + }`); + const abort = new AbortController(); + const request = child.request("call", { method: "wait" }, { signal: abort.signal }); + abort.abort(); + await assert.rejects(request, /cancelled/); + await assert.rejects(child.request("call", { method: "wait" }, { timeoutMs: 20 }), /timed out/); + await until(async () => (await child.request("call", { method: "count" })) === 2); + assert.equal(child.pending.size, 0); +}); + +test("a CPU-blocked service can be terminated within the shutdown deadline", async (t) => { + const child = await service(t, `export function activate({rpc}) { rpc.handle('block', () => { while(true) {} }); }`); + await assert.rejects(child.request("call", { method: "block" }, { timeoutMs: 30 }), /timed out/); + const start = Date.now(); + await child.close(); + assert.ok(Date.now() - start < 1500); + assert.equal(child.child.signalCode, "SIGKILL"); +}); + +test("oversized requests never enter the IPC queue", async (t) => { + const child = await service(t, `export function activate({rpc}) { rpc.handle('echo', value => value); }`); + await assert.rejects(child.request("call", { method: "echo", payload: "x".repeat(1024 * 1024) }), /exceeds/); + assert.equal(child.pending.size, 0); + assert.equal(await child.request("call", { method: "echo", payload: "ok" }), "ok"); +}); + +async function platform(t) { + const root = await directory(t); + await writeFile(join(root, "service.mjs"), `export function activate({rpc, events, clients}) { + rpc.handle('identity', (_, {clientId}) => clientId); + rpc.handle('echo', value => value); + rpc.handle('slow', value => new Promise(resolve => setTimeout(() => resolve(value), 60))); + clients.onConnect(id => events.publish('ready', id, id)); + }`); + await writeFile(join(root, "renderer.js"), `var CodexPlugin = { async activate(context) { + const record = { context, identity: await context.rpc.call('identity'), ready: null }; + context.events.subscribe('ready', id => { record.ready = id; }); + window.instances ??= {}; window.instances[context.id] = record; + context.onDispose(() => { delete window.instances[context.id]; }); + return { status: () => ({ identity: record.identity, ready: record.ready }) }; + }};`); + const path = join(root, "loader.json"); + await writeFile(path, JSON.stringify({ apiVersion: 1, plugins: ["one", "two"].map(id => ({ id, version: "1", entry: "renderer.js", service: "service.mjs" })) })); + const targets = ["a", "b"].map(id => ({ id, webSocketDebuggerUrl: `ws://127.0.0.1:9341/${id}` })); + const newWorld = () => vm.createContext({ window: {}, AbortController, TextEncoder, setTimeout, clearTimeout }); + const registry = new PluginTargetRegistry({ ownsEndpoint: async () => true, connect: async (target) => ({ + target, generation: 0, connected: true, context: newWorld(), bindingHandlers: new Map(), + evaluate(expression) { return vm.runInContext(expression, this.context); }, + async addBinding(name, handler) { + this.bindingHandlers.set(name, handler); + this.context.window[name] = payload => { void handler({ payload, executionContextId: this.generation + 1 }); }; + }, + async removeBinding(name) { this.bindingHandlers.delete(name); delete this.context.window[name]; }, + close() { this.connected = false; }, + }) }); + registry.discover = async () => targets; + const host = new PluginHost({ registry, manifest: await readManifest(path) }); + t.after(() => host.close()); + await host.start(); + assert.ok((await host.sync()).every(result => !result.error)); + return { host, registry, path, newWorld }; +} + +test("two services route RPC/events per window and scoped removal preserves peers", async (t) => { + const { host, registry, path } = await platform(t); + const a = registry.clients.get("a").context.window; + const b = registry.clients.get("b").context.window; + await until(() => a.instances.one.ready && b.instances.two.ready); + const identities = [a.instances.one.identity, b.instances.one.identity, a.instances.two.identity, b.instances.two.identity]; + assert.equal(new Set(identities).size, 4); + assert.equal(a.instances.one.ready, identities[0]); + assert.equal(b.instances.two.ready, identities[3]); + assert.equal(await a.instances.one.context.rpc.call("echo", "你好"), "你好"); + await assert.rejects(a.instances.one.context.rpc.call("echo", "界".repeat(30_000)), /exceeds/); + await assert.rejects(a.instances.one.context.rpc.call("unknown"), /failed/); + await setPluginEnabled(path, "one", false); + assert.ok((await host.sync()).every(result => !result.error)); + assert.equal(a.instances.one, undefined); + assert.equal(b.instances.one, undefined); + assert.equal(await a.instances.two.context.rpc.call("echo", 42), 42); +}); + +test("renderer replacement rejects old requests and never delivers stale replies into the new instance", async (t) => { + const { host, registry } = await platform(t); + const client = registry.clients.get("a"); + const loader = client.context.window.__codexPluginLoader; + const old = client.context.window.instances.one; + const pending = assert.rejects(old.context.rpc.call("slow", "stale"), /unloaded/); + await loader.unload("one"); + await pending; + assert.ok((await host.sync()).every(result => !result.error)); + const fresh = client.context.window.instances.one; + assert.notEqual(fresh.identity, old.identity); + assert.equal(await fresh.context.rpc.call("echo", "fresh"), "fresh"); + await delay(80); + assert.equal(await fresh.context.rpc.call("echo", "still fresh"), "still fresh"); +}); + +test("document reload reconnects services using a fresh client identity", async (t) => { + const { host, registry, newWorld } = await platform(t); + const client = registry.clients.get("a"); + const before = client.context.window.instances.one.identity; + client.context = newWorld(); client.generation++; + assert.ok((await host.sync()).every(result => !result.error)); + await until(() => client.context.window.instances.one.ready); + assert.notEqual(client.context.window.instances.one.identity, before); + assert.equal(await client.context.window.instances.two.context.rpc.call("echo", "reloaded"), "reloaded"); +}); + +test("oversized replies fail explicitly and the service remains usable", async (t) => { + const child = await service(t, `export function activate({rpc}) { rpc.handle('large', () => 'x'.repeat(1024 * 1024)); rpc.handle('ok', () => true); }`); + await assert.rejects(child.request("call", { method: "large" }), /oversized/); + assert.equal(await child.request("call", { method: "ok" }), true); +}); + +test("removing a module with an in-flight call completes without quarantining peers", async (t) => { + const { host, registry, path } = await platform(t); + const context = registry.clients.get("a").context.window.instances.one.context; + const request = assert.rejects(context.rpc.call("slow", "pending")); + await setPluginEnabled(path, "one", false); + await host.sync(); + await request; + assert.deepEqual(host.status(), [{ id: "two", state: "active" }]); +}); + +test("a replacement daemon replaces surviving renderer endpoints even at the same plugin version", async (t) => { + const { host, registry, path } = await platform(t); + const before = registry.clients.get("a").context.window.instances.one; + // Closing service scopes leaves renderer state behind, as an abrupt daemon exit would. + for (const record of host.modules) await record.scope.close(); + const replacement = new PluginHost({ registry, manifest: await readManifest(path) }); + t.after(() => replacement.close()); + await replacement.start(); + assert.ok((await replacement.sync()).every(result => !result.error)); + const current = registry.clients.get("a").context.window.instances.one; + assert.notEqual(current.identity, before.identity); + assert.equal(before.context.signal.aborted, true); + assert.equal(await current.context.rpc.call("echo", "reconnected"), "reconnected"); +}); diff --git a/runtime/test/manager-core.test.mjs b/runtime/test/manager-core.test.mjs index 61c8d53..266dd6d 100644 --- a/runtime/test/manager-core.test.mjs +++ b/runtime/test/manager-core.test.mjs @@ -73,7 +73,12 @@ test("installer creates a self-contained runtime and local Codex marketplace", a join(manager.paths.marketplacePluginRoot, "hooks", "session-naming.mjs"), "--context", ], { env: { ...process.env, CODEX_TAGS_SETTINGS_PATH: settingsPath } }); assert.deepEqual(JSON.parse(namingJson).tags, [{ name: "Example", description: "Example tasks" }]); - await access(join(manager.paths.launcherPath, "Contents", "MacOS", "codex-tags-launcher")); + const launcher = await readFile(join(manager.paths.launcherPath, "Contents", "MacOS", "codex-plugin-loader"), "utf8"); + assert.match(launcher, /plugin-loader\/cli\.mjs/u); + assert.match(launcher, /loader\.json/u); + assert.doesNotMatch(launcher, /app\.mjs/u); + const loaderConfig = JSON.parse(await readFile(join(manager.paths.installRoot, "loader.json"), "utf8")); + assert.deepEqual(loaderConfig.plugins, [{ id: "codex-tags", entry: "dist/injected.js", service: "tags-service.mjs", version: "6.3.0", config: { dataDirectory: manager.paths.installRoot } }]); const marketplace = JSON.parse(await readFile(join(manager.paths.marketplaceRoot, ".agents", "plugins", "marketplace.json"), "utf8")); assert.equal(marketplace.name, "codex-tags-cli"); assert.equal(marketplace.plugins[0].name, "codex-tags"); diff --git a/runtime/test/plugin-host.test.mjs b/runtime/test/plugin-host.test.mjs new file mode 100644 index 0000000..8aec008 --- /dev/null +++ b/runtime/test/plugin-host.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import { PluginHost } from "../src/plugin-loader/host.mjs"; +import { PluginTargetRegistry } from "../src/plugin-loader/target-registry.mjs"; +import { buildPluginExpression } from "../src/plugin-loader/expressions.mjs"; +import { readManifest, setPluginEnabled } from "../src/plugin-loader/manifest.mjs"; + +async function fixture(t, factories) { + const root = await mkdtemp(join(tmpdir(), "codex-host-")); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "loader.json"); + const events = []; + await writeFile(path, JSON.stringify({ apiVersion: 1, plugins: Object.keys(factories).map((id) => ({ id, entry: `${id}.mjs`, version: "1" })) })); + for (const id of Object.keys(factories)) await writeFile(join(root, `${id}.mjs`), ""); + const targets = [{ id: "window-a", webSocketDebuggerUrl: "ws://127.0.0.1:9341/a" }, { id: "window-b", webSocketDebuggerUrl: "ws://127.0.0.1:9341/b" }]; + const registry = new PluginTargetRegistry({ ownsEndpoint: async () => true }); + const clients = new Map(); + const newContext = () => vm.createContext({ window: {}, AbortController, setTimeout, clearTimeout }); + registry.connect = async (target) => { + const client = { + target, generation: 0, connected: true, context: newContext(), bindingHandlers: new Map(), + async evaluate(expression) { return vm.runInContext(expression, this.context); }, + async addBinding(name, handler) { events.push(`bind:${target.id}:${name}`); this.bindingHandlers.set(name, handler); }, + async removeBinding(name) { this.bindingHandlers.delete(name); }, + close() { this.connected = false; }, + }; + clients.set(target.id, client); + return client; + }; + registry.discover = async () => targets; + const host = new PluginHost({ registry, manifest: await readManifest(path), createModule: async (entry, context) => factories[entry.id](events)({ ...context, id: entry.id }) }); + t.after(() => host.close()); + return { host, registry, clients, events, path, newContext }; +} + +function factory(events) { + return async ({ id, onDispose }) => { + onDispose(() => events.push(`dispose:${id}`)); + return { + renderer: () => ({ id, version: "1", expression: buildPluginExpression({ id, version: "1", source: "var CodexPlugin = { activate() { return { status: () => ({ active: true }) }; } };" }) }), + bindings: [{ name: `__${id}`, handle: async () => {} }], + onReady(client) { assert.ok(client.bindingHandlers.has(`__${id}`)); assert.ok(client.context.window.__codexPluginLoader.status(id)); events.push(`ready:${client.target.id}:${id}`); }, + onTargetRemoved(targetId) { events.push(`removed:${targetId}:${id}`); }, + }; + }; +} + +test("Loader owns bindings before injection and refreshes snapshots on reconnect/reload", async (t) => { + const f = await fixture(t, { first: factory }); + await f.host.start(); + assert.ok((await f.host.sync()).every((item) => !item.error)); + assert.equal(f.registry.clients.size, 2, "per-target synchronization must not prune sibling windows"); + assert.ok(f.events.indexOf("bind:window-a:__first") < f.events.indexOf("ready:window-a:first")); + await f.host.sync(); + assert.equal(f.events.filter((event) => event.startsWith("ready:")).length, 2); + const first = f.clients.get("window-a"); + first.context = f.newContext(); first.generation += 1; + await f.host.sync(); + assert.equal(f.events.filter((event) => event === "ready:window-a:first").length, 2); + first.connected = false; + await f.host.sync(); + assert.equal(f.events.filter((event) => event === "ready:window-a:first").length, 3); +}); + +test("disabling one host module cleans it while preserving other modules and connections", async (t) => { + const f = await fixture(t, { first: factory, second: factory }); + await f.host.start(); await f.host.sync(); + const client = f.clients.get("window-a"); + await setPluginEnabled(f.path, "first", false); + await f.host.sync(); + assert.equal(client.context.window.__codexPluginLoader.status("first"), null); + assert.equal(client.context.window.__codexPluginLoader.status("second").state, "active"); + assert.equal(client.bindingHandlers.has("__first"), false); + assert.equal(client.bindingHandlers.has("__second"), true); + assert.equal(client.connected, true); + assert.ok(f.events.includes("dispose:first")); + assert.ok(!f.events.includes("dispose:second")); + await setPluginEnabled(f.path, "first", true); await f.host.sync(); + assert.equal(client.context.window.__codexPluginLoader.status("first").state, "active"); +}); + +test("partial host activation is cleaned and cannot block another module", async (t) => { + const f = await fixture(t, { broken: (events) => async ({ onDispose }) => { + onDispose(() => events.push("partial-cleanup")); throw new Error("private text"); + }, working: factory }); + await f.host.start(); const results = await f.host.sync(); + assert.ok(f.events.includes("partial-cleanup")); + assert.ok(results.some((item) => item.pluginId === "broken" && item.stage === "host-activation")); + assert.ok(results.some((item) => item.pluginId === "working" && !item.error)); + assert.ok(!JSON.stringify(results).includes("private text")); +}); + +test("a conflicting binding cannot replace or remove another module's handler", async (t) => { + const f = await fixture(t, { first: factory, second: (events) => async (context) => { + const module = await factory(events)(context); + module.bindings[0].name = "__first"; + return module; + } }); + await f.host.start(); const results = await f.host.sync(); + assert.ok(results.some((item) => item.pluginId === "second" && item.error)); + const firstHandler = f.clients.get("window-a").bindingHandlers.get("__first"); + await setPluginEnabled(f.path, "second", false); await f.host.sync(); + assert.equal(f.clients.get("window-a").bindingHandlers.get("__first"), firstHandler); +}); + +test("host shutdown drains pending messages before closing plugin resources", async (t) => { + let release; + const f = await fixture(t, { first: (events) => async (context) => { + const module = await factory(events)(context); + module.bindings[0].handle = () => new Promise((resolve) => { release = () => { events.push("message-done"); resolve(); }; }); + return module; + } }); + await f.host.start(); await f.host.sync(); + const pending = f.clients.get("window-a").bindingHandlers.get("__first")({}); + await Promise.resolve(); + const closing = f.host.close(); + release(); await pending; await closing; + assert.ok(f.events.indexOf("message-done") < f.events.indexOf("dispose:first")); + assert.equal(f.registry.clients.size, 0); +}); + + +test("a transient initial snapshot failure retries its window without disabling the module", async (t) => { + let attempts = 0; + const f = await fixture(t, { first: (events) => async (context) => { + const module = await factory(events)(context); + const ready = module.onReady; + module.onReady = (client) => { + if (client.target.id === "window-a" && attempts++ === 0) throw new Error("Window navigated"); + ready(client); + }; + return module; + } }); + await f.host.start(); + assert.ok((await f.host.sync()).some((item) => item.stage === "ready" && item.error)); + assert.equal(f.host.status()[0].state, "active"); + assert.ok((await f.host.sync()).every((item) => !item.error)); + assert.ok(f.events.includes("ready:window-a:first")); +}); + +test("failed module cleanup is quarantined without blocking healthy modules", async (t) => { + const f = await fixture(t, { first: (events) => async (context) => { + const module = await factory(events)(context); + context.onDispose(() => { throw new Error("Cleanup failed"); }); + return module; + }, second: factory }); + await f.host.start(); await f.host.sync(); + await setPluginEnabled(f.path, "first", false); + const results = await f.host.sync(); + assert.equal(f.host.status().find(({ id }) => id === "first").state, "cleanup-failed"); + assert.ok(results.some((item) => item.pluginId === "second" && !item.error)); + await f.host.sync(); + assert.equal(f.clients.get("window-a").context.window.__codexPluginLoader.status("second").state, "active"); +}); + + +test("host modules receive separate persistent data directories", async (t) => { + const directories = new Map(); + const capture = (events) => async (context) => { + directories.set(context.id, context.stateDirectory); + return factory(events)(context); + }; + const f = await fixture(t, { first: capture, second: capture }); + await f.host.start(); + assert.notEqual(directories.get("first"), directories.get("second")); + assert.ok(directories.get("first").endsWith("module-data/first")); +}); diff --git a/runtime/test/plugin-loader.test.mjs b/runtime/test/plugin-loader.test.mjs new file mode 100644 index 0000000..0db4986 --- /dev/null +++ b/runtime/test/plugin-loader.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { mkdtemp, rm, writeFile, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LOADER_VERSION, buildPluginExpression, PluginTargetRegistry } from "../src/plugin-loader/index.mjs"; +import { readPlugins } from "../src/plugin-loader/manifest.mjs"; + +function renderer() { + const context = vm.createContext({ window: {}, AbortController, setTimeout, clearTimeout, events: [] }); + const load = (id, version = "1", source = `var CodexPlugin = { activate({ id, onDispose }) { + events.push('start:' + id); + onDispose(() => events.push('stop:' + id)); + return { handleMessage: message => id + ':' + message, status: () => ({ ready: true }) }; + } };`) => vm.runInContext(buildPluginExpression({ id, version, source }), context); + return { context, load, get loader() { return context.window.__codexPluginLoader; } }; +} + +test("independent plugins coexist, route messages and unload only their own resources", async () => { + const host = renderer(); + await host.load("first"); + await host.load("second"); + assert.equal(host.loader.dispatch("first", "hi"), "first:hi"); + assert.equal(host.loader.dispatch("absent", "hi"), false); + await host.loader.unload("first"); + assert.equal(host.loader.status("first"), null); + assert.equal(host.loader.status("second").state, "active"); + assert.deepEqual(host.context.events, ["start:first", "start:second", "stop:first"]); +}); + +test("concurrent identical loads mount once; upgrade cleans before activation", async () => { + const host = renderer(); + await Promise.all([host.load("first"), host.load("first")]); + await host.load("first", "2"); + assert.deepEqual(host.context.events, ["start:first", "stop:first", "start:first"]); + assert.equal(host.loader.status("first").version, "2"); +}); + +test("externally disposed instances recover and invalid runtime contracts fail closed", async () => { + const host = renderer(); + const source = `var CodexPlugin = { activate() { + window.mounted = true; + events.push('mounted'); + return { isActive: () => window.mounted, handleMessage: () => true }; + } };`; + await host.load("first", "1", source); + host.context.window.mounted = false; + assert.equal(host.loader.status("first").state, "inactive"); + assert.equal(host.loader.dispatch("first", {}), false); + await host.load("first", "1", source); + assert.deepEqual(host.context.events, ["mounted", "mounted"]); + await assert.rejects(host.loader.load({ apiVersion: 1, version: "1" }, () => {}), /Invalid/); + await assert.rejects(host.loader.load({ apiVersion: 2, id: "other", version: "1" }, () => {}), /Invalid/); +}); + +test("failed activation cleans registered resources in reverse order and can retry", async () => { + const host = renderer(); + await assert.rejects(host.load("broken", "1", `var CodexPlugin = { activate({ onDispose, signal }) { + onDispose(() => events.push('first:' + signal.aborted)); + onDispose(() => events.push('second')); + throw new Error('private data must not appear in diagnostics'); + } };`), /^Error: Plugin broken activation failed$/); + assert.deepEqual(host.context.events, ["second", "first:true"]); + assert.equal(host.loader.status("broken").state, "failed"); + await host.load("working"); + await host.load("broken"); + assert.equal(host.loader.status("broken").state, "active"); +}); + +test("cleanup failure runs remaining disposers and blocks unsafe replacement", async () => { + const host = renderer(); + await host.load("broken", "1", `var CodexPlugin = { activate({ onDispose }) { + onDispose(() => events.push('remaining')); + onDispose(() => { throw new Error('failed'); }); + } };`); + await assert.rejects(host.load("broken", "2"), /cleanup failed/); + assert.deepEqual(host.context.events, ["remaining"]); + assert.equal(host.loader.status("broken").state, "cleanup-failed"); + await assert.rejects(host.load("broken", "2"), /renderer reload/); + await host.load("other"); +}); + +test("unload waits for pending activation and invokes instance cleanup", async () => { + const host = renderer(); + let release; + host.context.ready = new Promise((resolve) => { release = resolve; }); + const loading = host.load("slow", "1", `var CodexPlugin = { async activate() { + await ready; + return { dispose: () => events.push('disposed') }; + } };`); + const removing = host.loader.unload("slow"); + release(); + await Promise.all([loading, removing]); + assert.deepEqual(host.context.events, ["disposed"]); + assert.equal(host.loader.status("slow"), null); +}); + +test("target reconciliation isolates a failed plugin/window and reinjects new renderers", async () => { + const registry = new PluginTargetRegistry({ ownsEndpoint: async () => true }); + const hosts = { first: renderer(), second: renderer() }; + registry.discover = async () => Object.keys(hosts).map((id) => ({ id })); + registry.evaluate = async (target, expression) => vm.runInContext(expression, hosts[target.id].context); + const plugins = [ + { id: "bad", version: "1", expression: buildPluginExpression({ id: "bad", version: "1", source: "throw new Error('failed')" }) }, + { id: "good", version: "1", expression: buildPluginExpression({ id: "good", version: "1", source: "var CodexPlugin = { activate() { events.push('mounted'); } }" }) }, + ]; + const first = await registry.ensurePlugins(plugins); + assert.equal(first.results.filter((result) => result.error).length, 2); + assert.equal(hosts.second.loader.status("good").state, "active"); + await registry.ensurePlugins(plugins); + assert.deepEqual(hosts.first.context.events, ["mounted"]); + hosts.first = renderer(); + await registry.ensurePlugins(plugins); + assert.deepEqual(hosts.first.context.events, ["mounted"]); + await registry.removePlugins(["good"]); + assert.equal(hosts.second.loader.status("good"), null); +}); + +test("rejects foreign endpoints and remote target sockets before connecting", async () => { + const registry = new PluginTargetRegistry({ ownsEndpoint: async () => false }); + registry.discover = () => assert.fail("must not discover"); + await assert.rejects(registry.ensurePlugins([]), /not owned/); + await assert.rejects(registry.evaluate({ webSocketDebuggerUrl: "ws://example.com:9341/devtools/page/1" }, "1"), /loopback/); + await assert.rejects(registry.evaluate({ webSocketDebuggerUrl: "ws://127.0.0.1:9341/devtools/page/1" }, "1"), /not owned/); +}); + +test("local manifest rejects traversal, symlink escapes, duplicates and incompatible APIs", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loader-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const config = join(root, "loader.json"); + await writeFile(join(root, "entry.js"), "var CodexPlugin = { activate() {} };"); + const plugin = { id: "hello", version: "1", entry: "entry.js" }; + const save = (plugins, apiVersion = 1) => writeFile(config, JSON.stringify({ apiVersion, plugins })); + await save([plugin]); + assert.equal((await readPlugins(config))[0].id, "hello"); + await save([plugin, plugin]); + await assert.rejects(readPlugins(config), /duplicate/); + await save([plugin], 2); + await assert.rejects(readPlugins(config), /apiVersion/); + await symlink(import.meta.filename, join(root, "escape.js")); + await save([{ ...plugin, entry: "escape.js" }]); + await assert.rejects(readPlugins(config), /inside/); + await save([{ ...plugin, entry: "../missing.js" }]); + await assert.rejects(readPlugins(config)); +}); + + +test("configuration ownership prevents takeover and scoped removal of another module", async () => { + const host = renderer(); + await host.load("first"); await host.load("second"); + assert.equal(host.loader.claim("first", "config-a"), true); + assert.equal(host.loader.claim("first", "config-b"), false); + await assert.rejects(host.loader.release("first", "config-b"), /another Loader/); + assert.equal(host.loader.status("first").state, "active"); + await host.loader.release("first", "config-a"); + assert.equal(host.loader.status("second").state, "active"); +}); + +test("stalled activation is bounded and late resources are cleaned without unsafe replacement", async () => { + const host = renderer(); + host.context.setTimeout = (callback, ms) => setTimeout(callback, Math.min(ms, 15)); + let release; + host.context.ready = new Promise((resolve) => { release = resolve; }); + await assert.rejects(host.load("slow", "1", `var CodexPlugin = { async activate({ onDispose }) { + await ready; + onDispose(() => events.push('late-resource')); + return { dispose: () => events.push('late-instance') }; + } };`), /activation failed/); + assert.equal(host.loader.status("slow").state, "cleanup-failed"); + release(); await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(host.context.events, ["late-resource", "late-instance"]); + await assert.rejects(host.load("slow", "2"), /renderer reload/); + await host.load("other"); +}); + +test("Loader upgrades only after the older runtime's plugins have been removed", async () => { + const host = renderer(); + let active = true; + host.context.window.__codexPluginLoader = { apiVersion: 1, version: "0.1.0", status: () => active ? [{ id: "old" }] : [] }; + await assert.rejects(host.load("new"), /unload its plugins/); + active = false; + await host.load("new"); + assert.equal(host.loader.version, LOADER_VERSION); +}); diff --git a/runtime/test/runtime-client.test.ts b/runtime/test/runtime-client.test.ts index c57dac8..408a8f4 100644 --- a/runtime/test/runtime-client.test.ts +++ b/runtime/test/runtime-client.test.ts @@ -3,11 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import { RuntimeClient } from "../src/injected/runtime-client"; describe("RuntimeClient", () => { - it("sends versioned messages through the configured binding", () => { + it("sends versioned messages through the supplied transport", async () => { const binding = vi.fn(); - const client = new RuntimeClient("bridge", () => true, () => undefined, () => binding); - expect(client.send("search.request", { query: "架构" }, 9)).toBe(true); - expect(JSON.parse(binding.mock.calls[0][0])).toEqual({ + const client = new RuntimeClient(async (message) => { binding(message); }, () => true, () => undefined, () => undefined); + client.send("search.request", { query: "架构" }, 9); + await Promise.resolve(); + expect(binding.mock.calls[0][0]).toEqual({ protocolVersion: 1, type: "search.request", requestId: 9, @@ -18,9 +19,16 @@ describe("RuntimeClient", () => { it("rejects malformed messages before feature handlers", () => { const onMessage = vi.fn(() => true); const onRejected = vi.fn(); - const client = new RuntimeClient("bridge", onMessage, onRejected, () => undefined); + const client = new RuntimeClient(async () => {}, onMessage, onRejected, () => {}); expect(client.handle({ protocolVersion: 2, type: "hello", payload: {} })).toBe(false); expect(onMessage).not.toHaveBeenCalled(); expect(onRejected).toHaveBeenCalledWith("unsupported-version"); }); }); + +it("returns rejected transports to business error handling with the original request identity", async () => { + const onFailure = vi.fn(); + const client = new RuntimeClient(async () => { throw new Error("disconnected"); }, () => true, () => {}, onFailure); + client.send("search.request", { query: "offline" }, 27); + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledWith(expect.objectContaining({ type: "search.request", requestId: 27 }))); +}); diff --git a/runtime/test/runtime-config.test.ts b/runtime/test/runtime-config.test.ts index ae12317..60ea7b1 100644 --- a/runtime/test/runtime-config.test.ts +++ b/runtime/test/runtime-config.test.ts @@ -9,7 +9,6 @@ const validConfig = { settingsSource: "repository", colorPresets: [{ name: "珊瑚", color: "#D95C5C" }], legacyToneColors: { neutral: "#7C8798", blue: "#4F8FD7" }, - requestBinding: "__codexTagsRequest", } as const; describe("parseRuntimeConfig", () => { @@ -22,8 +21,8 @@ describe("parseRuntimeConfig", () => { })); }); - it("fails closed for incompatible protocols and unsafe bindings", () => { + it("fails closed for incompatible protocols and invalid palettes", () => { expect(() => parseRuntimeConfig({ ...validConfig, protocolVersion: 2 })).toThrow(/Unsupported/u); - expect(() => parseRuntimeConfig({ ...validConfig, requestBinding: "window.alert" })).toThrow(/binding/u); + expect(() => parseRuntimeConfig({ ...validConfig, colorPresets: [] })).toThrow(/color presets/u); }); }); diff --git a/runtime/test/runtime-target-registry.test.mjs b/runtime/test/runtime-target-registry.test.mjs index 4779478..bf23c3e 100644 --- a/runtime/test/runtime-target-registry.test.mjs +++ b/runtime/test/runtime-target-registry.test.mjs @@ -9,26 +9,3 @@ test("accepts only inspectable Codex application pages", () => { assert.equal(isCodexRendererTarget({ type: "worker", url: "app://-/index.html", webSocketDebuggerUrl: "ws://local" }), false); assert.equal(isCodexRendererTarget({ type: "page", url: "app://-/index.html" }), false); }); - -test("injects repository settings only into runtimes with a different version", async () => { - const registry = new RuntimeTargetRegistry({ - port: 9341, - ownsEndpoint: async () => true, - settingsRepository: { read: async () => { throw new Error("unexpected read"); } }, - }); - registry.discover = async () => [{ id: "target-1", webSocketDebuggerUrl: "ws://local" }]; - const expressions = []; - registry.evaluate = async (_target, expression) => { - expressions.push(expression); - if (expression.includes("?.version")) return "outdated"; - return { version: "current" }; - }; - const settingsState = { - exists: true, - settings: { schemaVersion: 2, tags: [{ name: "Review", color: "#123456", description: "复核" }] }, - }; - const result = await registry.ensureInjected(settingsState); - assert.deepEqual([...result.injectedTargetIds], ["target-1"]); - assert.match(expressions[1], /"settingsSource":"repository"/u); - assert.match(expressions[1], /"name":"Review"/u); -}); diff --git a/scripts/build.mjs b/scripts/build.mjs index fe1606a..6729619 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,7 +13,7 @@ await build({ outfile: join(outputDirectory, "injected.js"), bundle: true, format: "iife", - globalName: "CodexTagsInjected", + globalName: "CodexPlugin", platform: "browser", target: "chrome120", minify: false, diff --git a/scripts/manager-core.mjs b/scripts/manager-core.mjs index e7c50ad..0b7c750 100644 --- a/scripts/manager-core.mjs +++ b/scripts/manager-core.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { access, chmod, copyFile, cp, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { access, chmod, copyFile, cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { createRequire } from "node:module"; import { dirname, join, resolve, sep } from "node:path"; @@ -7,14 +7,22 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { activationHealth, runtimeHealthChecks } from "./health.mjs"; import { findCodexApp } from "../runtime/src/codex-process.mjs"; +import { RUNTIME_VERSION } from "../runtime/src/tags-plugin.mjs"; +import { daemonPaths } from "../runtime/src/plugin-loader/daemon.mjs"; +import { createLauncher as createLoaderLauncher } from "../runtime/src/plugin-loader/launcher.mjs"; const execFileAsync = promisify(execFile); const DEFAULT_MARKETPLACE = "codex-tags-cli"; const PLUGIN_NAME = "codex-tags"; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const loaderDirectory = join(packageRoot, "runtime", "src", "plugin-loader"); +const loaderFiles = (await readdir(loaderDirectory)).filter((file) => file.endsWith(".mjs") || file.endsWith(".d.mts") || file === "package.json"); const runtimeFiles = new Map([ ["controller.mjs", "app.mjs"], + ...loaderFiles.map((file) => [`plugin-loader/${file}`, `plugin-loader/${file}`]), + ["tags-plugin.mjs", "tags-plugin.mjs"], + ["tags-service.mjs", "tags-service.mjs"], ["codex-process.mjs", "codex-process.mjs"], ["controller-router.mjs", "controller-router.mjs"], ["cdp-client.mjs", "cdp-client.mjs"], @@ -128,48 +136,12 @@ export function createManager(options = {}) { async function createLauncher() { if (platform !== "darwin") return null; - await checkLauncherOwnership(); - await mkdir(applicationsRoot, { recursive: true }); - const nextLauncherPath = join(applicationsRoot, `.Codex Tags-${process.pid}.app`); - const logPath = join(installRoot, "launcher.log"); - const executableName = "codex-tags-launcher"; - const executablePath = join(nextLauncherPath, "Contents", "MacOS", executableName); - const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; - const script = [ - "#!/bin/sh", - `if ! ${shellQuote(nodePath)} ${shellQuote(installedController)} start >> ${shellQuote(logPath)} 2>&1 - - -CFBundleDisplayNameCodex Tags -CFBundleExecutable${executableName} -CFBundleIdentifierio.github.c0sc0s.codex-tags -CFBundleNameCodex Tags -CFBundleIconFileicon.icns -CFBundlePackageTypeAPPL -CFBundleShortVersionString1.0 -LSUIElement - -`; - try { - await rm(nextLauncherPath, { recursive: true, force: true }); - await mkdir(dirname(executablePath), { recursive: true }); - await mkdir(join(nextLauncherPath, "Contents", "Resources"), { recursive: true }); - await copyFile(join(root, "assets", "icon.icns"), join(nextLauncherPath, "Contents", "Resources", "icon.icns")); - await writeFile(executablePath, script, { encoding: "utf8", mode: 0o755 }); - await chmod(executablePath, 0o755); - await writeFile(join(nextLauncherPath, "Contents", "Info.plist"), infoPlist, { encoding: "utf8", mode: 0o644 }); - await rm(launcherPath, { recursive: true, force: true }); - await rename(nextLauncherPath, launcherPath); - } finally { - await rm(nextLauncherPath, { recursive: true, force: true }); - } - return launcherPath; + return createLoaderLauncher({ + launcherPath, nodePath, cliPath: join(installRoot, "plugin-loader", "cli.mjs"), + configPath: join(installRoot, "loader.json"), port: Number(process.env.CODEX_TAGS_CDP_PORT ?? 9341), + logPath: join(installRoot, "launcher.log"), iconPath: join(root, "assets", "icon.icns"), + bundleId: "io.github.c0sc0s.codex-tags", + }); } async function supervisorStatus() { @@ -273,11 +245,31 @@ export function createManager(options = {}) { async function installRuntime() { await checkOwnedDirectory(); await removeLaunchSupervisor(); + if (await pathExists(installedController)) await runController("restore"); + if (await pathExists(join(installRoot, "plugin-loader", "daemon.mjs"))) { + await run(nodePath, [join(installRoot, "plugin-loader", "cli.mjs"), "stop", "--config", join(installRoot, "loader.json"), "--port", String(process.env.CODEX_TAGS_CDP_PORT ?? 9341)]); + } await mkdir(installRoot, { recursive: true }); await chmod(installRoot, 0o700); for (const [sourceName, destinationName] of runtimeFiles) { await copyFileAtomically(join(root, "runtime", "src", sourceName), join(installRoot, destinationName)); } + const loaderConfigPath = join(installRoot, "loader.json"); + let loaderConfig = { apiVersion: 1, plugins: [] }; + if (await pathExists(loaderConfigPath)) { + loaderConfig = JSON.parse(await readFile(loaderConfigPath, "utf8")); + if (loaderConfig.apiVersion !== 1 || !Array.isArray(loaderConfig.plugins)) throw new Error("Invalid existing Loader configuration"); + } + const tagsEntry = { id: "codex-tags", entry: "dist/injected.js", service: "tags-service.mjs", version: RUNTIME_VERSION, config: { dataDirectory: installRoot } }; + const existingTags = loaderConfig.plugins.find(({ id }) => id === tagsEntry.id); + if (existingTags) { + if (existingTags.host !== "tags-plugin.mjs" && existingTags.entry !== tagsEntry.entry) throw new Error("The codex-tags module ID is owned by another entry"); + delete existingTags.host; + Object.assign(existingTags, { entry: tagsEntry.entry, service: tagsEntry.service, version: tagsEntry.version }); + existingTags.enabled = true; + existingTags.config = { dataDirectory: installRoot, ...existingTags.config }; + } else loaderConfig.plugins.push(tagsEntry); + await writeFile(loaderConfigPath, `${JSON.stringify(loaderConfig, null, 2)}\n`, { mode: 0o600 }); await copyRuntimeDependency(); await copyPluginSource(); const pluginVersion = await readPluginVersion(); @@ -384,9 +376,6 @@ export function createManager(options = {}) { probe.exec("CREATE VIRTUAL TABLE check_fts USING fts5(content, tokenize='trigram')"); probe.close(); for (const source of runtimeFiles.keys()) await access(join(root, "runtime", "src", source)); - // Stop old code before replacing its modules; a failed update stays disabled and retryable. - await removeLaunchSupervisor(); - if (await pathExists(installedController)) await runController("restore"); const installation = await installRuntime(); const plugin = await installCodexPlugin(); const controller = await runController("start"); @@ -407,12 +396,20 @@ export function createManager(options = {}) { async function uninstall({ purge = false } = {}) { await checkOwnedDirectory(); await checkLauncherOwnership(); + const loaderConfigPath = join(installRoot, "loader.json"); + if (await pathExists(loaderConfigPath)) { + const config = JSON.parse(await readFile(loaderConfigPath, "utf8")); + if (config.plugins?.some(({ id }) => id !== "codex-tags")) throw new Error("Other modules use this Loader installation. Disable Tags instead of uninstalling the shared infrastructure."); + } const disabled = await disable({ purge }); + if (await pathExists(join(installRoot, "plugin-loader", "cli.mjs"))) await run(nodePath, [join(installRoot, "plugin-loader", "cli.mjs"), "stop", "--config", loaderConfigPath, "--port", String(process.env.CODEX_TAGS_CDP_PORT ?? 9341)]); await removeCodexPlugin({ removeMarketplace: true }); for (const destinationName of runtimeFiles.values()) await rm(join(installRoot, destinationName), { force: true }); await rm(sqlitePackageDestination, { recursive: true, force: true }); for (const databaseFile of searchDatabaseFiles) await rm(join(installRoot, databaseFile), { force: true }); await rm(join(installRoot, "install.json"), { force: true }); + await rm(daemonPaths(join(installRoot, "loader.json"), Number(process.env.CODEX_TAGS_CDP_PORT ?? 9341)).root, { recursive: true, force: true }); + await rm(join(installRoot, "loader.json"), { force: true }); await rm(marketplaceRoot, { recursive: true, force: true }); await rm(launcherPath, { recursive: true, force: true }); await rm(join(installRoot, "controller.pid"), { force: true }); diff --git a/scripts/test-loader-package.mjs b/scripts/test-loader-package.mjs new file mode 100644 index 0000000..1cc38fc --- /dev/null +++ b/scripts/test-loader-package.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; +import vm from "node:vm"; + +const run = promisify(execFile); +const temporary = await mkdtemp(join(tmpdir(), "codex-loader-package-")); +try { + const { stdout } = await run("npm", ["pack", "--json", "--pack-destination", temporary], { cwd: resolve("runtime/src/plugin-loader") }); + const [packed] = JSON.parse(stdout); + assert.ok(packed.files.some(({ path }) => path === "examples/hello.js")); + assert.ok(!packed.files.some(({ path }) => /tag-settings|search-index|sqlite|controller|\.loader-|module-data/.test(path))); + const consumer = join(temporary, "consumer"); + await run("npm", ["install", "--prefix", consumer, "--ignore-scripts", "--no-audit", "--no-fund", join(temporary, packed.filename)]); + const packageRoot = join(consumer, "node_modules", "@c0sc0s", "codex-plugin-loader"); + const { stdout: help } = await run(process.execPath, [join(packageRoot, "cli.mjs"), "--help"]); + assert.match(help, /start\|apply\|watch\|status\|remove\|stop/); + await assert.rejects(run(process.execPath, [join(packageRoot, "cli.mjs"), "apply"])); + const api = await import(pathToFileURL(join(packageRoot, "index.mjs"))); + const { readPlugins } = await import(pathToFileURL(join(packageRoot, "manifest.mjs"))); + const [plugin] = await readPlugins(join(packageRoot, "examples", "loader.json")); + let mounted = false; + const context = vm.createContext({ window: {}, AbortController, setTimeout, clearTimeout, document: { + createElement: () => ({ style: {}, get isConnected() { return mounted; }, remove() { mounted = false; } }), + body: { append() { mounted = true; } }, + } }); + await vm.runInContext(plugin.expression, context); + assert.equal(mounted, true); + await vm.runInContext(api.buildPluginRemovalExpression("hello"), context); + assert.equal(mounted, false); + const { ServiceProcess } = await import(pathToFileURL(join(packageRoot, "service-process.mjs"))); + const service = new ServiceProcess({ entry: join(packageRoot, "examples/service.mjs"), id: "packed", stateDirectory: temporary }); + try { + await service.ready; + assert.equal(await service.request("call", { method: "greeting" }), "Hello from packed"); + } finally { await service.close(); } + console.log("Loader package smoke passed: standalone tarball, CLI, local manifest, activation, isolated service RPC and cleanup without Tags."); +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 9b0db56..b95e23f 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -33,6 +33,8 @@ try { }, }); await manager.installRuntime(); + await import(pathToFileURL(join(manager.paths.installRoot, "runtime-target-registry.mjs")).href); + await import(pathToFileURL(join(manager.paths.marketplacePluginRoot, "runtime", "src", "plugin-loader", "index.mjs")).href); const { SessionSearchIndex } = await import(pathToFileURL(join(manager.paths.installRoot, "search-index.mjs")).href); const index = new SessionSearchIndex(join(temporary, "probe.sqlite")); assert.equal(index.status().indexedSessions, 0); diff --git a/tsconfig.loader.json b/tsconfig.loader.json new file mode 100644 index 0000000..eda0205 --- /dev/null +++ b/tsconfig.loader.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" }, + "include": ["runtime/test/loader-api.types.ts"] +}