fix(codex): restore connected apps for token-authenticated runs (#115075)

* fix(codex): restore connected apps for token-authenticated runs

* fix(codex): keep app inventory protocol types private

* fix(codex): align native runtime with Codex 0.146.0

Co-authored-by: Steven Lee <stevenlee@openai.com>

* fix(codex): clean up latest app-server integration

Co-authored-by: Steven Lee <stevenlee@openai.com>

* fix(codex): keep internal protocol types private

* fix(ci): repair current main Codex landing gates

* fix(ci): format inherited code mode matrix

* fix(codex): reconcile native app-server contracts with main

Prepare a verified GitHub-hosted mainline merge while preserving the reviewed Codex 0.146.0 fixes and canonical OpenAI authentication.

Co-authored-by: Steven Lee <stevenlee@openai.com>

* fix(codex): keep QA evidence in its owning plugin

Resolve the current-main Code Mode test rename without resurrecting the retired core test path.

Co-authored-by: Steven Lee <stevenlee@openai.com>

* fix(codex): enforce canonical OpenAI app-server auth

Reject retired provider aliases without runtime compatibility, direct operators to the doctor migration, and remove the redundant OpenAI API-key predicate.

Co-authored-by: Steven Lee <stevenlee@openai.com>

* chore(codex): reconcile latest main dependency graph

Preserve current main dependency changes while preparing the original Codex PR for an ancestry-preserving signed mainline merge.

Co-authored-by: Steven Lee <stevenlee@openai.com>

* fix(codex): unify bundled Codex 0.146 runtimes

Keep the ACP adapter on the same 0.146.0 Codex release as the managed runtime, remove obsolete 0.145.0 platform artifacts and unused semver compatibility, and preserve the latest main dependency upgrades.

Co-authored-by: Steven Lee <stevenlee@openai.com>

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
stevenlee-oai
2026-07-29 03:21:25 -07:00
committed by GitHub
parent 7bc453f508
commit a00438a867
96 changed files with 6385 additions and 2009 deletions
+2 -1
View File
@@ -5860,7 +5860,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Marketplace choices
- H2: Bundled macOS marketplace
- H3: Shared plugin cache
- H2: Remote catalog limit
- H2: Remote marketplaces
- H2: Configuration reference
- H2: What OpenClaw checks
- H2: macOS permissions
@@ -5893,6 +5893,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Supervision and safe continuation
- H2: Visible replies and heartbeats
- H2: Hook boundaries
- H2: Experimental sandbox process streaming
- H2: V1 support contract
- H2: Native permissions and MCP elicitations
- H2: Queue steering
+32 -35
View File
@@ -183,12 +183,12 @@ requested action plus any supported marketplace flags in its migration guidance.
OpenClaw uses the same app-server API that Codex itself exposes. The
marketplace fields choose where Codex should find `computer-use`.
| Field | Use when | Install support |
| -------------------- | --------------------------------------------------------------- | -------------------------------------------------------- |
| No marketplace field | You want Codex app-server to use marketplaces it already knows. | Yes, when app-server returns a local marketplace. |
| `marketplaceSource` | You have a Codex marketplace source app-server can add. | Yes, for explicit `/codex computer-use install`. |
| `marketplacePath` | You already know the local marketplace file path on the host. | Yes, for explicit install and turn-start auto-install. |
| `marketplaceName` | You want to select one already registered marketplace by name. | Yes only when the selected marketplace has a local path. |
| Field | Use when | Install support |
| -------------------- | --------------------------------------------------------------- | ------------------------------------------------------ |
| No marketplace field | You want Codex app-server to use marketplaces it already knows. | Yes, from a discovered local or remote marketplace. |
| `marketplaceSource` | You have a Codex marketplace source app-server can add. | Yes, for explicit `/codex computer-use install`. |
| `marketplacePath` | You already know the local marketplace file path on the host. | Yes, for explicit install and turn-start auto-install. |
| `marketplaceName` | You want to select one already registered marketplace by name. | Yes, from the selected local or remote marketplace. |
Fresh Codex homes may need a short moment to seed their official
marketplaces. During install, OpenClaw polls `plugin/list` for up to
@@ -240,21 +240,17 @@ failed replacement copy also preserves the active cache. Explicit
`marketplaceName` or `marketplacePath` configuration disables this
reconciliation so OpenClaw does not override that selection.
## Remote catalog limit
## Remote marketplaces
Codex app-server can list and read remote-only catalog entries, but it does
not currently support remote `plugin/install`. That means `marketplaceName`
can select a remote-only marketplace for status checks, but installs and
re-enables still need a local marketplace via `marketplaceSource` or
`marketplacePath`.
Codex 0.146.0 can read and install Computer Use plugins from discovered remote
marketplaces. OpenClaw passes the opaque remote plugin ID returned by Codex to
`plugin/read` and `plugin/install`; a human-readable plugin name is not a valid
substitute.
If status says the plugin is available in a remote Codex marketplace but
remote install is unsupported, run install with a local source or path:
```text
/codex computer-use install --source <marketplace-source>
/codex computer-use install --marketplace-path <path>
```
`/codex computer-use install` can explicitly install or re-enable a discovered
remote plugin. Turn-start `autoInstall` can also use an already discovered local
or remote marketplace. Status checks and turns without `autoInstall` do not
install plugins or modify Codex configuration.
## Configuration reference
@@ -279,7 +275,8 @@ remote install is unsupported, run install with a local source or path:
Turn-start auto-install intentionally refuses configured `marketplaceSource`
values. Adding a new source is an explicit setup operation, so use
`/codex computer-use install --source <marketplace-source>` once, then let
`autoInstall` handle future re-enables from discovered local marketplaces.
`autoInstall` handle future re-enables from discovered local or remote
marketplaces.
Turn-start auto-install can use a configured `marketplacePath`, because that
is already a local path on the host.
@@ -309,17 +306,16 @@ matching config key is unset:
OpenClaw reports a stable setup reason internally and formats the
user-facing status for chat:
| Reason | Meaning | Next step |
| ---------------------------- | ------------------------------------------------------ | --------------------------------------------- |
| `disabled` | `computerUse.enabled` resolved to false. | Set `enabled` or another Computer Use field. |
| `marketplace_missing` | No matching marketplace was available. | Configure source, path, or marketplace name. |
| `plugin_not_installed` | Marketplace exists, but the plugin is not installed. | Run install or enable `autoInstall`. |
| `plugin_disabled` | Plugin is installed but disabled in Codex config. | Run install to re-enable it. |
| `remote_install_unsupported` | Selected marketplace is remote-only. | Use `marketplaceSource` or `marketplacePath`. |
| `mcp_missing` | Plugin is enabled, but the MCP server is unavailable. | Check Codex Computer Use and OS permissions. |
| `ready` | Plugin and MCP tools are available. | Start the Codex-mode turn. |
| `check_failed` | A Codex app-server request failed during status check. | Check app-server connectivity and logs. |
| `auto_install_blocked` | Turn-start setup would need to add a new source. | Run explicit install first. |
| Reason | Meaning | Next step |
| ---------------------- | ------------------------------------------------------ | -------------------------------------------- |
| `disabled` | `computerUse.enabled` resolved to false. | Set `enabled` or another Computer Use field. |
| `marketplace_missing` | No matching marketplace was available. | Configure source, path, or marketplace name. |
| `plugin_not_installed` | Marketplace exists, but the plugin is not installed. | Run install or enable `autoInstall`. |
| `plugin_disabled` | Plugin is installed but disabled in Codex config. | Run install to re-enable it. |
| `mcp_missing` | Plugin is enabled, but the MCP server is unavailable. | Check Codex Computer Use and OS permissions. |
| `ready` | Plugin and MCP tools are available. | Start the Codex-mode turn. |
| `check_failed` | A Codex app-server request failed during status check. | Check app-server connectivity and logs. |
| `auto_install_blocked` | Turn-start setup would need to add a new source. | Run explicit install first. |
The chat output includes the plugin state, MCP server state, marketplace,
tools when available, and the specific message for the failing setup step.
@@ -352,9 +348,10 @@ marketplace is not discovered, pass `--source` or `--marketplace-path`.
**Status says installed but disabled.** Run `/codex computer-use install`
again. Codex app-server install writes the plugin config back to enabled.
**Status says remote install is unsupported.** Use a local marketplace
source or path. Remote-only catalog entries can be inspected but not
installed through the current app-server API.
**A discovered remote plugin cannot be installed.** Confirm Codex reports the
marketplace and the plugin's opaque remote ID, then run `/codex computer-use
install`. Add a new `marketplaceSource` only through explicit install; turn-start
`autoInstall` uses remote marketplaces that Codex has already discovered.
**Status says the MCP server is unavailable.** Re-run install once so MCP
servers reload. If it remains unavailable, fix the Codex Computer Use app,
@@ -383,7 +380,7 @@ retry in a fresh session.
**Turn-start auto-install refuses a source.** This is intentional. Add the
source with explicit `/codex computer-use install --source
<marketplace-source>` first, then future turn-start auto-install can use the
discovered local marketplace.
discovered local or remote marketplace.
## Related
+76 -36
View File
@@ -131,7 +131,7 @@ flags, and plugin allow/deny references into this block. Explicit canonical
## App-server transport
For ordinary harness turns, OpenClaw starts the managed Codex binary shipped
with the official plugin (currently `@openai/codex` `0.145.0`):
with the official plugin (currently `@openai/codex` `0.146.0`):
```bash
codex app-server --listen stdio://
@@ -253,32 +253,60 @@ If the normal app-server runtime would be `danger-full-access`, enabling
permission profile instead. Codex-managed network enforcement is sandboxed
networking, so a full-access profile would not protect outbound traffic.
The plugin blocks older, newer-unvalidated, prerelease, build-suffixed, or
unversioned app-server handshakes. Codex app-server must report a stable version
from `0.143.0` through the bundled `0.145.0`.
The plugin accepts exactly stable Codex app-server `0.146.0`. Older or newer
versions, prereleases, build-suffixed versions, and unversioned app-server
handshakes are rejected. The same exact-version requirement applies to explicit
custom executables, remote app-servers, and macOS desktop binaries.
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
identity-bearing WebSocket auth through `appServer.authToken` or an
`Authorization` header. `appServer.authToken` and each `appServer.headers.*`
value can be a SecretInput; the secrets runtime resolves SecretRefs and env
shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native
Codex plugins are configured, OpenClaw uses the connected app-server's plugin
control plane to install or refresh those plugins and then refreshes app
inventory so plugin-owned apps are visible to the Codex thread. `app/installed`
provides authoritative runtime state, and `app/read` provides app metadata.
Callable apps with authorized metadata can be enabled directly. A modern
base-disabled app owned by an explicitly configured plugin may be enabled
provisionally in `thread/start`; OpenClaw immediately attests the effective
thread-scoped inventory and discards the thread before its first turn unless
the app is enabled and callable. Account-wide disabled apps and revoked,
unauthenticated, policy-blocked, or missing apps remain excluded. Supported
older app-server versions fall back to `app/list` only when `app/installed` is
unavailable, and that fallback never provisionally enables a disabled app.
This path only activates marketplace
plugins via `plugin/install` and refreshes inventory. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
structured SecretRefs fail before any token or header is sent.
When native Codex plugins are configured, OpenClaw caches one
runtime-and-workspace-scoped `plugin/installed` snapshot. This snapshot covers
installed curated and workspace plugins, including disabled ownership;
`plugin/read` resolves only exact configured plugin identities. Failed or
incomplete installed snapshots are never cached. OpenClaw uses `plugin/list`
only to find or repair an explicitly enabled curated plugin missing from that
installed snapshot. It calls `plugin/install` only for an explicitly configured
enabled curated plugin; it never installs, enables, or authenticates a
workspace plugin.
`app/installed` reports installed app runtime state, and `app/read` returns
authenticated metadata for at most 100 requested app IDs per call. OpenClaw
force-refreshes the first cold installed snapshot and consolidates successful
curated installations into one app-inventory refresh. Later cached reads do
not force repeated connector refreshes.
Deny-by-default Codex app policy is evaluated per thread, so an explicitly
allowed app can be installed and authenticated before it becomes callable.
OpenClaw provisionally admits only ownership-proven, policy-approved apps,
creates the thread with `_default.enabled = false` and explicit app overrides,
then calls `app/installed` once with that thread's ID and `forceRefresh: false`.
It exposes an app only when Codex confirms the app is enabled and callable for
the actual thread. Managed restrictions, workspace policy, missing metadata,
revoked auth, and unavailable tools still fail closed.
Attestation completes before OpenClaw injects history, starts a turn, or
persists the native thread binding. On failure, OpenClaw deletes a persistent
provisional thread with `thread/delete` or unsubscribes an ephemeral thread
with `thread/unsubscribe`. If safe cleanup cannot be confirmed, it retires the
owning app-server connection. Supervised branches also clean up their temporary
probe and retain recovery state when cleanup fails.
With `allow_all_plugins`, an explicitly disabled configured workspace plugin
still denies its owned apps. When `app/read` does not expose that ownership,
OpenClaw uses its `plugin/installed` snapshot and reads only the exact
configured plugin's details to reserve the denied app IDs. It does not scan
unrelated marketplaces or install, enable, or authenticate the disabled plugin;
missing ownership fails closed.
Only connect OpenClaw to a `0.146.0` remote app-server trusted to accept
configured marketplace plugin installs and inventory refreshes. Missing modern
inventory methods and server, authentication, or transport failures fail closed.
## Approval and sandbox modes
@@ -352,7 +380,7 @@ The stable default is fail-closed: active OpenClaw sandboxing disables native
Codex execution surfaces that would otherwise run from the Codex app-server
host. Use `appServer.experimental.sandboxExecServer: true` only when you want
to try Codex's remote environment support with OpenClaw's sandbox backend.
This preview path works with every supported Codex app-server version.
This preview path uses the pinned Codex `0.146.0` app-server.
```json5
{
@@ -379,6 +407,11 @@ with Codex app-server, and starts the Codex thread and turn with that
OpenClaw-owned environment. If the app-server cannot register the environment,
the run fails closed instead of silently falling back to host execution.
Sandboxed process output streams as ordered stdout, stderr, or PTY
notifications. OpenClaw retains only a bounded recent-output buffer for polling
and replay, so long-running processes cannot grow the app-server bridge without
limit. Process exit and cleanup remain tied to the sandbox-owned process.
This preview path is local-only. A remote WebSocket app-server cannot reach
the loopback exec-server unless it is running on the same host, so OpenClaw
rejects that combination.
@@ -603,16 +636,23 @@ points `appServer.command` at a different Codex binary. Availability can also
be account-scoped. Use `/codex models` on a running gateway to see the live
catalog for that harness and account.
If discovery fails or times out, OpenClaw uses a bundled fallback catalog:
If discovery is temporarily unavailable or times out, the subscription route
uses offline hints derived from the bundled OpenAI model manifest:
| Model id | Display name | Reasoning efforts |
| -------------- | ------------ | ------------------------ |
| `gpt-5.5` | gpt-5.5 | low, medium, high, xhigh |
| `gpt-5.4-mini` | GPT-5.4-Mini | low, medium, high, xhigh |
| Model id | Display name | Reasoning efforts |
| ------------- | ------------ | ------------------------------------ |
| `gpt-5.6-sol` | GPT-5.6 Sol | low, medium, high, xhigh, max, ultra |
| `gpt-5.5` | GPT-5.5 | low, medium, high, xhigh |
| `gpt-5.5-pro` | gpt-5.5-pro | medium, high, xhigh |
Offline hints never prove account entitlement. An authenticated discovery
response remains authoritative even if it contains no visible models; HTTP
`401` and `403` return an empty catalog rather than exposing fallback models.
<Note>
The current bundled harness is `@openai/codex` `0.145.0`. A `model/list` probe
against that bundled app-server returned these public picker rows:
The current bundled harness is `@openai/codex` `0.146.0`. A live `model/list`
probe against the official `0.146.0` app-server returned these public picker
rows:
| Model id | Input modalities | Reasoning efforts |
| --------------- | ---------------- | ------------------------------------ |
@@ -622,14 +662,14 @@ against that bundled app-server returned these public picker rows:
| `gpt-5.5` | text, image | low, medium, high, xhigh |
| `gpt-5.2` | text, image | low, medium, high, xhigh |
The app-server catalog can report `ultra`; OpenClaw reasoning controls currently
expose levels through `max`.
Available model IDs, input modalities, and reasoning efforts remain
account-scoped. Run `/codex models` after starting or upgrading the gateway to
inspect the actual public picker for your account.
Live picker rows are account-scoped and can change with the account, Codex
catalog, or bundled version; run `/codex models` for the current list rather
than relying on any point-in-time table. Hidden models can also appear in the
app-server catalog for internal or specialized flows without being normal
model-picker choices.
The app-server catalog can report `ultra`; OpenClaw reasoning controls currently
expose levels through `max`. Hidden models can also appear in the app-server
catalog for internal or specialized flows without being normal model-picker
choices.
</Note>
Tune discovery under `plugins.entries.codex.config.discovery`:
+33 -1
View File
@@ -18,6 +18,25 @@ continuation, and native compaction. OpenClaw owns channel routing, session
files, visible message delivery, OpenClaw dynamic tools, approvals, media
delivery, and a transcript mirror around that boundary.
For native connected apps, Codex also owns the final per-thread app and tool
policy. OpenClaw caches a runtime-and-workspace-scoped `plugin/installed`
snapshot, reads exact configured plugin details, provisionally admits only
explicitly allowed, ownership-proven apps, and creates a deny-by-default
native thread. One `app/installed` request verifies the actual thread ID
without forcing an inventory refresh. Native app execution begins only after
Codex confirms the app is enabled and callable for that thread.
This check finishes before OpenClaw injects history, starts a turn, or commits a
thread binding. Failed persistent provisional threads are deleted; ephemeral
threads are unsubscribed. OpenClaw retires the app-server connection when safe
cleanup cannot be confirmed. Supervised branches also clean up their temporary
probe and preserve recovery state if cleanup fails.
Account-wide app access cannot override an explicitly disabled configured
workspace plugin. OpenClaw uses its installed snapshot and reads only that
exact plugin's details to identify and deny its apps; it never scans unrelated
marketplaces or activates the plugin.
Prompt routing follows the selected runtime, not just the provider string. A
native Codex turn gets Codex app-server developer instructions; an explicit
OpenClaw compatibility route keeps the normal OpenClaw system prompt even when
@@ -188,6 +207,19 @@ Codex native `hook/started` and `hook/completed` app-server notifications are
projected as `codex_app_server.hook` agent events for trajectory and
debugging. They do not invoke OpenClaw plugin hooks.
## Experimental sandbox process streaming
Native sandbox execution remains opt-in through
`appServer.experimental.sandboxExecServer`. When enabled for an active
OpenClaw sandbox, sandboxed processes stream ordered stdout, stderr, or PTY
output notifications. OpenClaw retains only a bounded recent-output buffer for
polling and replay, so long-running processes cannot grow the app-server bridge
without limit. Process exit and cleanup remain tied to the sandbox-owned
process. Failed environment registration never falls back to host execution.
See [Sandboxed native execution](/plugins/codex-harness-reference#sandboxed-native-execution)
for configuration and local-only transport restrictions.
## V1 support contract
Supported in Codex runtime v1:
@@ -202,7 +234,7 @@ Supported in Codex runtime v1:
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.142.0` or newer. Blocking is supported; argument rewriting is not. |
| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on the pinned Codex app-server. Blocking is supported; argument rewriting is not. |
| Native permission policy | Supported through Codex app-server approvals and compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. |
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
+48 -25
View File
@@ -66,9 +66,10 @@ channel is the communication surface.
- The official `@openclaw/codex` plugin installed. Include `codex` in
`plugins.allow` if your config uses an allowlist.
- A stable Codex app-server from `0.143.0` through `0.145.0`. The plugin manages a compatible
binary by default, so a `codex` command on `PATH` does not affect normal
startup.
- Codex app-server `0.146.0`. The plugin ships and manages `@openai/codex`
`0.146.0` by default, so a `codex` command on `PATH` does not affect normal
startup. Explicit custom, remote, and macOS desktop-owned app-servers must
report the same exact stable `0.146.0` version.
- Node.js on the remote Codex app-server host when `remoteWorkspaceRoot` is set
and cross-machine workspace attachments must be transferred.
- Codex auth through `openclaw models auth login --provider openai`, an
@@ -374,9 +375,8 @@ context limits as native Codex app-server arguments:
Replace `openai:api-key` with the actual API-key profile id if needed. The
agent-scoped app-server receives only that prepared key; the operator's native
`~/.codex` ChatGPT login, plugins, connectors, and thread store remain
untouched. Codex app-server `0.144.6` does not attach a command-auth custom
provider's bearer on app-server turns, so use the injected API-key path above
rather than `homeScope: "user"` for this route.
untouched. Use the injected agent-scoped API-key path above for this route
rather than relying on `homeScope: "user"` to provide the intended credential.
After changing the catalog or app-server arguments, restart the Gateway and
start a fresh chat. Existing native threads preserve their recorded provider
@@ -731,21 +731,43 @@ for the same Codex run. When the reset time passes, the subscription
profile becomes eligible again without changing the selected `openai/gpt-*`
model or Codex runtime.
When native Codex plugins are configured, OpenClaw installs or refreshes
those plugins through the connected app-server before exposing plugin-owned
apps to the Codex thread. `app/installed` supplies app IDs and runtime
accessibility; `app/read` supplies app metadata. Callable apps with authorized
metadata can be enabled directly. When a modern `app/installed` response marks
an explicitly configured plugin-owned app base-disabled, OpenClaw may enable
it provisionally in `thread/start`, then immediately re-read thread-scoped
inventory. The thread is discarded before its first turn unless that app is
enabled and callable there. Account-wide disabled apps and revoked,
unauthenticated, policy-blocked, or missing apps remain excluded. Supported
older app-server versions use `app/list` when they do not implement
`app/installed`; that fallback never enables a disabled app provisionally.
This path does not invent app
installation for unknown ids; OpenClaw only activates marketplace plugins
with `plugin/install` and then refreshes inventory.
When native Codex plugins are configured, OpenClaw reads and caches one
runtime-and-workspace-scoped `plugin/installed` snapshot. That one snapshot
covers both curated and workspace plugins, including disabled plugin ownership.
`plugin/read` resolves only explicitly configured plugin details; `plugin/list`
is reserved for finding or repairing an explicitly enabled missing curated
plugin. OpenClaw never installs, enables, or authenticates workspace plugins.
`app/installed` supplies the installed app runtime snapshot, and `app/read`
supplies authenticated app metadata in batches of at most 100 app IDs. OpenClaw
force-refreshes a cold snapshot once and consolidates successful curated
installations into one app-inventory refresh. Ordinary cached reads do not
force a connector refresh for every thread.
An authorized app can initially appear disabled or non-callable because Codex
has not yet applied the target thread's restrictive app configuration.
OpenClaw provisionally admits only explicitly allowed, ownership-proven apps,
starts the thread with `_default.enabled = false`, and reads `app/installed`
once with that thread's ID and `forceRefresh: false`. An app is exposed only
after Codex confirms it is enabled and callable for the actual thread. Missing
metadata, revoked auth, managed restrictions, workspace policy, and unavailable
tools remain fail-closed.
The check runs before OpenClaw starts a turn or commits a thread binding. A
failed persistent provisional thread is deleted; an ephemeral thread is
unsubscribed. If cleanup cannot be confirmed, OpenClaw retires the app-server
connection instead of reusing an unsafe thread.
Account-wide app access never overrides an explicitly disabled configured
workspace plugin. When `app/read` omits that plugin's ownership, OpenClaw uses
the `plugin/installed` snapshot and reads only the exact configured plugin's
details to keep its apps denied. This check never installs, enables, or
authenticates the plugin.
OpenClaw does not install unknown apps; it activates only explicitly configured
marketplace plugins with `plugin/install` and refreshes their installed
inventory. Missing inventory methods, authentication errors, transport
failures, and connector refresh failures fail closed.
### Environment isolation
@@ -1148,10 +1170,11 @@ instead of a plain OpenAI API-key failure.
Doctor rewrites legacy model refs to `openai/*`, removes stale session and
whole-agent runtime pins, and preserves existing auth-profile overrides.
**The app-server is rejected:** use a stable Codex app-server from `0.143.0`
through the bundled `0.145.0`. Prereleases, build-suffixed versions, and newer
unvalidated releases are rejected because OpenClaw validates generated schemas
against the bundled app-server version.
**The app-server is rejected:** use exactly stable Codex `0.146.0`. Older or
newer versions, prereleases, build-suffixed versions, and unversioned servers
are rejected because OpenClaw validates generated schemas and runtime contracts
against the Codex version it ships. Update or remove custom, remote, or desktop
binary overrides that select another version.
**`/codex status` cannot connect:** check that the `codex` plugin
is enabled, that `plugins.allow` includes it when an allowlist is
+110 -74
View File
@@ -22,14 +22,17 @@ working.
- The agent runtime must be the native Codex harness.
- `plugins.entries.codex.enabled` is `true`.
- `plugins.entries.codex.config.codexPlugins.enabled` is `true`.
- Codex app-server reports exactly stable `0.146.0`. The official plugin ships
`@openai/codex` `0.146.0`; custom, remote, and macOS desktop-owned binaries
must use the same exact version.
- The target Codex app-server can see the expected marketplace, plugin, and
app inventory.
- Migration supports only `openai-curated` plugins that it observed as
source-installed in the source Codex home.
- Manually configured `workspace-directory` plugins require a Codex app-server
whose `plugin/list` accepts `marketplaceKinds` and whose pathless workspace
summaries include `remotePluginId`. The plugin must already be installed and
enabled, and its owned apps must be accessible in the app runtime snapshot.
- Manually configured `workspace-directory` plugins must already appear
installed and enabled under their exact marketplace-qualified identity in
`plugin/installed`. Their owned apps must be accessible and callable for the
configured Codex thread.
`codexPlugins` has no effect on OpenClaw-provider runs, ACP conversation
bindings, or other harnesses, because those paths never create Codex
@@ -93,8 +96,8 @@ config looks like this:
Migration remains limited to `openai-curated`. To use an existing
`workspace-directory` plugin, add it manually with the exact
marketplace-qualified `summary.id` returned by `plugin/list`. For example, if
Codex returns `example-plugin@workspace-directory`, configure that complete
marketplace-qualified `summary.id` returned by `plugin/installed`. For example,
if Codex returns `example-plugin@workspace-directory`, configure that complete
value instead of its display name:
```json5
@@ -125,9 +128,9 @@ OpenClaw does not call `plugin/install` or start authentication for a
`workspace-directory` plugin. Install, enable, and authenticate it in Codex
before adding or enabling the OpenClaw policy. OpenClaw keeps apps hidden when
the response omits the exact marketplace, plugin ID, detail ID, or app-readiness
evidence. If Codex rejects the explicit workspace `plugin/list` request,
OpenClaw reports `marketplace_missing` for each enabled workspace plugin and
keeps any independently discovered curated plugins available.
evidence. If the installed snapshot omits the workspace marketplace, OpenClaw
reports `marketplace_missing` for each enabled workspace plugin and keeps any
independently discovered curated plugins available.
After a `codexPlugins` change, new Codex conversations pick up the updated
app set automatically. Run `/new` or `/reset` to refresh the current
@@ -174,26 +177,30 @@ For `openai-curated` plugins, migration is the durable install/eligibility
step:
- During planning, OpenClaw reads source Codex `plugin/read` details and
checks that the source Codex app-server account is a ChatGPT subscription
account. A non-ChatGPT or missing account response skips app-backed
plugins with `codex_subscription_required`.
checks the source Codex app-server account. `codex_subscription_required`
means `account/read` positively identified an API-key or other
non-ChatGPT account; a missing account is not evidence that a subscription
is absent.
- By default, migration skips source app inventory calls: app-backed source
plugins that pass the account gate are planned without source app
accessibility verification, and account-lookup transport failures skip
with `codex_account_unavailable`.
accessibility verification. A missing account or failed `account/read`
skips them with `codex_account_unavailable`.
- With `--verify-plugin-apps`, migration takes a fresh source `app/installed`
snapshot, fetches app metadata with `app/read`, and requires every owned app
to be present, enabled, and
accessible before planning native activation. Account-lookup transport
failures then fall through to the source app-inventory gate instead of
skipping outright.
snapshot, fetches authenticated metadata with `app/read`, and requires every
owned app to be present, enabled, and accessible in the source Codex account
before planning native activation. If `account/read` is missing or fails,
strict verification can still prove access through the source app-server's
configured bearer or header authentication. A positively identified
non-ChatGPT account remains ineligible.
For `workspace-directory` plugins, setup happens outside OpenClaw. OpenClaw
queries that marketplace only when at least one enabled workspace entry is
configured, resolves each plugin by exact `summary.id`, and reuses the existing
`plugin/read` ownership and installed app readiness checks. An uninstalled,
disabled, inaccessible, or unauthenticated plugin exposes no apps; OpenClaw
does not attempt installation or authentication.
uses its `plugin/installed` snapshot only for explicitly configured enabled
entries, or when `allow_all_plugins` requires identifying apps owned by an
explicitly configured disabled workspace plugin. It resolves each plugin by
exact `summary.id` and uses `plugin/read` for ownership. The disabled-plugin
check is read-only: its apps stay denied, and OpenClaw does not install,
enable, or authenticate the plugin. Missing or ambiguous ownership fails
closed instead of granting account-wide access.
Runtime app inventory is the target-session accessibility check for both
migrated curated plugins and manually configured workspace plugins. Codex
@@ -207,20 +214,19 @@ current conversation.
- Only `openai-curated` plugins already installed in the source Codex
app-server inventory are migration-eligible.
- Runtime also supports explicit `workspace-directory` entries on app-server
builds whose `plugin/list` implements `marketplaceKinds` and returns
`remotePluginId` for pathless workspace summaries. These entries must use
their exact marketplace-qualified `summary.id` and must already be installed,
enabled, and app-accessible. A rejected workspace list request produces the
existing per-plugin `marketplace_missing` diagnostic; missing marketplace,
plugin, detail, or app evidence exposes no workspace app. Curated inventory
from the default list request remains usable.
- App-backed source plugins must pass the migration-time subscription gate.
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
accounts, and in verification mode inaccessible/disabled/missing source
apps or app-inventory refresh failures, are reported as skipped manual
items instead of enabled config entries. Unreadable plugin details are
skipped before the app-inventory gate.
- Runtime also supports explicit `workspace-directory` entries reported by
`plugin/installed`. These entries must use their exact
marketplace-qualified `summary.id` and must already be installed, enabled,
and app-accessible. A missing marketplace, plugin, ownership detail, or app
readiness evidence exposes no workspace app. OpenClaw never scans the
marketplace catalog to discover or activate a workspace plugin.
- Positively identified non-ChatGPT source accounts fail the subscription gate.
Missing or unreadable source accounts are unavailable by default.
`--verify-plugin-apps` can instead establish access through authenticated
source app inventory, including bearer- or header-authenticated app-servers.
Inaccessible, disabled, or missing source apps and inventory refresh failures
remain skipped manual items. Unreadable plugin details are skipped before the
app-inventory gate.
- Migration writes explicit plugin identities (`marketplaceName` and
`pluginName`); it does not write local `marketplacePath` cache paths.
- `codexPlugins.enabled` is the only global enablement switch; there is no
@@ -233,13 +239,27 @@ current conversation.
## App inventory and ownership
OpenClaw reads installed runtime state through app-server `app/installed` and
fetches canonical app metadata with `app/read` in batches of at most 100 app
IDs. It caches the combined inventory in memory for one hour and refreshes
stale or missing entries asynchronously. The cache is process-local;
restarting the CLI or gateway drops it, and OpenClaw rebuilds it from the next
inventory read. Supported older app-server versions that do not implement
`app/installed` continue to use `app/list`.
OpenClaw first reads and caches one `plugin/installed` snapshot scoped to the
target Codex app-server and configured workspace. That snapshot covers
installed curated and workspace plugins, including disabled plugin identities;
failed or incomplete snapshots are never cached. `plugin/read` is limited to
the exact configured plugin details required to establish ownership. Routine
thread setup never scans the marketplace catalog. `plugin/list` runs only to
find or repair an explicitly enabled missing curated plugin, and
`plugin/install` runs only for that explicitly configured curated plugin.
OpenClaw reads installed app runtime state through `app/installed` and fetches
canonical app metadata with `app/read` in batches of at most 100 app IDs. The
first read force-refreshes a cold installed runtime snapshot. When multiple
configured curated plugins are installed, OpenClaw combines their cache
invalidations into a single app-inventory refresh. Ordinary cached reads do
not force a connector refresh for every new thread. OpenClaw caches the
combined inventory in memory for one hour and refreshes stale or missing
entries asynchronously. The cache is process-local; restarting the CLI or
gateway drops it.
Missing inventory methods, authentication errors, transport failures, and
connector refresh failures fail closed.
Migration and runtime use separate cache keys:
@@ -247,9 +267,9 @@ Migration and runtime use separate cache keys:
options. It runs only with `--verify-plugin-apps` and forces a fresh
source runtime snapshot and metadata read for that planning run.
- Target runtime setup uses the target agent's Codex app-server identity when
building the thread app config. Curated plugin activation invalidates that
target cache key, then force-refreshes it after `plugin/install`.
`workspace-directory` setup never runs this activation path.
building and verifying the thread app config. Curated plugin activation
invalidates that target cache key, then force-refreshes it after
`plugin/install`. `workspace-directory` setup never runs this activation path.
A plugin app is exposed only when OpenClaw can map it back to the configured
plugin through stable ownership: an exact app id from plugin detail, a known
@@ -280,12 +300,20 @@ account without requiring a matching plugin package:
}
```
`allow_all_plugins: true` reads a complete installed app snapshot and app
metadata when a new native Codex thread is established and admits only apps
marked accessible for that
account. It does not install, authenticate, or enable apps globally. Existing
threads keep their persisted app set; use `/new`, `/reset`, or restart the
gateway to pick up newly connected or revoked apps.
`allow_all_plugins: true` reads the installed app snapshot and authenticated
metadata when a new native Codex thread is established. It admits only
account-accessible apps. Codex must also confirm each admitted app is enabled
and callable for that thread. OpenClaw does not install, authenticate, or enable
apps globally. Existing threads keep their persisted app set; use `/new`,
`/reset`, or restart the gateway to pick up newly connected or revoked apps.
An explicitly disabled configured plugin always overrides account-wide app
access. Because Codex `app/read` omits a disabled workspace plugin's display
names, OpenClaw uses its `plugin/installed` snapshot and reads only that exact
configured plugin's details to reserve its owned app IDs. This narrow,
read-only check does not discover unrelated marketplaces, activate the plugin,
or grant its apps. If the disabled plugin's ownership cannot be established,
the account-wide app selection fails closed.
Account apps inherit the global `codexPlugins.allow_destructive_actions` value,
which accepts `true`, `false`, `"auto"`, or `"ask"`. Explicit per-plugin policy
@@ -298,6 +326,17 @@ OpenClaw injects a restrictive `config.apps` patch for the Codex thread:
`_default` is disabled, and only apps owned by enabled configured plugins or
accessible account apps admitted by `allow_all_plugins` are enabled.
An app can be installed and authenticated but non-callable in the account-wide
snapshot while `_default` is disabled. OpenClaw provisionally admits only
ownership-proven, policy-allowed apps, creates the restrictive thread, and then
rereads `app/installed` once with the resulting thread ID and
`forceRefresh: false`. Codex must confirm each admitted app is enabled and
callable under the thread's effective app, managed, workspace, and tool
policies before the turn proceeds. If that attestation fails, the provisional
thread is never bound or used. OpenClaw deletes a failed persistent provisional
thread, unsubscribes a failed ephemeral thread, and retires the app-server
connection if safe cleanup cannot be confirmed.
`destructive_enabled` on each app comes from the effective global or
per-plugin `allow_destructive_actions` policy; `true`, `"auto"`, and `"ask"`
all set `destructive_enabled: true`, and `false` sets it `false`. Codex still
@@ -343,33 +382,30 @@ plugins, while unsafe schemas and ambiguous ownership fail closed:
| `auth_required` | Migration installed the plugin, but one of its apps still needs authentication. The entry is written disabled until you reauthorize. | Reauthorize the app in Codex, then enable the plugin in OpenClaw. |
| `app_inaccessible`, `app_disabled`, `app_missing` | With `--verify-plugin-apps`, the source Codex app inventory did not show all owned apps as present, enabled, and accessible. | Reauthorize or enable the app in Codex, then rerun migration with `--verify-plugin-apps`. |
| `app_inventory_unavailable` | Strict source app verification was requested but the source Codex app inventory refresh failed. | Fix source Codex app-server access, or retry without `--verify-plugin-apps` to accept the faster account-gated plan. |
| `codex_subscription_required` | The source Codex app-server account was not a ChatGPT subscription account. | Log in to the Codex app with subscription auth, then rerun migration. |
| `codex_account_unavailable` | The source Codex app-server account could not be read. | Fix source Codex app-server auth, or rerun with `--verify-plugin-apps` to let source app inventory decide eligibility. |
| `marketplace_missing`, `plugin_missing` | Marketplace or exact plugin unavailable; the explicit workspace catalog request may have been rejected; workspace apps fail closed. | Verify the compatible app-server contract and exact ID described below. |
| `plugin_detail_unavailable` | OpenClaw could not read plugin ownership details. | Inspect the target app-server's `plugin/list` and `plugin/read` responses. |
| `codex_subscription_required` | The source app-server positively identified an API-key or other non-ChatGPT account. | Log in to the Codex app with subscription auth, then rerun migration. |
| `codex_account_unavailable` | The source account was missing or `account/read` failed without strict app verification. | Restore source account access, or use `--verify-plugin-apps` when authenticated source app inventory can prove access. |
| `marketplace_missing`, `plugin_missing` | The exact marketplace or configured plugin is unavailable in the installed snapshot; workspace apps fail closed. | Verify the target app-server's `plugin/installed` response and exact configured plugin identity. |
| `plugin_detail_unavailable` | OpenClaw could not read the exact configured plugin's ownership details. | Inspect the target app-server's `plugin/installed` and `plugin/read` responses. |
| `plugin_disabled` | Codex reports the plugin installed but disabled. | Curated activation may repair it; enable a workspace plugin in Codex before retrying. |
| `plugin_activation_failed` | Plugin activation did not complete. | Use the attached diagnostic to distinguish marketplace, auth, refresh, or workspace-readiness failures. |
| `app_inventory_missing`, `app_inventory_stale` | App readiness came from an empty or stale cache. | OpenClaw schedules an async refresh automatically; plugin apps stay excluded until ownership and readiness are known. |
| `app_ownership_ambiguous` | App inventory only matched by display name. | The app stays hidden from the Codex thread until a later refresh proves ownership. |
**Workspace plugin is installed but not visible:** confirm the workspace
`plugin/list` result reports the exact configured ID as installed and enabled,
then confirm `app/installed` returns every owned app for the same Codex
account and `app/read` returns its metadata. A modern base-disabled app owned
by an explicitly configured plugin is enabled provisionally for a new thread
and must immediately attest as enabled and callable there. Account-wide
disabled apps, legacy-inventory disabled apps, and apps that fail that
thread-scoped attestation stay excluded; enable or reauthorize the app in Codex
before starting another thread. If you changed that state after the gateway cached app
inventory, wait for the one-hour cache refresh or restart the gateway, then use
`/new` or `/reset`. OpenClaw does not repair or authenticate workspace plugins.
If the explicit workspace list request is rejected, each enabled workspace
entry reports `marketplace_missing`; unrelated curated entries still proceed
from the default list response.
`plugin/installed` snapshot reports the exact configured ID as installed and
enabled, then confirm `app/installed` returns every owned app for the same
Codex account and `app/read` returns its metadata. An app disabled only by the
account-wide default can become callable after OpenClaw starts and verifies
its explicitly configured thread. Revoked auth, missing metadata, disabled
workspace plugins, and Codex managed or workspace restrictions still block
access. Reauthorize or repair those upstream conditions before starting a new
thread. If you changed that state after the gateway cached app inventory, wait
for the one-hour cache refresh or restart the gateway, then use `/new` or
`/reset`. OpenClaw does not repair or authenticate workspace plugins.
For `plugin_detail_unavailable`, a pathless workspace summary must include
`remotePluginId`; OpenClaw keeps owned apps hidden when that selector or the
subsequent `plugin/read` result is unavailable. For
For `plugin_detail_unavailable`, verify that the exact installed marketplace
and plugin identity select a matching `plugin/read` result. OpenClaw keeps
owned apps hidden when that selector or ownership detail is unavailable. For
`plugin_activation_failed`, curated plugins may report a marketplace, auth, or
post-install refresh failure. A workspace plugin reports this code when it is
not already active; install, enable, and authenticate it outside OpenClaw.
@@ -47,7 +47,7 @@ function threadStartResult() {
status: { type: "idle" },
path: null,
cwd: "/tmp/openclaw-agent",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
-1
View File
@@ -9,7 +9,6 @@
"type": "module",
"dependencies": {
"@openai/codex": "0.146.0",
"semver": "7.8.5",
"smol-toml": "1.7.1",
"typebox": "1.3.8",
"ws": "8.21.1",
@@ -29,7 +29,10 @@ describe("Codex app inventory cache", () => {
const snapshot = await cache.refreshNow({ key, request, nowMs: 0 });
expect(snapshot.apps).toEqual(apps);
expect(snapshot.source).toBe("installed");
expect(snapshot.installedApps).toEqual([
{ id: "app-1", runtimeName: "app-1", enabled: true, callable: true },
{ id: "app-2", runtimeName: "app-2", enabled: true, callable: true },
]);
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(2, "app/read", {
appIds: ["app-1", "app-2"],
@@ -82,6 +85,76 @@ describe("Codex app inventory cache", () => {
});
});
it("upgrades an in-flight targeted refresh before returning the complete account inventory", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const apps = [app("google-calendar-app"), app("unrelated-slack-app")];
let resolveTargetedInstall: ((response: v2.AppsInstalledResponse) => void) | undefined;
let installedCalls = 0;
const request = vi.fn(async (method, params) => {
if (method === "app/installed") {
installedCalls += 1;
if (installedCalls === 1) {
expect(params).toEqual({ forceRefresh: true });
return await new Promise<v2.AppsInstalledResponse>((resolve) => {
resolveTargetedInstall = resolve;
});
}
expect(params).toEqual({ forceRefresh: false });
}
return codexAppInventoryResponse(method, apps, params);
});
const targeted = cache.refreshNow({
key: "runtime",
request,
targetAppIds: ["google-calendar-app"],
});
const complete = cache.refreshNow({ key: "runtime", request, targetAppIds: [] });
expect(installedCalls).toBe(1);
resolveTargetedInstall?.(codexAppInventoryResponse("app/installed", apps));
const [targetedSnapshot, completeSnapshot] = await Promise.all([targeted, complete]);
expect(targetedSnapshot.apps).toEqual([app("google-calendar-app")]);
expect(completeSnapshot.apps).toEqual(apps);
expect(cache.read({ key: "runtime", request }).snapshot?.apps).toEqual(apps);
expect(request.mock.calls.filter(([method]) => method === "app/installed")).toEqual([
["app/installed", { forceRefresh: true }],
["app/installed", { forceRefresh: false }],
]);
expect(request.mock.calls.filter(([method]) => method === "app/read")).toEqual([
["app/read", { appIds: ["google-calendar-app"] }],
["app/read", { appIds: ["google-calendar-app", "unrelated-slack-app"] }],
]);
});
it("joins a complete in-flight refresh for a narrower plugin request", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const apps = [app("google-calendar-app"), app("unrelated-slack-app")];
let resolveInstall: ((response: v2.AppsInstalledResponse) => void) | undefined;
const request = vi.fn(async (method, params) => {
if (method === "app/installed") {
return await new Promise<v2.AppsInstalledResponse>((resolve) => {
resolveInstall = resolve;
});
}
return codexAppInventoryResponse(method, apps, params);
});
const complete = cache.refreshNow({ key: "runtime", request, targetAppIds: [] });
const targeted = cache.refreshNow({
key: "runtime",
request,
targetAppIds: ["google-calendar-app"],
});
resolveInstall?.(codexAppInventoryResponse("app/installed", apps));
const [completeSnapshot, targetedSnapshot] = await Promise.all([complete, targeted]);
expect(completeSnapshot.apps).toEqual(apps);
expect(targetedSnapshot.apps).toEqual(apps);
expect(request).toHaveBeenCalledTimes(2);
});
it("does not request metadata when a targeted app is not installed", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (method, params) =>
@@ -151,7 +224,7 @@ describe("Codex app inventory cache", () => {
});
});
it("retains disabled app metadata without marking its runtime callable", async () => {
it("keeps authorized disabled app metadata distinct from runtime callability", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const disabledApp = { ...app("disabled-app"), isEnabled: false };
const request = vi.fn(async (method, params) =>
@@ -160,10 +233,13 @@ describe("Codex app inventory cache", () => {
const snapshot = await cache.refreshNow({ key: "runtime", request });
expect(snapshot.apps).toEqual([{ ...disabledApp, isAccessible: false }]);
expect(snapshot.apps).toEqual([disabledApp]);
expect(snapshot.installedApps).toEqual([
{ id: "disabled-app", runtimeName: "disabled-app", enabled: false, callable: false },
]);
});
it("does not expose enabled installed apps that are not callable", async () => {
it("excludes installed apps whose account is not authorized to read their metadata", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const inaccessibleApp = { ...app("inaccessible-app"), isAccessible: false };
const request = vi.fn(async (method, params) =>
@@ -172,10 +248,40 @@ describe("Codex app inventory cache", () => {
const snapshot = await cache.refreshNow({ key: "runtime", request });
expect(snapshot.apps).toEqual([inaccessibleApp]);
expect(snapshot.apps).toEqual([]);
expect(snapshot.installedApps).toEqual([
{
id: "inaccessible-app",
runtimeName: "inaccessible-app",
enabled: true,
callable: false,
},
]);
});
it("force-refreshes the upstream runtime snapshot on every cache refresh", async () => {
it("keeps authorized app metadata when enabled runtime tools are not callable", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const authorizedApp = app("tool-blocked-app");
const request = vi.fn(async (method, params) =>
codexAppInventoryResponse(method, [authorizedApp], params, {
callableByAppId: { "tool-blocked-app": false },
}),
);
const snapshot = await cache.refreshNow({ key: "runtime", request });
expect(snapshot.apps).toEqual([authorizedApp]);
expect(snapshot.installedApps).toEqual([
{
id: "tool-blocked-app",
runtimeName: "tool-blocked-app",
enabled: true,
callable: false,
},
]);
});
it("seeds the upstream runtime once and reuses its snapshot on later refreshes", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
let installedCalls = 0;
const request = vi.fn(async (method, params) => {
@@ -190,6 +296,24 @@ describe("Codex app inventory cache", () => {
expect(first.apps).toEqual([app("refreshed-app-1")]);
expect(second.apps).toEqual([app("refreshed-app-2")]);
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(3, "app/installed", { forceRefresh: false });
});
it("refreshes the upstream snapshot when a caller explicitly requests it", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (method, params) =>
codexAppInventoryResponse(method, [app("refreshed-app")], params),
);
await cache.refreshNow({ key: "runtime", request, nowMs: 0 });
await cache.refreshNow({
key: "runtime",
request,
nowMs: 1,
forceRefetch: true,
});
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(3, "app/installed", { forceRefresh: true });
});
@@ -261,49 +385,21 @@ describe("Codex app inventory cache", () => {
expect(read.diagnostic?.message).toBe("app inventory failed");
});
it("preserves supported older app servers when app/installed is unavailable", async () => {
it("fails closed when the pinned server does not implement app/installed", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (method, params) => {
if (method === "app/installed") {
throw new CodexAppServerRpcError({ code: -32601, message: "Method not found" }, method);
}
return codexAppInventoryResponse(method, [app("legacy-app")], params);
return codexAppInventoryResponse(method, [app("current-app")], params);
});
const snapshot = await cache.refreshNow({ key: "runtime", request });
expect(snapshot.apps).toEqual([app("legacy-app")]);
expect(snapshot.source).toBe("legacy");
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(2, "app/list", {
cursor: undefined,
limit: 100,
forceRefetch: false,
});
await expect(cache.refreshNow({ key: "runtime", request })).rejects.toThrow("Method not found");
expect(request).toHaveBeenCalledExactlyOnceWith("app/installed", { forceRefresh: true });
expect(cache.read({ key: "runtime", request, suppressRefresh: true }).snapshot).toBeUndefined();
});
it("does not expose disabled apps from supported older app servers", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const disabledApp = { ...app("legacy-disabled-app"), isEnabled: false };
const request = vi.fn(async (method, params) => {
if (method === "app/installed") {
throw new CodexAppServerRpcError({ code: -32601, message: "Method not found" }, method);
}
return codexAppInventoryResponse(method, [disabledApp], params);
});
const snapshot = await cache.refreshNow({ key: "runtime", request });
expect(snapshot.apps).toEqual([{ ...disabledApp, isAccessible: false }]);
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(2, "app/list", {
cursor: undefined,
limit: 100,
forceRefetch: false,
});
});
it("does not fall back to the legacy directory for authorization failures", async () => {
it("fails closed when installed app inventory is unauthorized", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (method) => {
throw new CodexAppServerRpcError({ code: 403, message: "Forbidden" }, method);
@@ -313,6 +409,21 @@ describe("Codex app inventory cache", () => {
expect(request).toHaveBeenCalledExactlyOnceWith("app/installed", { forceRefresh: true });
});
it("fails closed when app metadata cannot be read", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (method, params) => {
if (method === "app/read") {
throw new CodexAppServerRpcError({ code: -32601, message: "Method not found" }, method);
}
return codexAppInventoryResponse(method, [app("current-app")], params);
});
await expect(cache.refreshNow({ key: "runtime", request })).rejects.toThrow("Method not found");
expect(request).toHaveBeenNthCalledWith(1, "app/installed", { forceRefresh: true });
expect(request).toHaveBeenNthCalledWith(2, "app/read", { appIds: ["current-app"] });
expect(cache.read({ key: "runtime", request, suppressRefresh: true }).snapshot).toBeUndefined();
});
it("omits challenge HTML when serializing app inventory errors", () => {
const error = new Error(
'failed to read apps: Request failed with status 403 Forbidden: <html><script src="/backend-api/connectors/directory/list?__cf_chl_tk=secret-token"></script></html>',
@@ -375,7 +486,7 @@ describe("Codex app inventory cache", () => {
await expect(forced).resolves.toStrictEqual({
key,
apps: [app("fresh-app")],
source: "installed",
installedApps: [{ id: "fresh-app", runtimeName: "fresh-app", enabled: true, callable: true }],
fetchedAtMs: 1,
expiresAtMs: 1_001,
revision: 2,
@@ -10,7 +10,6 @@ import {
} from "openclaw/plugin-sdk/number-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { CodexAppServerRpcError } from "./client.js";
import type {
CodexAppServerRequestParams,
CodexAppServerRequestResult,
@@ -20,13 +19,12 @@ import type {
/** Default app inventory cache freshness window. */
const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000;
// Codex 0.145.0 AppsReadParams rejects requests with more than 100 app IDs.
// Codex app/read rejects metadata requests containing more than 100 app IDs.
const CODEX_APP_READ_BATCH_LIMIT = 100;
const CODEX_TARGETED_LEGACY_APP_INVENTORY_LIMIT = 1_000;
const MAX_SERIALIZED_ERROR_MESSAGE_LENGTH = 500;
/** App-server request function used to read installed apps and their metadata. */
export type CodexAppInventoryRequest = <Method extends "app/installed" | "app/list" | "app/read">(
export type CodexAppInventoryRequest = <Method extends "app/installed" | "app/read">(
method: Method,
params: CodexAppServerRequestParams<Method>,
) => Promise<CodexAppServerRequestResult<Method>>;
@@ -52,7 +50,9 @@ type CodexAppInventoryCacheDiagnostic = {
export type CodexAppInventorySnapshot = {
key: string;
apps: v2.AppInfo[];
source: "installed" | "legacy";
installedApps: readonly v2.InstalledApp[];
/** Absent for complete inventory; present for plugin-targeted snapshots. */
targetAppIds?: readonly string[];
fetchedAtMs: number;
expiresAtMs: number;
revision: number;
@@ -85,11 +85,16 @@ type RefreshParams = {
targetAppIds?: readonly string[];
};
type InFlightRefresh = {
promise: Promise<CodexAppInventorySnapshot>;
targetAppIds: ReadonlySet<string>;
};
/** In-memory app inventory cache with coalesced refreshes per key. */
export class CodexAppInventoryCache {
private readonly ttlMs: number;
private readonly entries = new Map<string, CacheEntry>();
private readonly inFlight = new Map<string, Promise<CodexAppInventorySnapshot>>();
private readonly inFlight = new Map<string, InFlightRefresh>();
// Per-key refresh generation. Each refresh attempt claims the next token so
// an older request that finishes late cannot overwrite a newer snapshot.
private readonly refreshTokens = new Map<string, number>();
@@ -168,29 +173,34 @@ export class CodexAppInventoryCache {
}
private scheduleRefresh(params: RefreshParams): boolean {
if (this.inFlight.has(params.key) && !params.forceRefetch) {
const existing = this.inFlight.get(params.key);
if (existing && !params.forceRefetch && doesInFlightRefreshCover(existing, params)) {
return true;
}
const promise = this.refresh(params);
this.inFlight.set(params.key, promise);
promise.catch(() => undefined);
return true;
}
private async refresh(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
const existing = this.inFlight.get(params.key);
if (existing && !params.forceRefetch) {
return existing;
if (existing && !params.forceRefetch && doesInFlightRefreshCover(existing, params)) {
return existing.promise;
}
const refreshToken = (this.refreshTokens.get(params.key) ?? 0) + 1;
this.refreshTokens.set(params.key, refreshToken);
const promise = this.refreshUncoalesced(params, refreshToken);
this.inFlight.set(params.key, promise);
const previousRefresh = params.forceRefetch ? undefined : existing?.promise;
const promise = this.refreshUncoalesced(params, refreshToken, previousRefresh);
const currentRefresh = {
promise,
targetAppIds: new Set(params.targetAppIds?.filter(Boolean) ?? []),
};
this.inFlight.set(params.key, currentRefresh);
try {
return await promise;
} finally {
if (this.inFlight.get(params.key) === promise) {
if (this.inFlight.get(params.key) === currentRefresh) {
this.inFlight.delete(params.key);
}
}
@@ -199,20 +209,37 @@ export class CodexAppInventoryCache {
private async refreshUncoalesced(
params: RefreshParams,
refreshToken: number,
previousRefresh?: Promise<CodexAppInventorySnapshot>,
): Promise<CodexAppInventorySnapshot> {
const nowMs = resolveDateTimestampMs(params.nowMs);
try {
const inventory = await readInstalledApps(
params.request,
params.forceRefetch ?? false,
params.targetAppIds,
);
let previousRefreshSucceeded = false;
if (previousRefresh) {
try {
await previousRefresh;
previousRefreshSucceeded = true;
} catch {
// A failed narrow read does not seed Codex; let the broader read
// retry independently and perform the cold refresh when required.
}
}
const inventory = await readInstalledApps(params.request, {
// A cold upstream connector cache is empty until it is deliberately
// seeded. Later reads reuse its committed snapshot unless requested.
forceRefresh:
params.forceRefetch === true ||
(!this.entries.has(params.key) && !previousRefreshSucceeded),
targetAppIds: params.targetAppIds,
});
this.revision += 1;
const expiresAtMs = resolveExpiresAtMsFromDurationMs(this.ttlMs, { nowMs }) ?? 0;
const snapshot: CodexAppInventorySnapshot = {
key: params.key,
apps: inventory.apps,
source: inventory.source,
installedApps: inventory.installedApps,
...(params.targetAppIds?.some(Boolean)
? { targetAppIds: Array.from(new Set(params.targetAppIds.filter(Boolean))).toSorted() }
: {}),
fetchedAtMs: nowMs,
expiresAtMs,
revision: this.revision,
@@ -244,6 +271,17 @@ export class CodexAppInventoryCache {
}
}
function doesInFlightRefreshCover(existing: InFlightRefresh, params: RefreshParams): boolean {
if (existing.targetAppIds.size === 0) {
return true;
}
const requestedAppIds = new Set(params.targetAppIds?.filter(Boolean) ?? []);
return (
requestedAppIds.size > 0 &&
Array.from(requestedAppIds).every((appId) => existing.targetAppIds.has(appId))
);
}
/** Serializes a refresh failure without leaking large or sensitive error data. */
export function serializeCodexAppInventoryError(error: unknown): Record<string, unknown> {
const record = isRecord(error) ? error : undefined;
@@ -300,34 +338,17 @@ function normalizeRuntimeIdentityForCacheKey(
async function readInstalledApps(
request: CodexAppInventoryRequest,
forceRefetch: boolean,
targetAppIds: readonly string[] = [],
): Promise<{ apps: v2.AppInfo[]; source: CodexAppInventorySnapshot["source"] }> {
let installed: v2.AppsInstalledResponse;
try {
// A non-forced installed read returns the prior committed runtime snapshot;
// refreshing OpenClaw's cache must refresh the upstream snapshot as well.
installed = await request("app/installed", { forceRefresh: true });
} catch (error) {
// OpenClaw still supports Codex 0.143.0 and 0.144.x, which do not
// implement the 0.145.0 installed-app lifecycle methods.
if (
!(error instanceof CodexAppServerRpcError) ||
error.code !== -32601 ||
error.method !== "app/installed"
) {
throw error;
}
return {
apps: await readLegacyInstalledApps(request, forceRefetch, targetAppIds),
source: "legacy",
};
}
const targetIds = new Set(targetAppIds.filter(Boolean));
options: {
forceRefresh: boolean;
targetAppIds?: readonly string[];
},
): Promise<{ apps: v2.AppInfo[]; installedApps: v2.InstalledApp[] }> {
const installed = await request("app/installed", { forceRefresh: options.forceRefresh });
const targetIds = new Set((options.targetAppIds ?? []).filter(Boolean));
const apps =
targetIds.size === 0 ? installed.apps : installed.apps.filter((app) => targetIds.has(app.id));
if (apps.length === 0) {
return { apps: [], source: "installed" };
return { apps: [], installedApps: [] };
}
const metadataResponses = await Promise.all(
@@ -364,53 +385,18 @@ async function readInstalledApps(
appMetadata: null,
labels: null,
installUrl: metadata.installUrl ?? null,
isAccessible: installedApp.callable,
// app/read proves account authorization, while runtime callability
// remains separately visible in installedApps for thread admission.
isAccessible: true,
isEnabled: installedApp.enabled,
pluginDisplayNames: metadata.pluginDisplayNames,
},
];
}),
source: "installed",
installedApps: apps,
};
}
async function readLegacyInstalledApps(
request: CodexAppInventoryRequest,
forceRefetch: boolean,
targetAppIds: readonly string[],
): Promise<v2.AppInfo[]> {
const apps: v2.AppInfo[] = [];
const remainingTargetIds = new Set(targetAppIds.filter(Boolean));
const seenCursors = new Set<string>();
let cursor: string | null | undefined;
do {
const response = await request("app/list", {
cursor,
limit: remainingTargetIds.size > 0 ? CODEX_TARGETED_LEGACY_APP_INVENTORY_LIMIT : 100,
forceRefetch,
});
for (const app of response.data) {
// Legacy accessibility predates installed callability; disabled apps
// must obey the same fail-closed runtime contract as modern snapshots.
apps.push(app.isEnabled ? app : { ...app, isAccessible: false });
remainingTargetIds.delete(app.id);
}
if (targetAppIds.length > 0 && remainingTargetIds.size === 0) {
break;
}
cursor = response.nextCursor;
if (cursor && seenCursors.has(cursor)) {
throw new Error(`app/list returned repeated cursor ${cursor}`);
}
if (cursor) {
seenCursors.add(cursor);
}
} while (cursor);
return apps;
}
function stripEntryState(entry: CacheEntry): CodexAppInventorySnapshot {
const { invalidated: _invalidated, ...snapshot } = entry;
return snapshot;
@@ -1,69 +0,0 @@
import type { JsonValue } from "./protocol-json.js";
/** App inventory shape consumed by OpenClaw's existing plugin policy. */
export type CodexAppInfo = {
id: string;
name: string;
description?: string | null;
logoUrl?: string | null;
logoUrlDark?: string | null;
distributionChannel?: string | null;
branding?: JsonValue;
appMetadata?: JsonValue;
labels?: JsonValue;
installUrl?: string | null;
isAccessible: boolean;
isEnabled: boolean;
pluginDisplayNames: string[];
};
/** Legacy inventory contract retained for supported pre-0.145 app servers. */
export type CodexAppsListParams = {
cursor?: string | null;
limit?: number;
forceRefetch?: boolean;
};
export type CodexAppsListResponse = {
data: CodexAppInfo[];
nextCursor?: string | null;
};
/** Runtime app state returned by Codex app-server `app/installed`. */
type CodexInstalledApp = {
id: string;
runtimeName?: string | null;
enabled: boolean;
callable: boolean;
};
export type CodexAppsInstalledParams = {
threadId?: string | null;
forceRefresh?: boolean;
};
export type CodexAppsInstalledResponse = {
apps: CodexInstalledApp[];
};
/** Canonical connector metadata returned by Codex app-server `app/read`. */
type CodexConnectorMetadata = {
id: string;
name: string;
description?: string | null;
iconUrl?: string | null;
iconUrlDark?: string | null;
distributionChannel?: string | null;
installUrl?: string | null;
pluginDisplayNames: string[];
};
export type CodexAppsReadParams = {
appIds: string[];
includeTools?: boolean;
};
export type CodexAppsReadResponse = {
apps: CodexConnectorMetadata[];
missingAppIds: string[];
};
@@ -1,13 +1,13 @@
import type { CodexAppsReadParams } from "./app-inventory-protocol.js";
import type { CodexAppServerRequestParams, CodexAppServerRequestResult, v2 } from "./protocol.js";
type CodexAppInventoryMethod = "app/installed" | "app/list" | "app/read";
type CodexAppInventoryMethod = "app/installed" | "app/read";
/** Builds app-server inventory fixtures from the existing app policy test shape. */
export function codexAppInventoryResponse<Method extends CodexAppInventoryMethod>(
method: Method,
apps: readonly v2.AppInfo[],
params?: CodexAppServerRequestParams<Method>,
options?: { callableByAppId?: Readonly<Record<string, boolean>> },
): CodexAppServerRequestResult<Method> {
if (method === "app/installed") {
return {
@@ -15,34 +15,29 @@ export function codexAppInventoryResponse<Method extends CodexAppInventoryMethod
id: app.id,
runtimeName: app.name,
enabled: app.isEnabled,
callable: app.isAccessible && app.isEnabled,
callable: options?.callableByAppId?.[app.id] ?? (app.isAccessible && app.isEnabled),
})),
} as CodexAppServerRequestResult<Method>;
}
if (method === "app/read") {
const requestedIds = (params as CodexAppsReadParams | undefined)?.appIds;
const requestedIdSet = requestedIds ? new Set(requestedIds) : undefined;
const matchingApps = requestedIdSet ? apps.filter((app) => requestedIdSet.has(app.id)) : apps;
const returnedIds = new Set(matchingApps.map((app) => app.id));
return {
apps: matchingApps.map((app) => ({
id: app.id,
name: app.name,
description: app.description,
iconUrl: app.logoUrl,
iconUrlDark: app.logoUrlDark,
distributionChannel: app.distributionChannel,
installUrl: app.installUrl,
pluginDisplayNames: app.pluginDisplayNames,
})),
missingAppIds: requestedIds?.filter((id) => !returnedIds.has(id)) ?? [],
} as CodexAppServerRequestResult<Method>;
}
const requestedIds = (params as CodexAppServerRequestParams<"app/read"> | undefined)?.appIds;
const requestedIdSet = requestedIds ? new Set(requestedIds) : undefined;
const matchingApps = apps.filter(
(app) => app.isAccessible && (!requestedIdSet || requestedIdSet.has(app.id)),
);
const returnedIds = new Set(matchingApps.map((app) => app.id));
return {
data: [...apps],
nextCursor: null,
apps: matchingApps.map((app) => ({
id: app.id,
name: app.name,
description: app.description,
iconUrl: app.logoUrl,
iconUrlDark: app.logoUrlDark,
distributionChannel: app.distributionChannel,
installUrl: app.installUrl,
pluginDisplayNames: app.pluginDisplayNames,
})),
missingAppIds: requestedIds?.filter((id) => !returnedIds.has(id)) ?? [],
} as CodexAppServerRequestResult<Method>;
}
@@ -176,7 +176,7 @@ async function captureExpectedRuntimeArtifact(
before,
startOptions: appServer.start,
spawnIdentity,
runtimeIdentity: { serverVersion: "0.143.0", userAgent: "openclaw/0.143.0 (macOS; test)" },
runtimeIdentity: { serverVersion: "0.146.0", userAgent: "openclaw/0.146.0 (macOS; test)" },
});
}
@@ -186,7 +186,7 @@ async function answerInitialize(harness: ClientHarness): Promise<void> {
timeout: HARNESS_REQUEST_TIMEOUT_MS,
});
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.143.0 (macOS; test)" } });
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.146.0 (macOS; test)" } });
}
async function waitForRequest(
@@ -225,7 +225,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: "/repo",
cliVersion: "0.143.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -2053,7 +2053,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
authProfileId: "anthropic:work",
}),
).rejects.toThrow(
'Codex app-server auth profile "anthropic:work" must be OpenAI Codex auth or an OpenAI API-key backup.',
'Codex app-server auth profile "anthropic:work" must use the canonical OpenAI auth provider; run "openclaw doctor --fix" to migrate legacy provider IDs.',
);
expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
@@ -2518,6 +2518,51 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it.each(["codex-cli", "openai-codex"] as const)(
"rejects retired %s auth-provider profiles before app-server login",
async (provider) => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "token",
provider,
token: "legacy-access-token",
email: "legacy-codex@example.test",
},
});
await expect(
applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
}),
).rejects.toThrow(
'Codex app-server auth profile "openai:work" must use the canonical OpenAI auth provider; run "openclaw doctor --fix" to migrate legacy provider IDs.',
);
await expect(
resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai:work",
}),
).resolves.toBeUndefined();
await expect(
resolveCodexAppServerPreparedAuthProfileSnapshot({
agentDir,
authProfileId: "openai:work",
}),
).resolves.toBeUndefined();
expect(request).not.toHaveBeenCalled();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
},
);
it("answers app-server ChatGPT token refresh requests from the bound profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
@@ -2737,6 +2782,43 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it.each(["codex-cli", "openai-codex"] as const)(
"rejects retired %s auth-provider profiles before OAuth refresh",
async (provider) => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider,
access: "stale-alias-access-token",
refresh: "alias-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-legacy",
email: "legacy-codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
}),
).rejects.toThrow(
'Codex app-server auth profile "openai:work" must use the canonical OpenAI auth provider; run "openclaw doctor --fix" to migrate legacy provider IDs.',
);
expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled();
expect(
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin,
).not.toHaveBeenCalled();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
},
);
it("preserves a stored ChatGPT plan type when building token login params", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
+3 -12
View File
@@ -622,7 +622,7 @@ async function resolveCodexAppServerAuthProfileLoginParams(params: {
}
if (profileId && profile && !isCodexAppServerAuthProfileCredential(profile)) {
throw new CodexAppServerAuthProfileUnavailableError(
`Codex app-server auth profile "${profileId}" must be OpenAI Codex auth or an OpenAI API-key backup.`,
`Codex app-server auth profile "${profileId}" must use the canonical OpenAI auth provider; run "openclaw doctor --fix" to migrate legacy provider IDs.`,
);
}
return await resolveCodexAppServerAuthProfileLoginParamsInternal({
@@ -678,7 +678,7 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
}
if (!isCodexAppServerAuthProfileCredential(credential)) {
throw new Error(
`Codex app-server auth profile "${profileId}" must be OpenAI Codex auth or an OpenAI API-key backup.`,
`Codex app-server auth profile "${profileId}" must use the canonical OpenAI auth provider; run "openclaw doctor --fix" to migrate legacy provider IDs.`,
);
}
const loginParams = await resolveLoginParamsForCredential(profileId, credential, {
@@ -1001,17 +1001,8 @@ function isCodexAppServerAuthProvider(provider: string): boolean {
return provider.trim().toLowerCase() === CODEX_APP_SERVER_AUTH_PROVIDER;
}
function isOpenAIApiKeyBackupCredential(credential: AuthProfileCredential): boolean {
return (
credential.type === "api_key" &&
credential.provider.trim().toLowerCase() === CODEX_APP_SERVER_AUTH_PROVIDER
);
}
function isCodexAppServerAuthProfileCredential(credential: AuthProfileCredential): boolean {
return (
isCodexAppServerAuthProvider(credential.provider) || isOpenAIApiKeyBackupCredential(credential)
);
return isCodexAppServerAuthProvider(credential.provider);
}
function shouldClearOpenAiApiKeyForCodexAuthProfile(params: {
@@ -22,6 +22,7 @@ import {
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
let codexAppServerClientFactoryForTest: CodexTestAppServerClientFactory | undefined;
@@ -169,7 +170,7 @@ function threadStartResult(threadId = "thread-auth-contract") {
status: { type: "idle" },
path: null,
cwd: "",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -205,7 +206,7 @@ function turnStartResult(turnId = "turn-auth-contract") {
}
function getMockServerVersion() {
return "0.132.0";
return CODEX_APP_SERVER_VERSION;
}
function getMockRuntimeIdentity() {
@@ -3,6 +3,7 @@ import { runBoundedCodexAppServerTurn } from "./bounded-turn.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexServerNotification, JsonValue } from "./protocol.js";
import type { CodexAppServerClientFactory } from "./shared-client.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
function modelList() {
return {
@@ -37,7 +38,7 @@ function threadStartResult() {
updatedAt: 1,
status: { type: "idle" },
cwd: "/tmp/finalizer",
cliVersion: "0.144.5",
cliVersion: CODEX_APP_SERVER_VERSION,
source: "unknown",
agentNickname: null,
agentRole: null,
+27 -18
View File
@@ -1,6 +1,5 @@
// Codex tests cover client plugin behavior.
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { inc as incrementSemver } from "semver";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CodexAppServerClient,
@@ -9,7 +8,7 @@ import {
} from "./client.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
import { createClientHarness } from "./test-support.js";
import { MAX_CODEX_APP_SERVER_VERSION, MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 660_000;
@@ -300,7 +299,7 @@ describe("CodexAppServerClient", () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.143.0 (macOS; test)" },
result: { userAgent: "openclaw/0.146.0 (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
@@ -330,33 +329,46 @@ describe("CodexAppServerClient", () => {
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required, but detected 0.124.9`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.124.9`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks same-version Codex app-server prereleases below the stable floor", async () => {
it("blocks the previously bundled Codex app-server version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.143.0-alpha.2 (macOS; test)" },
result: { userAgent: "openclaw/0.145.0 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required, but detected 0.143.0-alpha.2`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.145.0`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks same-version Codex app-server build metadata below the stable floor", async () => {
it("blocks Codex app-server prereleases of the exact supported version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.143.0+alpha.2 (macOS; test)" },
result: { userAgent: "openclaw/0.146.0-alpha.2 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required, but detected 0.143.0+alpha.2`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.146.0-alpha.2`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks Codex app-server build metadata on the exact supported version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.146.0+alpha.2 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.146.0+alpha.2`,
);
expect(harness.writes).toHaveLength(1);
});
@@ -369,7 +381,7 @@ describe("CodexAppServerClient", () => {
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
);
expect(harness.writes).toHaveLength(1);
});
@@ -382,16 +394,13 @@ describe("CodexAppServerClient", () => {
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks stable Codex app-server versions newer than generated schemas", async () => {
const newerVersion = incrementSemver(MAX_CODEX_APP_SERVER_VERSION, "patch");
if (!newerVersion) {
throw new Error(`invalid maximum Codex app-server version: ${MAX_CODEX_APP_SERVER_VERSION}`);
}
const newerVersion = "0.146.1";
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
@@ -399,7 +408,7 @@ describe("CodexAppServerClient", () => {
});
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
);
expect(harness.writes).toHaveLength(1);
});
@@ -409,7 +418,7 @@ describe("CodexAppServerClient", () => {
harness.send({ id: outbound.id, result: {} });
await expect(initializing).rejects.toThrow(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
);
expect(harness.writes).toHaveLength(1);
});
+3 -14
View File
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { parse as parseSemver } from "semver";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import {
type CodexAppServerRequestMethod,
@@ -28,9 +27,8 @@ import {
closeCodexAppServerTransportAndWait,
type CodexAppServerTransport,
} from "./transport.js";
import { MAX_CODEX_APP_SERVER_VERSION, MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
/** Minimum supported Codex app-server version exported for callers/tests. */
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 8 * 1024 * 1024;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
@@ -1036,7 +1034,7 @@ class CodexAppServerVersionError extends Error {
? `detected ${detectedVersion}`
: "OpenClaw could not determine the running Codex version";
super(
`A stable Codex app-server from ${MIN_CODEX_APP_SERVER_VERSION} through ${MAX_CODEX_APP_SERVER_VERSION} is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
this.name = "CodexAppServerVersionError";
this.detectedVersion = detectedVersion;
@@ -1045,16 +1043,7 @@ class CodexAppServerVersionError extends Error {
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): string {
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
const parsedVersion = parseSemver(detectedVersion ?? "");
if (
!detectedVersion ||
!parsedVersion ||
parsedVersion.compare(MIN_CODEX_APP_SERVER_VERSION) < 0 ||
parsedVersion.compare(MAX_CODEX_APP_SERVER_VERSION) > 0 ||
// Generated schemas cover stable releases only; prereleases and custom builds can drift.
parsedVersion.prerelease.length > 0 ||
parsedVersion.build.length > 0
) {
if (detectedVersion !== CODEX_APP_SERVER_VERSION) {
throw new CodexAppServerVersionError(detectedVersion);
}
return detectedVersion;
@@ -1,9 +1,9 @@
/** Bounded, sandboxed argv execution over the existing app-server connection. */
export type CodexCommandExecParams = {
command: string[];
env?: Record<string, string | null>;
outputBytesCap?: number;
timeoutMs?: number;
env?: Partial<Record<string, string | null>> | null;
outputBytesCap?: number | null;
timeoutMs?: number | null;
};
export type CodexCommandExecResponse = {
@@ -34,6 +34,9 @@ type CodexComputerUseRequest = NonNullable<
NonNullable<Parameters<typeof ensureCodexComputerUse>[0]>["request"]
>;
const REMOTE_COMPUTER_USE_MARKETPLACE_NAME = "openai-curated-remote";
const REMOTE_COMPUTER_USE_PLUGIN_ID = "plugins~Plugin_00000000000000000000000000000000";
function expectStatusFields(
status: CodexComputerUseStatus,
fields: Partial<CodexComputerUseStatus>,
@@ -976,25 +979,228 @@ describe("Codex Computer Use setup", () => {
expectRequestMethodNotCalled(request, "plugin/read");
});
it("fails closed instead of installing from a remote-only Codex marketplace", async () => {
const request = createRemoteOnlyComputerUseRequest();
it("reads installed remote Computer Use plugins by their opaque Codex id", async () => {
const request = createComputerUseRequest({
installed: true,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
});
const status = await readCodexComputerUseStatus({
pluginConfig: {
computerUse: {
enabled: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
},
},
request,
});
expectStatusFields(status, {
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
tools: ["list_apps"],
});
expect(request).toHaveBeenCalledWith("plugin/read", {
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
});
expectRequestMethodNotCalled(request, "marketplace/add");
expectRequestMethodNotCalled(request, "experimentalFeature/enablement/set");
expectRequestMethodNotCalled(request, "plugin/install");
expectRequestMethodNotCalled(request, "config/mcpServer/reload");
});
it("reports an uninstalled remote Computer Use plugin without installing it", async () => {
const request = createComputerUseRequest({
installed: false,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
});
const status = await readCodexComputerUseStatus({
pluginConfig: {
computerUse: {
enabled: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
},
},
request,
});
expectStatusFields(status, {
ready: false,
reason: "plugin_not_installed",
installed: false,
pluginEnabled: false,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
});
expect(request).toHaveBeenCalledWith("plugin/read", {
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
});
expectRequestMethodNotCalled(request, "marketplace/add");
expectRequestMethodNotCalled(request, "experimentalFeature/enablement/set");
expectRequestMethodNotCalled(request, "plugin/install");
expectRequestMethodNotCalled(request, "config/mcpServer/reload");
});
it("installs a discovered remote Computer Use plugin by its opaque Codex id", async () => {
const request = createComputerUseRequest({
installed: false,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
});
const status = await installCodexComputerUse({
pluginConfig: {
computerUse: { marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME },
},
request,
});
expectStatusFields(status, {
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
tools: ["list_apps"],
});
expect(
requestCalls(request)
.filter(([method]) => method === "plugin/read")
.map(([, params]) => params),
).toStrictEqual([
{
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
{
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
]);
expect(request).toHaveBeenCalledWith("experimentalFeature/enablement/set", {
enablement: { plugins: true },
});
expect(request).toHaveBeenCalledWith("plugin/install", {
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
});
expect(request).toHaveBeenCalledWith("config/mcpServer/reload", undefined);
expectRequestMethodNotCalled(request, "marketplace/add");
});
it("auto-installs discovered remote Computer Use only when explicitly configured", async () => {
const request = createComputerUseRequest({
installed: false,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
});
const status = await ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
},
},
request,
});
expectStatusFields(status, {
ready: true,
reason: "ready",
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
});
expect(request).toHaveBeenCalledWith("plugin/install", {
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
});
expectRequestMethodNotCalled(request, "marketplace/add");
});
it("fails closed before reading a remote Computer Use plugin without its opaque id", async () => {
const request = createComputerUseRequest({
installed: false,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: null,
},
});
const status = await readCodexComputerUseStatus({
pluginConfig: {
computerUse: {
enabled: true,
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
},
},
request,
});
expectStatusFields(status, {
ready: false,
reason: "marketplace_missing",
installed: false,
pluginEnabled: false,
});
expectRequestMethodNotCalled(request, "plugin/read");
expectRequestMethodNotCalled(request, "plugin/install");
expectRequestMethodNotCalled(request, "experimentalFeature/enablement/set");
await expectSetupErrorStatus(
installCodexComputerUse({
pluginConfig: { computerUse: { marketplaceName: "openai-curated" } },
pluginConfig: {
computerUse: { marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME },
},
request,
}),
{
ready: false,
reason: "remote_install_unsupported",
installed: false,
pluginEnabled: false,
marketplaceName: "openai-curated",
message:
"Computer Use is available in remote Codex marketplace openai-curated, but Codex app-server does not support remote plugin install yet. Configure computerUse.marketplaceSource or computerUse.marketplacePath for a local marketplace, then run /codex computer-use install.",
},
{ ready: false, reason: "marketplace_missing" },
);
expectRequestMethodNotCalled(request, "plugin/read");
expectRequestMethodNotCalled(request, "plugin/install");
expectRequestMethodNotCalled(request, "marketplace/add");
});
it("prefers the official remote Computer Use marketplace over unrelated matches", async () => {
const request = createComputerUseRequest({
installed: false,
remoteMarketplace: {
name: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginId: REMOTE_COMPUTER_USE_PLUGIN_ID,
},
additionalMarketplaceNames: ["workspace-tools"],
});
const status = await installCodexComputerUse({
pluginConfig: { computerUse: {} },
request,
});
expectStatusFields(status, {
ready: true,
reason: "ready",
marketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
});
expect(request).toHaveBeenCalledWith("plugin/install", {
remoteMarketplaceName: REMOTE_COMPUTER_USE_MARKETPLACE_NAME,
pluginName: REMOTE_COMPUTER_USE_PLUGIN_ID,
});
expectRequestMethodNotCalled(request, "marketplace/add");
});
it("waits for the default Codex marketplace during install", async () => {
@@ -1051,12 +1257,24 @@ function createComputerUseRequest(params: {
marketplaceAvailableAfterListCalls?: number;
liveTestFailures?: number;
mcpToolsAvailable?: boolean;
remoteMarketplace?: {
name: string;
pluginId?: string | null;
};
additionalMarketplaceNames?: readonly string[];
}): CodexComputerUseRequest {
let installed = params.installed;
let enabled = params.enabled ?? installed;
let pluginListCalls = 0;
let liveTestFailures = params.liveTestFailures ?? 0;
let threadStartCalls = 0;
const marketplaceName = params.remoteMarketplace?.name ?? "desktop-tools";
const marketplacePath = params.remoteMarketplace
? null
: `/marketplaces/${marketplaceName}/.agents/plugins/marketplace.json`;
const source = params.remoteMarketplace ? "remote" : "local";
const currentPluginSummary = () =>
pluginSummary(installed, marketplaceName, enabled, source, params.remoteMarketplace?.pluginId);
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
@@ -1075,11 +1293,14 @@ function createComputerUseRequest(params: {
return {
marketplaces: marketplaceAvailable
? [
...(params.additionalMarketplaceNames ?? []).map((name) =>
marketplaceEntry(name, false),
),
{
name: "desktop-tools",
path: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
name: marketplaceName,
path: marketplacePath,
interface: null,
plugins: [pluginSummary(installed, "desktop-tools", enabled)],
plugins: [currentPluginSummary()],
},
]
: [],
@@ -1088,12 +1309,19 @@ function createComputerUseRequest(params: {
};
}
if (method === "plugin/read") {
expect(requireRecord(requestParams, "plugin read params").pluginName).toBe("computer-use");
expect(requestParams).toEqual(
params.remoteMarketplace
? {
remoteMarketplaceName: marketplaceName,
pluginName: params.remoteMarketplace.pluginId,
}
: { marketplacePath, pluginName: "computer-use" },
);
return {
plugin: {
marketplaceName: "desktop-tools",
marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
summary: pluginSummary(installed, "desktop-tools", enabled),
marketplaceName,
marketplacePath,
summary: currentPluginSummary(),
description: "Control desktop apps.",
skills: [],
apps: [],
@@ -1102,6 +1330,12 @@ function createComputerUseRequest(params: {
};
}
if (method === "plugin/install") {
if (params.remoteMarketplace) {
expect(requestParams).toEqual({
remoteMarketplaceName: marketplaceName,
pluginName: params.remoteMarketplace.pluginId,
});
}
installed = true;
enabled = true;
return { authPolicy: "ON_INSTALL", appsNeedingAuth: [] };
@@ -1165,46 +1399,6 @@ function createComputerUseRequest(params: {
}) as CodexComputerUseRequest;
}
function createRemoteOnlyComputerUseRequest(): CodexComputerUseRequest {
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
}
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "openai-curated",
path: null,
interface: null,
plugins: [pluginSummary(false, "openai-curated", false, "remote")],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
expect(requestParams).toEqual({
remoteMarketplaceName: "openai-curated",
pluginName: "computer-use",
});
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: null,
summary: pluginSummary(false, "openai-curated", false, "remote"),
description: "Control desktop apps.",
skills: [],
apps: [],
mcpServers: ["computer-use"],
},
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}
function createAmbiguousComputerUseRequest(): CodexComputerUseRequest {
return vi.fn(async (method: string) => {
if (method === "plugin/list") {
@@ -1434,9 +1628,11 @@ function pluginSummary(
marketplaceName = "desktop-tools",
enabled = installed,
source: "local" | "remote" = "local",
remotePluginId?: string | null,
) {
return {
id: `computer-use@${marketplaceName}`,
...(source === "remote" ? { remotePluginId: remotePluginId ?? null } : {}),
name: "computer-use",
source:
source === "local"
+45 -60
View File
@@ -52,7 +52,6 @@ type CodexComputerUseStatusReason =
| "marketplace_missing"
| "plugin_not_installed"
| "plugin_disabled"
| "remote_install_unsupported"
| "mcp_missing"
| "live_test_failed"
| "ready"
@@ -166,6 +165,7 @@ type MarketplaceRef =
kind: "remote";
name: string;
remoteMarketplaceName: string;
remotePluginId: string;
};
type MarketplaceResolution = {
@@ -184,7 +184,12 @@ type PluginInspection =
};
const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000;
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"];
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = [
"openai-bundled",
"openai-curated",
"openai-curated-remote",
"local",
];
const COMPUTER_USE_LIVE_TEST_RETRY_COUNT = 1;
const COMPUTER_USE_LIVE_TEST_THREAD_NAME = "OpenClaw Computer Use readiness probe";
@@ -427,20 +432,8 @@ async function ensureComputerUsePlugin(params: {
config: params.config,
plugin,
tools: [],
reason: pluginSetupReason(plugin, params.marketplace),
message: pluginSetupMessage(params.config, plugin, params.marketplace),
}),
};
}
if (params.marketplace.kind === "remote") {
return {
ok: false,
status: statusFromPlugin({
config: params.config,
plugin,
tools: [],
reason: "remote_install_unsupported",
message: remoteInstallUnsupportedMessage(plugin, params.marketplace),
reason: pluginSetupReason(plugin),
message: pluginSetupMessage(params.config, plugin),
}),
};
}
@@ -462,8 +455,8 @@ async function ensureComputerUsePlugin(params: {
config: params.config,
plugin,
tools: [],
reason: pluginSetupReason(plugin, params.marketplace),
message: pluginSetupMessage(params.config, plugin, params.marketplace),
reason: pluginSetupReason(plugin),
message: pluginSetupMessage(params.config, plugin),
}),
};
}
@@ -783,21 +776,33 @@ function findComputerUseMarketplaces(
listed: CodexPluginListResponse,
pluginName: string,
): MarketplaceRef[] {
return listed.marketplaces
.filter((marketplace) =>
marketplace.plugins.some(
(plugin) =>
plugin.name === pluginName ||
plugin.id === pluginName ||
plugin.id === `${pluginName}@${marketplace.name}`,
),
)
.map((marketplace) => {
if (marketplace.path) {
return { kind: "local", name: marketplace.name, path: marketplace.path };
}
return { kind: "remote", name: marketplace.name, remoteMarketplaceName: marketplace.name };
});
return listed.marketplaces.flatMap((marketplace): MarketplaceRef[] => {
const plugin = marketplace.plugins.find(
(candidate) =>
candidate.name === pluginName ||
candidate.id === pluginName ||
candidate.id === `${pluginName}@${marketplace.name}`,
);
if (!plugin) {
return [];
}
if (marketplace.path) {
return [{ kind: "local", name: marketplace.name, path: marketplace.path }];
}
const remotePluginId = plugin.remotePluginId?.trim();
if (!remotePluginId) {
// Remote plugin/read and plugin/install reject the human-readable slug.
return [];
}
return [
{
kind: "remote",
name: marketplace.name,
remoteMarketplaceName: marketplace.name,
remotePluginId,
},
];
});
}
function chooseKnownComputerUseMarketplace(
@@ -887,48 +892,28 @@ async function reloadMcpServers(request: CodexComputerUseRequest): Promise<void>
}
function pluginRequestParams(marketplace: MarketplaceRef, pluginName: string) {
return {
...(marketplace.kind === "local" ? { marketplacePath: marketplace.path } : {}),
...(marketplace.kind === "remote"
? { remoteMarketplaceName: marketplace.remoteMarketplaceName }
: {}),
pluginName,
};
return marketplace.kind === "local"
? { marketplacePath: marketplace.path, pluginName }
: {
remoteMarketplaceName: marketplace.remoteMarketplaceName,
pluginName: marketplace.remotePluginId,
};
}
function pluginSetupReason(
plugin: CodexPluginDetail,
marketplace: MarketplaceRef,
): CodexComputerUseStatusReason {
if (marketplace.kind === "remote") {
return "remote_install_unsupported";
}
function pluginSetupReason(plugin: CodexPluginDetail): CodexComputerUseStatusReason {
return plugin.summary.installed ? "plugin_disabled" : "plugin_not_installed";
}
function pluginSetupMessage(
config: ResolvedCodexComputerUseConfig,
plugin: CodexPluginDetail,
marketplace: MarketplaceRef,
): string {
if (marketplace.kind === "remote") {
return remoteInstallUnsupportedMessage(plugin, marketplace);
}
if (!plugin.summary.installed) {
return "Computer Use is available but not installed. Run /codex computer-use install or enable computerUse.autoInstall.";
}
return `Computer Use is installed, but the ${config.pluginName} plugin is disabled. Run /codex computer-use install or enable computerUse.autoInstall to re-enable it.`;
}
function remoteInstallUnsupportedMessage(
plugin: CodexPluginDetail,
marketplace: MarketplaceRef,
): string {
const marketplaceName = marketplace.name ?? plugin.marketplaceName;
const state = plugin.summary.installed ? "installed but disabled" : "available";
return `Computer Use is ${state} in remote Codex marketplace ${marketplaceName}, but Codex app-server does not support remote plugin install yet. Configure computerUse.marketplaceSource or computerUse.marketplacePath for a local marketplace, then run /codex computer-use install.`;
}
function statusFromPlugin(params: {
config: ResolvedCodexComputerUseConfig;
plugin: CodexPluginDetail;
@@ -315,8 +315,8 @@ function normalizeRequirementsApprovalPolicy(
value: string,
): CodexAppServerApprovalPolicy | undefined {
const normalized = value.trim().toLowerCase();
// Codex 0.143 keeps this deprecated requirements-file alias in its core
// parser, but app-server exposes only the canonical on-request value.
// Codex still accepts this alias in persisted requirements, while its
// app-server exposes only the canonical on-request value.
if (normalized === "on-failure") {
return "on-request";
}
@@ -301,9 +301,8 @@ describe("CodexAppServerEventProjector assistant projection", () => {
});
it("streams assistant deltas when the app-server omits the item phase", async () => {
// Newer Codex app-servers (>= 0.139) stream agentMessage deltas without a
// "final_answer" phase. These surface on the replaceable agent-event path;
// legacy append-oriented partial callbacks stay quiet.
// Codex can stream agentMessage deltas without a final-answer phase. Route
// them through replaceable events, not append-oriented partial callbacks.
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const params = await createParams();
@@ -75,7 +75,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.143.0 (macOS; test)" },
result: { userAgent: "openclaw/0.146.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
@@ -137,7 +137,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.143.0 (macOS; test)" },
result: { userAgent: "openclaw/0.146.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const firstList = JSON.parse(harness.writes[2] ?? "{}") as {
@@ -217,7 +217,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.143.0 (macOS; test)" },
result: { userAgent: "openclaw/0.146.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const firstList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
@@ -810,8 +810,8 @@ class Monitor {
? "other"
: "unavailable";
if (threadStatus === "systemerror") {
// The 0.142 protocol floor guarantees the paged history API, whose live
// snapshot distinguishes the failed current turn from older persisted results.
// The pinned protocol's paged history distinguishes the failed current
// turn from earlier persisted results.
const turnsResponse = await this.requestLatestThreadTurn(childThreadId).catch(
() => undefined,
);
@@ -104,7 +104,7 @@ describe("Codex plugin activation", () => {
calls.push({ method, params });
if (method === "plugin/list") {
pluginListCalls += 1;
expect(params).toEqual({});
expect(params).toEqual(pluginListCalls === 1 ? {} : { forceRefetch: true });
return pluginList([
pluginSummary("google-calendar", {
installed: pluginListCalls > 1,
@@ -299,6 +299,44 @@ describe("Codex plugin activation", () => {
]);
});
it("does not install a remote curated plugin without its opaque remote id", async () => {
const calls: string[] = [];
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
request: async (method) => {
calls.push(method);
if (method === "plugin/list") {
return {
...pluginList([]),
marketplaces: [
{
name: "openai-curated-remote",
path: null,
interface: null,
plugins: [
pluginSummary("google-calendar@openai-curated-remote", {
name: "google-calendar",
installed: false,
enabled: false,
}),
],
},
],
} satisfies v2.PluginListResponse;
}
throw new Error(`unexpected request ${method}`);
},
});
expectActivationResult(result, {
ok: false,
reason: "plugin_missing",
installAttempted: false,
});
expect(calls).toEqual(["plugin/list"]);
expect(result.diagnostics[0]?.message).toContain("did not return a remote plugin id");
});
it("settles a missing plugin from the remote curated marketplace snapshot", async () => {
const metadataCache = new CodexPluginMetadataCache();
const request = vi.fn(async (_method: string, params: unknown) => {
@@ -52,6 +52,8 @@ type EnsureCodexPluginActivationParams = {
appCacheKey?: string;
metadataCache?: CodexPluginMetadataCache;
installEvenIfActive?: boolean;
/** Thread setup batches app refresh once after all plugin activations. */
deferAppInventoryRefresh?: boolean;
targetAppIds?: readonly string[];
};
@@ -85,6 +87,12 @@ export async function ensureCodexPluginActivation(
});
}
if (resolved.marketplace.remoteMarketplaceName && !resolved.summary.remotePluginId) {
return activationFailure(params.identity, "plugin_missing", {
message: `${params.identity.pluginName} detail unavailable: Codex did not return a remote plugin id.`,
});
}
if (resolved.summary.installed && resolved.summary.enabled && !params.installEvenIfActive) {
return {
identity: params.identity,
@@ -116,6 +124,7 @@ export async function ensureCodexPluginActivation(
appCache: params.appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
deferAppInventoryRefresh: params.deferAppInventoryRefresh,
targetAppIds: params.targetAppIds,
});
refreshDiagnostics.push(...refreshResult.diagnostics);
@@ -156,10 +165,11 @@ async function refreshCodexPluginRuntimeState(params: {
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
metadataCache?: CodexPluginMetadataCache;
deferAppInventoryRefresh?: boolean;
targetAppIds?: readonly string[];
}): Promise<CodexPluginRuntimeRefreshResult> {
const diagnostics: CodexPluginActivationDiagnostic[] = [];
await listCuratedCodexPluginMetadata(params);
await listCuratedCodexPluginMetadata(params, { forceRefetch: true });
await (params.request("skills/list", {
cwds: [],
forceReload: true,
@@ -177,6 +187,9 @@ async function refreshCodexPluginRuntimeState(params: {
if (params.appCache && params.appCacheKey) {
params.appCache.invalidate(params.appCacheKey, "Codex plugin activation changed app inventory");
if (params.deferAppInventoryRefresh) {
return { diagnostics };
}
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
try {
@@ -198,12 +211,17 @@ async function refreshCodexPluginRuntimeState(params: {
return { diagnostics };
}
async function listCuratedCodexPluginMetadata(params: {
request: CodexPluginRuntimeRequest;
metadataCache?: CodexPluginMetadataCache;
appCacheKey?: string;
}): Promise<v2.PluginListResponse> {
const requestParams = {} satisfies v2.PluginListParams;
async function listCuratedCodexPluginMetadata(
params: {
request: CodexPluginRuntimeRequest;
metadataCache?: CodexPluginMetadataCache;
appCacheKey?: string;
},
options: { forceRefetch?: boolean } = {},
): Promise<v2.PluginListResponse> {
const requestParams = (
options.forceRefetch ? { forceRefetch: true } : {}
) satisfies v2.PluginListParams;
if (!params.metadataCache || !params.appCacheKey) {
return (await params.request("plugin/list", requestParams)) as v2.PluginListResponse;
}
@@ -217,7 +235,7 @@ async function listCuratedCodexPluginMetadata(params: {
// marketplace itself (upstream returns local-only on remote fetch failure
// without a load error). See listCodexPluginMetadata in plugin-inventory.
cacheable: (response: v2.PluginListResponse) =>
(response.marketplaces ?? []).some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
response.marketplaces.some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
});
return snapshot.response;
}
@@ -2,12 +2,12 @@
import { describe, expect, it } from "vitest";
import { CodexAppInventoryCache } from "./app-inventory-cache.js";
import { codexAppInventoryResponse } from "./app-inventory.test-helpers.js";
import { CodexAppServerRpcError } from "./client.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
} from "./config.js";
import { findOpenAiCuratedPluginSummary, readCodexPluginInventory } from "./plugin-inventory.js";
import { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin inventory", () => {
@@ -42,8 +42,8 @@ describe("Codex plugin inventory", () => {
nowMs: 1,
request: async (method, params) => {
calls.push(method);
if (method === "plugin/list") {
return pluginList([
if (method === "plugin/installed") {
return pluginInstalled([
pluginSummary("google-calendar", { installed: true, enabled: true }),
pluginSummary("slack", { installed: true, enabled: true }),
]);
@@ -75,7 +75,80 @@ describe("Codex plugin inventory", () => {
needsAuth: false,
},
]);
expect(calls).toEqual(["plugin/list", "plugin/read"]);
expect(calls).toEqual(["plugin/installed", "plugin/read"]);
});
it("reuses one installed snapshot for consecutive configured plugin inventories", async () => {
const metadataCache = new CodexPluginMetadataCache();
const calls: Array<{ method: string; params: unknown }> = [];
const params = {
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
github: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "github",
},
},
},
},
appCacheKey: "runtime",
configCwd: "/repo/project",
metadataCache,
readPluginDetails: false,
request: async (method: string, requestParams?: unknown) => {
calls.push({ method, params: requestParams });
if (method === "plugin/installed") {
return pluginInstalled([pluginSummary("github", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
};
const first = await readCodexPluginInventory(params);
const second = await readCodexPluginInventory(params);
expect(first.records[0]?.summary.id).toBe("github");
expect(second.records[0]?.summary.id).toBe("github");
expect(calls).toEqual([{ method: "plugin/installed", params: { cwds: ["/repo/project"] } }]);
});
it("reads the curated catalog only for an explicitly requested missing plugin", async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
calendar: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "calendar",
},
},
},
},
readPluginDetails: false,
request: async (method, params) => {
calls.push({ method, params });
if (method === "plugin/installed") {
return pluginInstalled([]);
}
if (method === "plugin/list") {
return pluginList([pluginSummary("calendar")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(calls).toEqual([
{ method: "plugin/installed", params: {} },
{ method: "plugin/list", params: {} },
]);
expect(inventory.records[0]).toMatchObject({
activationRequired: true,
summary: { id: "calendar", installed: false },
});
});
it("matches namespaced curated plugin ids by normalized path segment", async () => {
@@ -114,8 +187,8 @@ describe("Codex plugin inventory", () => {
appCacheKey: "runtime",
nowMs: 1,
request: async (method, params) => {
if (method === "plugin/list") {
return listed;
if (method === "plugin/installed") {
return asPluginInstalled(listed);
}
if (method === "plugin/read") {
expect(params).toEqual({
@@ -185,8 +258,8 @@ describe("Codex plugin inventory", () => {
appCacheKey: "runtime",
nowMs: 1,
request: async (method, params) => {
if (method === "plugin/list") {
return listed;
if (method === "plugin/installed") {
return asPluginInstalled(listed);
}
if (method === "plugin/read") {
expect(params).toEqual({
@@ -204,7 +277,46 @@ describe("Codex plugin inventory", () => {
expect(inventory.diagnostics).toStrictEqual([]);
});
it("queries workspace-directory only when configured and resolves the exact catalog id", async () => {
it("fails closed when an installed remote curated plugin omits its opaque id", async () => {
const calls: string[] = [];
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
request: async (method) => {
calls.push(method);
if (method === "plugin/installed") {
return pluginInstalled(
[
pluginSummary("google-calendar@openai-curated-remote", {
name: "google-calendar",
installed: true,
enabled: true,
}),
],
{ name: "openai-curated-remote", path: null },
);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(calls).toEqual(["plugin/installed"]);
expect(inventory.records[0]?.detail).toBeUndefined();
expect(inventory.diagnostics).toContainEqual(
expect.objectContaining({ code: "plugin_detail_unavailable" }),
);
});
it("resolves an installed workspace plugin from the one canonical installed snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
@@ -237,11 +349,8 @@ describe("Codex plugin inventory", () => {
nowMs: 1,
request: async (method, params) => {
calls.push({ method, params });
if (method === "plugin/list" && !(params as v2.PluginListParams).marketplaceKinds) {
return pluginList([]);
}
if (method === "plugin/list") {
return pluginList(
if (method === "plugin/installed") {
return pluginInstalled(
[
pluginSummary("other-workspace-data@workspace-directory", {
name: "Workspace Data",
@@ -268,19 +377,14 @@ describe("Codex plugin inventory", () => {
},
});
expect(calls.slice(0, 2)).toStrictEqual([
{ method: "plugin/list", params: {} },
{
method: "plugin/list",
params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] },
},
]);
expect(calls[0]).toStrictEqual({ method: "plugin/installed", params: {} });
expect(calls.map((call) => call.method)).not.toContain("plugin/list");
expect(inventory.records[0]?.summary).toBe(exactSummary);
expect(inventory.records[0]?.ownedAppIds).toStrictEqual(["workspace-data-app"]);
expect(inventory.diagnostics).toStrictEqual([]);
});
it("does not query workspace-directory for curated-only policy", async () => {
it("uses only the cached installed snapshot for an installed curated plugin", async () => {
const calls: unknown[] = [];
await readCodexPluginInventory({
pluginConfig: {
@@ -296,9 +400,9 @@ describe("Codex plugin inventory", () => {
},
readPluginDetails: false,
request: async (method, params) => {
if (method === "plugin/list") {
if (method === "plugin/installed") {
calls.push(params);
return pluginList([pluginSummary("github", { installed: true, enabled: true })]);
return pluginInstalled([pluginSummary("github", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
@@ -321,13 +425,10 @@ describe("Codex plugin inventory", () => {
},
},
},
request: async (method, params) => {
request: async (method) => {
calls.push(method);
if (method === "plugin/list" && !(params as v2.PluginListParams).marketplaceKinds) {
return pluginList([]);
}
if (method === "plugin/list") {
return pluginList(
if (method === "plugin/installed") {
return pluginInstalled(
[
pluginSummary("workspace-data@workspace-directory", {
name: "Workspace Data",
@@ -342,7 +443,7 @@ describe("Codex plugin inventory", () => {
},
});
expect(calls).toStrictEqual(["plugin/list", "plugin/list"]);
expect(calls).toStrictEqual(["plugin/installed"]);
expect(inventory.records[0]?.detail).toBeUndefined();
expect(inventory.diagnostics.map((diagnostic) => diagnostic.code)).toStrictEqual([
"plugin_detail_unavailable",
@@ -367,13 +468,11 @@ describe("Codex plugin inventory", () => {
},
},
readPluginDetails: false,
request: async (method, params) => {
if (method !== "plugin/list") {
request: async (method) => {
if (method !== "plugin/installed") {
throw new Error(`unexpected request ${method}`);
}
return (params as v2.PluginListParams).marketplaceKinds
? { marketplaces: [], marketplaceLoadErrors: [], featuredPluginIds: [] }
: pluginList([pluginSummary("github", { installed: true, enabled: true })]);
return pluginInstalled([pluginSummary("github", { installed: true, enabled: true })]);
},
});
@@ -386,7 +485,7 @@ describe("Codex plugin inventory", () => {
]);
});
it("keeps curated records and diagnoses each workspace plugin when its explicit list is rejected", async () => {
it("diagnoses every missing workspace owner from the canonical installed snapshot", async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const inventory = await readCodexPluginInventory({
pluginConfig: {
@@ -411,26 +510,14 @@ describe("Codex plugin inventory", () => {
readPluginDetails: false,
request: async (method, params) => {
calls.push({ method, params });
if (method !== "plugin/list") {
if (method !== "plugin/installed") {
throw new Error(`unexpected request ${method}`);
}
if ((params as v2.PluginListParams).marketplaceKinds) {
throw new CodexAppServerRpcError(
{ code: -32_603, message: "list remote plugin catalog failed" },
method,
);
}
return pluginList([pluginSummary("github", { installed: true, enabled: true })]);
return pluginInstalled([pluginSummary("github", { installed: true, enabled: true })]);
},
});
expect(calls).toStrictEqual([
{ method: "plugin/list", params: {} },
{
method: "plugin/list",
params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] },
},
]);
expect(calls).toStrictEqual([{ method: "plugin/installed", params: {} }]);
expect(inventory.records.map((record) => record.policy.configKey)).toStrictEqual(["github"]);
expect(
inventory.diagnostics.map((diagnostic) => ({
@@ -452,8 +539,8 @@ describe("Codex plugin inventory", () => {
]);
});
it("does not hide non-RPC failures from the explicit workspace list", async () => {
const failure = new Error("workspace plugin/list transport closed");
it("does not hide installed-plugin inventory transport failures", async () => {
const failure = new Error("plugin/installed transport closed");
await expect(
readCodexPluginInventory({
pluginConfig: {
@@ -468,14 +555,11 @@ describe("Codex plugin inventory", () => {
},
},
readPluginDetails: false,
request: async (method, params) => {
if (method !== "plugin/list") {
throw new Error(`unexpected request ${method}`);
}
if ((params as v2.PluginListParams).marketplaceKinds) {
request: async (method) => {
if (method === "plugin/installed") {
throw failure;
}
return pluginList([]);
throw new Error(`unexpected request ${method}`);
},
}),
).rejects.toBe(failure);
@@ -504,8 +588,10 @@ describe("Codex plugin inventory", () => {
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
if (method === "plugin/installed") {
return pluginInstalled([
pluginSummary("google-calendar", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -529,6 +615,56 @@ describe("Codex plugin inventory", () => {
]);
});
it("keeps an authorized disabled plugin app distinct from an authentication failure", async () => {
const appCache = new CodexAppInventoryCache();
const disabledApp = { ...appInfo("google-calendar-app", true), isEnabled: false };
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async (method, params) => codexAppInventoryResponse(method, [disabledApp], params),
});
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/installed") {
return pluginInstalled([
pluginSummary("google-calendar", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(inventory.records[0]?.appOwnership).toBe("proven");
expect(inventory.records[0]?.authRequired).toBe(false);
expect(inventory.records[0]?.apps).toEqual([
{
id: "google-calendar-app",
name: "google-calendar-app",
accessible: true,
enabled: false,
needsAuth: false,
},
]);
});
it("marks display-name-only app matches ambiguous instead of exposing app ids", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -559,8 +695,8 @@ describe("Codex plugin inventory", () => {
nowMs: 1,
readPluginDetails: false,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([
if (method === "plugin/installed") {
return pluginInstalled([
pluginSummary("google-calendar", {
name: "Google Calendar",
installed: true,
@@ -599,8 +735,10 @@ describe("Codex plugin inventory", () => {
if (method === "app/installed" || method === "app/read") {
return codexAppInventoryResponse(method, []);
}
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
if (method === "plugin/installed") {
return pluginInstalled([
pluginSummary("google-calendar", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -618,6 +756,18 @@ describe("Codex plugin inventory", () => {
});
});
function asPluginInstalled(listed: v2.PluginListResponse): v2.PluginInstalledResponse {
const { featuredPluginIds: _featuredPluginIds, ...installed } = listed;
return installed;
}
function pluginInstalled(
plugins: v2.PluginSummary[],
marketplace: { name?: string; path?: string | null } = {},
): v2.PluginInstalledResponse {
return asPluginInstalled(pluginList(plugins, marketplace));
}
function pluginList(
plugins: v2.PluginSummary[],
marketplace: { name?: string; path?: string | null } = {},
@@ -678,7 +828,7 @@ function appSummary(id: string): v2.AppSummary {
name: id,
description: null,
installUrl: null,
needsAuth: false,
category: null,
};
}
@@ -8,7 +8,6 @@ import type {
CodexAppInventoryCacheRead,
CodexAppInventoryRequest,
} from "./app-inventory-cache.js";
import { CodexAppServerRpcError } from "./client.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
@@ -28,9 +27,7 @@ const CODEX_PLUGINS_REMOTE_MARKETPLACE_NAME = `${CODEX_PLUGINS_MARKETPLACE_NAME}
/** Request callback used to call Codex app-server plugin/app methods. */
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
type CodexWorkspacePluginListResult =
| { kind: "listed"; response: v2.PluginListResponse }
| { kind: "rejected" };
type CodexPluginMarketplaceResponse = v2.PluginInstalledResponse | v2.PluginListResponse;
/** Stable reference to a supported Codex plugin marketplace. */
export type CodexPluginMarketplaceRef = {
@@ -93,6 +90,7 @@ type ReadCodexPluginInventoryParams = {
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
configCwd?: string;
metadataCache?: CodexPluginMetadataCache;
nowMs?: number;
readPluginDetails?: boolean;
@@ -118,29 +116,8 @@ export async function readCodexPluginInventory(
}
const appInventory = readCachedAppInventory(params);
const curatedListed = await listCodexPluginMetadata(params, "curated-global", {});
const shouldListWorkspacePlugins = policy.pluginPolicies.some(
(pluginPolicy) =>
pluginPolicy.enabled &&
pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
);
let workspaceListResult: CodexWorkspacePluginListResult | undefined;
if (shouldListWorkspacePlugins) {
try {
workspaceListResult = {
kind: "listed",
response: await listCodexPluginMetadata(params, "workspace-directory", {
cwds: [],
marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME],
}),
};
} catch (error) {
if (!(error instanceof CodexAppServerRpcError)) {
throw error;
}
workspaceListResult = { kind: "rejected" };
}
}
const installedPlugins = await readInstalledCodexPluginMetadata({ ...params, policy });
let curatedCatalog: Promise<v2.PluginListResponse> | undefined;
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
const records: CodexPluginInventoryRecord[] = [];
@@ -157,22 +134,32 @@ export async function readCodexPluginInventory(
}
for (const pluginPolicy of policy.pluginPolicies) {
if (!pluginPolicy.enabled) {
if (!pluginPolicy.enabled && !policy.allowAllPlugins) {
continue;
}
const listed =
let listed: CodexPluginMarketplaceResponse = installedPlugins;
let resolvedPlugin =
pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? workspaceListResult?.kind === "listed"
? workspaceListResult.response
: undefined
: curatedListed;
? findWorkspaceMarketplacePlugin(listed, pluginPolicy.pluginName)
: findOpenAiCuratedMarketplacePlugin(listed, pluginPolicy.pluginName);
if (
!resolvedPlugin &&
pluginPolicy.enabled &&
pluginPolicy.marketplaceName === CODEX_PLUGINS_MARKETPLACE_NAME
) {
// The installed snapshot deliberately excludes remote catalog entries.
// Fetch the catalog only to install an explicitly requested missing plugin.
curatedCatalog ??= listCodexPluginMetadata(params);
listed = await curatedCatalog;
resolvedPlugin = findOpenAiCuratedMarketplacePlugin(listed, pluginPolicy.pluginName);
}
const hasMarketplace =
pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? listed?.marketplaces.some(
? listed.marketplaces.some(
(entry) => entry.name === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
) === true
: listed?.marketplaces.some(isOpenAiCuratedMarketplace) === true;
if (!listed || !hasMarketplace) {
)
: listed.marketplaces.some(isOpenAiCuratedMarketplace);
if (!hasMarketplace) {
diagnostics.push({
code: "marketplace_missing",
plugin: pluginPolicy,
@@ -180,10 +167,6 @@ export async function readCodexPluginInventory(
});
continue;
}
const resolvedPlugin =
pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? findWorkspaceMarketplacePlugin(listed, pluginPolicy.pluginName)
: findOpenAiCuratedMarketplacePlugin(listed, pluginPolicy.pluginName);
if (!resolvedPlugin) {
diagnostics.push({
code: "plugin_missing",
@@ -238,7 +221,7 @@ export async function readCodexPluginInventory(
policy: pluginPolicy,
summary,
...(detail ? { detail } : {}),
activationRequired: !summary.installed || !summary.enabled,
activationRequired: pluginPolicy.enabled && (!summary.installed || !summary.enabled),
authRequired: apps.some((app) => app.needsAuth || !app.accessible),
appOwnership,
ownedAppIds,
@@ -257,7 +240,7 @@ export async function readCodexPluginInventory(
/** Finds one plugin summary in the OpenAI curated marketplace response. */
export function findOpenAiCuratedPluginSummary(
listed: v2.PluginListResponse,
listed: CodexPluginMarketplaceResponse,
pluginName: string,
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
const resolved = findOpenAiCuratedMarketplacePlugin(listed, pluginName);
@@ -288,6 +271,7 @@ export function resolveRecoverableCodexPluginConfigKeys(params: {
policy: ResolvedCodexPluginsPolicy;
metadataCache: CodexPluginMetadataCache;
appCacheKey: string;
configCwd?: string;
}): string[] {
return params.policy.pluginPolicies
.filter(
@@ -297,6 +281,7 @@ export function resolveRecoverableCodexPluginConfigKeys(params: {
pluginPolicy,
metadataCache: params.metadataCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
}),
)
.map((pluginPolicy) => pluginPolicy.configKey)
@@ -305,18 +290,14 @@ export function resolveRecoverableCodexPluginConfigKeys(params: {
async function listCodexPluginMetadata(
params: ReadCodexPluginInventoryParams,
queryKind: CodexPluginMetadataQueryKind,
requestParams: v2.PluginListParams,
): Promise<v2.PluginListResponse> {
// Workspace-directory plugins are activated OUTSIDE OpenClaw, so a cached
// miss has no invalidation signal; keep those queries live so external
// activation is visible on the next turn (bounded by the build deadline).
if (!params.metadataCache || !params.appCacheKey || queryKind === "workspace-directory") {
const requestParams = {} satisfies v2.PluginListParams;
if (!params.metadataCache || !params.appCacheKey) {
return (await params.request("plugin/list", requestParams)) as v2.PluginListResponse;
}
const snapshot = await params.metadataCache.load({
appCacheKey: params.appCacheKey,
queryKind,
queryKind: "curated-global",
requestParams,
request: async (method, listedParams) =>
(await params.request(method, listedParams)) as v2.PluginListResponse,
@@ -325,7 +306,37 @@ async function listCodexPluginMetadata(
// is indistinguishable from a genuinely absent plugin. Settle curated
// negatives only when the curated marketplace itself is present.
cacheable: (response: v2.PluginListResponse) =>
(response.marketplaces ?? []).some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
response.marketplaces.some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
});
return snapshot.response;
}
async function readInstalledCodexPluginMetadata(
params: ReadCodexPluginInventoryParams & { policy: ResolvedCodexPluginsPolicy },
): Promise<v2.PluginInstalledResponse> {
const requestParams = (
params.configCwd ? { cwds: [params.configCwd] } : {}
) satisfies v2.PluginInstalledParams;
if (!params.metadataCache || !params.appCacheKey) {
return (await params.request("plugin/installed", requestParams)) as v2.PluginInstalledResponse;
}
const snapshot = await params.metadataCache.load({
appCacheKey: params.appCacheKey,
queryKind: "installed",
requestParams,
request: async (method, installedParams) =>
(await params.request(method, installedParams)) as v2.PluginInstalledResponse,
// Codex can fail open to local-only marketplaces when its remote installed
// fetch fails. Never settle a snapshot that cannot prove a configured owner.
cacheable: (response) =>
params.policy.pluginPolicies.every((pluginPolicy) => {
if (!pluginPolicy.enabled && !params.policy.allowAllPlugins) {
return true;
}
return pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? findWorkspaceMarketplacePlugin(response, pluginPolicy.pluginName) !== undefined
: findOpenAiCuratedMarketplacePlugin(response, pluginPolicy.pluginName) !== undefined;
}),
});
return snapshot.response;
}
@@ -334,16 +345,19 @@ function isSettledMissingPluginPolicy(params: {
pluginPolicy: ResolvedCodexPluginPolicy;
metadataCache: CodexPluginMetadataCache;
appCacheKey: string;
configCwd?: string;
}): boolean {
const queryKind: CodexPluginMetadataQueryKind =
params.pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? "workspace-directory"
? "installed"
: "curated-global";
const listed = params.metadataCache.read(params.appCacheKey, queryKind)?.response;
const requestParams =
queryKind === "installed" && params.configCwd ? { cwds: [params.configCwd] } : {};
const listed = params.metadataCache.read(params.appCacheKey, queryKind, requestParams)?.response;
if (!listed) {
return false;
}
if (queryKind === "workspace-directory") {
if (queryKind === "installed") {
return !findWorkspaceMarketplacePlugin(listed, params.pluginPolicy.pluginName);
}
return !findOpenAiCuratedMarketplacePlugin(listed, params.pluginPolicy.pluginName);
@@ -375,11 +389,7 @@ async function readPluginDetail(
if (params.readPluginDetails === false) {
return undefined;
}
if (
marketplace.name === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME &&
marketplace.remoteMarketplaceName &&
!summary.remotePluginId
) {
if (marketplace.remoteMarketplaceName && !summary.remotePluginId) {
diagnostics.push({
code: "plugin_detail_unavailable",
plugin: pluginPolicy,
@@ -462,7 +472,9 @@ function resolveOwnedApps(params: {
name: app.name,
accessible: info.isAccessible,
enabled: info.isEnabled,
needsAuth: app.needsAuth || !info.isAccessible,
// Modern plugin summaries carry no auth bit; account-authorized
// app/read metadata is the canonical connector access proof.
needsAuth: !info.isAccessible,
};
})
.toSorted((left, right) => left.id.localeCompare(right.id));
@@ -482,7 +494,7 @@ function findPluginSummary(
}
function findOpenAiCuratedMarketplacePlugin(
listed: v2.PluginListResponse,
listed: CodexPluginMarketplaceResponse,
pluginName: string,
): { marketplace: v2.PluginMarketplaceEntry; summary: v2.PluginSummary } | undefined {
for (const marketplace of listed.marketplaces) {
@@ -498,7 +510,7 @@ function findOpenAiCuratedMarketplacePlugin(
}
function findWorkspaceMarketplacePlugin(
listed: v2.PluginListResponse,
listed: CodexPluginMarketplaceResponse,
pluginName: string,
): { marketplace: v2.PluginMarketplaceEntry; summary: v2.PluginSummary } | undefined {
// Workspace display names are not unique; the configured pluginName is the
@@ -33,6 +33,226 @@ describe("Codex plugin metadata cache", () => {
expect(request).toHaveBeenCalledTimes(1);
});
it("coalesces installed plugins through the exact Codex 0.146 endpoint", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginInstalledResponse) => void) | undefined;
const request = vi.fn(
async () =>
await new Promise<v2.PluginInstalledResponse>((resolve) => {
release = resolve;
}),
);
const requestParams = { cwds: ["/workspace/a"] } satisfies v2.PluginInstalledParams;
const params = {
appCacheKey: "runtime-a",
queryKind: "installed" as const,
requestParams,
request,
};
const first = cache.load(params);
const second = cache.load(params);
expect(request).toHaveBeenCalledExactlyOnceWith("plugin/installed", requestParams);
const response = installedPlugins("workspace-directory", "calendar");
release?.(response);
const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]);
expect(firstSnapshot).toBe(secondSnapshot);
expect(firstSnapshot.response).toBe(response);
expect(cache.read("runtime-a", "installed", requestParams)).toBe(firstSnapshot);
expect(cache.read("runtime-a", "installed")).toBeUndefined();
await expect(cache.load(params)).resolves.toBe(firstSnapshot);
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps installed workspaces, catalog queries, and runtime identities separate", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(
async (
method: "plugin/list" | "plugin/installed",
params: v2.PluginListParams | v2.PluginInstalledParams,
) =>
method === "plugin/installed"
? installedPlugins("workspace-directory", params.cwds?.[0] ?? "home")
: pluginList("openai-curated-remote", "calendar"),
);
const installedRequest = async (
method: "plugin/installed",
params: v2.PluginInstalledParams,
): Promise<v2.PluginInstalledResponse> =>
(await request(method, params)) as v2.PluginInstalledResponse;
const catalogRequest = async (
method: "plugin/list",
params: v2.PluginListParams,
): Promise<v2.PluginListResponse> => (await request(method, params)) as v2.PluginListResponse;
const workspaceA = await cache.load({
appCacheKey: "runtime-a",
queryKind: "installed",
requestParams: { cwds: ["/workspace/a"] },
request: installedRequest,
});
const workspaceB = await cache.load({
appCacheKey: "runtime-a",
queryKind: "installed",
requestParams: { cwds: ["/workspace/b"] },
request: installedRequest,
});
const curated = await cache.load({
appCacheKey: "runtime-a",
queryKind: "curated-global",
requestParams: {},
request: catalogRequest,
});
const otherRuntime = await cache.load({
appCacheKey: "runtime-b",
queryKind: "installed",
requestParams: { cwds: ["/workspace/a"] },
request: installedRequest,
});
expect(request).toHaveBeenCalledTimes(4);
expect(request).toHaveBeenNthCalledWith(1, "plugin/installed", {
cwds: ["/workspace/a"],
});
expect(request).toHaveBeenNthCalledWith(2, "plugin/installed", {
cwds: ["/workspace/b"],
});
expect(request).toHaveBeenNthCalledWith(3, "plugin/list", {});
expect(request).toHaveBeenNthCalledWith(4, "plugin/installed", {
cwds: ["/workspace/a"],
});
expect(cache.read("runtime-a", "installed", { cwds: ["/workspace/a"] })).toBe(workspaceA);
expect(cache.read("runtime-a", "installed", { cwds: ["/workspace/b"] })).toBe(workspaceB);
expect(cache.read("runtime-a", "curated-global")).toBe(curated);
expect(cache.read("runtime-b", "installed", { cwds: ["/workspace/a"] })).toBe(otherRuntime);
});
it("coalesces omitted and null installed-plugin scope as the same upstream query", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => installedPlugins("openai-curated-remote", "calendar"));
const omitted = await cache.load({
appCacheKey: "runtime-a",
queryKind: "installed",
requestParams: {},
request,
});
const explicitNull = await cache.load({
appCacheKey: "runtime-a",
queryKind: "installed",
requestParams: { cwds: null, installSuggestionPluginNames: null },
request,
});
expect(explicitNull).toBe(omitted);
expect(cache.read("runtime-a", "installed")).toBe(omitted);
expect(request).toHaveBeenCalledExactlyOnceWith("plugin/installed", {});
});
it("keys installed suggestion sets using Codex's actual order-independent contract", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => installedPlugins("workspace-directory", "calendar"));
const load = (installSuggestionPluginNames: string[]) =>
cache.load({
appCacheKey: "runtime-a",
queryKind: "installed",
requestParams: {
cwds: ["/workspace/a"],
installSuggestionPluginNames,
},
request,
});
const first = await load(["calendar", "drive", "calendar"]);
await expect(load(["drive", "calendar"])).resolves.toBe(first);
await load(["calendar"]);
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, "plugin/installed", {
cwds: ["/workspace/a"],
installSuggestionPluginNames: ["calendar", "drive", "calendar"],
});
expect(request).toHaveBeenNthCalledWith(2, "plugin/installed", {
cwds: ["/workspace/a"],
installSuggestionPluginNames: ["calendar"],
});
});
it("does not cache a failed installed-plugin request", async () => {
const cache = new CodexPluginMetadataCache();
const response = installedPlugins("workspace-directory", "calendar");
const request = vi
.fn<() => Promise<v2.PluginInstalledResponse>>()
.mockRejectedValueOnce(new Error("installed plugins unavailable"))
.mockResolvedValueOnce(response);
const requestParams = { cwds: ["/workspace/a"] } satisfies v2.PluginInstalledParams;
const params = {
appCacheKey: "runtime-a",
queryKind: "installed" as const,
requestParams,
request,
};
await expect(cache.load(params)).rejects.toThrow("installed plugins unavailable");
expect(cache.read("runtime-a", "installed", requestParams)).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({ response });
expect(cache.read("runtime-a", "installed", requestParams)?.response).toBe(response);
expect(request).toHaveBeenCalledTimes(2);
});
it("does not cache installed marketplaces that Codex reports as incomplete", async () => {
const cache = new CodexPluginMetadataCache();
const incomplete = installedPlugins("workspace-directory", "calendar");
incomplete.marketplaceLoadErrors.push({
marketplacePath: "/workspace/a/.codex/plugins",
message: "workspace marketplace unavailable",
});
const healthy = installedPlugins("workspace-directory", "calendar");
const request = vi
.fn<() => Promise<v2.PluginInstalledResponse>>()
.mockResolvedValueOnce(incomplete)
.mockResolvedValueOnce(healthy);
const requestParams = { cwds: ["/workspace/a"] } satisfies v2.PluginInstalledParams;
const params = {
appCacheKey: "runtime-a",
queryKind: "installed" as const,
requestParams,
request,
};
await expect(cache.load(params)).resolves.toMatchObject({ response: incomplete });
expect(cache.read("runtime-a", "installed", requestParams)).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({ response: healthy });
expect(cache.read("runtime-a", "installed", requestParams)?.response).toBe(healthy);
expect(request).toHaveBeenCalledTimes(2);
});
it("invalidates every installed workspace snapshot for the changed runtime only", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => installedPlugins("workspace-directory", "calendar"));
const load = (appCacheKey: string, cwd: string) =>
cache.load({
appCacheKey,
queryKind: "installed",
requestParams: { cwds: [cwd] },
request,
});
await load("runtime-a", "/workspace/a");
await load("runtime-a", "/workspace/b");
const unrelated = await load("runtime-b", "/workspace/a");
cache.invalidate("runtime-a");
expect(cache.read("runtime-a", "installed", { cwds: ["/workspace/a"] })).toBeUndefined();
expect(cache.read("runtime-a", "installed", { cwds: ["/workspace/b"] })).toBeUndefined();
expect(cache.read("runtime-b", "installed", { cwds: ["/workspace/a"] })).toBe(unrelated);
await load("runtime-a", "/workspace/a");
expect(request).toHaveBeenCalledTimes(4);
});
it("does not settle snapshots the caller marks uncacheable", async () => {
// Upstream plugin/list fails open for remote catalogs (local-only response,
// empty marketplaceLoadErrors); such a snapshot must not settle negatives.
@@ -78,11 +298,9 @@ describe("Codex plugin metadata cache", () => {
expect(request).toHaveBeenCalledTimes(2);
});
it("keeps query kinds and runtime identities separate", async () => {
it("keeps curated catalog runtime identities separate", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async (_method: string, params: v2.PluginListParams) =>
pluginList(params.marketplaceKinds ? "workspace-directory" : "openai-curated-remote"),
);
const request = vi.fn(async () => pluginList("openai-curated-remote"));
await cache.load({
appCacheKey: "runtime-a",
@@ -90,12 +308,6 @@ describe("Codex plugin metadata cache", () => {
requestParams: {},
request,
});
await cache.load({
appCacheKey: "runtime-a",
queryKind: "workspace-directory",
requestParams: { cwds: [], marketplaceKinds: ["workspace-directory"] },
request,
});
await cache.load({
appCacheKey: "runtime-b",
queryKind: "curated-global",
@@ -103,7 +315,7 @@ describe("Codex plugin metadata cache", () => {
request,
});
expect(request).toHaveBeenCalledTimes(3);
expect(request).toHaveBeenCalledTimes(2);
});
it("does not cache failed requests", async () => {
@@ -129,7 +341,9 @@ describe("Codex plugin metadata cache", () => {
it("does not cache responses with marketplace load errors", async () => {
const cache = new CodexPluginMetadataCache();
const incomplete = pluginList("openai-curated-remote");
incomplete.marketplaceLoadErrors = [{ message: "catalog unavailable" }];
incomplete.marketplaceLoadErrors = [
{ marketplacePath: "/marketplaces/openai-curated", message: "catalog unavailable" },
];
const request = vi
.fn<() => Promise<v2.PluginListResponse>>()
.mockResolvedValueOnce(incomplete)
@@ -208,24 +422,6 @@ describe("Codex plugin metadata cache", () => {
expect(joiningRequest).toHaveBeenCalledTimes(1);
});
it("reuses a successful workspace snapshot for the process lifetime", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => pluginList("workspace-directory"));
const params = {
appCacheKey: "runtime-a",
queryKind: "workspace-directory" as const,
requestParams: {
cwds: [],
marketplaceKinds: ["workspace-directory"],
} satisfies v2.PluginListParams,
request,
};
const first = await cache.load(params);
await expect(cache.load(params)).resolves.toBe(first);
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps an unrelated runtime load cacheable across invalidation", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
@@ -305,3 +501,17 @@ function pluginList(marketplaceName: string, pluginId?: string): v2.PluginListRe
featuredPluginIds: [],
};
}
function installedPlugins(marketplaceName: string, pluginId?: string): v2.PluginInstalledResponse {
const { marketplaces } = pluginList(marketplaceName, pluginId);
for (const marketplace of marketplaces) {
for (const plugin of marketplace.plugins) {
plugin.installed = true;
plugin.enabled = true;
}
}
return {
marketplaces,
marketplaceLoadErrors: [],
};
}
@@ -1,5 +1,5 @@
/**
* Process-local cache for successful Codex plugin/list snapshots.
* Process-local cache for successful Codex plugin catalog and installed snapshots.
*/
import type { v2 } from "./protocol.js";
@@ -9,19 +9,30 @@ import type { v2 } from "./protocol.js";
const CODEX_PLUGIN_METADATA_CACHE_TTL_MS = 60 * 60 * 1_000;
/** Plugin catalog query whose request shape affects the returned marketplaces. */
export type CodexPluginMetadataQueryKind = "curated-global" | "workspace-directory";
export type CodexPluginMetadataQueryKind = "curated-global" | "installed";
type CodexPluginMetadataMethod<QueryKind extends CodexPluginMetadataQueryKind> =
QueryKind extends "installed" ? "plugin/installed" : "plugin/list";
type CodexPluginMetadataRequestParams<QueryKind extends CodexPluginMetadataQueryKind> =
QueryKind extends "installed" ? v2.PluginInstalledParams : v2.PluginListParams;
type CodexPluginMetadataResponse<QueryKind extends CodexPluginMetadataQueryKind> =
QueryKind extends "installed" ? v2.PluginInstalledResponse : v2.PluginListResponse;
/** Request callback used to read Codex plugin metadata. */
type CodexPluginMetadataRequest = (
method: "plugin/list",
params: v2.PluginListParams,
) => Promise<v2.PluginListResponse>;
type CodexPluginMetadataRequest<QueryKind extends CodexPluginMetadataQueryKind> = (
method: CodexPluginMetadataMethod<QueryKind>,
params: CodexPluginMetadataRequestParams<QueryKind>,
) => Promise<CodexPluginMetadataResponse<QueryKind>>;
/** Successful plugin metadata snapshot scoped to one app-server runtime. */
type CodexPluginMetadataSnapshot = {
type CodexPluginMetadataSnapshot<
QueryKind extends CodexPluginMetadataQueryKind = CodexPluginMetadataQueryKind,
> = {
appCacheKey: string;
queryKind: CodexPluginMetadataQueryKind;
response: v2.PluginListResponse;
queryKind: QueryKind;
response: CodexPluginMetadataResponse<QueryKind>;
};
type CachedCodexPluginMetadataEntry = {
@@ -29,18 +40,18 @@ type CachedCodexPluginMetadataEntry = {
expiresAtMs: number;
};
type LoadCodexPluginMetadataParams = {
type LoadCodexPluginMetadataParams<QueryKind extends CodexPluginMetadataQueryKind> = {
appCacheKey: string;
queryKind: CodexPluginMetadataQueryKind;
requestParams: v2.PluginListParams;
request: CodexPluginMetadataRequest;
queryKind: QueryKind;
requestParams: CodexPluginMetadataRequestParams<QueryKind>;
request: CodexPluginMetadataRequest<QueryKind>;
/**
* Guards against fail-open responses: upstream plugin/list only warns when a
* remote catalog fetch fails with omitted marketplaceKinds, returning local
* marketplaces with empty marketplaceLoadErrors. Such a snapshot must not
* settle for the process lifetime, or configured plugins never recover.
*/
cacheable?: (response: v2.PluginListResponse) => boolean;
cacheable?: (response: CodexPluginMetadataResponse<QueryKind>) => boolean;
};
type InFlightCodexPluginMetadataLoad = {
@@ -58,11 +69,12 @@ export class CodexPluginMetadataCache {
constructor(private readonly nowMs: () => number = Date.now) {}
/** Returns a fresh cached snapshot without issuing a request. */
read(
read<QueryKind extends CodexPluginMetadataQueryKind>(
appCacheKey: string,
queryKind: CodexPluginMetadataQueryKind,
): CodexPluginMetadataSnapshot | undefined {
const entryKey = buildMetadataCacheEntryKey(appCacheKey, queryKind);
queryKind: QueryKind,
requestParams?: CodexPluginMetadataRequestParams<QueryKind>,
): CodexPluginMetadataSnapshot<QueryKind> | undefined {
const entryKey = buildMetadataCacheEntryKey(appCacheKey, queryKind, requestParams);
const entry = this.entries.get(entryKey);
if (!entry) {
return undefined;
@@ -71,20 +83,27 @@ export class CodexPluginMetadataCache {
this.entries.delete(entryKey);
return undefined;
}
return entry.snapshot;
// The entry key binds the runtime, query kind, and installed request scope.
return entry.snapshot as CodexPluginMetadataSnapshot<QueryKind>;
}
/** Returns a fresh cached snapshot or coalesces one plugin/list request. */
async load(params: LoadCodexPluginMetadataParams): Promise<CodexPluginMetadataSnapshot> {
const entryKey = buildMetadataCacheEntryKey(params.appCacheKey, params.queryKind);
const cached = this.read(params.appCacheKey, params.queryKind);
/** Returns a fresh snapshot or coalesces one catalog or installed-plugin request. */
async load<QueryKind extends CodexPluginMetadataQueryKind>(
params: LoadCodexPluginMetadataParams<QueryKind>,
): Promise<CodexPluginMetadataSnapshot<QueryKind>> {
const entryKey = buildMetadataCacheEntryKey(
params.appCacheKey,
params.queryKind,
params.requestParams,
);
const cached = this.read(params.appCacheKey, params.queryKind, params.requestParams);
if (cached) {
return cached;
}
const pending = this.inFlight.get(entryKey);
if (pending) {
try {
return await pending.promise;
return (await pending.promise) as CodexPluginMetadataSnapshot<QueryKind>;
} catch {
if (this.inFlight.get(entryKey) === pending) {
this.inFlight.delete(entryKey);
@@ -96,12 +115,15 @@ export class CodexPluginMetadataCache {
const generation = this.generations.get(params.appCacheKey) ?? 0;
const clearGeneration = this.clearGeneration;
const promise = (async () => {
const response = await params.request("plugin/list", params.requestParams);
const method = (
params.queryKind === "installed" ? "plugin/installed" : "plugin/list"
) as CodexPluginMetadataMethod<QueryKind>;
const response = await params.request(method, params.requestParams);
const snapshot = {
appCacheKey: params.appCacheKey,
queryKind: params.queryKind,
response,
} satisfies CodexPluginMetadataSnapshot;
} satisfies CodexPluginMetadataSnapshot<QueryKind>;
// Settled snapshots survive until install invalidation, identity change,
// TTL expiry, restart, or test reset — never a per-turn refresh.
if (
@@ -154,13 +176,27 @@ export class CodexPluginMetadataCache {
/** Shared plugin metadata cache used by Codex app-server runtime paths. */
export const defaultCodexPluginMetadataCache = new CodexPluginMetadataCache();
function hasMarketplaceLoadErrors(response: v2.PluginListResponse): boolean {
return (response.marketplaceLoadErrors?.length ?? 0) > 0;
function hasMarketplaceLoadErrors(
response: v2.PluginListResponse | v2.PluginInstalledResponse,
): boolean {
return response.marketplaceLoadErrors.length > 0;
}
function buildMetadataCacheEntryKey(
appCacheKey: string,
queryKind: CodexPluginMetadataQueryKind,
requestParams?: v2.PluginListParams | v2.PluginInstalledParams,
): string {
return JSON.stringify([appCacheKey, queryKind]);
if (queryKind !== "installed") {
return JSON.stringify([appCacheKey, queryKind]);
}
const installedParams = requestParams as v2.PluginInstalledParams | undefined;
// Codex discovers workspace marketplaces from these exact roots. Reusing one
// runtime's installed snapshot for another cwd exposes the wrong plugins.
return JSON.stringify([
appCacheKey,
queryKind,
installedParams?.cwds ?? [],
Array.from(new Set(installedParams?.installSuggestionPluginNames ?? [])).toSorted(),
]);
}
@@ -16,7 +16,7 @@ import type { CodexAppServerRequestResult } from "./protocol.js";
import { isJsonObject, type JsonObject, type v2 } from "./protocol.js";
export type CodexPluginThreadAppAdmissionDiagnostic = {
code: "account_app_inventory_unavailable";
code: "account_app_inventory_unavailable" | "account_app_config_unavailable";
message: string;
};
@@ -27,20 +27,25 @@ type CodexPluginThreadAppAdmissionParams = {
nowMs?: number;
};
export async function refreshAppInventoryNow(
/** Effective Codex config and active layers from one authoritative read. */
export type CodexPluginThreadAppAdmissionConfig = {
config: JsonObject;
layers: readonly JsonObject[];
};
export async function refreshCodexPluginAppInventory(
params: CodexPluginThreadAppAdmissionParams,
appCache: CodexAppInventoryCache,
options: { forceRefetch?: boolean; reason?: string; targetAppIds?: readonly string[] } = {},
): Promise<CodexAppInventorySnapshot | undefined> {
const appCacheKey = params.appCacheKey;
if (!appCacheKey) {
if (!params.appCacheKey) {
return undefined;
}
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
try {
return await appCache.refreshNow({
key: appCacheKey,
key: params.appCacheKey,
request,
nowMs: params.nowMs,
forceRefetch: options.forceRefetch,
@@ -52,32 +57,82 @@ export async function refreshAppInventoryNow(
forceRefetch: options.forceRefetch === true,
error: serializeCodexAppInventoryError(error),
});
// Keep building from the diagnostic inventory state; app exposure remains scoped below.
return undefined;
}
}
export function collectInventoryOwnedAppIds(inventory: CodexPluginInventory): string[] {
export function collectCodexPluginOwnedAppIds(inventory: CodexPluginInventory): string[] {
return Array.from(
new Set(inventory.records.flatMap((record) => record.ownedAppIds).filter(Boolean)),
).toSorted();
}
export async function readThreadAdmissibleAccountApps(
export function collectCodexReservedPluginAppIds(params: {
policy: ResolvedCodexPluginsPolicy;
inventory: CodexPluginInventory;
accountApps: readonly v2.AppInfo[];
}): Set<string> {
const reserved = new Set(
params.inventory.records.flatMap((record) =>
record.appOwnership === "proven" ? record.ownedAppIds : [],
),
);
const recordsByConfigKey = new Map(
params.inventory.records.map((record) => [record.policy.configKey, record] as const),
);
const configuredOwnerNames = new Set(
params.policy.pluginPolicies.flatMap((policy) => {
const record = recordsByConfigKey.get(policy.configKey);
return [policy.configKey, policy.pluginName, record?.summary.name, record?.summary.id]
.filter((name): name is string => Boolean(name))
.map(normalizeCodexPluginOwnerName);
}),
);
for (const app of params.accountApps) {
if (
app.pluginDisplayNames.some((name) =>
configuredOwnerNames.has(normalizeCodexPluginOwnerName(name)),
)
) {
reserved.add(app.id);
}
}
return reserved;
}
function normalizeCodexPluginOwnerName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
}
export async function readCodexThreadAdmissibleAccountApps(
params: CodexPluginThreadAppAdmissionParams,
appCache: CodexAppInventoryCache,
): Promise<{
apps: v2.AppInfo[];
source?: CodexAppInventorySnapshot["source"];
diagnostic?: CodexPluginThreadAppAdmissionDiagnostic;
}> {
// Account-wide mode needs metadata for every installed app, not only the
// configured plugin-owned app ids used by targeted startup refreshes.
const snapshot = await refreshAppInventoryNow(params, appCache, {
forceRefetch: false,
reason: "account_apps_all",
targetAppIds: [],
// Account-wide policy must use a complete snapshot; a targeted plugin read
// cannot establish which other account apps are authorized for this thread.
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
const cachedInventory = appCache.read({
key: params.appCacheKey,
request,
nowMs: params.nowMs,
suppressRefresh: true,
});
const snapshot =
cachedInventory.state === "fresh" && !cachedInventory.snapshot?.targetAppIds?.length
? cachedInventory.snapshot
: await refreshCodexPluginAppInventory(params, appCache, {
forceRefetch: false,
reason: "account_apps_all",
targetAppIds: [],
});
if (!snapshot) {
return {
apps: [],
@@ -87,17 +142,21 @@ export async function readThreadAdmissibleAccountApps(
},
};
}
const installedAppsById = new Map(snapshot.installedApps.map((app) => [app.id, app]));
return {
apps: snapshot.apps
// Account-wide discovery must preserve Codex's effective enablement policy.
// Only an explicitly configured plugin may provisionally enable its owned app.
.filter((app) => resolveAccountAppThreadAdmission(app, snapshot.source) === "ready")
.filter(
(app) =>
resolveCodexInstalledAppThreadAdmission(
toCodexPluginOwnedAccountApp(app),
installedAppsById.get(app.id),
) !== "blocked",
)
.toSorted((left, right) => left.id.localeCompare(right.id)),
source: snapshot.source,
};
}
export function toOwnedAccountApp(app: v2.AppInfo): CodexPluginOwnedApp {
export function toCodexPluginOwnedAccountApp(app: v2.AppInfo): CodexPluginOwnedApp {
return {
id: app.id,
name: app.name,
@@ -107,112 +166,111 @@ export function toOwnedAccountApp(app: v2.AppInfo): CodexPluginOwnedApp {
};
}
export function resolveThreadConfigAppsForRecord(params: {
export function resolveCodexThreadConfigAppsForRecord(params: {
record: CodexPluginInventoryRecord;
inventory: CodexPluginInventory;
}): CodexPluginOwnedApp[] {
if (params.inventory.appInventory?.state === "missing") {
return [];
}
return params.record.apps;
return params.inventory.appInventory?.state === "missing" ? [] : params.record.apps;
}
type CodexPluginAppThreadAdmission = "ready" | "provisional" | "blocked";
export function resolveAccountAppThreadAdmission(
app: v2.AppInfo,
source: CodexAppInventorySnapshot["source"] | undefined,
): CodexPluginAppThreadAdmission {
return resolveAppInfoThreadAdmission(app, source);
}
export function resolvePluginAppThreadAdmission(
export function resolveCodexPluginAppThreadAdmission(
app: CodexPluginOwnedApp,
inventory: CodexPluginInventory,
): CodexPluginAppThreadAdmission {
if (app.accessible) {
return "ready";
}
const snapshot = inventory.appInventory?.snapshot;
if (!snapshot) {
return "blocked";
}
const appInfo = snapshot.apps.find((candidate) => candidate.id === app.id);
return appInfo ? resolveAppInfoThreadAdmission(appInfo, snapshot.source) : "blocked";
return resolveCodexInstalledAppThreadAdmission(
app,
snapshot.installedApps.find((candidate) => candidate.id === app.id),
);
}
function resolveAppInfoThreadAdmission(
app: v2.AppInfo,
source: CodexAppInventorySnapshot["source"] | undefined,
function resolveCodexInstalledAppThreadAdmission(
app: Pick<CodexPluginOwnedApp, "accessible" | "needsAuth">,
installed: v2.InstalledApp | undefined,
): CodexPluginAppThreadAdmission {
if (app.isAccessible) {
if (!app.accessible || app.needsAuth || !installed) {
return "blocked";
}
if (installed.enabled && installed.callable) {
return "ready";
}
// The explicit OpenClaw plugin entry is the unmanaged enablement decision.
// Codex still applies feature/workspace gates and managed app requirements
// after this thread override; thread-scoped attestation fails closed if they win.
return source === "installed" && !app.isEnabled ? "provisional" : "blocked";
// Explicit plugin and account-wide policy can both override deny-by-default.
// An enabled app with no callable tools cannot be repaired by thread policy.
return !installed.enabled && !installed.callable ? "provisional" : "blocked";
}
export async function readConfigLayersForAppAdmission(
export async function readCodexConfigForAppAdmission(
params: CodexPluginThreadAppAdmissionParams,
): Promise<readonly JsonObject[] | undefined> {
): Promise<CodexPluginThreadAppAdmissionConfig | undefined> {
try {
const response = await params.request("config/read", {
includeLayers: true,
...(params.configCwd ? { cwd: params.configCwd } : {}),
});
if (!isJsonObject(response) || !Array.isArray(response.layers)) {
throw new Error("Codex config/read omitted config layers");
if (
!isJsonObject(response) ||
!isJsonObject(response.config) ||
!Array.isArray(response.layers)
) {
throw new Error("Codex config/read omitted effective config or config layers");
}
return response.layers.flatMap((layer) => {
if (!isJsonObject(layer)) {
throw new Error("Codex config/read returned an invalid config layer");
}
if (layer.disabledReason !== undefined && layer.disabledReason !== null) {
if (typeof layer.disabledReason !== "string") {
throw new Error("Codex config/read returned an invalid disabled layer");
return {
config: response.config,
layers: response.layers.flatMap((layer) => {
if (!isJsonObject(layer)) {
throw new Error("Codex config/read returned an invalid config layer");
}
return [];
}
if (!isJsonObject(layer.config)) {
throw new Error("Codex config/read returned an invalid layer config");
}
return [layer.config];
});
if (layer.disabledReason !== undefined && layer.disabledReason !== null) {
if (typeof layer.disabledReason !== "string") {
throw new Error("Codex config/read returned an invalid disabled layer");
}
return [];
}
if (!isJsonObject(layer.config)) {
throw new Error("Codex config/read returned an invalid layer config");
}
return [layer.config];
}),
};
} catch (error) {
embeddedAgentLog.warn("codex plugin app admission config read failed", { error });
embeddedAgentLog.warn("codex plugin app admission config read failed", {
error: serializeCodexAppInventoryError(error),
});
return undefined;
}
}
export function resolveExplicitAppEnablement(
export function resolveCodexExplicitAppEnablement(
layersHighestPrecedenceFirst: readonly JsonObject[],
appId: string,
): boolean | undefined {
// Codex includes disabled layers and orders active config from highest to lowest.
// The first app-specific value wins; ignoring `_default` preserves the deny-all
// service-account baseline while preventing SessionFlags from undoing an explicit opt-out.
// The first active app-specific value wins. `_default` does not prevent an
// explicitly selected plugin from safely requesting thread-only enablement.
for (const layer of layersHighestPrecedenceFirst) {
const apps = layer.apps;
const app = isJsonObject(apps) ? apps[appId] : undefined;
if (!isJsonObject(app) || !Object.hasOwn(app, "enabled")) {
continue;
if (isJsonObject(app) && Object.hasOwn(app, "enabled")) {
return app.enabled === true;
}
return app.enabled === true;
}
return undefined;
}
export function shouldForceRefreshForNotReadyPluginApps(
export function shouldForceRefreshCodexNotReadyPluginApps(
params: CodexPluginThreadAppAdmissionParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
if (!params.appCacheKey || !policy.pluginPolicies.some((plugin) => plugin.enabled)) {
return false;
}
if (inventory.appInventory?.state === "missing") {
if (
!params.appCacheKey ||
!policy.pluginPolicies.some((plugin) => plugin.enabled) ||
inventory.appInventory?.state === "missing"
) {
return false;
}
return inventory.records.some(
@@ -1,10 +1,12 @@
// Codex tests cover provisional plugin app attestation behavior.
import { describe, expect, it, vi } from "vitest";
import { attestCodexPluginThreadApps } from "./plugin-thread-attestation.js";
import {
attestCodexPluginThreadApps,
discardUnattestedCodexPluginThread,
} from "./plugin-thread-attestation.js";
import type { v2 } from "./protocol.js";
describe("Codex provisional plugin app attestation", () => {
it("reads effective app state directly from the newly started thread", async () => {
describe("Codex plugin thread app attestation", () => {
it("reads the committed snapshot with the started thread's effective policy", async () => {
const request = vi.fn(async () => installedApps(["linear-app"]));
await attestCodexPluginThreadApps({
@@ -15,14 +17,56 @@ describe("Codex provisional plugin app attestation", () => {
expect(request).toHaveBeenCalledExactlyOnceWith(
"app/installed",
{
threadId: "thread-linear",
forceRefresh: true,
},
{ threadId: "thread-linear", forceRefresh: false },
{ signal: undefined },
);
});
it("attests configured plugin and account apps against one thread snapshot", async () => {
const request = vi.fn(async () => installedApps(["plugin-app", "account-app"]));
await attestCodexPluginThreadApps({
client: { request } as never,
threadId: "thread-mixed-apps",
appIds: ["plugin-app", "account-app", "plugin-app"],
});
expect(request).toHaveBeenCalledExactlyOnceWith(
"app/installed",
{ threadId: "thread-mixed-apps", forceRefresh: false },
{ signal: undefined },
);
});
it("rejects a mixed snapshot when the thread denies its account-wide app", async () => {
const request = vi.fn(async () => installedApps(["plugin-app"]));
await expect(
attestCodexPluginThreadApps({
client: { request } as never,
threadId: "thread-mixed-apps",
appIds: ["plugin-app", "account-app"],
}),
).rejects.toMatchObject({
name: "CodexPluginThreadAppAttestationError",
message: expect.stringContaining("account-app:missing"),
});
expect(request).toHaveBeenCalledOnce();
});
it("does not read the app snapshot when no apps need attestation", async () => {
const request = vi.fn();
await attestCodexPluginThreadApps({
client: { request } as never,
threadId: "thread-linear",
appIds: [],
});
expect(request).not.toHaveBeenCalled();
});
it.each([
{
state: "missing",
@@ -39,7 +83,7 @@ describe("Codex provisional plugin app attestation", () => {
response: installedApps(["linear-app"], { callable: false }),
failure: "linear-app:not-callable",
},
])("fails closed when a provisional app is $state", async ({ response, failure }) => {
])("fails closed when an admitted app is $state", async ({ response, failure }) => {
await expect(
attestCodexPluginThreadApps({
client: { request: vi.fn(async () => response) } as never,
@@ -51,6 +95,85 @@ describe("Codex provisional plugin app attestation", () => {
message: expect.stringContaining(failure),
});
});
it("preserves the original cause when thread-scoped discovery fails", async () => {
const cause = new Error("committed app snapshot unavailable");
await expect(
attestCodexPluginThreadApps({
client: {
request: vi.fn(async () => {
throw cause;
}),
} as never,
threadId: "thread-linear",
appIds: ["linear-app"],
}),
).rejects.toMatchObject({
name: "CodexPluginThreadAppAttestationError",
cause,
});
});
});
describe("unattested Codex plugin thread cleanup", () => {
it("deletes a persistent thread before its first rollout", async () => {
const request = vi.fn(async () => ({}));
await expect(
discardUnattestedCodexPluginThread({
client: { request } as never,
threadId: "thread-persistent",
ephemeral: false,
}),
).resolves.toBe(true);
expect(request).toHaveBeenCalledExactlyOnceWith(
"thread/delete",
{ threadId: "thread-persistent" },
{ timeoutMs: 5_000 },
);
});
it("unsubscribes an ephemeral thread that Codex cannot delete", async () => {
const request = vi.fn(async () => ({}));
await expect(
discardUnattestedCodexPluginThread({
client: { request } as never,
threadId: "thread-ephemeral",
ephemeral: true,
}),
).resolves.toBe(true);
expect(request).toHaveBeenCalledExactlyOnceWith(
"thread/unsubscribe",
{ threadId: "thread-ephemeral" },
{ timeoutMs: 5_000 },
);
});
it("does not treat unsubscribe as proof that a persistent thread was deleted", async () => {
const request = vi.fn(async (method: string) => {
if (method === "thread/delete") {
throw new Error("thread deletion unavailable");
}
return {};
});
await expect(
discardUnattestedCodexPluginThread({
client: { request } as never,
threadId: "thread-persistent",
ephemeral: false,
}),
).resolves.toBe(false);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/delete",
"thread/unsubscribe",
]);
});
});
function installedApps(
@@ -1,11 +1,15 @@
/**
* Confirms provisionally admitted Codex apps against the effective config of a
* newly started thread before OpenClaw persists or turns on that thread.
* Confirms admitted plugin and account apps against their actual Codex thread before
* OpenClaw commits a binding or starts a turn.
*/
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
unsubscribeCodexThreadBestEffort,
} from "./attempt-client-cleanup.js";
import type { CodexAppServerClient } from "./client.js";
import type { v2 } from "./protocol.js";
/** Raised when a fresh thread does not expose every provisionally admitted app. */
class CodexPluginThreadAppAttestationError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
@@ -13,7 +17,7 @@ class CodexPluginThreadAppAttestationError extends Error {
}
}
/** Reads thread-scoped runtime state directly so it never enters the account cache. */
/** Reads the existing runtime snapshot with the started thread's effective app policy. */
export async function attestCodexPluginThreadApps(params: {
client: CodexAppServerClient;
threadId: string;
@@ -29,15 +33,12 @@ export async function attestCodexPluginThreadApps(params: {
try {
response = await params.client.request(
"app/installed",
{
threadId: params.threadId,
forceRefresh: true,
},
{ threadId: params.threadId, forceRefresh: false },
{ signal: params.signal },
);
} catch (error) {
throw new CodexPluginThreadAppAttestationError(
`Codex could not confirm provisional apps for thread ${params.threadId}`,
`Codex could not confirm admitted apps for thread ${params.threadId}`,
{ cause: error },
);
}
@@ -55,7 +56,40 @@ export async function attestCodexPluginThreadApps(params: {
});
if (failures.length > 0) {
throw new CodexPluginThreadAppAttestationError(
`Codex thread ${params.threadId} did not expose provisional apps: ${failures.join(", ")}`,
`Codex thread ${params.threadId} did not expose admitted apps: ${failures.join(", ")}`,
);
}
}
/** Deletes a persistent pre-turn thread; ephemeral threads can only be unsubscribed. */
export async function discardUnattestedCodexPluginThread(params: {
client: CodexAppServerClient;
threadId: string;
ephemeral: boolean;
}): Promise<boolean> {
if (params.ephemeral) {
return await unsubscribeCodexThreadBestEffort(params.client, {
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
}
try {
await params.client.request(
"thread/delete",
{ threadId: params.threadId },
{ timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS },
);
return true;
} catch (error) {
embeddedAgentLog.debug("codex plugin app attestation thread deletion failed", {
threadId: params.threadId,
error,
});
await unsubscribeCodexThreadBestEffort(params.client, {
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
return false;
}
}
@@ -56,7 +56,7 @@ export function resolveCodexPluginThreadConfigStartupPolicy(params: {
const pluginThreadConfigRequired =
!params.nativeToolSurfaceEnabled || shouldBuildCodexPluginThreadConfig(params.pluginConfig);
// Restricted runs still need a config so thread/start carries an explicit
// apps._default denial patch without app/list discovery.
// apps._default denial patch without app inventory discovery.
const pluginThreadConfigPluginConfig = params.nativeToolSurfaceEnabled
? params.pluginConfig
: disableCodexPluginThreadConfig(params.pluginConfig);
@@ -195,6 +195,7 @@ export function createCodexPluginThreadConfigStartupProvider(params: {
policy,
metadataCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
})
: undefined,
build: () =>
File diff suppressed because it is too large Load Diff
@@ -26,19 +26,20 @@ import {
} from "./plugin-inventory.js";
import type { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import {
collectInventoryOwnedAppIds,
readConfigLayersForAppAdmission,
readThreadAdmissibleAccountApps,
refreshAppInventoryNow,
resolveAccountAppThreadAdmission,
resolveExplicitAppEnablement,
resolvePluginAppThreadAdmission,
resolveThreadConfigAppsForRecord,
shouldForceRefreshForNotReadyPluginApps,
toOwnedAccountApp,
collectCodexPluginOwnedAppIds,
collectCodexReservedPluginAppIds,
readCodexConfigForAppAdmission,
readCodexThreadAdmissibleAccountApps,
refreshCodexPluginAppInventory,
resolveCodexExplicitAppEnablement,
resolveCodexPluginAppThreadAdmission,
resolveCodexThreadConfigAppsForRecord,
shouldForceRefreshCodexNotReadyPluginApps,
toCodexPluginOwnedAccountApp,
type CodexPluginThreadAppAdmissionConfig,
type CodexPluginThreadAppAdmissionDiagnostic,
} from "./plugin-thread-app-admission.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import { isJsonObject, type CodexConfigEdit, type JsonObject, type JsonValue } from "./protocol.js";
/** Policy context for one app id exposed by a configured Codex plugin. */
export type PluginAppPolicyContextEntry = {
@@ -76,6 +77,7 @@ type CodexPluginThreadConfigDiagnostic =
| CodexPluginThreadAppAdmissionDiagnostic
| {
code:
| "account_app_ownership_unavailable"
| "plugin_activation_failed"
| "plugin_config_timeout"
| "app_not_ready"
@@ -88,6 +90,7 @@ type CodexPluginThreadConfigDiagnostic =
export type CodexPluginThreadConfig = {
enabled: boolean;
configPatch?: JsonObject;
/** Modern app IDs that must be attested against the effective Codex thread. */
provisionalAppIds?: readonly string[];
fingerprint: string;
inputFingerprint: string;
@@ -172,6 +175,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
@@ -181,13 +185,13 @@ export async function buildCodexPluginThreadConfig(
inventory.records.some((record) => record.activationRequired) &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
await refreshCodexPluginAppInventory(params, appCache, {
// OpenClaw is missing its process-local snapshot, but Codex may already
// have a current inventory. Avoid rebuilding the entire remote catalog
// during thread startup; post-install and readiness repair still force.
forceRefetch: false,
reason: "initial_missing",
targetAppIds: collectInventoryOwnedAppIds(inventory),
targetAppIds: collectCodexPluginOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
@@ -195,6 +199,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
@@ -215,6 +220,7 @@ export async function buildCodexPluginThreadConfig(
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
deferAppInventoryRefresh: true,
targetAppIds: record.ownedAppIds,
});
activationResults.push(activation);
@@ -236,10 +242,10 @@ export async function buildCodexPluginThreadConfig(
!postInstallRefreshRequired &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
await refreshAppInventoryNow(params, appCache, {
await refreshCodexPluginAppInventory(params, appCache, {
forceRefetch: true,
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
targetAppIds: collectInventoryOwnedAppIds(inventory),
targetAppIds: collectCodexPluginOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
@@ -247,6 +253,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
@@ -255,11 +262,11 @@ export async function buildCodexPluginThreadConfig(
appCacheKey: params.appCacheKey,
});
}
if (shouldForceRefreshForNotReadyPluginApps(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
if (shouldForceRefreshCodexNotReadyPluginApps(params, policy, inventory)) {
await refreshCodexPluginAppInventory(params, appCache, {
forceRefetch: true,
reason: "not_ready_plugin_apps",
targetAppIds: collectInventoryOwnedAppIds(inventory),
targetAppIds: collectCodexPluginOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
@@ -267,6 +274,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
configCwd: params.configCwd,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
@@ -276,8 +284,10 @@ export async function buildCodexPluginThreadConfig(
});
}
const accountAppsResult: Awaited<ReturnType<typeof readThreadAdmissibleAccountApps>> =
policy.allowAllPlugins ? await readThreadAdmissibleAccountApps(params, appCache) : { apps: [] };
const accountAppsResult: Awaited<ReturnType<typeof readCodexThreadAdmissibleAccountApps>> =
policy.allowAllPlugins
? await readCodexThreadAdmissibleAccountApps(params, appCache)
: { apps: [] };
const diagnostics: CodexPluginThreadConfigDiagnostic[] = [
...inventory.diagnostics,
@@ -294,13 +304,34 @@ export async function buildCodexPluginThreadConfig(
};
const policyApps: Record<string, CodexAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
let configLayersForAppAdmission: Promise<readonly JsonObject[] | undefined> | undefined;
const pluginOwnedAppIds = new Set(
inventory.records.flatMap((record) =>
record.appOwnership === "proven" ? record.ownedAppIds : [],
),
);
let configForAppAdmission: Promise<CodexPluginThreadAppAdmissionConfig | undefined> | undefined;
const pluginOwnedAppIds = collectCodexReservedPluginAppIds({
policy,
inventory,
accountApps: accountAppsResult.apps,
});
const unresolvedDisabledPluginOwnership = policy.allowAllPlugins
? policy.pluginPolicies.find(
(pluginPolicy) =>
!pluginPolicy.enabled &&
!inventory.records.some(
(record) => record.policy.configKey === pluginPolicy.configKey && record.detail,
),
)
: undefined;
if (unresolvedDisabledPluginOwnership) {
// Codex omits disabled plugin ownership from app/read display names. A
// broad account policy cannot safely proceed without authoritative detail.
diagnostics.push({
code: "account_app_ownership_unavailable",
plugin: unresolvedDisabledPluginOwnership,
message: `Could not verify disabled Codex plugin app ownership for ${unresolvedDisabledPluginOwnership.pluginName}; account apps were not exposed.`,
});
}
for (const record of inventory.records) {
if (!record.policy.enabled) {
continue;
}
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
@@ -311,16 +342,16 @@ export async function buildCodexPluginThreadConfig(
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
let admission = resolvePluginAppThreadAdmission(app, inventory);
if (admission === "provisional") {
configLayersForAppAdmission ??= readConfigLayersForAppAdmission(params);
const layers = await configLayersForAppAdmission;
if (layers === undefined || resolveExplicitAppEnablement(layers, app.id) === false) {
admission = "blocked";
}
}
if (admission === "blocked") {
for (const app of resolveCodexThreadConfigAppsForRecord({ record, inventory })) {
const admission = resolveCodexPluginAppThreadAdmission(app, inventory);
const admissionConfig =
admission === "blocked"
? undefined
: await (configForAppAdmission ??= readCodexConfigForAppAdmission(params));
if (
!admissionConfig ||
resolveCodexExplicitAppEnablement(admissionConfig.layers, app.id) === false
) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
@@ -333,6 +364,7 @@ export async function buildCodexPluginThreadConfig(
!(await clearPersistedAppToolApprovalOverrides({
request: params.request,
configCwd: params.configCwd,
config: admissionConfig.config,
plugin: record.policy,
app,
diagnostics,
@@ -340,9 +372,7 @@ export async function buildCodexPluginThreadConfig(
) {
continue;
}
if (admission === "provisional") {
provisionalAppIds.add(app.id);
}
provisionalAppIds.add(app.id);
apps[app.id] = buildEnabledAppConfig(record.policy);
policyApps[app.id] = {
configKey: record.policy.configKey,
@@ -355,29 +385,41 @@ export async function buildCodexPluginThreadConfig(
}
}
for (const app of accountAppsResult.apps) {
for (const app of unresolvedDisabledPluginOwnership ? [] : accountAppsResult.apps) {
// An explicit plugin policy is more specific than the account-wide policy.
// Reserve proven ownership even when activation/readiness fails so a broad
// account policy cannot re-admit an app that the explicit path excluded.
if (pluginOwnedAppIds.has(app.id)) {
continue;
}
const admission = resolveAccountAppThreadAdmission(app, accountAppsResult.source);
const accountApp = toOwnedAccountApp(app);
configForAppAdmission ??= readCodexConfigForAppAdmission(params);
const admissionConfig = await configForAppAdmission;
if (!admissionConfig) {
diagnostics.push({
code: "account_app_config_unavailable",
message: "Codex account app configuration was unavailable; account apps were not exposed.",
});
break;
}
if (resolveCodexExplicitAppEnablement(admissionConfig.layers, app.id) === false) {
continue;
}
const accountApp = toCodexPluginOwnedAccountApp(app);
if (
policy.destructiveApprovalMode === "ask" &&
!(await clearPersistedAppToolApprovalOverrides({
request: params.request,
configCwd: params.configCwd,
config: admissionConfig.config,
app: accountApp,
diagnostics,
}))
) {
continue;
}
if (admission === "provisional") {
provisionalAppIds.add(app.id);
}
// Global callability does not prove this thread's workspace/managed
// policy. Attest only apps that also passed destructive-approval checks.
provisionalAppIds.add(app.id);
apps[app.id] = buildEnabledAppConfig(policy);
policyApps[app.id] = {
source: "account",
@@ -531,25 +573,48 @@ function buildPluginAppPolicyContext(
async function clearPersistedAppToolApprovalOverrides(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
config: JsonObject;
plugin?: ResolvedCodexPluginPolicy;
app: CodexPluginOwnedApp;
diagnostics: CodexPluginThreadConfigDiagnostic[];
}): Promise<boolean> {
try {
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
for (const toolName of overrideNames) {
const response = await params.request("config/value/write", {
const overrideNames = readPersistedAppToolApprovalOverrideNames(params.config, params.app);
if (overrideNames.length === 0) {
return true;
}
const edits = overrideNames.map(
(toolName): CodexConfigEdit => ({
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
toolName,
)}.approval_mode`,
value: null,
mergeStrategy: "replace",
});
if (isOverriddenConfigWriteResponse(response)) {
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
}
}),
);
const response = await params.request("config/batchWrite", { edits });
if (
!isJsonObject(response) ||
(response.status !== "ok" && response.status !== "okOverridden")
) {
throw new Error("Codex did not confirm the approval override batch");
}
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
if (response.status === "okOverridden") {
throw new Error(
`approval override for ${overrideNames.join(", ")} is controlled by another config layer`,
);
}
const confirmed = await params.request("config/read", {
includeLayers: false,
...(params.configCwd ? { cwd: params.configCwd } : {}),
});
if (!isJsonObject(confirmed) || !isJsonObject(confirmed.config)) {
throw new Error("Codex did not confirm effective app approval configuration");
}
const remainingOverrideNames = readPersistedAppToolApprovalOverrideNames(
confirmed.config,
params.app,
);
if (remainingOverrideNames.length > 0) {
throw new Error(
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
@@ -568,22 +633,12 @@ async function clearPersistedAppToolApprovalOverrides(params: {
}
}
async function readPersistedAppToolApprovalOverrideNames(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
app: CodexPluginOwnedApp;
}): Promise<string[]> {
const response = await params.request("config/read", {
includeLayers: false,
...(params.configCwd ? { cwd: params.configCwd } : {}),
});
const config = isJsonObject(response) ? response.config : undefined;
const appsRoot = isJsonObject(config) ? config.apps : undefined;
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
const appConfig = isJsonObject(appsRoot)
? (appsRoot[params.app.id] ??
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
: undefined;
function readPersistedAppToolApprovalOverrideNames(
config: JsonObject,
app: CodexPluginOwnedApp,
): string[] {
const appsRoot = config.apps;
const appConfig = isJsonObject(appsRoot) ? appsRoot[app.id] : undefined;
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
if (!isJsonObject(tools)) {
return [];
@@ -595,13 +650,7 @@ async function readPersistedAppToolApprovalOverrideNames(params: {
}
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
return (
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
);
}
function isOverriddenConfigWriteResponse(response: unknown): boolean {
return isJsonObject(response) && response.status === "okOverridden";
return isJsonObject(value) && value.approval_mode !== undefined;
}
function quoteConfigKeyPathSegment(segment: string): string {
@@ -0,0 +1,277 @@
import type { JsonObject, JsonValue } from "./protocol-json.js";
/** Current Codex marketplace, app, skill, hook, and config wire contracts. */
export type CodexPluginSummary = {
id: string;
remotePluginId?: string | null;
name: string;
source?: JsonObject;
installed: boolean;
enabled: boolean;
installPolicy?: string;
authPolicy?: string;
availability?: string;
interface?: JsonValue;
};
export type CodexAppSummary = {
id: string;
name: string;
description: string | null;
installUrl: string | null;
category: string | null;
};
export type CodexPluginDetail = {
marketplaceName?: string;
marketplacePath?: string | null;
summary: CodexPluginSummary;
description?: string | null;
skills?: JsonValue[];
apps: CodexAppSummary[];
mcpServers: string[];
};
export type CodexPluginMarketplaceEntry = {
name: string;
path?: string | null;
interface?: JsonValue;
plugins: CodexPluginSummary[];
};
type CodexMarketplaceLoadErrorInfo = {
marketplacePath: string;
message: string;
};
export type CodexPluginInstalledParams = {
cwds?: string[] | null;
installSuggestionPluginNames?: string[] | null;
};
export type CodexPluginInstalledResponse = {
marketplaces: CodexPluginMarketplaceEntry[];
marketplaceLoadErrors: CodexMarketplaceLoadErrorInfo[];
};
export type CodexPluginListResponse = {
marketplaces: CodexPluginMarketplaceEntry[];
marketplaceLoadErrors: CodexMarketplaceLoadErrorInfo[];
featuredPluginIds: string[];
};
export type CodexPluginReadResponse = {
plugin: CodexPluginDetail;
};
type CodexPluginListMarketplaceKind =
| "local"
| "vertical"
| "workspace-directory"
| "shared-with-me"
| "created-by-me-remote";
export type CodexPluginListParams = {
cwds?: string[] | null;
forceRefetch?: boolean;
marketplaceKinds?: CodexPluginListMarketplaceKind[] | null;
};
export type CodexPluginReadParams = {
marketplacePath?: string | null;
remoteMarketplaceName?: string | null;
pluginName: string;
};
export type CodexPluginInstallParams = CodexPluginReadParams;
export type CodexPluginInstallResponse = {
authPolicy: string;
appsNeedingAuth: CodexAppSummary[];
};
/** App inventory shape consumed by OpenClaw's existing plugin policy. */
export type CodexAppInfo = {
id: string;
name: string;
description?: string | null;
logoUrl?: string | null;
logoUrlDark?: string | null;
distributionChannel?: string | null;
branding?: JsonValue;
appMetadata?: JsonValue;
labels?: Record<string, string | undefined> | null;
installUrl?: string | null;
isAccessible: boolean;
isEnabled: boolean;
pluginDisplayNames: string[];
};
export type CodexAppsListParams = {
cursor?: string | null;
limit?: number | null;
threadId?: string | null;
forceRefetch?: boolean;
};
export type CodexAppsListResponse = {
data: CodexAppInfo[];
nextCursor?: string | null;
};
export type CodexInstalledApp = {
id: string;
runtimeName: string | null;
enabled: boolean;
callable: boolean;
};
export type CodexAppsInstalledParams = {
threadId?: string | null;
forceRefresh?: boolean;
};
export type CodexAppsInstalledResponse = {
apps: CodexInstalledApp[];
};
type CodexAppToolSummary = {
name: string;
title: string | null;
description: string;
isEnabled: boolean;
disabledReason: string | null;
isReadOnly: boolean;
};
type CodexConnectorMetadata = {
id: string;
name: string;
description: string | null;
iconUrl: string | null;
iconUrlDark: string | null;
distributionChannel: string | null;
installUrl: string | null;
pluginDisplayNames: string[];
toolSummaries: CodexAppToolSummary[] | null;
};
export type CodexAppsReadParams = {
appIds: string[];
includeTools?: boolean;
};
export type CodexAppsReadResponse = {
apps: CodexConnectorMetadata[];
missingAppIds: string[];
};
export type CodexSkillsListParams = {
cwds: string[];
forceReload?: boolean;
};
type CodexSkillScope = "user" | "repo" | "system" | "admin";
type CodexSkillMetadata = {
name: string;
description: string;
shortDescription?: string;
interface?: JsonObject;
dependencies?: JsonObject;
path: string;
scope: CodexSkillScope;
enabled: boolean;
};
type CodexSkillErrorInfo = {
path: string;
message: string;
};
type CodexSkillsListEntry = {
cwd: string;
skills: CodexSkillMetadata[];
errors: CodexSkillErrorInfo[];
};
export type CodexSkillsListResponse = {
data: CodexSkillsListEntry[];
};
export type CodexHooksListParams = {
cwds: string[];
};
export type CodexHooksListResponse = {
data: JsonValue[];
nextCursor?: string | null;
};
export type CodexConfigReadResponse = {
config: JsonObject;
layers?: JsonValue[] | null;
};
type CodexConfigMergeStrategy = "replace" | "upsert";
export type CodexConfigEdit = {
keyPath: string;
value: JsonValue;
mergeStrategy: CodexConfigMergeStrategy;
};
export type CodexConfigValueWriteParams = CodexConfigEdit & {
filePath?: string | null;
expectedVersion?: string | null;
};
export type CodexConfigBatchWriteParams = {
edits: CodexConfigEdit[];
filePath?: string | null;
expectedVersion?: string | null;
reloadUserConfig?: boolean;
};
type CodexConfigLayerSource =
| { type: "mdm"; domain: string; key: string }
| { type: "system"; file: string }
| { type: "enterpriseManaged"; id: string; name: string }
| { type: "user"; file: string; profile: string | null }
| { type: "project"; dotCodexFolder: string }
| { type: "sessionFlags" }
| { type: "legacyManagedConfigTomlFromFile"; file: string }
| { type: "legacyManagedConfigTomlFromMdm" };
type CodexConfigLayerMetadata = {
name: CodexConfigLayerSource;
version: string;
};
// Codex permits optional response object entries; outbound JSON stays strict.
type CodexWireJsonValue =
| null
| boolean
| number
| string
| CodexWireJsonValue[]
| { [key in string]?: CodexWireJsonValue };
type CodexConfigOverriddenMetadata = {
message: string;
overridingLayer: CodexConfigLayerMetadata;
effectiveValue: CodexWireJsonValue;
};
type CodexConfigWriteStatus = "ok" | "okOverridden";
export type CodexConfigWriteResponse = {
status: CodexConfigWriteStatus;
version: string;
filePath: string;
overriddenMetadata: CodexConfigOverriddenMetadata | null;
};
export type CodexConfigRequirementsReadResponse = {
requirements: JsonObject | null;
};
@@ -78,6 +78,7 @@
"team",
"self_serve_business_usage_based",
"business",
"ent26",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
@@ -1137,6 +1137,11 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"isPinned": {
"default": false,
"description": "Whether the thread has been pinned by the user.",
"type": "boolean"
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1469,6 +1474,14 @@
"id": {
"type": "string"
},
"pluginId": {
"default": null,
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Identifier for the underlying PTY process (when available).",
"type": [
@@ -1476,6 +1489,14 @@
"null"
]
},
"scriptPath": {
"default": null,
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"source": {
"allOf": [
{
@@ -1137,6 +1137,11 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"isPinned": {
"default": false,
"description": "Whether the thread has been pinned by the user.",
"type": "boolean"
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1469,6 +1474,14 @@
"id": {
"type": "string"
},
"pluginId": {
"default": null,
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Identifier for the underlying PTY process (when available).",
"type": [
@@ -1476,6 +1489,14 @@
"null"
]
},
"scriptPath": {
"default": null,
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"source": {
"allOf": [
{
@@ -914,6 +914,14 @@
"id": {
"type": "string"
},
"pluginId": {
"default": null,
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Identifier for the underlying PTY process (when available).",
"type": [
@@ -921,6 +929,14 @@
"null"
]
},
"scriptPath": {
"default": null,
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"source": {
"allOf": [
{
@@ -914,6 +914,14 @@
"id": {
"type": "string"
},
"pluginId": {
"default": null,
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Identifier for the underlying PTY process (when available).",
"type": [
@@ -921,6 +929,14 @@
"null"
]
},
"scriptPath": {
"default": null,
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
"type": [
"string",
"null"
]
},
"source": {
"allOf": [
{
@@ -7,12 +7,13 @@ import {
assertCodexThreadStartResponse,
assertCodexThreadResumeResponse,
} from "./protocol-validators.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
function makeMinimalThread(overrides: Record<string, unknown> = {}) {
return {
id: "thread-1",
sessionId: "session-1",
cliVersion: "0.129.0",
cliVersion: CODEX_APP_SERVER_VERSION,
createdAt: 1715299200,
updatedAt: 1715299200,
cwd: "/tmp",
@@ -39,8 +40,8 @@ function makeMinimalResponse(threadOverrides: Record<string, unknown> = {}) {
}
describe("Codex thread response validators", () => {
// The 0.143 floor guarantees both thread ids; pre-0.131 servers without
// sessionId must fail loudly instead of being silently normalized.
// The pinned Codex protocol requires both thread identities; never silently
// invent a session identity when a malformed response omits one.
it("rejects thread responses missing sessionId", () => {
for (const assertResponse of [
assertCodexThreadStartResponse,
+47 -128
View File
@@ -1,16 +1,46 @@
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
import type {
CodexAppInfo,
CodexAppSummary,
CodexAppsInstalledParams,
CodexAppsInstalledResponse,
CodexAppsListParams,
CodexAppsListResponse,
CodexAppsReadParams,
CodexAppsReadResponse,
} from "./app-inventory-protocol.js";
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
CodexConfigBatchWriteParams,
CodexConfigReadResponse,
CodexConfigRequirementsReadResponse,
CodexConfigValueWriteParams,
CodexConfigWriteResponse,
CodexHooksListParams,
CodexHooksListResponse,
CodexInstalledApp,
CodexPluginDetail,
CodexPluginInstalledParams,
CodexPluginInstalledResponse,
CodexPluginInstallParams,
CodexPluginInstallResponse,
CodexPluginListParams,
CodexPluginListResponse,
CodexPluginMarketplaceEntry,
CodexPluginReadParams,
CodexPluginReadResponse,
CodexPluginSummary,
CodexSkillsListParams,
CodexSkillsListResponse,
} from "./protocol-control-plane.js";
import type { JsonObject, JsonValue } from "./protocol-json.js";
import type * as CodexMcpProtocol from "./protocol-mcp.js";
export type {
CodexConfigEdit,
CodexConfigReadResponse,
CodexConfigRequirementsReadResponse,
CodexPluginDetail,
CodexPluginListResponse,
CodexPluginReadResponse,
} from "./protocol-control-plane.js";
export type { CodexListMcpServerStatusResponse, CodexMcpServerStatus } from "./protocol-mcp.js";
export type { JsonObject, JsonValue } from "./protocol-json.js";
@@ -273,6 +303,8 @@ type CodexThreadDeleteParams = JsonObject & {
threadId: string;
};
type CodexThreadDeleteResponse = Record<string, never>;
type CodexThreadUnarchiveResponse = {
thread: CodexThread;
};
@@ -568,130 +600,6 @@ export type CodexLoginAccountParams =
chatgptPlanType: string | null;
};
type CodexPluginSummary = {
id: string;
remotePluginId?: string;
name: string;
source?: JsonObject;
installed: boolean;
enabled: boolean;
installPolicy?: string;
authPolicy?: string;
availability?: string;
interface?: JsonValue;
};
type CodexAppSummary = {
id: string;
name: string;
description?: string | null;
installUrl?: string | null;
needsAuth: boolean;
};
export type CodexPluginDetail = {
marketplaceName?: string;
marketplacePath?: string | null;
summary: CodexPluginSummary;
description?: string | null;
skills?: JsonValue[];
apps: CodexAppSummary[];
mcpServers: string[];
};
type CodexPluginMarketplaceEntry = {
name: string;
path?: string | null;
interface?: JsonValue;
plugins: CodexPluginSummary[];
};
export type CodexPluginListResponse = {
marketplaces: CodexPluginMarketplaceEntry[];
marketplaceLoadErrors?: JsonValue[];
featuredPluginIds?: string[];
};
export type CodexPluginReadResponse = {
plugin: CodexPluginDetail;
};
type CodexPluginListMarketplaceKind =
| "local"
| "vertical"
| "workspace-directory"
| "shared-with-me"
| "created-by-me-remote";
type CodexPluginListParams = {
cwds?: string[];
marketplaceKinds?: CodexPluginListMarketplaceKind[];
};
type CodexPluginReadParams = {
marketplacePath?: string;
remoteMarketplaceName?: string;
pluginName: string;
};
type CodexPluginInstallParams = CodexPluginReadParams;
type CodexPluginInstallResponse = {
authPolicy: string;
appsNeedingAuth: CodexAppSummary[];
};
type CodexSkillsListParams = {
cwds: string[];
forceReload?: boolean;
};
type CodexSkillScope = "user" | "repo" | "system" | "admin";
type CodexSkillMetadata = {
name: string;
description: string;
shortDescription?: string;
interface?: JsonObject;
dependencies?: JsonObject;
path: string;
scope: CodexSkillScope;
enabled: boolean;
};
type CodexSkillErrorInfo = {
path: string;
message: string;
};
type CodexSkillsListEntry = {
cwd: string;
skills: CodexSkillMetadata[];
errors: CodexSkillErrorInfo[];
};
type CodexSkillsListResponse = {
data: CodexSkillsListEntry[];
};
type CodexHooksListParams = {
cwds: string[];
};
type CodexHooksListResponse = {
data: JsonValue[];
nextCursor?: string | null;
};
export type CodexConfigReadResponse = {
config: JsonObject;
layers?: JsonValue[] | null;
};
export type CodexConfigRequirementsReadResponse = {
requirements: JsonObject | null;
};
export type CodexRequestObject = Record<string, unknown>;
export declare namespace v2 {
@@ -700,7 +608,10 @@ export declare namespace v2 {
export type AppsInstalledResponse = CodexAppsInstalledResponse;
export type HooksListParams = CodexHooksListParams;
export type HooksListResponse = CodexHooksListResponse;
export type InstalledApp = CodexInstalledApp;
export type PluginDetail = CodexPluginDetail;
export type PluginInstalledParams = CodexPluginInstalledParams;
export type PluginInstalledResponse = CodexPluginInstalledResponse;
export type PluginInstallParams = CodexPluginInstallParams;
export type PluginInstallResponse = CodexPluginInstallResponse;
export type PluginListParams = CodexPluginListParams;
@@ -718,7 +629,13 @@ type CodexAppServerRequestParamsOverride = {
"app/list": CodexAppsListParams;
"app/read": CodexAppsReadParams;
"command/exec": CodexCommandExecParams;
"config/batchWrite": CodexConfigBatchWriteParams;
"config/value/write": CodexConfigValueWriteParams;
"environment/add": { environmentId: string; execServerUrl: string };
"plugin/installed": CodexPluginInstalledParams;
"plugin/install": CodexPluginInstallParams;
"plugin/list": CodexPluginListParams;
"plugin/read": CodexPluginReadParams;
"thread/fork": CodexThreadForkParams;
"thread/archive": CodexThreadArchiveParams;
"thread/delete": CodexThreadDeleteParams;
@@ -746,10 +663,11 @@ type CodexAppServerRequestResultMap = {
"app/list": CodexAppsListResponse;
"app/read": CodexAppsReadResponse;
"command/exec": CodexCommandExecResponse;
"config/batchWrite": CodexConfigWriteResponse;
"config/mcpServer/reload": JsonValue;
"config/read": CodexConfigReadResponse;
"configRequirements/read": CodexConfigRequirementsReadResponse;
"config/value/write": JsonValue;
"config/value/write": CodexConfigWriteResponse;
"environment/add": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;
@@ -760,6 +678,7 @@ type CodexAppServerRequestResultMap = {
"mcpServer/tool/call": CodexMcpProtocol.ToolCallResult;
"model/list": CodexModelListResponse;
"modelProvider/capabilities/read": CodexModelProviderCapabilitiesReadResponse;
"plugin/installed": CodexPluginInstalledResponse;
"plugin/install": CodexPluginInstallResponse;
"plugin/list": CodexPluginListResponse;
"plugin/read": CodexPluginReadResponse;
@@ -767,7 +686,7 @@ type CodexAppServerRequestResultMap = {
"skills/list": CodexSkillsListResponse;
"thread/compact/start": JsonValue;
"thread/archive": JsonValue;
"thread/delete": JsonValue;
"thread/delete": CodexThreadDeleteResponse;
"thread/fork": CodexThreadForkResponse;
"thread/inject_items": JsonValue;
"thread/list": CodexThreadListResponse;
@@ -57,7 +57,7 @@ function createLocalCommandClient() {
try {
const result = await execFileAsync(params.command[0]!, params.command.slice(1), {
maxBuffer: Math.max(1_024, params.outputBytesCap ?? 1024 * 1024),
...(params.timeoutMs === undefined ? {} : { timeout: params.timeoutMs }),
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),
});
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
+155 -1
View File
@@ -20,7 +20,8 @@ vi.mock("./shared-client.js", () => ({
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
}));
const { readCodexAppServerUsage, requestCodexAppServerJson } = await import("./request.js");
const { readCodexAppServerUsage, requestCodexAppServerJson, withCodexAppServerJsonClient } =
await import("./request.js");
const expectDeadlineOptions = () =>
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.anything() });
@@ -85,6 +86,102 @@ describe("requestCodexAppServerJson sandbox guard", () => {
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, expectDeadlineOptions());
});
it.each([
{
method: "app/installed" as const,
requestParams: { threadId: "thread-1", forceRefresh: false },
response: { apps: [] },
},
{
method: "app/read" as const,
requestParams: { appIds: ["calendar-app"], includeTools: false },
response: { apps: [] },
},
{
method: "plugin/installed" as const,
requestParams: { cwds: [] },
response: { marketplaces: [], marketplaceLoadErrors: [] },
},
{
method: "config/batchWrite" as const,
requestParams: {
edits: [
{
keyPath: 'apps."calendar".tools."create".approval_mode',
value: null,
mergeStrategy: "replace" as const,
},
],
},
response: {
status: "ok",
version: "1",
filePath: "/codex/config.toml",
overriddenMetadata: null,
},
},
])(
"allows the $method control-plane request under sandbox and node execution policies",
async ({ method, requestParams, response }) => {
const request = vi.fn(async () => response);
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
for (const policy of [
{
config: { agents: { defaults: { sandbox: { mode: "all" as const } } } },
sessionKey: "sandboxed-session",
},
{
config: { tools: { exec: { host: "node" as const, node: "worker-1" } } },
sessionKey: "node-session",
},
]) {
await expect(
requestCodexAppServerJson({
method,
requestParams,
...policy,
}),
).resolves.toEqual(response);
}
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, method, requestParams, expectDeadlineOptions());
expect(request).toHaveBeenNthCalledWith(2, method, requestParams, expectDeadlineOptions());
},
);
it.each([
{
description: "sandboxed",
config: { agents: { defaults: { sandbox: { mode: "all" as const } } } },
sessionKey: "sandboxed-session",
reason: "OpenClaw sandboxing is active for this session",
},
{
description: "node-hosted",
config: { tools: { exec: { host: "node" as const, node: "worker-1" } } },
sessionKey: "node-session",
reason: "OpenClaw exec host=node is active for this session",
},
])(
"fails closed for unlisted app methods in $description sessions",
async ({ config, sessionKey, reason }) => {
await expect(
requestCodexAppServerJson({
method: "app/activate",
requestParams: {},
config,
sessionKey,
}),
).rejects.toThrow(
`Codex-native app-server method \`app/activate\` is unavailable because ${reason}.`,
);
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
},
);
it("allows current native thread management methods in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ ok: true }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
@@ -355,6 +452,63 @@ describe("requestCodexAppServerJson sandbox guard", () => {
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("shares one guarded isolated client across installed-plugin and account reads", async () => {
const installed = { marketplaces: [], marketplaceLoadErrors: [] };
const account = { account: { email: "codex-source@example.com" } };
const request = vi.fn(async (method: string) =>
method === "plugin/installed" ? installed : account,
);
const closeAndWait = vi.fn(async () => undefined);
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue({
request,
closeAndWait,
});
const startOptions = {
transport: "stdio" as const,
command: "codex",
args: ["app-server", "--listen", "stdio://"],
headers: {},
env: { CODEX_HOME: "/source/.codex", HOME: "/source" },
};
await expect(
withCodexAppServerJsonClient(
{ timeoutMs: 5_000, startOptions, authProfileId: null, isolated: true },
async (scopedRequest) => ({
installed: await scopedRequest<typeof installed>({
method: "plugin/installed",
requestParams: { cwds: [] },
}),
account: await scopedRequest<typeof account>({
method: "account/read",
requestParams: { refreshToken: false },
}),
}),
),
).resolves.toEqual({ installed, account });
expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledTimes(1);
expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ startOptions, authProfileId: null }),
);
expect(request).toHaveBeenNthCalledWith(
1,
"plugin/installed",
{ cwds: [] },
expectDeadlineOptions(),
);
expect(request).toHaveBeenNthCalledWith(
2,
"account/read",
{ refreshToken: false },
expectDeadlineOptions(),
);
expect(closeAndWait).toHaveBeenCalledExactlyOnceWith({
exitTimeoutMs: 2_000,
forceKillDelayMs: 250,
});
});
it("reads usage and account identity over one isolated client", async () => {
const request = vi.fn(async (method: string) =>
method === "account/rateLimits/read"
+2 -2
View File
@@ -111,7 +111,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
);
}
type CodexAppServerScopedRequest = <T = JsonValue | undefined>(request: {
export type CodexAppServerScopedRequest = <T = JsonValue | undefined>(request: {
method: string;
requestParams?: unknown;
}) => Promise<T>;
@@ -200,7 +200,7 @@ async function readCodexAccountEmailBestEffort(
* isolated child) so related reads see the same app-server session. The whole
* callback re-runs once when the client's start selection changed underneath it.
*/
async function withCodexAppServerJsonClient<T>(
export async function withCodexAppServerJsonClient<T>(
params: {
timeoutMs?: number;
timeoutMessage?: string;
@@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { clearRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime";
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
import type { ExecApprovalsFile } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
@@ -17,6 +18,7 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import type { CodexAppServerClient } from "./client.js";
import * as codexRequirements from "./config-requirements.js";
import { dynamicToolBuildState } from "./dynamic-tool-build-state.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { nativeHookRelayUnregisterQueue } from "./native-hook-relay-state.js";
@@ -36,8 +38,22 @@ import {
createCodexTestToolTerminalObserver,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
import { codexWorkspaceDirCache } from "./workspace-dir-cache.js";
const execApprovalsRuntimeMocks = vi.hoisted(() => ({
loadExecApprovals: vi.fn<() => ExecApprovalsFile>(() => ({ version: 1, agents: {} })),
}));
vi.mock("openclaw/plugin-sdk/exec-approvals-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/exec-approvals-runtime")>();
return {
...actual,
loadExecApprovals: execApprovalsRuntimeMocks.loadExecApprovals,
};
});
export let tempDir: string;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
const multiplexedTestClients = new WeakSet<CodexAppServerClient>();
@@ -291,7 +307,7 @@ export function mockCall(mock: unknown, label: string, index = 0): unknown[] {
}
function getMockServerVersion() {
return "0.132.0";
return CODEX_APP_SERVER_VERSION;
}
export function getMockRuntimeIdentity() {
@@ -320,7 +336,7 @@ export function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -625,6 +641,13 @@ export function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTes
export function setupRunAttemptTestHooks(): void {
beforeEach(async () => {
// Machine-managed sandbox requirements must not leak into policy fixtures.
vi.spyOn(codexRequirements, "readCodexRequirementsToml").mockReturnValue(undefined);
// An uninitialized real host approvals store intentionally fails closed.
execApprovalsRuntimeMocks.loadExecApprovals.mockReset();
execApprovalsRuntimeMocks.loadExecApprovals.mockReturnValue({ version: 1, agents: {} });
defaultCodexAppInventoryCache.clear();
defaultCodexPluginMetadataCache.clear();
resetCodexTestBindingStore();
clearRuntimeAuthProfileStoreSnapshots();
vi.useRealTimers();
@@ -22,6 +22,7 @@ import {
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
// The keyed router, client runtime, and subagent monitor each add handlers on
// the physical client; single-slot mocks would keep only the last one.
@@ -99,7 +100,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -135,7 +136,7 @@ function turnStartResult(turnId = "turn-1") {
}
function getMockServerVersion() {
return "0.132.0";
return CODEX_APP_SERVER_VERSION;
}
function getMockRuntimeIdentity() {
@@ -37,6 +37,7 @@ import {
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
@@ -232,7 +233,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -268,7 +269,7 @@ function turnStartResult(turnId = "turn-1", status = "inProgress") {
}
function getMockServerVersion() {
return "0.132.0";
return CODEX_APP_SERVER_VERSION;
}
function getMockRuntimeIdentity() {
@@ -653,7 +653,7 @@ function googleCalendarAppInfo(isEnabled: boolean): v2.AppInfo {
};
}
const GOOGLE_CALENDAR_PLUGIN_LIST_RESULT = {
const GOOGLE_CALENDAR_PLUGIN_INSTALLED_RESULT = {
marketplaces: [
{
name: "openai-curated",
@@ -675,8 +675,12 @@ const GOOGLE_CALENDAR_PLUGIN_LIST_RESULT = {
},
],
marketplaceLoadErrors: [],
} satisfies v2.PluginInstalledResponse;
const GOOGLE_CALENDAR_PLUGIN_LIST_RESULT = {
...GOOGLE_CALENDAR_PLUGIN_INSTALLED_RESULT,
featuredPluginIds: [],
} as const;
} satisfies v2.PluginListResponse;
const GOOGLE_CALENDAR_PLUGIN_READ_RESULT = {
plugin: {
@@ -701,7 +705,7 @@ const GOOGLE_CALENDAR_PLUGIN_READ_RESULT = {
name: "Google Calendar",
description: null,
installUrl: null,
needsAuth: false,
category: null,
},
],
mcpServers: ["google-calendar"],
@@ -711,10 +715,24 @@ const GOOGLE_CALENDAR_PLUGIN_READ_RESULT = {
function createGoogleCalendarRequest(
appInventory?: (method: "app/installed" | "app/read") => unknown,
) {
return vi.fn(async (method: string) => {
let threadAppEnabled = false;
return vi.fn(async (method: string, params?: unknown) => {
if (method === "config/read") {
expect((params as { includeLayers?: boolean } | undefined)?.includeLayers).toBe(true);
return { config: {}, layers: [] };
}
if (
method === "app/installed" &&
typeof (params as { threadId?: unknown } | undefined)?.threadId === "string"
) {
return codexAppInventoryResponse("app/installed", [googleCalendarAppInfo(threadAppEnabled)]);
}
if ((method === "app/installed" || method === "app/read") && appInventory) {
return appInventory(method);
}
if (method === "plugin/installed") {
return GOOGLE_CALENDAR_PLUGIN_INSTALLED_RESULT;
}
if (method === "plugin/list") {
return GOOGLE_CALENDAR_PLUGIN_LIST_RESULT;
}
@@ -722,6 +740,9 @@ function createGoogleCalendarRequest(
return GOOGLE_CALENDAR_PLUGIN_READ_RESULT;
}
if (method === "thread/start") {
const config = (params as { config?: { apps?: Record<string, { enabled?: boolean }> } })
?.config;
threadAppEnabled = config?.apps?.["google-calendar-app"]?.enabled === true;
return threadStartResult("thread-1");
}
if (method === "turn/start") {
@@ -4226,8 +4247,8 @@ describe("runCodexAppServerAttempt", () => {
_meta: null,
});
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return {
if (method === "plugin/installed" || method === "plugin/list") {
const installed = {
marketplaces: [
{
name: "openai-bundled",
@@ -4247,8 +4268,10 @@ describe("runCodexAppServerAttempt", () => {
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
} satisfies v2.PluginInstalledResponse;
return method === "plugin/installed"
? installed
: ({ ...installed, featuredPluginIds: [] } satisfies v2.PluginListResponse);
}
if (method === "plugin/read") {
return {
@@ -4461,7 +4484,7 @@ describe("runCodexAppServerAttempt", () => {
expectedAppEnabled: true,
},
{
name: "does not expose a cached disabled app when runtime callability is unknown",
name: "provisionally enables a cached disabled plugin app after thread attestation",
cachedEnabled: false,
cacheKey: ({ appServer, agentDir }: GoogleCalendarCacheKeyInput) =>
buildCodexPluginAppCacheKey({
@@ -4471,8 +4494,8 @@ describe("runCodexAppServerAttempt", () => {
}),
appInventory: (method: "app/installed" | "app/read") =>
codexAppInventoryResponse(method, [googleCalendarAppInfo(false)]),
expectsAppInventory: true,
expectedAppEnabled: undefined,
expectsAppInventory: false,
expectedAppEnabled: true,
},
{
name: "keys plugin app inventory by inherited API key fallback credentials",
@@ -4527,13 +4550,32 @@ describe("runCodexAppServerAttempt", () => {
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(
expectedAppEnabled,
);
const globalAppInventoryRequests = requests.filter(
(entry) =>
entry.method === "app/installed" &&
typeof (entry.params as { threadId?: unknown } | undefined)?.threadId !== "string",
);
if (expectsAppInventory) {
expect(requests.map((entry) => entry.method)).toContain("app/installed");
expect(globalAppInventoryRequests).not.toHaveLength(0);
expect(requests.map((entry) => entry.method)).toContain("app/read");
} else {
expect(requests.map((entry) => entry.method)).not.toContain("app/installed");
expect(globalAppInventoryRequests).toHaveLength(0);
expect(requests.map((entry) => entry.method)).not.toContain("app/read");
}
if (!cachedEnabled) {
expect(
requests.filter(
(entry) =>
entry.method === "app/installed" &&
typeof (entry.params as { threadId?: unknown } | undefined)?.threadId === "string",
),
).toEqual([
expect.objectContaining({
method: "app/installed",
params: { threadId: "thread-1", forceRefresh: false },
}),
]);
}
},
);
@@ -12,6 +12,7 @@ import {
readCodexAppServerClientRuntimeArtifact,
validateCodexAppServerRuntimeArtifact,
} from "./runtime-artifact.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
function startOptions(
command: string,
@@ -51,7 +52,7 @@ async function captureBinding(params: {
before,
startOptions: params.options,
spawnIdentity: identity,
runtimeIdentity: { serverVersion: "0.144.1", userAgent: "codex-test" },
runtimeIdentity: { serverVersion: CODEX_APP_SERVER_VERSION, userAgent: "codex-test" },
});
bindCodexAppServerRuntimeArtifact(client, binding);
return { binding, client };
@@ -210,7 +211,7 @@ describe("Codex app-server runtime artifact", () => {
before,
startOptions: options,
spawnIdentity: identity,
runtimeIdentity: { serverVersion: "0.144.1" },
runtimeIdentity: { serverVersion: CODEX_APP_SERVER_VERSION },
}),
).rejects.toThrow("changed during startup");
});
@@ -0,0 +1,532 @@
// Covers bounded Codex sandbox file streams, handles, and cancellation.
import { afterEach, describe, expect, it, vi } from "vitest";
import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js";
import { ensureCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js";
import {
codexFsSandboxContext,
createClient,
createSandboxContext,
execServerUrlFromClient,
globPath,
openSocket,
rpc,
specialPath,
waitForSocketClose,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await sandboxExecServerRegistry.closeAll();
});
describe("OpenClaw Codex sandbox exec-server filesystem streaming", () => {
it("streams sandbox files through connection-owned Codex file handles", async () => {
const data = Buffer.from("0123456789");
const readFile = vi.fn(async () => data);
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: data.byteLength, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/open", {
handleId: "stream-1",
path: "file:///workspace/attachment.txt",
}),
).resolves.toEqual({ handleId: "stream-1" });
for (const [offset, len, expected, eof] of [
[6, 3, "678", false],
[1, 2, "12", false],
[8, 4, "89", true],
[8, 2, "89", true],
[0, 2, "01", false],
[0, 10, "0123456789", true],
[10, 2, "", true],
] as const) {
await expect(
rpc(socket, "fs/readBlock", { handleId: "stream-1", offset, len }),
).resolves.toEqual({
chunk: Buffer.from(expected).toString("base64"),
eof,
});
}
expect(readFile).toHaveBeenCalledTimes(1);
expect(readFile).toHaveBeenCalledWith({
filePath: "/workspace/attachment.txt",
maxBytes: data.byteLength,
signal: expect.any(AbortSignal),
});
await expect(rpc(socket, "fs/close", { handleId: "stream-1" })).resolves.toEqual({});
await expect(rpc(socket, "fs/close", { handleId: "stream-1" })).resolves.toEqual({});
await expect(
rpc(socket, "fs/readBlock", { handleId: "stream-1", offset: 0, len: 1 }),
).rejects.toMatchObject({ code: -32004 });
socket.close();
});
it("isolates file handles between authenticated exec-server connections", async () => {
const readFile = vi.fn(async ({ filePath }: { filePath: string }) =>
Buffer.from(filePath.endsWith("first.txt") ? "first" : "second"),
);
const sandbox = createSandboxContext({
readFile,
stat: async ({ filePath }) => ({
type: "file",
size: filePath.endsWith("first.txt") ? 5 : 6,
mtimeMs: 1,
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const first = await openSocket(execServerUrlFromClient(client));
const second = await openSocket(execServerUrlFromClient(client));
for (const socket of [first, second]) {
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
}
await rpc(first, "fs/open", {
handleId: "shared-id",
path: "file:///workspace/first.txt",
});
await rpc(second, "fs/open", {
handleId: "shared-id",
path: "file:///workspace/second.txt",
});
await expect(
rpc(first, "fs/readBlock", { handleId: "shared-id", offset: 0, len: 6 }),
).resolves.toEqual({ chunk: Buffer.from("first").toString("base64"), eof: true });
await expect(
rpc(second, "fs/readBlock", { handleId: "shared-id", offset: 0, len: 7 }),
).resolves.toEqual({ chunk: Buffer.from("second").toString("base64"), eof: true });
await rpc(first, "fs/close", { handleId: "shared-id" });
await expect(
rpc(second, "fs/readBlock", { handleId: "shared-id", offset: 1, len: 2 }),
).resolves.toEqual({ chunk: Buffer.from("ec").toString("base64"), eof: false });
first.close();
second.close();
});
it("enforces sandbox read policy before opening a streamed file", async () => {
const readFile = vi.fn(async () => Buffer.from("secret"));
const sandbox = createSandboxContext({ readFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/open", {
handleId: "denied",
path: "file:///workspace/private/secret.txt",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: globPath("private/*.txt"), access: "deny" },
],
}),
}),
).rejects.toThrow("Codex fs sandbox denied read access");
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("rejects duplicate, oversized, and invalid sandbox file read handles", async () => {
const data = Buffer.from("bounded");
const sandbox = createSandboxContext({
readFile: async () => data,
stat: async () => ({ type: "file", size: data.byteLength, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const path = "file:///workspace/bounded.txt";
await rpc(socket, "fs/open", { handleId: "bounded", path });
await expect(rpc(socket, "fs/open", { handleId: "bounded", path })).rejects.toMatchObject({
code: -32600,
});
await expect(rpc(socket, "fs/open", { handleId: "x".repeat(33), path })).rejects.toMatchObject({
code: -32600,
});
for (const params of [
{ handleId: "bounded", offset: -1, len: 1 },
{ handleId: "bounded", offset: 0, len: 0 },
{ handleId: "bounded", offset: 0, len: 1024 * 1024 + 1 },
]) {
await expect(rpc(socket, "fs/readBlock", params)).rejects.toMatchObject({ code: -32600 });
}
await expect(
rpc(socket, "fs/readBlock", { handleId: "missing", offset: 0, len: 1 }),
).rejects.toMatchObject({ code: -32004 });
socket.close();
});
it("rejects buffered sandbox file handles above the connection memory budget", async () => {
const readFile = vi.fn(async () => Buffer.from("not read"));
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: 64 * 1024 * 1024 + 1, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/open", { handleId: "oversized", path: "file:///workspace/oversized.bin" }),
).rejects.toMatchObject({
code: -32600,
message: "sandbox file read exceeds the per-connection buffered file limit",
});
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("bounds grown sandbox files to their pre-reserved size and releases failed handles", async () => {
const readFile = vi
.fn(async (_params: { filePath: string; maxBytes?: number }) => Buffer.from("small"))
.mockResolvedValueOnce(Buffer.from("grown!"));
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: 5, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const params = { handleId: "growing", path: "file:///workspace/growing.txt" };
await expect(rpc(socket, "fs/open", params)).rejects.toMatchObject({
code: -32600,
message: "sandbox file read exceeds the per-connection buffered file limit",
});
expect(readFile).toHaveBeenNthCalledWith(1, {
filePath: "/workspace/growing.txt",
maxBytes: 5,
signal: expect.any(AbortSignal),
});
await expect(rpc(socket, "fs/open", params)).resolves.toEqual({ handleId: "growing" });
await expect(
rpc(socket, "fs/readBlock", { handleId: "growing", offset: 0, len: 5 }),
).resolves.toEqual({ chunk: Buffer.from("small").toString("base64"), eof: true });
socket.close();
});
it("reserves concurrent sandbox file reads before any asynchronous bridge read", async () => {
const halfBudget = 32 * 1024 * 1024;
const releaseReads = new Map<string, (data: Buffer) => void>();
const readFile = vi.fn(
({ filePath }: { filePath: string; maxBytes?: number }) =>
new Promise<Buffer>((resolve) => {
releaseReads.set(filePath, resolve);
}),
);
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: halfBudget, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const first = rpc(socket, "fs/open", {
handleId: "first",
path: "file:///workspace/first.txt",
});
const second = rpc(socket, "fs/open", {
handleId: "second",
path: "file:///workspace/second.txt",
});
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalledTimes(2);
});
expect(readFile).toHaveBeenNthCalledWith(1, {
filePath: "/workspace/first.txt",
maxBytes: halfBudget,
signal: expect.any(AbortSignal),
});
expect(readFile).toHaveBeenNthCalledWith(2, {
filePath: "/workspace/second.txt",
maxBytes: halfBudget,
signal: expect.any(AbortSignal),
});
await expect(
rpc(socket, "fs/open", {
handleId: "overflow",
path: "file:///workspace/overflow.txt",
}),
).rejects.toMatchObject({
code: -32600,
message: "sandbox file read exceeds the per-connection buffered file limit",
});
expect(readFile).toHaveBeenCalledTimes(2);
releaseReads.get("/workspace/first.txt")?.(Buffer.from("first"));
releaseReads.get("/workspace/second.txt")?.(Buffer.from("second"));
await expect(first).resolves.toEqual({ handleId: "first" });
await expect(second).resolves.toEqual({ handleId: "second" });
socket.close();
});
it("caps pending sandbox stats and retains their slots until cancellation settles", async () => {
const releaseStats = new Map<
string,
(stat: { type: "file"; size: number; mtimeMs: number }) => void
>();
const signals: AbortSignal[] = [];
const stat = vi.fn(
({ filePath, signal }: { filePath: string; signal?: AbortSignal }) =>
new Promise<{ type: "file"; size: number; mtimeMs: number }>((resolve) => {
if (signal) {
signals.push(signal);
}
releaseStats.set(filePath, resolve);
}),
);
const readFile = vi.fn(async () => Buffer.from("never read"));
const sandbox = createSandboxContext({ readFile, stat });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const responses = new Map<number, { error?: { code: number } }>();
socket.on("message", (data) => {
const response = JSON.parse(Buffer.from(data as Buffer).toString("utf8")) as {
id?: number;
error?: { code: number };
};
if (typeof response.id === "number") {
responses.set(response.id, response);
}
});
for (let index = 0; index < 128; index += 1) {
socket.send(
JSON.stringify({
id: 20_000 + index,
method: "fs/open",
params: {
handleId: `stat-${index}`,
path: `file:///workspace/stat-${index}.bin`,
},
}),
);
}
await vi.waitFor(() => {
expect(stat).toHaveBeenCalledTimes(128);
});
expect(signals).toHaveLength(128);
expect(readFile).not.toHaveBeenCalled();
await expect(rpc(socket, "fs/close", { handleId: "stat-0" })).resolves.toEqual({});
expect(signals[0]?.aborted).toBe(true);
await expect(
rpc(socket, "fs/open", {
handleId: "stat-0",
path: "file:///workspace/reused.bin",
}),
).rejects.toMatchObject({ code: -32600 });
await expect(
rpc(socket, "fs/open", {
handleId: "stat-overflow",
path: "file:///workspace/overflow.bin",
}),
).rejects.toMatchObject({
code: -32600,
message: "at most 128 file reads may be open per connection",
});
expect(stat).toHaveBeenCalledTimes(128);
expect(readFile).not.toHaveBeenCalled();
releaseStats.get("/workspace/stat-0.bin")?.({ type: "file", size: 1, mtimeMs: 1 });
await vi.waitFor(() => {
expect(responses.get(20_000)?.error?.code).toBe(-32004);
});
socket.send(
JSON.stringify({
id: 30_000,
method: "fs/open",
params: { handleId: "stat-0", path: "file:///workspace/recovered.bin" },
}),
);
await vi.waitFor(() => {
expect(stat).toHaveBeenCalledTimes(129);
});
expect(readFile).not.toHaveBeenCalled();
const disconnected = waitForSocketClose(socket);
socket.close();
await disconnected;
await vi.waitFor(() => {
expect(signals).toHaveLength(129);
expect(signals.every((signal) => signal.aborted)).toBe(true);
});
for (const releaseStat of releaseStats.values()) {
releaseStat({ type: "file", size: 1, mtimeMs: 1 });
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(readFile).not.toHaveBeenCalled();
});
it("retains closed in-flight reservations until their sandbox reads settle", async () => {
const halfBudget = 32 * 1024 * 1024;
const releaseReads = new Map<string, (data: Buffer) => void>();
const readFile = vi.fn(
({ filePath }: { filePath: string; maxBytes?: number; signal?: AbortSignal }) =>
new Promise<Buffer>((resolve) => {
releaseReads.set(filePath, resolve);
}),
);
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: halfBudget, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const first = rpc(socket, "fs/open", {
handleId: "first",
path: "file:///workspace/first.txt",
});
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalledTimes(1);
});
const firstSignal = readFile.mock.calls[0]?.[0].signal;
await expect(rpc(socket, "fs/close", { handleId: "first" })).resolves.toEqual({});
expect(firstSignal?.aborted).toBe(true);
await expect(
rpc(socket, "fs/readBlock", { handleId: "first", offset: 0, len: 1 }),
).rejects.toMatchObject({ code: -32004 });
await expect(
rpc(socket, "fs/open", {
handleId: "first",
path: "file:///workspace/first.txt",
}),
).rejects.toMatchObject({ code: -32600 });
const second = rpc(socket, "fs/open", {
handleId: "second",
path: "file:///workspace/second.txt",
});
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalledTimes(2);
});
await expect(rpc(socket, "fs/close", { handleId: "second" })).resolves.toEqual({});
expect(readFile.mock.calls[1]?.[0].signal?.aborted).toBe(true);
await expect(
rpc(socket, "fs/open", {
handleId: "overflow",
path: "file:///workspace/overflow.txt",
}),
).rejects.toMatchObject({
code: -32600,
message: "sandbox file read exceeds the per-connection buffered file limit",
});
expect(readFile).toHaveBeenCalledTimes(2);
const firstSettled = expect(first).rejects.toMatchObject({ code: -32004 });
releaseReads.get("/workspace/first.txt")?.(Buffer.from("first"));
await firstSettled;
const secondSettled = expect(second).rejects.toMatchObject({ code: -32004 });
releaseReads.get("/workspace/second.txt")?.(Buffer.from("second"));
await secondSettled;
const recovered = rpc(socket, "fs/open", {
handleId: "first",
path: "file:///workspace/first.txt",
});
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalledTimes(3);
});
releaseReads.get("/workspace/first.txt")?.(Buffer.from("first"));
await expect(recovered).resolves.toEqual({ handleId: "first" });
socket.close();
});
it("aborts pending reads and never starts new sandbox reads after socket disconnect", async () => {
let releaseRead: ((data: Buffer) => void) | undefined;
let releaseStat: ((stat: { type: "file"; size: number; mtimeMs: number }) => void) | undefined;
const readFile = vi.fn(
(_params: { filePath: string; maxBytes?: number; signal?: AbortSignal }) =>
new Promise<Buffer>((resolve) => {
releaseRead = resolve;
}),
);
const stat = vi.fn(({ filePath }: { filePath: string }) => {
if (filePath.endsWith("stat-pending.txt")) {
return new Promise<{ type: "file"; size: number; mtimeMs: number }>((resolve) => {
releaseStat = resolve;
});
}
return Promise.resolve({ type: "file" as const, size: 5, mtimeMs: 1 });
});
const sandbox = createSandboxContext({ readFile, stat });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
socket.send(
JSON.stringify({
id: 1,
method: "fs/open",
params: { handleId: "pending", path: "file:///workspace/pending.txt" },
}),
);
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalledTimes(1);
});
const signal = readFile.mock.calls[0]?.[0].signal;
expect(signal?.aborted).toBe(false);
socket.send(
JSON.stringify({
id: 2,
method: "fs/open",
params: { handleId: "stat-pending", path: "file:///workspace/stat-pending.txt" },
}),
);
await vi.waitFor(() => {
expect(stat).toHaveBeenCalledTimes(2);
});
const disconnected = waitForSocketClose(socket);
socket.close();
await disconnected;
// The client can close before the server observes and cancels the peer connection.
await vi.waitFor(() => {
expect(signal?.aborted).toBe(true);
});
releaseStat?.({ type: "file", size: 5, mtimeMs: 1 });
releaseRead?.(Buffer.from("small"));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(readFile).toHaveBeenCalledTimes(1);
});
});
@@ -19,6 +19,29 @@ afterEach(async () => {
});
describe("OpenClaw Codex sandbox exec-server filesystem", () => {
it("returns the required Codex file size in sandbox metadata", async () => {
const sandbox = createSandboxContext({
stat: async () => ({ type: "file", size: 1234, mtimeMs: 5678 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/getMetadata", { path: "file:///workspace/attachment.txt" }),
).resolves.toEqual({
isDirectory: false,
isFile: true,
isSymlink: false,
size: 1234,
createdAtMs: 0,
modifiedAtMs: 5678,
});
socket.close();
});
it("routes file writes through the sandbox fs bridge", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
@@ -427,6 +450,41 @@ describe("OpenClaw Codex sandbox exec-server filesystem", () => {
socket.close();
});
it("bounds buffered file copies when a sandbox bridge cannot stream them", async () => {
const data = Buffer.from("copy me");
const readFile = vi.fn(async () => data);
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: data.byteLength, mtimeMs: 1 }),
writeFile,
});
if (sandbox.fsBridge) {
sandbox.fsBridge.copyFile = undefined;
}
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/copy", {
sourcePath: "file:///workspace/source.txt",
destinationPath: "file:///workspace/destination.txt",
});
expect(readFile).toHaveBeenCalledWith({
filePath: "/workspace/source.txt",
maxBytes: 512 * 1024 * 1024,
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/destination.txt",
data,
mkdir: true,
});
socket.close();
});
it("rejects recursive directory copies into their own subtree", async () => {
const mkdirp = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
@@ -475,6 +533,29 @@ describe("OpenClaw Codex sandbox exec-server filesystem", () => {
socket.close();
});
it("bounds legacy whole-file reads within the sandbox filesystem bridge", async () => {
const data = Buffer.from("bounded legacy read");
const readFile = vi.fn(async () => data);
const sandbox = createSandboxContext({
readFile,
stat: async () => ({ type: "file", size: data.byteLength, mtimeMs: 1 }),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/readFile", { path: "file:///workspace/note.txt" }),
).resolves.toEqual({ dataBase64: data.toString("base64") });
expect(readFile).toHaveBeenCalledWith({
filePath: "/workspace/note.txt",
maxBytes: 512 * 1024 * 1024,
});
socket.close();
});
it("rejects oversized file reads before buffering through the fs bridge", async () => {
const readFile = vi.fn(async () => Buffer.from("too-large"));
const sandbox = createSandboxContext({
@@ -5,11 +5,12 @@
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { vi } from "vitest";
import WebSocket from "ws";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
type RpcResponse = {
id: number;
result?: unknown;
error?: { message: string };
error?: { code: number; message: string };
};
/** Builds a minimal enabled sandbox context with overridable backend and fs bridge hooks. */
@@ -82,7 +83,7 @@ export function createSandboxContext(overrides: {
/** Creates a fake Codex app-server client with a configurable server version. */
export function createClient(options: { serverVersion?: string } = {}) {
return {
getServerVersion: vi.fn(() => options.serverVersion ?? "0.132.0"),
getServerVersion: vi.fn(() => options.serverVersion ?? CODEX_APP_SERVER_VERSION),
request: vi.fn(async (_method: string, _params?: unknown) => ({})),
};
}
@@ -242,7 +243,7 @@ export function rpc(socket: WebSocket, method: string, params: unknown): Promise
}
socket.off("message", onMessage);
if (response.error) {
reject(new Error(response.error.message));
reject(Object.assign(new Error(response.error.message), { code: response.error.code }));
return;
}
resolve(response.result);
@@ -5,6 +5,7 @@ import {
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
const CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES = 100 * 1024 * 1024;
import {
@@ -47,7 +48,7 @@ describe("OpenClaw Codex sandbox exec-server", () => {
it("reports unavailable app-server remote environment support without exposing an environment", async () => {
const sandbox = createSandboxContext({});
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
getServerVersion: vi.fn(() => CODEX_APP_SERVER_VERSION),
request: vi.fn(async () => {
throw new Error("unknown variant environment/add");
}),
@@ -61,6 +62,40 @@ describe("OpenClaw Codex sandbox exec-server", () => {
).resolves.toBeUndefined();
});
it.each([
{ containerWorkdir: "/workspace", cwd: "file:///workspace" },
{
containerWorkdir: "/workspace/space #project",
cwd: "file:///workspace/space%20%23project",
},
])(
"reports target shell, encoded workdir, and readiness for $containerWorkdir",
async ({ containerWorkdir, cwd }) => {
const sandbox = createSandboxContext({});
sandbox.containerWorkdir = containerWorkdir;
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "environment/info", {})).resolves.toEqual({
shell: { name: "sh", path: "/bin/sh" },
cwd,
capabilities: { networkProxyLaunch: false },
});
await expect(rpc(socket, "environment/status", {})).resolves.toEqual({
status: "ready",
});
socket.close();
},
);
it("does not advertise a local exec-server URL to remote app-servers", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
@@ -112,7 +147,7 @@ describe("OpenClaw Codex sandbox exec-server", () => {
const sandbox = createSandboxContext({ buildExecSpec });
const requests: Array<{ method: string; params: unknown }> = [];
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
getServerVersion: vi.fn(() => CODEX_APP_SERVER_VERSION),
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
return {};
@@ -503,6 +538,36 @@ describe("OpenClaw Codex sandbox exec-server", () => {
socket.close();
});
it("distinguishes unsupported exec methods from missing filesystem resources", async () => {
const sandbox = createSandboxContext({ stat: async () => null });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
for (const method of ["fs/walk", "process/signal", "unsupported/method"]) {
await expect(rpc(socket, method, {})).rejects.toMatchObject({
code: -32601,
message: `Unsupported OpenClaw sandbox exec-server method: ${method}`,
});
}
await expect(
rpc(socket, "fs/getMetadata", { path: "file:///workspace/missing" }),
).rejects.toMatchObject({
code: -32004,
message: "file not found",
});
await expect(rpc(socket, "environment/status", {})).resolves.toEqual({
status: "ready",
});
socket.close();
});
it("rejects WebSocket clients that do not know the exec-server capability path", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
@@ -6,6 +6,7 @@ import { createHash, randomUUID } from "node:crypto";
import { once } from "node:events";
import type { IncomingMessage } from "node:http";
import { isIP, type AddressInfo } from "node:net";
import { pathToFileURL } from "node:url";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
@@ -14,16 +15,22 @@ import type { CodexAppServerStartOptions } from "./config.js";
import type { JsonValue } from "./protocol.js";
import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js";
import {
closeAllFileReads,
closeFile,
type CodexSandboxFileReadHandles,
createDirectory,
copyPath,
getMetadata,
openFile,
readDirectory,
readFile,
readFileBlock,
removePath,
writeFile,
} from "./sandbox-exec-server/filesystem.js";
import { httpRequest } from "./sandbox-exec-server/http.js";
import {
JSON_RPC_METHOD_NOT_FOUND,
JsonRpcProtocolError,
parseRequest,
sendError,
@@ -252,12 +259,14 @@ function isAuthorizedExecServerRequest(
function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): void {
const processes = new Map<string, ManagedProcess>();
const fileReads: CodexSandboxFileReadHandles = new Map();
socket.on("message", (data) => {
void handleMessage(execServer, processes, socket, data).catch((error: unknown) => {
void handleMessage(execServer, processes, fileReads, socket, data).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox exec-server message failed", { error });
});
});
socket.on("close", () => {
closeAllFileReads(fileReads);
for (const process of processes.values()) {
process.abortController.abort();
}
@@ -271,6 +280,7 @@ function handleExecServerSocketError(error: unknown): void {
async function handleMessage(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
fileReads: CodexSandboxFileReadHandles,
socket: WebSocket,
data: RawData,
): Promise<void> {
@@ -287,7 +297,10 @@ async function handleMessage(
return;
}
try {
const result = await dispatchRequest(execServer, processes, socket, { ...request, method });
const result = await dispatchRequest(execServer, processes, fileReads, socket, {
...request,
method,
});
sendResult(socket, request.id, result);
} catch (error) {
sendError(
@@ -302,12 +315,22 @@ async function handleMessage(
async function dispatchRequest(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
fileReads: CodexSandboxFileReadHandles,
socket: WebSocket,
request: Required<Pick<JsonRpcRequest, "method">> & Pick<JsonRpcRequest, "id" | "params">,
): Promise<JsonValue | undefined> {
switch (request.method) {
case "initialize":
return { sessionId: randomUUID() };
case "environment/info":
// The shell and cwd describe the sandbox target, not the Gateway host.
return {
shell: { name: "sh", path: "/bin/sh" },
cwd: pathToFileURL(execServer.sandbox.containerWorkdir, { windows: false }).href,
capabilities: { networkProxyLaunch: false },
};
case "environment/status":
return { status: "ready" };
// These method names are the Codex exec-server remote-environment RPCs.
// The app-server process-control surface uses different names such as
// process/spawn, but those are not sent to registered exec-server URLs.
@@ -319,6 +342,12 @@ async function dispatchRequest(
return writeProcess(processes, request.params);
case "process/terminate":
return terminateProcess(processes, request.params);
case "fs/open":
return await openFile(execServer, fileReads, request.params);
case "fs/readBlock":
return readFileBlock(fileReads, request.params);
case "fs/close":
return closeFile(fileReads, request.params);
case "fs/readFile":
return await readFile(execServer, request.params);
case "fs/writeFile":
@@ -340,6 +369,9 @@ async function dispatchRequest(
case "http/request":
return await httpRequest(execServer, socket, request.params);
default:
throw new Error(`Unsupported OpenClaw sandbox exec-server method: ${request.method}`);
throw new JsonRpcProtocolError(
JSON_RPC_METHOD_NOT_FOUND,
`Unsupported OpenClaw sandbox exec-server method: ${request.method}`,
);
}
}
@@ -18,6 +18,7 @@ import {
JSON_RPC_NOT_FOUND,
JsonRpcProtocolError,
requireBase64String,
requireNumber,
requireObject,
requireString,
} from "./json-rpc.js";
@@ -26,6 +27,198 @@ import { requireBackend, requireFsBridge } from "./runtime.js";
import type { DirectoryEntry, OpenClawExecServer, ResolvedFsSandboxPolicy } from "./types.js";
const CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES = 512 * 1024 * 1024;
const CODEX_SANDBOX_EXEC_SERVER_MAX_OPEN_FILE_READS = 128;
const CODEX_SANDBOX_EXEC_SERVER_MAX_BUFFERED_FILE_READ_BYTES = 64 * 1024 * 1024;
const CODEX_SANDBOX_EXEC_SERVER_MAX_READ_BLOCK_BYTES = 1024 * 1024;
const CODEX_SANDBOX_EXEC_SERVER_MAX_FILE_READ_HANDLE_ID_BYTES = 32;
type CodexSandboxFileReadHandle = {
abortController: AbortController;
closeRequested: boolean;
reservedBytes: number;
data?: Buffer;
};
/** Sandboxed file contents and pending reservations owned by one connection. */
export type CodexSandboxFileReadHandles = Map<string, CodexSandboxFileReadHandle> & {
closed?: boolean;
};
/** Opens a policy-checked sandbox file under a bounded, connection-owned handle. */
export async function openFile(
execServer: OpenClawExecServer,
handles: CodexSandboxFileReadHandles,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/open params");
const handleId = requireFileReadHandleId(record.handleId);
if (handles.closed) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, `unknown file read handle \`${handleId}\``);
}
if (handles.has(handleId)) {
throw new JsonRpcProtocolError(-32600, `file read handle \`${handleId}\` already exists`);
}
if (handles.size >= CODEX_SANDBOX_EXEC_SERVER_MAX_OPEN_FILE_READS) {
throw new JsonRpcProtocolError(
-32600,
`at most ${CODEX_SANDBOX_EXEC_SERVER_MAX_OPEN_FILE_READS} file reads may be open per connection`,
);
}
const filePath = resolveExecServerPath(requireString(record.path, "path"), "read path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
// Claim the handle before even stat so slow or cancelled stats cannot bypass
// the connection's handle cap or lose their cancellation and ownership.
const handle: CodexSandboxFileReadHandle = {
abortController: new AbortController(),
closeRequested: false,
reservedBytes: 0,
};
handles.set(handleId, handle);
try {
const stat = await fsBridge.stat({ filePath, signal: handle.abortController.signal });
if (handles.get(handleId) !== handle || handle.closeRequested || handles.closed) {
throw new JsonRpcProtocolError(
JSON_RPC_NOT_FOUND,
`unknown file read handle \`${handleId}\``,
);
}
if (!stat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
if (stat.type !== "file") {
throw new JsonRpcProtocolError(-32600, "file read handle requires a regular file");
}
if (!Number.isSafeInteger(stat.size) || stat.size < 0) {
throw new JsonRpcProtocolError(-32600, "file size must be a non-negative safe integer");
}
if (
stat.size >
CODEX_SANDBOX_EXEC_SERVER_MAX_BUFFERED_FILE_READ_BYTES - bufferedFileReadBytes(handles)
) {
throw new JsonRpcProtocolError(
-32600,
"sandbox file read exceeds the per-connection buffered file limit",
);
}
// Charge actual bytes before awaiting the bounded read; concurrent stats
// cannot overbook memory, even when a backend ignores cancellation.
handle.reservedBytes = stat.size;
const data = await fsBridge.readFile({
filePath,
maxBytes: handle.reservedBytes,
signal: handle.abortController.signal,
});
if (handles.get(handleId) !== handle || handle.closeRequested || handles.closed) {
throw new JsonRpcProtocolError(
JSON_RPC_NOT_FOUND,
`unknown file read handle \`${handleId}\``,
);
}
if (data.byteLength > handle.reservedBytes) {
throw new JsonRpcProtocolError(
-32600,
"sandbox file read exceeds the per-connection buffered file limit",
);
}
handle.reservedBytes = data.byteLength;
handle.data = data;
return { handleId };
} catch (error) {
if (handles.get(handleId) === handle) {
handles.delete(handleId);
}
throw error;
}
}
/** Reads a bounded base64 block from a handle belonging to this connection. */
export function readFileBlock(
handles: CodexSandboxFileReadHandles,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "fs/readBlock params");
const handleId = requireFileReadHandleId(record.handleId);
const handle = handles.get(handleId);
if (!handle?.data) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, `unknown file read handle \`${handleId}\``);
}
const offset = requireNumber(record.offset, "offset");
const length = requireNumber(record.len, "len");
if (!Number.isSafeInteger(offset) || offset < 0) {
throw new JsonRpcProtocolError(-32600, "file read offset must be a non-negative safe integer");
}
if (
!Number.isSafeInteger(length) ||
length < 1 ||
length > CODEX_SANDBOX_EXEC_SERVER_MAX_READ_BLOCK_BYTES
) {
throw new JsonRpcProtocolError(
-32600,
`file read block length must be between 1 and ${CODEX_SANDBOX_EXEC_SERVER_MAX_READ_BLOCK_BYTES}`,
);
}
const chunk = handle.data.subarray(offset, Math.min(offset + length, handle.data.byteLength));
// The complete buffered file makes exactly filled final blocks terminal without another RPC.
return {
chunk: chunk.toString("base64"),
eof: offset + chunk.byteLength >= handle.data.byteLength,
};
}
/** Closes one connection-owned file handle; repeated closes are harmless. */
export function closeFile(
handles: CodexSandboxFileReadHandles,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "fs/close params");
closeFileReadHandle(handles, requireFileReadHandleId(record.handleId));
return {};
}
/** Cancels a disconnected socket without releasing unsettled read reservations. */
export function closeAllFileReads(handles: CodexSandboxFileReadHandles): void {
handles.closed = true;
for (const handleId of handles.keys()) {
closeFileReadHandle(handles, handleId);
}
}
function closeFileReadHandle(handles: CodexSandboxFileReadHandles, handleId: string): void {
const handle = handles.get(handleId);
if (!handle) {
return;
}
handle.closeRequested = true;
if (handle.data !== undefined) {
handles.delete(handleId);
return;
}
// A backend may not honor abort; retain its reservation until readFile settles.
handle.abortController.abort();
}
function bufferedFileReadBytes(handles: CodexSandboxFileReadHandles): number {
let total = 0;
for (const handle of handles.values()) {
total += handle.reservedBytes;
}
return total;
}
function requireFileReadHandleId(value: unknown): string {
const handleId = requireString(value, "handleId");
if (
Buffer.byteLength(handleId, "utf8") > CODEX_SANDBOX_EXEC_SERVER_MAX_FILE_READ_HANDLE_ID_BYTES
) {
throw new JsonRpcProtocolError(
-32600,
`file read handle ID must not exceed ${CODEX_SANDBOX_EXEC_SERVER_MAX_FILE_READ_HANDLE_ID_BYTES} bytes`,
);
}
return handleId;
}
/** Reads a sandbox file as base64 after read-policy and size checks. */
export async function readFile(
@@ -43,6 +236,7 @@ export async function readFile(
assertSandboxFileReadWithinLimit(stat);
const data = await fsBridge.readFile({
filePath,
maxBytes: CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES,
});
return { dataBase64: data.toString("base64") };
}
@@ -268,7 +462,10 @@ async function copySandboxPath(
// Shipped third-party bridges may not expose streaming copy yet. Keep their
// buffered fallback bounded while built-in bridges use the path-native copy above.
assertSandboxFileReadWithinLimit(sourceStat);
const data = await fsBridge.readFile({ filePath: params.sourcePath });
const data = await fsBridge.readFile({
filePath: params.sourcePath,
maxBytes: CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES,
});
await fsBridge.writeFile({
filePath: params.destinationPath,
data,
@@ -289,6 +486,7 @@ function metadataResponse(stat: SandboxFsStat | null): JsonObject {
isDirectory: stat?.type === "directory",
isFile: stat?.type === "file",
isSymlink: false,
size: stat?.size ?? 0,
createdAtMs: 0,
modifiedAtMs: stat?.mtimeMs ?? 0,
};
@@ -6,9 +6,12 @@ import type { RawData, WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import type { HttpHeader, JsonRpcRequest } from "./types.js";
/** JSON-RPC error code used when a sandbox exec-server method is unknown. */
/** JSON-RPC error code used when a sandbox filesystem resource does not exist. */
export const JSON_RPC_NOT_FOUND = -32004;
/** JSON-RPC error code used when a sandbox exec-server method is unsupported. */
export const JSON_RPC_METHOD_NOT_FOUND = -32601;
/** Protocol-level error carrying the JSON-RPC error code to send to the client. */
export class JsonRpcProtocolError extends Error {
constructor(
@@ -20,6 +20,7 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["app/installed", "allowed-control-plane"],
["app/list", "allowed-control-plane"],
["app/read", "allowed-control-plane"],
["config/batchWrite", "allowed-control-plane"],
["config/mcpServer/reload", "allowed-control-plane"],
["config/read", "allowed-control-plane"],
["config/value/write", "allowed-control-plane"],
@@ -32,6 +33,7 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["mcpServerStatus/list", "allowed-control-plane"],
["model/list", "allowed-control-plane"],
["plugin/install", "allowed-control-plane"],
["plugin/installed", "allowed-control-plane"],
["plugin/list", "allowed-control-plane"],
["plugin/read", "allowed-control-plane"],
["skills/list", "allowed-control-plane"],
@@ -76,7 +76,7 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -9,6 +9,7 @@ import type { CodexAppServerStartOptions } from "./config.js";
import { acquireCodexNativeConfigFence } from "./native-config-fence.js";
import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js";
import { createClientHarness } from "./test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
@@ -257,7 +258,7 @@ describe("shared Codex app-server client", () => {
await sendInitializeResult(harness, "openclaw/0.117.9 (macOS; test)");
await expect(listPromise).rejects.toThrow(
"A stable Codex app-server from 0.143.0 through 0.145.0 is required",
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
);
expect(harness.process.stdin.destroyed).toBe(true);
startSpy.mockRestore();
@@ -323,7 +324,7 @@ describe("shared Codex app-server client", () => {
const options = { config, startOptions, timeoutMs: 1_000 };
const firstAcquire = getLeasedSharedCodexAppServerClient(options);
await sendInitializeResult(first, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (Linux; test)");
await expect(firstAcquire).resolves.toBe(first.client);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledOnce();
@@ -343,7 +344,7 @@ describe("shared Codex app-server client", () => {
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
const replacementAcquire = getLeasedSharedCodexAppServerClient(options);
await sendInitializeResult(replacement, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(replacement, "openclaw/0.146.0 (Linux; test)");
await expect(replacementAcquire).resolves.toBe(replacement.client);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledTimes(3);
@@ -394,7 +395,7 @@ describe("shared Codex app-server client", () => {
await expect(first).rejects.toThrow("codex app-server initialize aborted");
expect(harness.stdinDestroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (Linux; test)");
await expect(second).resolves.toBe(harness.client);
expect(releaseLeasedSharedCodexAppServerClient(harness.client)).toBe(true);
});
@@ -404,7 +405,7 @@ describe("shared Codex app-server client", () => {
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const options = { timeoutMs: 1_000 };
const firstLease = getLeasedSharedCodexAppServerClient(options);
await sendInitializeResult(harness, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (Linux; test)");
const client = await firstLease;
await expect(getLeasedSharedCodexAppServerClient(options)).resolves.toBe(client);
const ownedLease = { client };
@@ -448,7 +449,7 @@ describe("shared Codex app-server client", () => {
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
await sendInitializeResult(pluginLocal, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(pluginLocal, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(pluginLocal);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -486,13 +487,13 @@ describe("shared Codex app-server client", () => {
};
const normalPromise = getLeasedSharedCodexAppServerClient({ startOptions });
await sendInitializeResult(normal, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(normal, "openclaw/0.146.0 (Linux; test)");
const normalClient = await normalPromise;
const capturedPromise = getLeasedSharedCodexAppServerClient({
startOptions,
runtimeArtifactMode: "capture",
});
await sendInitializeResult(captured, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(captured, "openclaw/0.146.0 (Linux; test)");
const capturedClient = await capturedPromise;
expect(capturedClient).not.toBe(normalClient);
@@ -544,7 +545,7 @@ describe("shared Codex app-server client", () => {
runtimeArtifactMode: "capture",
});
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
await sendInitializeResult(fallback, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(fallback, "openclaw/0.146.0 (macOS; test)");
const client = await acquire;
const { readCodexAppServerClientRuntimeArtifact, validateCodexAppServerRuntimeArtifact } =
await import("./runtime-artifact.js");
@@ -607,7 +608,7 @@ describe("shared Codex app-server client", () => {
startOptions,
agentDir,
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
const client = await clientPromise;
expect(readCodexAppServerClientProcessIdentity(client)).toEqual({
@@ -616,8 +617,8 @@ describe("shared Codex app-server client", () => {
argsFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
commandSource: "resolved-managed",
nativeCommand: "/cache/openclaw/codex.native",
serverVersion: "0.143.0",
userAgent: "openclaw/0.143.0 (macOS; test)",
serverVersion: "0.146.0",
userAgent: "openclaw/0.146.0 (macOS; test)",
});
expect(() =>
@@ -701,7 +702,7 @@ describe("shared Codex app-server client", () => {
};
const clientPromise = createIsolatedCodexAppServerClient({ startOptions, agentDir });
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
const client = await clientPromise;
const fenceKey = resolveCodexNativeConfigFenceKey({ client });
expect(fenceKey).toBeTypeOf("string");
@@ -757,7 +758,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.stdin.destroyed).toBe(true);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -790,7 +791,7 @@ describe("shared Codex app-server client", () => {
await expect(shortAcquire).rejects.toThrow("codex app-server initialize timed out");
expect(harness.process.stdin.destroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(longAcquire).resolves.toBe(harness.client);
expect(startSpy).toHaveBeenCalledTimes(1);
@@ -803,7 +804,7 @@ describe("shared Codex app-server client", () => {
const releaseAuth = deferNextAuthProfileApplication();
const acquire = getSharedCodexAppServerClient({ timeoutMs: 100 });
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(acquire).rejects.toThrow("codex app-server authentication timed out");
expect(harness.process.stdin.destroyed).toBe(true);
@@ -817,7 +818,7 @@ describe("shared Codex app-server client", () => {
const shortAcquire = getSharedCodexAppServerClient({ timeoutMs: 100 });
const longAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(shortAcquire).rejects.toThrow("codex app-server authentication timed out");
expect(harness.process.stdin.destroyed).toBe(false);
@@ -846,7 +847,7 @@ describe("shared Codex app-server client", () => {
abandonController.abort();
expect(harness.process.stdin.destroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await abandonedRejection;
await expect(activeAcquire).resolves.toBe(harness.client);
@@ -892,7 +893,7 @@ describe("shared Codex app-server client", () => {
const rejection = expect(clientPromise).rejects.toThrow(
"codex app-server initialize timed out",
);
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await rejection;
expect(harness.process.stdin.destroyed).toBe(true);
@@ -908,7 +909,7 @@ describe("shared Codex app-server client", () => {
const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
now = 101;
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out");
expect(mocks.applyCodexAppServerAuthProfile).not.toHaveBeenCalled();
@@ -923,7 +924,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -950,7 +951,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
authProfileStore,
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({
@@ -1009,7 +1010,7 @@ describe("shared Codex app-server client", () => {
store: authProfileStore,
},
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled();
@@ -1103,7 +1104,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
preparedAuth: { kind: "profile", profileId: "openai:scoped", store: firstStore },
});
await sendInitializeResult(firstHarness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(firstHarness, "openclaw/0.146.0 (macOS; test)");
await expect(firstPromise).resolves.toBe(firstHarness.client);
const secondPromise = getSharedCodexAppServerClient({
@@ -1111,7 +1112,7 @@ describe("shared Codex app-server client", () => {
preparedAuth: { kind: "profile", profileId: "openai:scoped", store: secondStore },
});
await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2));
await sendInitializeResult(secondHarness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(secondHarness, "openclaw/0.146.0 (macOS; test)");
await expect(secondPromise).resolves.toBe(secondHarness.client);
expect(resolvedCacheKeys).toEqual(["account:sha256:first", "account:sha256:second"]);
@@ -1147,7 +1148,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
preparedAuth: { kind: "api-key", apiKey: "platform-key" },
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled();
@@ -1196,7 +1197,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
preparedAuth: { kind: "api-key", apiKey: "first-platform-key" },
});
await sendInitializeResult(firstHarness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(firstHarness, "openclaw/0.146.0 (macOS; test)");
await expect(firstPromise).resolves.toBe(firstHarness.client);
const secondPromise = getSharedCodexAppServerClient({
@@ -1204,7 +1205,7 @@ describe("shared Codex app-server client", () => {
preparedAuth: { kind: "api-key", apiKey: "second-platform-key" },
});
await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2));
await sendInitializeResult(secondHarness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(secondHarness, "openclaw/0.146.0 (macOS; test)");
await expect(secondPromise).resolves.toBe(secondHarness.client);
expect(cacheKeys).toEqual(["api_key:sha256:first", "api_key:sha256:second"]);
@@ -1232,7 +1233,7 @@ describe("shared Codex app-server client", () => {
authProfileId: "openai:persisted",
agentDir: "/tmp/openclaw-persisted-agent",
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
const priorWriteCount = harness.writes.length;
@@ -1269,7 +1270,7 @@ describe("shared Codex app-server client", () => {
agentDir: "/tmp/openclaw-target-agent",
config,
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
@@ -1298,7 +1299,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
@@ -1316,7 +1317,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
config,
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -1343,7 +1344,7 @@ describe("shared Codex app-server client", () => {
authProfileId: "openai:work",
agentDir: "/tmp/openclaw-agent-nova",
});
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -1367,7 +1368,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1375,7 +1376,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1394,7 +1395,7 @@ describe("shared Codex app-server client", () => {
}));
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -1433,7 +1434,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(harness, "openclaw/0.144.1 (macOS; test)");
await sendInitializeResult(harness, `openclaw/${CODEX_APP_SERVER_VERSION} (macOS; test)`);
await expect(clientPromise).resolves.toBe(harness.client);
expect(managedStartOptionsCall().managedCommandOrder).toBe("desktop-first");
@@ -1459,7 +1460,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1474,7 +1475,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1497,7 +1498,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
authRequirement: "api-key",
});
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1505,7 +1506,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
authRequirement: "api-key",
});
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1527,7 +1528,7 @@ describe("shared Codex app-server client", () => {
authProfileId: "openai:work",
authRequirement: "api-key",
});
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1536,7 +1537,7 @@ describe("shared Codex app-server client", () => {
authProfileId: "openai:work",
authRequirement: "subscription",
});
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1591,7 +1592,7 @@ describe("shared Codex app-server client", () => {
});
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1609,7 +1610,7 @@ describe("shared Codex app-server client", () => {
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1617,7 +1618,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.stdin.destroyed).toBe(true);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1635,7 +1636,7 @@ describe("shared Codex app-server client", () => {
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1643,7 +1644,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.stdin.destroyed).toBe(false);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1664,7 +1665,7 @@ describe("shared Codex app-server client", () => {
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1683,7 +1684,7 @@ describe("shared Codex app-server client", () => {
await expect(activeRequest).rejects.toThrow("codex app-server client is closed");
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1703,7 +1704,7 @@ describe("shared Codex app-server client", () => {
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client);
const clientPromise = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(harness, "openclaw/0.143.0 (Linux; test)");
await sendInitializeResult(harness, "openclaw/0.146.0 (Linux; test)");
const client = await clientPromise;
const deliverCompletion = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
const taskRuntime = {
@@ -1790,7 +1791,7 @@ describe("shared Codex app-server client", () => {
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await expect(firstLease).resolves.toBe(first.client);
await expect(secondLease).resolves.toBe(first.client);
@@ -1824,7 +1825,7 @@ describe("shared Codex app-server client", () => {
const completedRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
const siblingRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await expect(completedRunLease).resolves.toBe(first.client);
await expect(siblingRunLease).resolves.toBe(first.client);
@@ -1873,7 +1874,7 @@ describe("shared Codex app-server client", () => {
await expect(pendingLease).rejects.toThrow("codex app-server client is closed");
const freshLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await expect(freshLease).resolves.toBe(second.client);
expect(second.process.stdin.destroyed).toBe(false);
});
@@ -1883,7 +1884,7 @@ describe("shared Codex app-server client", () => {
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await expect(lease).resolves.toBe(first.client);
// Routine cleanup detaches gracefully; a later terminal-idle kill must
@@ -1909,7 +1910,7 @@ describe("shared Codex app-server client", () => {
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await expect(lease).resolves.toBe(first.client);
// Routine cleanup (e.g. one-shot bundle-MCP) must not yank a healthy
@@ -1937,7 +1938,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -1945,7 +1946,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.143.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.146.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -1971,7 +1972,7 @@ describe("shared Codex app-server client", () => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.143.0" } }),
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.146.0" } }),
);
return;
}
@@ -286,7 +286,7 @@ function threadResult(threadId: string) {
status: { type: "idle" },
path: null,
cwd: "/tmp/workspace",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -17,7 +17,10 @@ import {
type CodexNativeSkillIsolation,
} from "./native-skill-isolation.js";
import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js";
import { attestCodexPluginThreadApps } from "./plugin-thread-attestation.js";
import {
attestCodexPluginThreadApps,
discardUnattestedCodexPluginThread,
} from "./plugin-thread-attestation.js";
import {
buildCodexPluginAppsConfigPatchFromPolicyContext,
mergeCodexThreadConfigs,
@@ -469,48 +472,25 @@ export async function startFreshCodexThread(
}
});
const response = assertCodexThreadStartResponse(threadStartResponse);
// The thread config may be what makes a base-disabled app callable. Attest
// after start but before persistence so a failed app can never reach a turn.
if (pluginThreadConfig?.provisionalAppIds?.length) {
const provisionalAppIds = pluginThreadConfig?.provisionalAppIds;
// A deny-by-default app becomes callable only under this exact thread's
// allowlist. Never persist or run the thread before Codex confirms it.
if (provisionalAppIds?.length) {
try {
await lifecycleTiming.measure("plugin-app-attestation", () =>
attestCodexPluginThreadApps({
client: params.client,
threadId: response.thread.id,
appIds: pluginThreadConfig.provisionalAppIds ?? [],
appIds: provisionalAppIds,
signal: params.signal,
}),
);
} catch (error) {
// Persistent pre-turn threads have no rollout for thread/archive, so delete
// them explicitly. Ephemeral threads cannot be deleted and unload after
// releasing their only subscription.
let cleanupConfirmed: boolean;
if (startParams.ephemeral === true) {
cleanupConfirmed = await unsubscribeCodexThreadBestEffort(params.client, {
threadId: response.thread.id,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
} else {
try {
await params.client.request(
"thread/delete",
{ threadId: response.thread.id },
{ timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS },
);
cleanupConfirmed = true;
} catch (cleanupError) {
embeddedAgentLog.debug("codex plugin app attestation thread deletion failed", {
threadId: response.thread.id,
cleanupError,
});
await unsubscribeCodexThreadBestEffort(params.client, {
threadId: response.thread.id,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
cleanupConfirmed = false;
}
}
const cleanupConfirmed = await discardUnattestedCodexPluginThread({
client: params.client,
threadId: response.thread.id,
ephemeral: startParams.ephemeral === true,
});
if (!cleanupConfirmed) {
await (params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)))();
throw new CodexAppServerUnsafeSubscriptionError(
@@ -336,6 +336,39 @@ function createProvisionalPluginThreadConfigProvider(appId: string) {
};
}
function createAttestedAccountAppThreadConfigProvider(appId: string) {
const pluginProvider = createProvisionalPluginThreadConfigProvider(appId);
const inputFingerprint = `account-input-${appId}`;
return {
enabled: true,
inputFingerprint,
enabledPluginConfigKeys: [],
recoverablePluginConfigKeys: [],
accountAppRecoveryEnabled: true,
build: vi.fn(async (): Promise<CodexPluginThreadConfig> => {
const pluginConfig = await pluginProvider.build();
return {
...pluginConfig,
fingerprint: `account-config-${appId}`,
inputFingerprint,
policyContext: {
fingerprint: `account-policy-${appId}`,
apps: {
[appId]: {
source: "account",
appName: "Account App",
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
mcpServerNames: [],
},
},
pluginAppIds: {},
},
};
}),
};
}
async function seedAdoptedThreadBinding(params: EmbeddedRunAttemptParams, cwd: string) {
const threadId = "thread-adopted";
const request = vi.fn(async (method: string) => {
@@ -423,7 +456,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -1921,7 +1954,7 @@ describe("Codex plugin binding recovery", () => {
});
});
describe("Codex provisional plugin app attestation", () => {
describe("Codex thread-effective app attestation", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-plugin-attestation-"));
resetCodexTestBindingStore();
@@ -1932,80 +1965,110 @@ describe("Codex provisional plugin app attestation", () => {
vi.restoreAllMocks();
});
it("attests the effective thread app before committing the binding", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-linear");
}
if (method === "app/installed") {
expect(requestParams).toEqual({
threadId: "thread-linear",
forceRefresh: true,
});
return {
apps: [
{
id: "linear-app",
runtimeName: "Linear",
enabled: true,
callable: true,
},
],
};
}
throw new Error(`unexpected method: ${method}`);
});
const mutate = vi.fn(
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) =>
await testCodexAppServerBindingStore.mutate(...args),
);
const bindingStore: CodexAppServerBindingStore = {
...testCodexAppServerBindingStore,
mutate,
};
it.each([
{
source: "configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
fingerprint: "plugin-config-linear-app",
},
{
source: "account-wide policy",
createProvider: createAttestedAccountAppThreadConfigProvider,
fingerprint: "account-config-linear-app",
},
])(
"attests a $source app before committing the binding",
async ({ createProvider, fingerprint }) => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-linear");
}
if (method === "app/installed") {
expect(requestParams).toEqual({ threadId: "thread-linear", forceRefresh: false });
return {
apps: [
{
id: "linear-app",
runtimeName: "Linear",
enabled: true,
callable: true,
},
],
};
}
throw new Error(`unexpected method: ${method}`);
});
const mutate = vi.fn(
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) =>
await testCodexAppServerBindingStore.mutate(...args),
);
const bindingStore: CodexAppServerBindingStore = {
...testCodexAppServerBindingStore,
mutate,
};
await startOrResumeThreadImpl({
client: { request } as never,
bindingStore,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvisionalPluginThreadConfigProvider("linear-app"),
});
await startOrResumeThreadImpl({
client: { request } as never,
bindingStore,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvider("linear-app"),
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "app/installed"]);
expect(request.mock.invocationCallOrder[1]).toBeLessThan(
mutate.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
await expect(
testCodexAppServerBindingStore.read(
sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
}),
),
).resolves.toMatchObject({
threadId: "thread-linear",
pluginAppsFingerprint: "plugin-config-linear-app",
});
});
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
]);
expect(request.mock.invocationCallOrder[1]).toBeLessThan(
mutate.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
await expect(
testCodexAppServerBindingStore.read(
sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
}),
),
).resolves.toMatchObject({
threadId: "thread-linear",
pluginAppsFingerprint: fingerprint,
});
},
);
it("deletes the persistent nonmaterialized thread when attestation fails", async () => {
it.each([
{
state: "missing",
apps: [],
failure: "linear-app:missing",
},
{
state: "disabled by managed or workspace policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: false, callable: false }],
failure: "linear-app:disabled",
},
{
state: "not callable under thread policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: true, callable: false }],
failure: "linear-app:not-callable",
},
])("deletes the unbound persistent thread when its app is $state", async ({ apps, failure }) => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(
async (method: string, _requestParams?: unknown, _requestOptions?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-linear-missing");
return threadStartResult("thread-linear-blocked");
}
if (method === "app/installed") {
return { apps: [] };
return { apps };
}
if (method === "thread/delete") {
return {};
@@ -2024,14 +2087,14 @@ describe("Codex provisional plugin app attestation", () => {
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvisionalPluginThreadConfigProvider("linear-app"),
}),
).rejects.toThrow("linear-app:missing");
).rejects.toThrow(failure);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
"thread/delete",
]);
expect(request.mock.calls[2]?.[1]).toEqual({ threadId: "thread-linear-missing" });
expect(request.mock.calls[2]?.[1]).toEqual({ threadId: "thread-linear-blocked" });
expect(request.mock.calls[2]?.[2]).toEqual({ timeoutMs: 5_000 });
expect(abandonClient).not.toHaveBeenCalled();
await expect(
@@ -2046,7 +2109,93 @@ describe("Codex provisional plugin app attestation", () => {
).resolves.toBeUndefined();
});
it("abandons the client when a persistent attestation failure cannot delete the thread", async () => {
it.each([
{
source: "globally ready configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
state: "disabled by thread policy",
enabled: false,
callable: false,
failure: "global-ready-app:disabled",
},
{
source: "globally ready account-wide app",
createProvider: createAttestedAccountAppThreadConfigProvider,
state: "disabled by thread policy",
enabled: false,
callable: false,
failure: "global-ready-app:disabled",
},
{
source: "globally ready configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
state: "not callable under thread policy",
enabled: true,
callable: false,
failure: "global-ready-app:not-callable",
},
{
source: "globally ready account-wide app",
createProvider: createAttestedAccountAppThreadConfigProvider,
state: "not callable under thread policy",
enabled: true,
callable: false,
failure: "global-ready-app:not-callable",
},
])(
"rejects a $source when it is $state in the actual thread",
async ({ createProvider, enabled, callable, failure }) => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-global-ready");
}
if (method === "app/installed") {
expect(requestParams).toEqual({ threadId: "thread-global-ready", forceRefresh: false });
return {
apps: [{ id: "global-ready-app", runtimeName: "Global App", enabled, callable }],
};
}
if (method === "thread/delete") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvider("global-ready-app"),
}),
).rejects.toThrow(failure);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
"thread/delete",
]);
expect(abandonClient).not.toHaveBeenCalled();
await expect(
testCodexAppServerBindingStore.read(
sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
}),
),
).resolves.toBeUndefined();
},
);
it("retires the client when a persistent unattested thread cannot be deleted", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const abandonClient = vi.fn(async () => undefined);
@@ -2087,7 +2236,47 @@ describe("Codex provisional plugin app attestation", () => {
expect(abandonClient).toHaveBeenCalledOnce();
});
it("unsubscribes an ephemeral thread when attestation fails", async () => {
it("deletes an unbound thread when its app snapshot request fails", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-linear-snapshot-error");
}
if (method === "app/installed") {
throw new Error("committed app snapshot unavailable");
}
if (method === "thread/delete") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvisionalPluginThreadConfigProvider("linear-app"),
}),
).rejects.toMatchObject({
name: "CodexPluginThreadAppAttestationError",
cause: expect.objectContaining({ message: "committed app snapshot unavailable" }),
});
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
"thread/delete",
]);
expect(abandonClient).not.toHaveBeenCalled();
});
it("unsubscribes an ephemeral thread when its app cannot be attested", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.sessionKey = "agent:main:internal-session-effects:incognito-plugin-attestation";
@@ -2125,6 +2314,44 @@ describe("Codex provisional plugin app attestation", () => {
]);
expect(abandonClient).not.toHaveBeenCalled();
});
it("retires the client when an ephemeral unattested thread cannot be unsubscribed", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.sessionKey = "agent:main:internal-session-effects:incognito-plugin-attestation";
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-linear-ephemeral-unsafe");
}
if (method === "app/installed") {
return { apps: [] };
}
if (method === "thread/unsubscribe") {
throw new Error("unsubscribe unavailable");
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvisionalPluginThreadConfigProvider("linear-app"),
}),
).rejects.toThrow("Codex plugin app attestation cleanup failed");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
"thread/unsubscribe",
]);
expect(abandonClient).toHaveBeenCalledOnce();
});
});
describe("Codex app-server adopted thread lifecycle", () => {
@@ -2411,79 +2638,223 @@ describe("Codex app-server supervised branch lifecycle", () => {
});
});
it("cleans tracked supervision branches when provisional app attestation fails", async () => {
const sourceThreadId = "thread-source";
const probeThreadId = "thread-probe";
const finalThreadId = "thread-final";
const workspaceDir = path.join(tempDir, "workspace");
const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const identity = await seedPendingSupervisionBinding({
attempt,
cwd: workspaceDir,
pending: { sourceThreadId },
});
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(async (method: string, requestParams: unknown) => {
if (method === "thread/read") {
return { thread: sourceThread({ threadId: sourceThreadId }) };
}
if (method === "thread/fork") {
return nativeThreadResult(probeThreadId, "native-effective", "native-provider");
}
if (method === "thread/start") {
return nativeThreadResult(finalThreadId, "native-effective", "native-provider");
}
if (method === "app/installed") {
expect(requestParams).toEqual({
threadId: finalThreadId,
forceRefresh: true,
});
return { apps: [] };
}
if (method === "thread/delete") {
return {};
}
if (method === "thread/archive") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params: attempt,
it.each([
{
source: "configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
fingerprint: "plugin-config-linear-app",
},
{
source: "account-wide policy",
createProvider: createAttestedAccountAppThreadConfigProvider,
fingerprint: "account-config-linear-app",
},
])(
"attests a supervised $source app before committing the canonical branch",
async ({ createProvider, fingerprint }) => {
const sourceThreadId = "thread-source";
const probeThreadId = "thread-probe";
const finalThreadId = "thread-final";
const lastTurnId = "turn-terminal";
const workspaceDir = path.join(tempDir, "workspace");
const attempt = createThreadLifecycleParams(
path.join(tempDir, "session.jsonl"),
workspaceDir,
);
const identity = await seedPendingSupervisionBinding({
attempt,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvisionalPluginThreadConfigProvider("linear-app"),
}),
).rejects.toThrow("linear-app:missing");
pending: { sourceThreadId, lastTurnId },
});
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/read") {
return {
thread: sourceThread({
threadId: sourceThreadId,
turns: [
{
id: lastTurnId,
status: "completed",
items: [
{
id: "user-1",
type: "userMessage",
content: [{ type: "text", text: "Visible question" }],
},
],
},
],
}),
};
}
if (method === "thread/fork") {
return nativeThreadResult(probeThreadId, "native-effective", "native-provider");
}
if (method === "thread/start") {
return nativeThreadResult(finalThreadId, "native-effective", "native-provider");
}
if (method === "app/installed") {
expect(requestParams).toEqual({ threadId: finalThreadId, forceRefresh: false });
return {
apps: [
{
id: "linear-app",
runtimeName: "Linear",
enabled: true,
callable: true,
},
],
};
}
if (method === "thread/inject_items" || method === "thread/archive") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/read",
"thread/fork",
"thread/start",
"app/installed",
"thread/delete",
"thread/archive",
]);
expect(request.mock.calls[4]?.[1]).toEqual({ threadId: finalThreadId });
expect(request.mock.calls[5]?.[1]).toEqual({ threadId: probeThreadId });
expect(abandonClient).not.toHaveBeenCalled();
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
pendingSupervisionBranch: {
sourceThreadId,
},
});
expect(
(await testCodexAppServerBindingStore.read(identity))?.pendingSupervisionBranch
?.cleanupThreadIds ?? [],
).toEqual([]);
});
await expect(
startOrResumeThread({
client: { request } as never,
params: attempt,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvider("linear-app"),
}),
).resolves.toMatchObject({ threadId: finalThreadId, lifecycle: { action: "forked" } });
it("abandons a supervised client when attestation cannot delete the canonical branch", async () => {
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/read",
"thread/fork",
"thread/start",
"app/installed",
"thread/inject_items",
"thread/archive",
]);
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
threadId: finalThreadId,
pluginAppsFingerprint: fingerprint,
});
expect(
(await testCodexAppServerBindingStore.read(identity))?.pendingSupervisionBranch,
).toBeUndefined();
},
);
it.each([
{
source: "configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
state: "missing from the effective thread",
apps: [],
failure: "linear-app:missing",
},
{
source: "account-wide policy",
createProvider: createAttestedAccountAppThreadConfigProvider,
state: "missing from the effective thread",
apps: [],
failure: "linear-app:missing",
},
{
source: "configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
state: "disabled by managed or workspace policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: false, callable: false }],
failure: "linear-app:disabled",
},
{
source: "account-wide policy",
createProvider: createAttestedAccountAppThreadConfigProvider,
state: "disabled by managed or workspace policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: false, callable: false }],
failure: "linear-app:disabled",
},
{
source: "configured plugin",
createProvider: createProvisionalPluginThreadConfigProvider,
state: "not callable under thread policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: true, callable: false }],
failure: "linear-app:not-callable",
},
{
source: "account-wide policy",
createProvider: createAttestedAccountAppThreadConfigProvider,
state: "not callable under thread policy",
apps: [{ id: "linear-app", runtimeName: "Linear", enabled: true, callable: false }],
failure: "linear-app:not-callable",
},
])(
"cleans both supervised branches when a $source app is $state",
async ({ createProvider, apps, failure }) => {
const sourceThreadId = "thread-source";
const probeThreadId = "thread-probe";
const finalThreadId = "thread-final";
const workspaceDir = path.join(tempDir, "workspace");
const attempt = createThreadLifecycleParams(
path.join(tempDir, "session.jsonl"),
workspaceDir,
);
const identity = await seedPendingSupervisionBinding({
attempt,
cwd: workspaceDir,
pending: { sourceThreadId },
});
const abandonClient = vi.fn(async () => undefined);
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/read") {
return { thread: sourceThread({ threadId: sourceThreadId }) };
}
if (method === "thread/fork") {
return nativeThreadResult(probeThreadId, "native-effective", "native-provider");
}
if (method === "thread/start") {
return nativeThreadResult(finalThreadId, "native-effective", "native-provider");
}
if (method === "app/installed") {
expect(requestParams).toEqual({ threadId: finalThreadId, forceRefresh: false });
return { apps };
}
if (method === "thread/delete" || method === "thread/archive") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params: attempt,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: createProvider("linear-app"),
}),
).rejects.toThrow(failure);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/read",
"thread/fork",
"thread/start",
"app/installed",
"thread/delete",
"thread/archive",
]);
expect(request.mock.calls[4]?.[1]).toEqual({ threadId: finalThreadId });
expect(request.mock.calls[5]?.[1]).toEqual({ threadId: probeThreadId });
expect(abandonClient).not.toHaveBeenCalled();
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
pendingSupervisionBranch: { sourceThreadId },
});
expect(
(await testCodexAppServerBindingStore.read(identity))?.pendingSupervisionBranch
?.cleanupThreadIds ?? [],
).toEqual([]);
},
);
it("retires a supervised client when its unattested canonical branch cannot be deleted", async () => {
const sourceThreadId = "thread-source";
const probeThreadId = "thread-probe";
const finalThreadId = "thread-final";
@@ -36,7 +36,7 @@ function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
status: { type: "idle" },
path: null,
cwd: "/tmp",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -186,8 +186,7 @@ export function buildThreadStartParams(
developerInstructions:
options.developerInstructions ??
buildDeveloperInstructions(params, { dynamicTools: options.dynamicTools }),
// Canonical typed specs (`type: "function" | "namespace"`); the 0.142 floor
// accepts them natively (codex-rs normalize_dynamic_tool_specs).
// Codex 0.146 accepts canonical typed function and namespace specs natively.
dynamicTools: [...options.dynamicTools],
experimentalRawEvents: true,
// Codex `ephemeral` skips rollout/state DB writes while loaded threads remain reusable
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { resumeCodexAppServerThread } from "./thread-resume.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
function resumeResponse(threadId: string, restoredTurns = 0) {
return {
@@ -16,7 +17,7 @@ function resumeResponse(threadId: string, restoredTurns = 0) {
status: { type: "idle" },
path: null,
cwd: "/repo",
cliVersion: "0.139.0",
cliVersion: CODEX_APP_SERVER_VERSION,
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -13,7 +13,10 @@ import {
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js";
import { attestCodexPluginThreadApps } from "./plugin-thread-attestation.js";
import {
attestCodexPluginThreadApps,
discardUnattestedCodexPluginThread,
} from "./plugin-thread-attestation.js";
import {
assertCodexThreadForkResponse,
assertCodexThreadStartResponse,
@@ -216,13 +219,13 @@ export async function materializePendingSupervisionBranch(
}),
);
} catch (error) {
// The canonical branch has not reached a turn, so persistent threads
// require delete rather than archive; the forked probe has a rollout.
const finalCleanupConfirmed = await cleanUnmaterializedSupervisionThread(
params.client,
finalThreadId,
startParams.ephemeral === true,
);
// The fresh persistent branch has no rollout yet; delete it before
// archiving the probe, and retain both for recovery if cleanup fails.
const finalCleanupConfirmed = await discardUnattestedCodexPluginThread({
client: params.client,
threadId: finalThreadId,
ephemeral: startParams.ephemeral === true,
});
if (
!finalCleanupConfirmed ||
!(await archiveSupervisionArtifact(params.client, probeThreadId))
@@ -677,34 +680,3 @@ async function archiveSupervisionArtifact(
return false;
}
}
async function cleanUnmaterializedSupervisionThread(
client: CodexAppServerClient,
threadId: string,
ephemeral: boolean,
): Promise<boolean> {
if (ephemeral) {
return unsubscribeCodexThreadBestEffort(client, {
threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
}
try {
await client.request(
"thread/delete",
{ threadId },
{ timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS },
);
return true;
} catch (error) {
await unsubscribeCodexThreadBestEffort(client, {
threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
embeddedAgentLog.warn("failed to delete unmaterialized Codex supervision thread", {
threadId,
error,
});
return false;
}
}
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer, type RawData } from "ws";
import { CodexAppServerClient } from "./client.js";
import { createWebSocketTransport } from "./transport-websocket.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
describe("Codex app-server websocket transport", () => {
const clients: CodexAppServerClient[] = [];
@@ -53,7 +54,7 @@ describe("Codex app-server websocket transport", () => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.143.0" } }),
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.146.0" } }),
);
return;
}
@@ -169,7 +170,10 @@ describe("Codex app-server websocket transport", () => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.144.1" } }),
JSON.stringify({
id: message.id,
result: { userAgent: `openclaw/${CODEX_APP_SERVER_VERSION}` },
}),
);
return;
}
@@ -79,7 +79,7 @@ function forkResponse(threadId = "thread-forked") {
thread: {
id: threadId,
sessionId: "session-forked",
cliVersion: "0.143.0",
cliVersion: "0.146.0",
createdAt: 1715299200,
updatedAt: 1715299200,
cwd: "/tmp",
+2 -6
View File
@@ -1,11 +1,7 @@
/**
* Version and package pins for the managed Codex app-server runtime.
*/
// The supported range tracks protocol shapes validated against the managed
// package. Raising the ceiling requires regenerating schemas from that tag.
/** Minimum Codex app-server version supported by the OpenClaw Codex bridge. */
export const MIN_CODEX_APP_SERVER_VERSION = "0.143.0";
/** Newest Codex app-server version validated by the OpenClaw Codex bridge. */
export const MAX_CODEX_APP_SERVER_VERSION = "0.145.0";
/** Exact Codex app-server version shipped and supported by the OpenClaw Codex bridge. */
export const CODEX_APP_SERVER_VERSION = "0.146.0";
/** npm package name for the managed Codex app-server binary. */
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
+2 -1
View File
@@ -23,6 +23,7 @@ import {
testCodexAppServerBindingStore,
} from "./app-server/session-binding.test-helpers.js";
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js";
import { codexDiagnosticsFeedbackState } from "./command-diagnostics-state.js";
import { handleCodexCommand as dispatchCodexCommand } from "./command-dispatch.js";
import type { CodexCommandDepsOverride } from "./command-handlers.js";
@@ -139,7 +140,7 @@ function createThreadResumeResponse(params: {
thread: {
id: params.threadId,
sessionId: params.threadId,
cliVersion: "0.139.0",
cliVersion: CODEX_APP_SERVER_VERSION,
createdAt: 1,
updatedAt: 1,
cwd,
@@ -192,7 +192,7 @@ function conversationThreadStartResult(threadId: string) {
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
+22 -2
View File
@@ -1,7 +1,7 @@
// Codex tests cover manifest plugin behavior.
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import { MAX_CODEX_APP_SERVER_VERSION } from "./app-server/version.js";
import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js";
type CodexPackageManifest = {
dependencies?: Record<string, string>;
@@ -23,7 +23,8 @@ describe("codex package manifest", () => {
) as CodexPackageManifest;
expect(packageJson.devDependencies).toHaveProperty("@openclaw/plugin-sdk");
expect(packageJson.dependencies?.["@openai/codex"]).toBe(MAX_CODEX_APP_SERVER_VERSION);
expect(packageJson.dependencies?.["@openai/codex"]).toBe(CODEX_APP_SERVER_VERSION);
expect(packageJson.dependencies).not.toHaveProperty("semver");
expect(packageJson.openclaw?.release?.requireLatestDependencies).toEqual(["@openai/codex"]);
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
"@openai/codex-linux-x64",
@@ -34,4 +35,23 @@ describe("codex package manifest", () => {
"@openai/codex-win32-arm64",
]);
});
it("keeps managed Codex and the ACP adapter on the same exact bundled version", () => {
const workspace = fs.readFileSync(
new URL("../../../pnpm-workspace.yaml", import.meta.url),
"utf8",
);
const lockfile = fs.readFileSync(new URL("../../../pnpm-lock.yaml", import.meta.url), "utf8");
expect(workspace).toContain(
`"@agentclientprotocol/codex-acp@1.1.7>@openai/codex": ${CODEX_APP_SERVER_VERSION}`,
);
expect(lockfile).toContain(
`'@agentclientprotocol/codex-acp@1.1.7>@openai/codex': ${CODEX_APP_SERVER_VERSION}`,
);
const lockedCodexVersions = new Set(
[...lockfile.matchAll(/@openai\/codex@(\d+\.\d+\.\d+)/g)].map((match) => match[1]),
);
expect([...lockedCodexVersions]).toEqual([CODEX_APP_SERVER_VERSION]);
});
});
@@ -31,6 +31,6 @@ export function sanitizeAppsNeedingAuth(apps: readonly v2.AppSummary[]): Array<{
return apps.map((app) => ({
id: app.id,
name: app.name,
needsAuth: app.needsAuth,
needsAuth: true,
}));
}
File diff suppressed because it is too large Load Diff
@@ -28,7 +28,6 @@ export type CodexPluginMigrationBlockCode =
export type CodexPluginMigrationAppFact = {
id: string;
name: string;
needsAuth?: boolean;
isAccessible?: boolean;
isEnabled?: boolean;
};
+183 -82
View File
@@ -8,6 +8,7 @@ import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { CodexAppServerStartOptions } from "../app-server/config.js";
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
import {
isOpenAiCuratedMarketplace,
pluginReadParams,
type CodexPluginMarketplaceRef,
} from "../app-server/plugin-inventory.js";
@@ -16,7 +17,10 @@ import type {
CodexGetAccountResponse,
v2,
} from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import {
withCodexAppServerJsonClient,
type CodexAppServerScopedRequest,
} from "../app-server/request.js";
import { exists, isDirectory, resolveHomePath, resolveUserHomeDir } from "./helpers.js";
import {
discoverCodexMemorySources,
@@ -62,6 +66,14 @@ type CodexSourceDiscoveryOptions = {
type SourceAppServerRequestOptions = {
startOptions: CodexAppServerStartOptions;
request: CodexAppServerScopedRequest;
};
type InstalledCuratedPlugin = {
plugin: CodexPluginSource;
marketplace: CodexPluginMarketplaceRef;
readPluginName?: string;
remote: boolean;
};
type PluginReadResult =
@@ -94,38 +106,56 @@ async function discoverInstalledCuratedPlugins(
error?: string;
}> {
const startOptions = sourceCodexAppServerStartOptions(codexHome);
const requestOptions = { startOptions };
try {
const response = await requestSourceCodexAppServerJson<v2.PluginListResponse>(requestOptions, {
method: "plugin/list",
requestParams: { cwds: [] } satisfies v2.PluginListParams,
});
const marketplace = response.marketplaces.find(
(entry) => entry.name === CODEX_PLUGINS_MARKETPLACE_NAME,
return await withCodexAppServerJsonClient(
{
timeoutMs: 60_000,
startOptions,
authProfileId: null,
isolated: true,
},
async (request) => {
const requestOptions = { startOptions, request };
const response = await request<v2.PluginInstalledResponse>({
method: "plugin/installed",
requestParams: { cwds: [] } satisfies v2.PluginInstalledParams,
});
const curatedMarketplaces = response.marketplaces.filter(isOpenAiCuratedMarketplace);
const curatedMarketplacePaths = new Set(
curatedMarketplaces.flatMap((marketplace) =>
marketplace.path ? [marketplace.path] : [],
),
);
const curatedMarketplaceErrors = response.marketplaceLoadErrors.filter((error) => {
const marketplaceName = path.basename(error.marketplacePath);
return (
curatedMarketplacePaths.has(error.marketplacePath) ||
marketplaceName === CODEX_PLUGINS_MARKETPLACE_NAME ||
marketplaceName === `${CODEX_PLUGINS_MARKETPLACE_NAME}-remote`
);
});
if (curatedMarketplaceErrors.length > 0) {
return {
plugins: [],
error: curatedMarketplaceErrors.map((error) => error.message).join("; "),
};
}
const installed = discoverInstalledCuratedPluginSources(response);
const plugins =
options.evaluatePluginMigrationEligibility === true
? await withPluginMigrationEligibility({
plugins: installed,
requestOptions,
verifyPluginApps: options.verifyPluginApps === true,
})
: installed.map(({ plugin }) => plugin);
return {
plugins: plugins.toSorted((left, right) =>
(left.pluginName ?? left.name).localeCompare(right.pluginName ?? right.name),
),
};
},
);
if (!marketplace) {
return {
plugins: [],
error: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found in source plugin inventory.`,
};
}
const plugins = marketplace.plugins
.filter((plugin) => plugin.installed)
.map((plugin) => buildInstalledPluginSource(plugin))
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined);
const withEligibility =
options.evaluatePluginMigrationEligibility === true
? await withPluginMigrationEligibility({
plugins,
marketplace: marketplaceRef(marketplace),
requestOptions,
verifyPluginApps: options.verifyPluginApps === true,
})
: plugins;
const sorted = withEligibility.toSorted((a, b) =>
(a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name),
);
return { plugins: sorted };
} catch (error) {
return {
plugins: [],
@@ -149,23 +179,6 @@ function sourceCodexAppServerStartOptions(codexHome: string): CodexAppServerStar
};
}
async function requestSourceCodexAppServerJson<T>(
options: SourceAppServerRequestOptions,
params: {
method: string;
requestParams?: unknown;
},
): Promise<T> {
return await requestCodexAppServerJson<T>({
method: params.method,
requestParams: params.requestParams,
timeoutMs: 60_000,
startOptions: options.startOptions,
authProfileId: null,
isolated: true,
});
}
function buildInstalledPluginSource(plugin: v2.PluginSummary): CodexPluginSource | undefined {
const pluginName = pluginNameFromSummary(plugin);
if (!pluginName) {
@@ -182,6 +195,40 @@ function buildInstalledPluginSource(plugin: v2.PluginSummary): CodexPluginSource
};
}
function discoverInstalledCuratedPluginSources(
response: v2.PluginInstalledResponse,
): InstalledCuratedPlugin[] {
const installedByName = new Map<string, InstalledCuratedPlugin>();
for (const marketplace of response.marketplaces) {
if (!isOpenAiCuratedMarketplace(marketplace)) {
continue;
}
const remote = marketplace.name !== CODEX_PLUGINS_MARKETPLACE_NAME;
for (const summary of marketplace.plugins) {
if (!summary.installed) {
continue;
}
const plugin = buildInstalledPluginSource(summary);
if (!plugin?.pluginName) {
continue;
}
const existing = installedByName.get(plugin.pluginName);
if (existing && (!remote || existing.remote)) {
continue;
}
installedByName.set(plugin.pluginName, {
plugin,
marketplace: marketplaceRef(marketplace),
...(remote
? { readPluginName: summary.remotePluginId?.trim() || undefined }
: { readPluginName: plugin.pluginName }),
remote,
});
}
}
return Array.from(installedByName.values());
}
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
return {
name: CODEX_PLUGINS_MARKETPLACE_NAME,
@@ -191,15 +238,14 @@ function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMark
}
async function withPluginMigrationEligibility(params: {
plugins: CodexPluginSource[];
marketplace: CodexPluginMarketplaceRef;
plugins: InstalledCuratedPlugin[];
requestOptions: SourceAppServerRequestOptions;
verifyPluginApps: boolean;
}): Promise<CodexPluginSource[]> {
const pending: Array<{ plugin: CodexPluginSource; apps: CodexPluginMigrationAppFact[] }> = [];
const evaluated: CodexPluginSource[] = [];
for (const plugin of params.plugins) {
for (const { plugin, marketplace, readPluginName } of params.plugins) {
if (plugin.enabled !== true) {
evaluated.push({
...plugin,
@@ -210,7 +256,12 @@ async function withPluginMigrationEligibility(params: {
continue;
}
const detail = await readPluginDetail(params.requestOptions, params.marketplace, plugin);
const detail = await readPluginDetail(
params.requestOptions,
marketplace,
plugin,
readPluginName,
);
if (!detail.ok) {
evaluated.push({
...plugin,
@@ -240,23 +291,27 @@ async function withPluginMigrationEligibility(params: {
}
let sourceAccount: Awaited<ReturnType<typeof readSourceCodexAccount>> | undefined;
let sourceAccountError: string | undefined;
try {
sourceAccount = await readSourceCodexAccount(params.requestOptions);
} catch (error) {
if (!params.verifyPluginApps) {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "codex_account_unavailable", apps, error: message },
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but the source Codex app-server account could not be read: ${message}`,
});
}
return evaluated;
if (sourceAccount === "missing") {
sourceAccountError = "Codex app-server did not report an authenticated source account.";
}
} catch (error) {
sourceAccountError = error instanceof Error ? error.message : String(error);
}
if (sourceAccount && sourceAccount !== "chatgpt") {
if (sourceAccountError && !params.verifyPluginApps) {
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "codex_account_unavailable", apps, error: sourceAccountError },
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but the source Codex app-server account could not be read: ${sourceAccountError}`,
});
}
return evaluated;
}
if (sourceAccount === "non_chatgpt") {
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
@@ -302,9 +357,16 @@ async function withPluginMigrationEligibility(params: {
}
const appInfoById = new Map(snapshot.apps.map((app) => [app.id, app] as const));
const installedAppsById = new Map(snapshot.installedApps.map((app) => [app.id, app] as const));
for (const { plugin, apps: declaredApps } of pending) {
const apps = declaredApps
.map((app) => sourcePluginAppFactWithInventory(app, appInfoById.get(app.id)))
.map((app) =>
sourcePluginAppFactWithInventory(
app,
appInfoById.get(app.id),
installedAppsById.get(app.id),
),
)
.toSorted((left, right) => left.id.localeCompare(right.id));
const blockCode = migrationBlockCodeForApps(apps);
if (!blockCode) {
@@ -325,7 +387,7 @@ async function withPluginMigrationEligibility(params: {
async function readSourceCodexAccount(
options: SourceAppServerRequestOptions,
): Promise<"chatgpt" | "non_chatgpt" | "missing"> {
const response = await requestSourceCodexAppServerJson<CodexGetAccountResponse>(options, {
const response = await options.request<CodexGetAccountResponse>({
method: "account/read",
requestParams: { refreshToken: false },
});
@@ -336,19 +398,33 @@ async function readSourceCodexAccount(
) {
return "missing";
}
const type = response.account.type;
return type === "chatgpt" ? "chatgpt" : "non_chatgpt";
switch (response.account.type) {
case "chatgpt":
return "chatgpt";
case "apiKey":
case "amazonBedrock":
return "non_chatgpt";
default:
return "missing";
}
}
async function readPluginDetail(
options: SourceAppServerRequestOptions,
marketplace: CodexPluginMarketplaceRef,
plugin: CodexPluginSource,
readPluginName: string | undefined,
): Promise<PluginReadResult> {
if (!readPluginName) {
return {
ok: false,
error: `Codex remote plugin "${plugin.pluginName ?? plugin.name}" has no readable remote plugin id.`,
};
}
try {
const response = await requestSourceCodexAppServerJson<v2.PluginReadResponse>(options, {
const response = await options.request<v2.PluginReadResponse>({
method: "plugin/read",
requestParams: pluginReadParams(marketplace, plugin.pluginName ?? plugin.name),
requestParams: pluginReadParams(marketplace, readPluginName),
});
return { ok: true, detail: response.plugin };
} catch (error) {
@@ -363,7 +439,7 @@ async function refreshSourceAppInventory(
appServer: { start: options.startOptions },
});
const request: CodexAppInventoryRequest = async (method, requestParams) =>
await requestSourceCodexAppServerJson<CodexAppServerRequestResult<typeof method>>(options, {
await options.request<CodexAppServerRequestResult<typeof method>>({
method,
requestParams,
});
@@ -378,26 +454,41 @@ function sourcePluginAppFact(app: v2.AppSummary): CodexPluginMigrationAppFact {
return {
id: app.id,
name: app.name,
needsAuth: app.needsAuth,
};
}
type SourcePluginRuntimeAppFact = CodexPluginMigrationAppFact & {
isCallable?: false;
};
function sourcePluginAppFactWithInventory(
app: CodexPluginMigrationAppFact,
info: v2.AppInfo | undefined,
): CodexPluginMigrationAppFact {
if (!info) {
installedApp?: v2.InstalledApp,
): SourcePluginRuntimeAppFact {
if (!installedApp) {
return app;
}
if (!info) {
return installedApp.enabled
? { ...app, isAccessible: false, isEnabled: true }
: { ...app, isEnabled: false };
}
if (!installedApp.enabled) {
return { ...app, isAccessible: info.isAccessible, isEnabled: false };
}
return {
...app,
isAccessible: info.isAccessible,
// Metadata proves authorization, but only the committed runtime proves
// that this enabled app actually exposes a model-callable tool.
isAccessible: info.isAccessible && installedApp.callable,
isEnabled: info.isEnabled,
...(!installedApp.callable ? { isCallable: false as const } : {}),
};
}
function migrationBlockCodeForApps(
apps: readonly CodexPluginMigrationAppFact[],
apps: readonly SourcePluginRuntimeAppFact[],
): CodexPluginMigrationBlockCode | undefined {
if (apps.some((app) => app.isAccessible === false)) {
return "app_inaccessible";
@@ -413,11 +504,17 @@ function migrationBlockCodeForApps(
function appInventoryBlockMessage(
plugin: CodexPluginSource,
apps: readonly CodexPluginMigrationAppFact[],
apps: readonly SourcePluginRuntimeAppFact[],
code: CodexPluginMigrationBlockCode,
): string {
const status =
code === "app_inaccessible" ? "inaccessible" : code === "app_disabled" ? "disabled" : "missing";
code === "app_inaccessible"
? apps.some((app) => app.isCallable === false)
? "not callable"
: "inaccessible"
: code === "app_disabled"
? "disabled"
: "missing";
const blocking =
apps.find((app) =>
code === "app_inaccessible"
@@ -439,14 +536,18 @@ function codexSubscriptionRequiredMessage(plugin: CodexPluginSource): string {
}
function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
const candidates = [summary.id, summary.name];
const candidates = [summary.name, summary.id];
for (const candidate of candidates) {
const trimmed = candidate.trim();
if (!trimmed) {
continue;
}
const withoutMarketplaceSuffix = trimmed.endsWith(`@${CODEX_PLUGINS_MARKETPLACE_NAME}`)
? trimmed.slice(0, -`@${CODEX_PLUGINS_MARKETPLACE_NAME}`.length)
const marketplaceSuffix = [
`@${CODEX_PLUGINS_MARKETPLACE_NAME}-remote`,
`@${CODEX_PLUGINS_MARKETPLACE_NAME}`,
].find((suffix) => trimmed.endsWith(suffix));
const withoutMarketplaceSuffix = marketplaceSuffix
? trimmed.slice(0, -marketplaceSuffix.length)
: trimmed;
const pathSegment = withoutMarketplaceSuffix.split("/").at(-1)?.trim();
const normalized = pathSegment?.toLowerCase().replaceAll(/\s+/gu, "-");
@@ -48,7 +48,7 @@ function threadStartResult() {
status: { type: "idle" },
path: null,
cwd: "/tmp/openclaw-agent",
cliVersion: "0.125.0",
cliVersion: "0.146.0",
source: "unknown",
agentNickname: null,
agentRole: null,
+2 -1
View File
@@ -57,12 +57,13 @@ class MxcFsBridge implements SandboxFsBridge {
};
}
async readFile(params: { filePath: string; cwd?: string }): Promise<Buffer> {
async readFile(params: { filePath: string; cwd?: string; maxBytes?: number }): Promise<Buffer> {
const target = this.resolveTarget(params);
return (await (
await fsRoot(target.mount.hostRoot)
).readBytes(target.mountRelativePath, {
hardlinks: "reject",
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
})) as Buffer;
}
+14
View File
@@ -741,6 +741,20 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests
expect(await bridge?.readFile({ filePath: "notes/one.txt", cwd: workdir })).toEqual(
Buffer.from("hello mxc"),
);
await expect(
bridge?.readFile({
filePath: "notes/one.txt",
cwd: workdir,
maxBytes: "hello mxc".length,
}),
).resolves.toEqual(Buffer.from("hello mxc"));
await expect(
bridge?.readFile({
filePath: "notes/one.txt",
cwd: workdir,
maxBytes: "hello mxc".length - 1,
}),
).rejects.toThrow();
expect(await bridge?.stat({ filePath: "notes/one.txt", cwd: workdir })).toMatchObject({
type: "file",
size: "hello mxc".length,
+9
View File
@@ -53,6 +53,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
filePath: string;
cwd?: string;
signal?: AbortSignal;
maxBytes?: number;
}): Promise<Buffer> {
const target = this.resolveTarget(params);
const hostPath = this.requireHostPath(target);
@@ -65,6 +66,14 @@ class OpenShellFsBridge implements SandboxFsBridge {
allowFinalSymlinkForUnlink: false,
});
const root = await fsRoot(target.mountHostRoot);
if (params.maxBytes !== undefined) {
return (
await root.read(path.relative(target.mountHostRoot, hostPath), {
hardlinks: "reject",
maxBytes: params.maxBytes,
})
).buffer;
}
opened = await root.open(path.relative(target.mountHostRoot, hostPath), {
hardlinks: "reject",
});
@@ -1458,6 +1458,12 @@ describe("openshell fs bridges", () => {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).resolves.toEqual(
Buffer.from("inside"),
);
await expect(bridge.readFile({ filePath: "subdir/secret.txt", maxBytes: 6 })).resolves.toEqual(
Buffer.from("inside"),
);
await expect(bridge.readFile({ filePath: "subdir/secret.txt", maxBytes: 5 })).rejects.toThrow(
"Sandbox boundary checks failed",
);
});
it("reads materialized sandbox skills from the protected skills workspace", async () => {
+2 -71
View File
@@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@agentclientprotocol/codex-acp@1.1.7>@openai/codex': 0.146.0
'@anthropic-ai/sdk': 0.115.0
'@opentelemetry/core': 2.10.0
'@opentelemetry/propagator-jaeger': 2.10.0
@@ -630,9 +631,6 @@ importers:
'@openai/codex':
specifier: 0.146.0
version: 0.146.0
semver:
specifier: 7.8.5
version: 7.8.5
smol-toml:
specifier: 1.7.1
version: 1.7.1
@@ -4024,46 +4022,6 @@ packages:
resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@openai/codex@0.145.0':
resolution: {integrity: sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==}
engines: {node: '>=16'}
'@openai/codex@0.145.0-darwin-arm64':
resolution: {integrity: sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@openai/codex@0.145.0-darwin-x64':
resolution: {integrity: sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@openai/codex@0.145.0-linux-arm64':
resolution: {integrity: sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@openai/codex@0.145.0-linux-x64':
resolution: {integrity: sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@openai/codex@0.145.0-win32-arm64':
resolution: {integrity: sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [win32]
'@openai/codex@0.145.0-win32-x64':
resolution: {integrity: sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@openai/codex@0.146.0':
resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==}
engines: {node: '>=16'}
@@ -9203,7 +9161,7 @@ snapshots:
'@agentclientprotocol/codex-acp@1.1.7':
dependencies:
'@agentclientprotocol/sdk': 1.3.0(zod@4.4.3)
'@openai/codex': 0.145.0
'@openai/codex': 0.146.0
diff: 9.0.0
open: 11.0.0
vscode-jsonrpc: 9.0.1
@@ -11050,33 +11008,6 @@ snapshots:
'@npmcli/redact@5.0.0': {}
'@openai/codex@0.145.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.145.0-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.145.0-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.145.0-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.145.0-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.145.0-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.145.0-win32-x64'
'@openai/codex@0.145.0-darwin-arm64':
optional: true
'@openai/codex@0.145.0-darwin-x64':
optional: true
'@openai/codex@0.145.0-linux-arm64':
optional: true
'@openai/codex@0.145.0-linux-x64':
optional: true
'@openai/codex@0.145.0-win32-arm64':
optional: true
'@openai/codex@0.145.0-win32-x64':
optional: true
'@openai/codex@0.146.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64'
+1
View File
@@ -117,6 +117,7 @@ nodeLinker: hoisted
blockExoticSubdeps: true
overrides:
"@agentclientprotocol/codex-acp@1.1.7>@openai/codex": 0.146.0
"@anthropic-ai/sdk": 0.115.0
"@opentelemetry/core": 2.10.0
"@opentelemetry/propagator-jaeger": 2.10.0
+246
View File
@@ -57,6 +57,141 @@ const checks: Array<{ file: string; snippets: string[] }> = [
file: "v2/Account.ts",
snippets: ['type: "apiKey"', 'type: "chatgpt"', 'type: "amazonBedrock"'],
},
{
file: "v2/AppSummary.ts",
snippets: [
"description: string | null",
"installUrl: string | null",
"category: string | null",
],
},
{
file: "v2/AppsInstalledParams.ts",
snippets: ["threadId?: string | null", "forceRefresh?: boolean"],
},
{
file: "v2/AppsInstalledResponse.ts",
snippets: ["apps: Array<InstalledApp>"],
},
{
file: "v2/AppsReadParams.ts",
snippets: ["appIds: Array<string>", "includeTools?: boolean"],
},
{
file: "v2/AppsReadResponse.ts",
snippets: ["apps: Array<ConnectorMetadata>", "missingAppIds: Array<string>"],
},
{
file: "v2/CommandExecParams.ts",
snippets: [
"command: Array<string>",
"outputBytesCap?: number | null",
"timeoutMs?: number | null",
"env?: { [key in string]?: string | null } | null",
],
},
{
file: "v2/CommandExecResponse.ts",
snippets: ["exitCode: number", "stdout: string", "stderr: string"],
},
{
file: "v2/ConfigBatchWriteParams.ts",
snippets: [
"edits: Array<ConfigEdit>",
"filePath?: string | null",
"expectedVersion?: string | null",
"reloadUserConfig?: boolean",
],
},
{
file: "v2/ConfigEdit.ts",
snippets: ["keyPath: string", "value: JsonValue", "mergeStrategy: MergeStrategy"],
},
{
file: "v2/ConfigValueWriteParams.ts",
snippets: [
"keyPath: string",
"value: JsonValue",
"mergeStrategy: MergeStrategy",
"filePath?: string | null",
"expectedVersion?: string | null",
],
},
{
file: "v2/ConfigWriteResponse.ts",
snippets: [
"status: WriteStatus",
"version: string",
"filePath: AbsolutePathBuf",
"overriddenMetadata: OverriddenMetadata | null",
],
},
{
file: "v2/InstalledApp.ts",
snippets: ["runtimeName: string | null", "enabled: boolean", "callable: boolean"],
},
{
file: "v2/MarketplaceLoadErrorInfo.ts",
snippets: ["marketplacePath: AbsolutePathBuf", "message: string"],
},
{
file: "v2/MergeStrategy.ts",
snippets: ['"replace"', '"upsert"'],
},
{
file: "v2/OverriddenMetadata.ts",
snippets: [
"message: string",
"overridingLayer: ConfigLayerMetadata",
"effectiveValue: JsonValue",
],
},
{
file: "v2/PluginSummary.ts",
snippets: ["remotePluginId: string | null"],
},
{
file: "v2/PluginListParams.ts",
snippets: ["forceRefetch?: boolean"],
},
{
file: "v2/PluginInstalledParams.ts",
snippets: [
"cwds?: Array<AbsolutePathBuf> | null",
"installSuggestionPluginNames?: Array<string> | null",
],
},
{
file: "v2/PluginInstalledResponse.ts",
snippets: [
"marketplaces: Array<PluginMarketplaceEntry>",
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
],
},
{
file: "v2/PluginListResponse.ts",
snippets: [
"marketplaces: Array<PluginMarketplaceEntry>",
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
"featuredPluginIds: Array<string>",
],
},
{
file: "v2/PluginReadParams.ts",
snippets: ["pluginName: string"],
},
{
file: "v2/PluginReadResponse.ts",
snippets: ["plugin: PluginDetail"],
},
{
file: "v2/PluginInstallParams.ts",
snippets: ["pluginName: string"],
},
{
file: "v2/PluginInstallResponse.ts",
snippets: ["appsNeedingAuth: Array<AppSummary>"],
},
{
file: "v2/ThreadStartParams.ts",
snippets: [
@@ -69,6 +204,10 @@ const checks: Array<{ file: string; snippets: string[] }> = [
file: "v2/TurnStartParams.ts",
snippets: ["permissions?: string | null", "serviceTier?: string | null"],
},
{
file: "v2/WriteStatus.ts",
snippets: ['"ok"', '"okOverridden"'],
},
{
file: "ReviewDecision.ts",
snippets: ['"approved"', '"approved_for_session"', "denied: { rejection: string }", '"abort"'],
@@ -142,9 +281,12 @@ async function checkMaintainedProtocolTypes(sourceRoot: string): Promise<void> {
const probe = `
import type {
CodexAppServerRequestParams,
CodexAppServerRequestResult,
CodexConfigEdit,
CodexDynamicToolSpec,
CodexDynamicToolCallParams,
CodexErrorNotification,
CodexGetAccountResponse,
CodexModelListResponse,
CodexThreadForkParams,
CodexThreadForkResponse,
@@ -154,11 +296,37 @@ import type {
CodexThreadStartResponse,
CodexTurnEnvironmentParams,
CodexTurnStartParams,
v2,
} from ${JSON.stringify(protocolImport)};
import type { AppSummary } from ${JSON.stringify(generatedImport("v2/AppSummary.ts"))};
import type { AppsInstalledParams } from ${JSON.stringify(generatedImport("v2/AppsInstalledParams.ts"))};
import type { AppsInstalledResponse } from ${JSON.stringify(generatedImport("v2/AppsInstalledResponse.ts"))};
import type { AppsListParams } from ${JSON.stringify(generatedImport("v2/AppsListParams.ts"))};
import type { AppsListResponse } from ${JSON.stringify(generatedImport("v2/AppsListResponse.ts"))};
import type { AppsReadParams } from ${JSON.stringify(generatedImport("v2/AppsReadParams.ts"))};
import type { AppsReadResponse } from ${JSON.stringify(generatedImport("v2/AppsReadResponse.ts"))};
import type { CommandExecParams } from ${JSON.stringify(generatedImport("v2/CommandExecParams.ts"))};
import type { CommandExecResponse } from ${JSON.stringify(generatedImport("v2/CommandExecResponse.ts"))};
import type { ConfigBatchWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigBatchWriteParams.ts"))};
import type { ConfigEdit } from ${JSON.stringify(generatedImport("v2/ConfigEdit.ts"))};
import type { ConfigValueWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigValueWriteParams.ts"))};
import type { ConfigWriteResponse } from ${JSON.stringify(generatedImport("v2/ConfigWriteResponse.ts"))};
import type { DynamicToolCallParams } from ${JSON.stringify(generatedImport("v2/DynamicToolCallParams.ts"))};
import type { DynamicToolSpec } from ${JSON.stringify(generatedImport("v2/DynamicToolSpec.ts"))};
import type { ErrorNotification } from ${JSON.stringify(generatedImport("v2/ErrorNotification.ts"))};
import type { GetAccountResponse } from ${JSON.stringify(generatedImport("v2/GetAccountResponse.ts"))};
import type { MarketplaceLoadErrorInfo } from ${JSON.stringify(generatedImport("v2/MarketplaceLoadErrorInfo.ts"))};
import type { ModelListResponse } from ${JSON.stringify(generatedImport("v2/ModelListResponse.ts"))};
import type { PluginInstalledParams } from ${JSON.stringify(generatedImport("v2/PluginInstalledParams.ts"))};
import type { PluginInstalledResponse } from ${JSON.stringify(generatedImport("v2/PluginInstalledResponse.ts"))};
import type { PluginInstallParams } from ${JSON.stringify(generatedImport("v2/PluginInstallParams.ts"))};
import type { PluginInstallResponse } from ${JSON.stringify(generatedImport("v2/PluginInstallResponse.ts"))};
import type { PluginListParams } from ${JSON.stringify(generatedImport("v2/PluginListParams.ts"))};
import type { PluginListResponse } from ${JSON.stringify(generatedImport("v2/PluginListResponse.ts"))};
import type { PluginReadParams } from ${JSON.stringify(generatedImport("v2/PluginReadParams.ts"))};
import type { PluginReadResponse } from ${JSON.stringify(generatedImport("v2/PluginReadResponse.ts"))};
import type { ThreadDeleteParams } from ${JSON.stringify(generatedImport("v2/ThreadDeleteParams.ts"))};
import type { ThreadDeleteResponse } from ${JSON.stringify(generatedImport("v2/ThreadDeleteResponse.ts"))};
import type { ThreadForkParams } from ${JSON.stringify(generatedImport("v2/ThreadForkParams.ts"))};
import type { ThreadForkResponse } from ${JSON.stringify(generatedImport("v2/ThreadForkResponse.ts"))};
import type { ThreadResumeParams } from ${JSON.stringify(generatedImport("v2/ThreadResumeParams.ts"))};
@@ -169,6 +337,33 @@ import type { TurnEnvironmentParams } from ${JSON.stringify(generatedImport("v2/
import type { TurnInterruptParams } from ${JSON.stringify(generatedImport("v2/TurnInterruptParams.ts"))};
import type { TurnStartParams } from ${JSON.stringify(generatedImport("v2/TurnStartParams.ts"))};
declare const openClawAppsInstalledParams: CodexAppServerRequestParams<"app/installed">;
const generatedAppsInstalledParams: AppsInstalledParams = openClawAppsInstalledParams;
declare const openClawAppsListParams: CodexAppServerRequestParams<"app/list">;
const generatedAppsListParams: AppsListParams = openClawAppsListParams;
declare const openClawAppsReadParams: CodexAppServerRequestParams<"app/read">;
const generatedAppsReadParams: AppsReadParams = openClawAppsReadParams;
declare const openClawAppSummary: v2.AppSummary;
const generatedAppSummary: AppSummary = openClawAppSummary;
declare const openClawCommandExecParams: CodexAppServerRequestParams<"command/exec">;
const generatedCommandExecParams: CommandExecParams = openClawCommandExecParams;
declare const generatedNullableCommandExecParams: CommandExecParams;
const openClawNullableCommandExecParams: CodexAppServerRequestParams<"command/exec"> =
generatedNullableCommandExecParams;
declare const openClawConfigBatchWriteParams: CodexAppServerRequestParams<"config/batchWrite">;
const generatedConfigBatchWriteParams: ConfigBatchWriteParams = openClawConfigBatchWriteParams;
declare const openClawConfigEdit: CodexConfigEdit;
const generatedConfigEdit: ConfigEdit = openClawConfigEdit;
declare const openClawConfigValueWriteParams: CodexAppServerRequestParams<"config/value/write">;
const generatedConfigValueWriteParams: ConfigValueWriteParams = openClawConfigValueWriteParams;
declare const openClawPluginInstalledParams: CodexAppServerRequestParams<"plugin/installed">;
const generatedPluginInstalledParams: PluginInstalledParams = openClawPluginInstalledParams;
declare const openClawPluginInstallParams: CodexAppServerRequestParams<"plugin/install">;
const generatedPluginInstallParams: PluginInstallParams = openClawPluginInstallParams;
declare const openClawPluginListParams: CodexAppServerRequestParams<"plugin/list">;
const generatedPluginListParams: PluginListParams = openClawPluginListParams;
declare const openClawPluginReadParams: CodexAppServerRequestParams<"plugin/read">;
const generatedPluginReadParams: PluginReadParams = openClawPluginReadParams;
declare const openClawDynamicToolSpec: CodexDynamicToolSpec;
const generatedDynamicToolSpec: DynamicToolSpec = openClawDynamicToolSpec;
declare const openClawTurnEnvironmentParams: CodexTurnEnvironmentParams;
@@ -179,18 +374,69 @@ declare const openClawThreadResumeParams: CodexThreadResumeParams;
const generatedThreadResumeParams: ThreadResumeParams = openClawThreadResumeParams;
declare const openClawThreadForkParams: CodexThreadForkParams;
const generatedThreadForkParams: ThreadForkParams = openClawThreadForkParams;
declare const openClawThreadDeleteParams: CodexAppServerRequestParams<"thread/delete">;
const generatedThreadDeleteParams: ThreadDeleteParams = openClawThreadDeleteParams;
declare const openClawTurnInterruptParams: CodexAppServerRequestParams<"turn/interrupt">;
const generatedTurnInterruptParams: TurnInterruptParams = openClawTurnInterruptParams;
declare const openClawTurnStartParams: CodexTurnStartParams;
const generatedTurnStartParams: TurnStartParams = openClawTurnStartParams;
declare const generatedAppsInstalledResponse: AppsInstalledResponse;
const openClawAppsInstalledResponse: CodexAppServerRequestResult<"app/installed"> =
generatedAppsInstalledResponse;
declare const generatedAppsListResponse: AppsListResponse;
const openClawAppsListResponse: CodexAppServerRequestResult<"app/list"> =
generatedAppsListResponse;
declare const generatedAppsReadResponse: AppsReadResponse;
const openClawAppsReadResponse: CodexAppServerRequestResult<"app/read"> =
generatedAppsReadResponse;
declare const generatedAppSummaryResponse: AppSummary;
const openClawAppSummaryResponse: v2.AppSummary = generatedAppSummaryResponse;
declare const generatedCommandExecResponse: CommandExecResponse;
const openClawCommandExecResponse: CodexAppServerRequestResult<"command/exec"> =
generatedCommandExecResponse;
declare const generatedConfigWriteResponse: ConfigWriteResponse;
const openClawConfigBatchWriteResponse: CodexAppServerRequestResult<"config/batchWrite"> =
generatedConfigWriteResponse;
const openClawConfigValueWriteResponse: CodexAppServerRequestResult<"config/value/write"> =
generatedConfigWriteResponse;
const generatedExactConfigBatchWriteResponse: ConfigWriteResponse =
openClawConfigBatchWriteResponse;
const generatedExactConfigValueWriteResponse: ConfigWriteResponse =
openClawConfigValueWriteResponse;
declare const generatedPluginInstalledResponse: PluginInstalledResponse;
const openClawPluginInstalledResponse: CodexAppServerRequestResult<"plugin/installed"> =
generatedPluginInstalledResponse;
const generatedPluginInstalledMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
openClawPluginInstalledResponse.marketplaceLoadErrors;
type InstalledPluginResponseHasNoFeaturedCatalog =
"featuredPluginIds" extends keyof v2.PluginInstalledResponse ? never : true;
const installedPluginResponseHasNoFeaturedCatalog: InstalledPluginResponseHasNoFeaturedCatalog =
true;
declare const generatedPluginInstallResponse: PluginInstallResponse;
const openClawPluginInstallResponse: CodexAppServerRequestResult<"plugin/install"> =
generatedPluginInstallResponse;
declare const generatedPluginListResponse: PluginListResponse;
const openClawPluginListResponse: CodexAppServerRequestResult<"plugin/list"> =
generatedPluginListResponse;
const generatedPluginListMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
openClawPluginListResponse.marketplaceLoadErrors;
const generatedPluginListFeaturedPluginIds: string[] = openClawPluginListResponse.featuredPluginIds;
declare const generatedPluginReadResponse: PluginReadResponse;
const openClawPluginReadResponse: CodexAppServerRequestResult<"plugin/read"> =
generatedPluginReadResponse;
declare const generatedDynamicToolCallParams: Omit<DynamicToolCallParams, "arguments">;
const openClawDynamicToolCallParams: Omit<CodexDynamicToolCallParams, "arguments"> =
generatedDynamicToolCallParams;
declare const generatedErrorNotification: ErrorNotification;
const openClawErrorNotification: CodexErrorNotification = generatedErrorNotification;
declare const generatedGetAccountResponse: GetAccountResponse;
const openClawGetAccountResponse: CodexGetAccountResponse = generatedGetAccountResponse;
declare const generatedModelListResponse: ModelListResponse;
const openClawModelListResponse: CodexModelListResponse = generatedModelListResponse;
declare const generatedThreadDeleteResponse: ThreadDeleteResponse;
const openClawThreadDeleteResponse: CodexAppServerRequestResult<"thread/delete"> =
generatedThreadDeleteResponse;
// Thread and turn bodies are normalized behind checked-in JSON schemas. Their
// raw generated shapes must not be confused with the projector-facing types.
@@ -191,6 +191,55 @@ describe("sandbox pinned mutation helper", () => {
},
);
it.runIf(process.platform !== "win32")(
"bounds pinned file reads and rejects growth on the opened descriptor",
async () => {
await withTempDir({ prefix: "openclaw-mutation-bounded-read-" }, async (root) => {
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(path.join(workspace, "exact.txt"), "hello", "utf8");
await fs.writeFile(path.join(workspace, "empty.txt"), "", "utf8");
await fs.writeFile(path.join(workspace, "growing.txt"), "hello", "utf8");
const exact = runMutation(["read", workspace, "", "exact.txt", "5"]);
expect(exact.status).toBe(0);
expect(exact.stdout).toBe("hello");
const oversized = runMutation(["read", workspace, "", "exact.txt", "4"]);
expect(oversized.status).not.toBe(0);
expect(oversized.stdout).toBe("");
expect(oversized.stderr).toMatch(/bounded read limit/i);
const empty = runMutation(["read", workspace, "", "empty.txt", "0"]);
expect(empty.status).toBe(0);
expect(empty.stdout).toBe("");
const growingSource = SANDBOX_PINNED_MUTATION_PYTHON.replace(
" if max_bytes is not None and file_stat.st_size > max_bytes:",
[
" if basename == 'growing.txt':",
" growth_fd = os.open(basename, os.O_WRONLY | os.O_APPEND, dir_fd=parent_fd)",
" try:",
" write_all(growth_fd, b'!')",
" finally:",
" os.close(growth_fd)",
" if max_bytes is not None and file_stat.st_size > max_bytes:",
].join("\n"),
);
const grown = runMutationWithSource(growingSource, [
"read",
workspace,
"",
"growing.txt",
"5",
]);
expect(grown.status).not.toBe(0);
expect(grown.stdout).toBe("");
expect(grown.stderr).toMatch(/bounded read limit/i);
});
},
);
it.runIf(process.platform !== "win32")(
"copies regular files atomically and rejects hardlinked sources",
async () => {
@@ -201,7 +201,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" except FileNotFoundError:",
" pass",
"",
"def read_file(parent_fd, basename):",
"def read_file_impl(parent_fd, basename, max_bytes):",
" file_fd = os.open(basename, READ_FLAGS, dir_fd=parent_fd)",
" try:",
" file_stat = os.fstat(file_fd)",
@@ -209,14 +209,29 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" raise OSError(errno.EPERM, 'only regular files are allowed', basename)",
" if file_stat.st_nlink > 1:",
" raise OSError(errno.EPERM, 'hardlinked file is not allowed', basename)",
" if max_bytes is not None and file_stat.st_size > max_bytes:",
" raise OSError(errno.EFBIG, 'file exceeds bounded read limit', basename)",
" bytes_read = 0",
" while True:",
" chunk = os.read(file_fd, 65536)",
" read_size = 65536 if max_bytes is None else min(65536, max_bytes - bytes_read + 1)",
" chunk = os.read(file_fd, read_size)",
" if not chunk:",
" break",
" os.write(1, chunk)",
" bytes_read += len(chunk)",
" if max_bytes is not None and bytes_read > max_bytes:",
" raise OSError(errno.EFBIG, 'file exceeds bounded read limit', basename)",
" write_all(1, chunk)",
" finally:",
" os.close(file_fd)",
"",
"def read_file(parent_fd, basename):",
" read_file_impl(parent_fd, basename, None)",
"",
"def read_file_bounded(parent_fd, basename, max_bytes):",
" if max_bytes < 0:",
" raise OSError(errno.EINVAL, 'read limit must be non-negative', basename)",
" read_file_impl(parent_fd, basename, max_bytes)",
"",
"def remove_tree(parent_fd, basename):",
" entry_stat = os.lstat(basename, dir_fd=parent_fd)",
" if not stat.S_ISDIR(entry_stat.st_mode) or stat.S_ISLNK(entry_stat.st_mode):",
@@ -415,7 +430,10 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" parent_fd = None",
" try:",
" parent_fd = walk_dir(root_fd, sys.argv[3], False)",
" read_file(parent_fd, sys.argv[4])",
" if len(sys.argv) > 5:",
" read_file_bounded(parent_fd, sys.argv[4], int(sys.argv[5]))",
" else:",
" read_file(parent_fd, sys.argv[4])",
" finally:",
" if parent_fd is not None:",
" os.close(parent_fd)",
@@ -15,6 +15,7 @@ import {
getDockerScript,
installFsBridgeTestHarness,
mockedExecDockerRaw,
mockedOpenRootFile,
withTempDir,
} from "./fs-bridge.test-helpers.js";
@@ -82,6 +83,71 @@ describe("sandbox fs bridge anchored ops", () => {
});
});
it.each([
{ name: "empty files", contents: "", maxBytes: 0 },
{ name: "files at the exact limit", contents: "hello", maxBytes: 5 },
{
name: "files spanning bounded read chunks",
contents: "x".repeat(64 * 1024 + 1),
maxBytes: 64 * 1024 + 1,
},
])("reads $name through one pinned descriptor", async (testCase) => {
await withTempDir("openclaw-fs-bridge-bounded-read-", async (stateDir) => {
const { bridge } = await createSeededSandboxFsBridge(stateDir, {
rootContents: testCase.contents,
});
await expect(
bridge.readFile({ filePath: "from.txt", maxBytes: testCase.maxBytes }),
).resolves.toEqual(Buffer.from(testCase.contents));
expect(mockedOpenRootFile).toHaveBeenCalledTimes(1);
expect(mockedExecDockerRaw).not.toHaveBeenCalled();
});
});
it.each([
{ name: "oversized files", maxBytes: 4, error: /exceeds 4 bytes/ },
{ name: "negative limits", maxBytes: -1, error: /non-negative safe integer/ },
{ name: "unsafe limits", maxBytes: Number.NaN, error: /non-negative safe integer/ },
])("rejects $name without an unbounded read", async (testCase) => {
await withTempDir("openclaw-fs-bridge-bounded-reject-", async (stateDir) => {
const { bridge } = await createSeededSandboxFsBridge(stateDir, {
rootContents: "hello",
});
await expect(
bridge.readFile({ filePath: "from.txt", maxBytes: testCase.maxBytes }),
).rejects.toThrow(testCase.error);
expect(mockedOpenRootFile).toHaveBeenCalledTimes(1);
expect(mockedExecDockerRaw).not.toHaveBeenCalled();
});
});
it("rejects files that grow after the sandbox descriptor is opened", async () => {
await withTempDir("openclaw-fs-bridge-bounded-growth-", async (stateDir) => {
const { bridge, workspaceDir } = await createSeededSandboxFsBridge(stateDir, {
rootContents: "hello",
});
const openRootFile = mockedOpenRootFile.getMockImplementation();
if (!openRootFile) {
throw new Error("expected the real sandbox root-file opener");
}
mockedOpenRootFile.mockImplementationOnce(async (params) => {
const opened = await openRootFile(params);
if (opened.ok) {
await fs.appendFile(path.join(workspaceDir, "from.txt"), "!");
}
return opened;
});
await expect(bridge.readFile({ filePath: "from.txt", maxBytes: 5 })).rejects.toThrow(
/exceeds 5 bytes/,
);
expect(mockedOpenRootFile).toHaveBeenCalledTimes(1);
expect(mockedExecDockerRaw).not.toHaveBeenCalled();
});
});
const pinnedCases = [
{
name: "write pins canonical parent + basename",
+25 -3
View File
@@ -5,6 +5,7 @@
*/
import fs from "node:fs";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { readFileDescriptorBoundedSync } from "../../infra/boundary-file-read.js";
import type {
SandboxBackendCommandResult,
SandboxFsBridgeContext,
@@ -76,9 +77,10 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
filePath: string;
cwd?: string;
signal?: AbortSignal;
maxBytes?: number;
}): Promise<Buffer> {
const target = this.resolveResolvedPath(params);
return this.readPinnedFile(target);
return this.readPinnedFile(target, params.maxBytes);
}
async copyFile(params: {
@@ -281,10 +283,30 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
});
}
private async readPinnedFile(target: SandboxResolvedFsPath): Promise<Buffer> {
private async readPinnedFile(target: SandboxResolvedFsPath, maxBytes?: number): Promise<Buffer> {
const opened = await this.pathGuard.openReadableFile(target);
try {
return fs.readFileSync(opened.fd);
if (maxBytes === undefined) {
return fs.readFileSync(opened.fd);
}
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError("maxBytes must be a non-negative safe integer");
}
const initialStat = fs.fstatSync(opened.fd);
if (!initialStat.isFile()) {
throw new Error(`Sandbox read requires a regular file: ${target.containerPath}`);
}
if (initialStat.size > maxBytes) {
throw new RangeError(`File exceeds ${maxBytes} bytes`);
}
// Read and recheck the same guarded descriptor so path swaps and file
// growth cannot bypass the byte limit or allocate an unbounded buffer.
const data = readFileDescriptorBoundedSync(opened.fd, maxBytes);
const finalStat = fs.fstatSync(opened.fd);
if (!finalStat.isFile() || finalStat.size > maxBytes) {
throw new RangeError(`File exceeds ${maxBytes} bytes`);
}
return data;
} finally {
fs.closeSync(opened.fd);
}
+7 -1
View File
@@ -21,7 +21,13 @@ export type SandboxFsStat = {
/** Filesystem operations exposed across the sandbox boundary. */
export type SandboxFsBridge = {
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath;
readFile(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<Buffer>;
/** Reads a safely opened regular file, rejecting growth beyond an optional byte limit. */
readFile(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
maxBytes?: number;
}): Promise<Buffer>;
/** Streams a regular file within the sandbox when the backend supports native copying. */
copyFile?(params: {
sourcePath: string;
@@ -133,6 +133,39 @@ describe("remote sandbox fs bridge", () => {
},
);
it.runIf(process.platform !== "win32")(
"forwards and enforces bounded pinned file reads",
async () => {
await withTempDir("openclaw-remote-fs-bounded-read-", async (stateDir) => {
const workspacePath = path.join(stateDir, "workspace");
await fs.mkdir(workspacePath, { recursive: true });
const workspaceDir = await fs.realpath(workspacePath);
await fs.writeFile(path.join(workspaceDir, "note.txt"), "hello", "utf8");
const { calls, runtime } = createLocalRemoteRuntime({
remoteWorkspaceDir: workspaceDir,
remoteAgentWorkspaceDir: workspaceDir,
});
const bridge = createRemoteShellSandboxFsBridge({
sandbox: createSandbox({ workspaceDir, agentWorkspaceDir: workspaceDir }),
runtime,
});
await expect(bridge.readFile({ filePath: "note.txt", maxBytes: 5 })).resolves.toEqual(
Buffer.from("hello"),
);
expect(calls[0]?.args).toEqual(["read", workspaceDir, "", "note.txt", "5"]);
await expect(bridge.readFile({ filePath: "note.txt", maxBytes: 4 })).rejects.toThrow(
/bounded read limit/i,
);
await expect(bridge.readFile({ filePath: "note.txt", maxBytes: -1 })).rejects.toThrow(
/non-negative safe integer/i,
);
expect(calls).toHaveLength(2);
});
},
);
it.runIf(process.platform !== "win32")(
"streams file copies with the pinned mutation helper",
async () => {
+8
View File
@@ -80,7 +80,14 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
filePath: string;
cwd?: string;
signal?: AbortSignal;
maxBytes?: number;
}): Promise<Buffer> {
if (
params.maxBytes !== undefined &&
(!Number.isSafeInteger(params.maxBytes) || params.maxBytes < 0)
) {
throw new RangeError("Sandbox file read limit must be a non-negative safe integer.");
}
const target = this.resolveTarget(params);
const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
if (
@@ -96,6 +103,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
target.mountRootPath,
path.posix.dirname(relativePath) === "." ? "" : path.posix.dirname(relativePath),
path.posix.basename(relativePath),
...(params.maxBytes === undefined ? [] : [String(params.maxBytes)]),
],
signal: params.signal,
});