Merge branch 'main' into meow/whatsapp-qr-actions

This commit is contained in:
Val Alexander
2026-05-07 20:14:51 -05:00
committed by GitHub
255 changed files with 10656 additions and 1529 deletions

View File

@@ -6,9 +6,12 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev.
- Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146)
- Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus.
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
- Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply.
- Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0-alpha.15` and defer OpenClaw integration dynamic tools behind Codex tool search by default, saving roughly 5.5k upfront dynamic-tool tokens on source-reply Codex turns while keeping `codexDynamicToolsLoading: "direct"` as a compatibility escape hatch.
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.
- Discord/voice: keep TTS playback running when another user starts speaking, ignore new capture during playback to avoid feedback loops, and downgrade expected receive-stream aborts to verbose diagnostics.
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana.
@@ -20,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: make voice capture less choppy by extending the default post-speech silence grace to 2.5s, add `voice.captureSilenceGraceMs` for noisy Discord sessions, and tighten the spoken-output prompt around live STT fragments. Thanks @vincentkoc.
- Discord/streaming: default Discord replies to progress draft previews so tool/work activity appears in one edited Discord message unless `channels.discord.streaming.mode` is set to `off`.
- OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model.
- OpenAI/realtime: default realtime voice to `gpt-realtime-2`, use the GA Realtime WebSocket session shape for backend OpenAI bridges, and cover backend, WebRTC, Google Live, and Gateway relay paths in the live Talk smoke. (#79130)
- Plugins/install: add `npm-pack:<path.tgz>` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins.
- Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen.
- Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu.
@@ -28,6 +32,8 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
- Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash.
- Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.
- Codex/plugins: enable migrated source-installed `openai-curated` Codex plugins in the same Codex harness thread with explicit `codexPlugins` config, cached app readiness, and fail-closed destructive-action policy. Thanks @kevinslin.
- Codex/plugins: enforce native plugin destructive-action policy with Codex app-level `destructive_enabled` config instead of OpenClaw-maintained per-tool deny lists, leave plugin app `open_world_enabled` on by default, and invalidate existing plugin app thread bindings so old generated app config is rebuilt. Thanks @kevinslin.
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
- ACPX/Codex: preserve trusted Codex project declarations when launching isolated Codex ACP sessions, avoiding interactive trust prompts in headless runs. Thanks @Stedyclaw.
@@ -73,7 +79,7 @@ Docs: https://docs.openclaw.ai
- Gateway/performance: avoid resolving plugin auto-enable metadata twice in one runtime config pass, reducing repeated dashboard turn metadata scans. Thanks @shakkernerd.
- Auth/providers: pass `config` and `workspaceDir` lookup context through to provider-id resolution so workspace-scoped auth aliases resolve correctly when no explicit alias map is supplied. Thanks @shakkernerd.
- Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and opt-in sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc.
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
- QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence.
@@ -127,7 +133,7 @@ Docs: https://docs.openclaw.ai
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
- Control UI/performance: record browser long animation frame or long task entries in the debug event log when supported, making slow dashboard renders easier to attribute from the UI.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and opt-in sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- QA/Codex harness: add targeted live Docker/Testbox diagnostics, auth preflight checks, cache mount fixes, and app-server protocol checkout discovery so maintainer harness failures are easier to reproduce. Thanks @vincentkoc.
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
- QA/Mantis: add visual desktop tasks with Crabbox MP4 recording, screenshot capture, and optional image-understanding assertions, and preserve video artifacts in Mantis before/after reports.
@@ -156,7 +162,12 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
- Providers: preserve non-OK `text/event-stream` response bodies so provider HTTP errors keep their JSON detail instead of collapsing to generic streaming failures. Fixes #78180.
- Gateway/auth: make explicit `trusted-proxy` mode fail closed instead of accepting local password fallback credentials after trusted-proxy identity checks fail. Fixes #78684.
- Active memory: treat Google Chat `spaces/...` conversation ids as scoped targets instead of runnable channel names so recall runs no longer fail bundled-plugin dirName validation. Fixes #78918.
- Active memory: make `/active-memory status` honor the configured agent allowlist instead of reporting on for agents where recall is disabled. Fixes #78986.
- Mistral: normalize structured OpenAI-compatible completions content blocks so thinking objects are not persisted as `[object Object]` visible reply text. Fixes #78846.
- Tools/session status: render the active heartbeat/run model for `session_status({"sessionKey":"current"})` instead of falling back to the persisted session default. Fixes #77493.
- Doctor/secrets: allow safe inherited exec SecretRef `passEnv` names such as `HOME` while still blocking dangerous runtime env hooks. Fixes #78216.
- Chat commands: make `/model default` reset the session model override instead of treating it as a literal model name. Fixes #78182.
@@ -166,6 +177,7 @@ Docs: https://docs.openclaw.ai
- Skills: cap skills watcher directory traversal at the same depth used by skill discovery so large non-skill trees under configured skill roots do not exhaust file descriptors on startup. Fixes #75501. Thanks @wzq-xzwj.
- Docs/Docker: document a local Compose override for Docker Desktop DNS failures in the shared-network `openclaw-cli` sidecar, keeping the default compose setup hardened while unblocking `openclaw plugins install` when users opt in. Fixes #79018. Thanks @Jason-Vaughan.
- Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762.
- Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews.
- Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987.
- fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987.
- Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987.

View File

@@ -1,2 +1,2 @@
28e280d21693216c99cfa8da553589b41741d37c0ada956e316ee01d3d6c202c plugin-sdk-api-baseline.json
633dae33da97f6a073c5561709c57d5c0b7ff67af0512d0261f05455c24b38de plugin-sdk-api-baseline.jsonl
887d2fee5f77f1de984bfb6ec0f001c0484c0367dbc8b5f42b62027df352c2e1 plugin-sdk-api-baseline.json
8e2b4e64a801b47c4d45d5d4a2073180abcc1ecf7e677fae035799c6a68f7c82 plugin-sdk-api-baseline.jsonl

View File

@@ -662,7 +662,7 @@ Default slash command settings:
</Accordion>
<Accordion title="Live stream preview">
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label is a rolling line, so it scrolls away like the rest once enough work appears. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
Set `channels.discord.streaming.mode` to `off` to disable Discord preview edits. If Discord block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming.
@@ -687,6 +687,7 @@ Default slash command settings:
- `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints, clamped to `textChunkLimit`).
- Media, error, and explicit-reply finals cancel pending preview edits.
- `streaming.preview.toolProgress` (default `true`) controls whether tool/progress updates reuse the preview message.
- Tool/progress rows render as compact emoji + title + detail when available, for example `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`.
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only).
Hide raw command/exec text while keeping compact progress lines:
@@ -1184,7 +1185,10 @@ Auto-join example:
reconnectGraceMs: 15000,
tts: {
provider: "openai",
openai: { voice: "onyx" },
openai: {
model: "gpt-4o-mini-tts",
voice: "cedar",
},
},
},
},
@@ -1195,8 +1199,9 @@ Auto-join example:
Notes:
- `voice.tts` overrides `messages.tts` for voice playback only.
- `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model.
- `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model. Do not set this to `gpt-realtime-2`; Discord voice channels use STT plus TTS playback, not the OpenAI Realtime session transport.
- STT uses `tools.media.audio`; `voice.model` does not affect transcription.
- For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.openai.voice` or `voice.tts.providers.openai.voice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model.
- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel.
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent.

View File

@@ -21,9 +21,11 @@ openclaw migrate list
openclaw migrate claude --dry-run
openclaw migrate codex --dry-run
openclaw migrate codex --skill gog-vault77-google-workspace
openclaw migrate codex --plugin google-calendar --dry-run
openclaw migrate hermes --dry-run
openclaw migrate hermes
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
openclaw migrate apply codex --yes --plugin google-calendar
openclaw migrate apply codex --yes
openclaw migrate apply claude --yes
openclaw migrate apply hermes --yes
@@ -54,6 +56,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
<ParamField path="--skill <name>" type="string">
Select one skill copy item by skill name or item id. Repeat the flag to migrate multiple skills. When omitted, interactive Codex migrations show a checkbox selector and non-interactive migrations keep all planned skills.
</ParamField>
<ParamField path="--plugin <name>" type="string">
Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. This only applies to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory.
</ParamField>
<ParamField path="--no-backup" type="boolean">
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
</ParamField>
@@ -129,20 +134,51 @@ openclaw migrate codex --dry-run --skill gog-vault77-google-workspace
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
```
Use `--plugin <name>` to limit native Codex plugin migration to one or more
source-installed curated plugins:
```bash
openclaw migrate codex --dry-run --plugin google-calendar
openclaw migrate apply codex --yes --plugin google-calendar
```
### What Codex imports
- Codex CLI skill directories under `$CODEX_HOME/skills`, excluding Codex's
`.system` cache.
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
OpenClaw agent workspace when you want per-agent ownership.
- Source-installed `openai-curated` Codex plugins discovered through Codex
app-server `plugin/list`. Apply calls app-server `plugin/install` for each
selected plugin, even if the target app-server already reports that plugin as
installed and enabled. Migrated Codex plugins are usable only in sessions that
select the native Codex harness; they are not exposed to Pi, normal OpenAI
provider runs, ACP conversation bindings, or other harnesses.
### Manual-review Codex state
Codex native plugins, `config.toml`, and native `hooks/hooks.json` are not
activated automatically. Plugins may expose MCP servers, apps, hooks, or other
executable behavior, so the provider reports them for review instead of loading
them into OpenClaw. Config and hook files are copied into the migration report
for manual review.
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, and
cached plugin bundles that are not source-installed curated plugins are not
activated automatically. They are copied or reported in the migration report for
manual review.
For migrated source-installed curated plugins, apply writes:
- `plugins.entries.codex.enabled: true`
- `plugins.entries.codex.config.codexPlugins.enabled: true`
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions: false`
- one explicit plugin entry with `marketplaceName: "openai-curated"` and
`pluginName` for each selected plugin
Migration never writes `plugins["*"]` and never stores local marketplace cache
paths. Auth-required installs are reported on the affected plugin item with
`status: "skipped"`, `reason: "auth_required"`, and sanitized app identifiers.
Their explicit config entries are written disabled until you reauthorize and
enable them. Other install failures are item-scoped `error` results.
If Codex app-server plugin inventory is unavailable during planning, migration
falls back to cached bundle advisory items instead of failing the whole
migration.
## Hermes provider

View File

@@ -33,7 +33,7 @@ It also emits `security.trust_model.multi_user_heuristic` when config suggests l
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.token` reuses the Gateway token, when `hooks.token` is short, when `hooks.path="/"`, when `hooks.defaultSessionKey` is unset, when `hooks.allowedAgentIds` is unrestricted, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).

View File

@@ -18,9 +18,9 @@ into the final answer when the channel can do that safely.
```text
Shelling...
📖 Read: from docs/concepts/progress-drafts.md
📖 from docs/concepts/progress-drafts.md
🔎 Web Search: for "discord edit message"
🛠️ Exec: run tests
🛠️ Bash: run tests
```
Use progress drafts when you want one tidy status message during tool-heavy work
@@ -51,15 +51,17 @@ progress chatter for that turn.
A progress draft has two parts:
| Part | Purpose |
| -------------- | --------------------------------------------------------------------------- |
| Label | A short title such as `Thinking...` or `Shelling...`. |
| Progress lines | Compact run updates using the same tool labels and icons as verbose output. |
| Part | Purpose |
| -------------- | ------------------------------------------------------------------------------------- |
| Label | A short starter/status line such as `Thinking...` or `Shelling...`. |
| Progress lines | Compact run updates using the same tool icons and detail formatter as verbose output. |
The label appears after the agent starts meaningful work and either remains busy
for five seconds or emits a second work event. Plain text-only replies do not
show a progress draft. Progress lines are added only when the agent emits useful
work updates, for example `🛠️ Exec`, `🔎 Web Search`, or `✍️ Write: to /tmp/file`.
for five seconds or emits a second work event. It is part of the rolling progress
line list, so the starter status scrolls away once enough concrete work appears.
Plain text-only replies do not show a progress draft. Progress lines are added
only when the agent emits useful work updates, for example `🛠️ Bash: run tests`,
`🔎 Web Search: for "discord edit message"`, or `✍️ Write: to /tmp/file`.
By default they use the same compact explain mode as `/verbose`; set
`agents.defaults.toolProgressDetail: "raw"` when debugging and you also want raw
commands/details appended.
@@ -189,16 +191,16 @@ OpenClaw uses the same formatter for progress drafts and `/verbose`:
```
`"explain"` is the default and keeps drafts stable with concise labels like
`🛠️ Exec: check JS syntax for /tmp/app.js`. `"raw"` appends the underlying
`🛠️ check JS syntax for /tmp/app.js`. `"raw"` appends the underlying
command/detail when available, which is useful while debugging but noisier in
chat.
For example, the same command appears differently depending on the detail mode:
| Mode | Progress line |
| --------- | -------------------------------------------------------------------- |
| `explain` | `🛠️ Exec: check JS syntax for /tmp/app.js` |
| `raw` | `🛠️ Exec: check JS syntax for /tmp/app.js, node --check /tmp/app.js` |
| Mode | Progress line |
| --------- | -------------------------------------------------------------- |
| `explain` | `🛠️ check JS syntax for /tmp/app.js` |
| `raw` | `🛠️ check JS syntax for /tmp/app.js, node --check /tmp/app.js` |
Limit how many lines stay visible:

View File

@@ -1388,8 +1388,8 @@ Defaults for Talk mode (macOS/iOS/Android).
provider: "openai",
providers: {
openai: {
model: "gpt-realtime",
voice: "alloy",
model: "gpt-realtime-2",
voice: "cedar",
},
},
mode: "realtime",

View File

@@ -200,6 +200,70 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
- Channel plugin account/runtime settings live under `channels.<id>` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry.
### Codex harness plugin config
The bundled `codex` plugin owns native Codex app-server harness settings under
`plugins.entries.codex.config`. See [Codex harness](/plugins/codex-harness) for
the full runtime model.
`codexPlugins` applies only to sessions that select the native Codex harness.
It does not enable Codex plugins for Pi, normal OpenAI provider runs, ACP
conversation bindings, or any non-Codex harness.
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "google-calendar",
allow_destructive_actions: false,
},
},
},
},
},
},
},
}
```
- `plugins.entries.codex.config.codexPlugins.enabled`: enables native Codex
plugin/app support for the Codex harness. Default: `false`.
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
default destructive-action policy for migrated plugin app elicitations.
Default: `false`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
migrated plugin entry when global `codexPlugins.enabled` is also true.
Default: `true` for explicit entries.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.marketplaceName`:
stable marketplace identity. V1 only supports `"openai-curated"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.pluginName`: stable
Codex plugin identity from migration, for example `"google-calendar"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
per-plugin destructive-action override. When omitted, the global
`allow_destructive_actions` value is used.
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
entries written by migration are the durable install and repair eligibility set.
`plugins["*"]` is not supported, there is no `install` switch, and local
`marketplacePath` values are intentionally not config fields because they are
host-specific.
`app/list` readiness checks are cached for one hour and refreshed
asynchronously when stale. Codex thread app config is computed at Codex harness
session establishment, not on every turn; use `/new`, `/reset`, or a gateway
restart after changing native plugin config.
- `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings.
- `apiKey`: Firecrawl API key (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var.
- `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`; self-hosted overrides must target private/internal endpoints).

View File

@@ -64,6 +64,7 @@ Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool.
- Tool policy filters tool availability by name; it does not inspect side effects inside `exec`. If `exec` is allowed, denying `write`, `edit`, or `apply_patch` does not make shell commands read-only.
- `/exec` only changes session defaults for authorized senders; it does not grant tool access.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`).
@@ -88,6 +89,7 @@ Available groups:
- `group:runtime`: `exec`, `process`, `code_execution` (`bash` is accepted as
an alias for `exec`)
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
For read-only agents, deny `group:runtime` as well as mutating filesystem tools unless sandbox filesystem policy or a separate host boundary enforces the read-only constraint.
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:web`: `web_search`, `x_search`, `web_fetch`

View File

@@ -91,6 +91,7 @@ exhaustive):
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` fails closed when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |

View File

@@ -220,6 +220,7 @@ Advisory triage guidance:
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
- **Exec filesystem drift**: are mutating filesystem tools denied while `exec`/`process` remain available without sandbox filesystem constraints?
- **Exec approval drift** (`security=full`, `autoAllowSkills`, interpreter allowlists without `strictInlineEval`): are host-exec guardrails still doing what you think they are?
- `security="full"` is a broad posture warning, not proof of a bug. It is the chosen default for trusted personal-assistant setups; tighten it only when your threat model needs approval or allowlist guardrails.
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel, weak/short auth tokens).

View File

@@ -97,6 +97,7 @@ If a tool is not allowed by policy, the endpoint returns **404**.
Important boundary notes:
- Exec approvals are operator guardrails, not a separate authorization boundary for this HTTP endpoint. If a tool is reachable here via Gateway auth + tool policy, `/tools/invoke` does not add an extra per-call approval prompt.
- If `exec` is reachable here, treat it as a mutating shell surface. Denying `write`, `edit`, `apply_patch`, or HTTP filesystem-write tools does not make shell execution read-only.
- Do not share Gateway bearer credentials with untrusted callers. If you need separation across trust boundaries, run separate gateways (and ideally separate OS users/hosts).
Gateway HTTP also applies a hard deny list by default (even if session policy allows the tool):

View File

@@ -96,9 +96,9 @@ add Node's sync I/O trace flag through the source runner:
OPENCLAW_TRACE_SYNC_IO=1 pnpm openclaw gateway --force
```
`pnpm gateway:watch` enables this flag by default for the watched Gateway child.
Set `OPENCLAW_TRACE_SYNC_IO=0` to suppress Node sync I/O trace output in watch
mode.
`pnpm gateway:watch` leaves this flag disabled by default for the watched
Gateway child. Set `OPENCLAW_TRACE_SYNC_IO=1` when you explicitly want Node
sync I/O trace output in watch mode.
## Gateway watch mode

View File

@@ -195,6 +195,10 @@ inside every shard.
- Installs an OpenClaw package candidate in Docker, runs installed-package
onboarding, configures Telegram through the installed CLI, then reuses the
live Telegram QA lane with that installed package as the SUT Gateway.
- The wrapper mounts only the `qa-lab` harness source from the checkout; the
installed package owns `dist`, `openclaw/plugin-sdk`, and bundled plugin
runtime so the lane does not mix current checkout plugins into the package
under test.
- Defaults to `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@beta`; set
`OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ=/path/to/openclaw-current.tgz` or
`OPENCLAW_CURRENT_PACKAGE_TGZ` to test a resolved local tarball instead of

View File

@@ -81,8 +81,8 @@ Supported keys:
providers: {
openai: {
apiKey: "openai_api_key",
model: "gpt-realtime",
voice: "alloy",
model: "gpt-realtime-2",
voice: "cedar",
},
},
mode: "realtime",
@@ -104,6 +104,7 @@ Defaults:
- `providers.elevenlabs.apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available).
- `realtime.provider`: selects the active browser/server realtime voice provider. Use `openai` for WebRTC, `google` for provider WebSocket, or a bridge-only provider through Gateway relay.
- `realtime.providers.<provider>` stores provider-owned realtime config. The browser receives only ephemeral or constrained session credentials, never a standard API key.
- `realtime.providers.openai.voice`: built-in OpenAI Realtime voice id. Current `gpt-realtime-2` voices are `alloy`, `ash`, `ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, `marin`, and `cedar`; `marin` and `cedar` are recommended for best quality.
- `realtime.brain`: `agent-consult` routes realtime tool calls through Gateway policy; `direct-tools` is owner-only compatibility behavior; `none` is for transcription or external orchestration.
- `talk.catalog` exposes each provider's valid modes, transports, brain strategies, realtime audio formats, and capability flags so first-party Talk clients can avoid unsupported combinations.
- Streaming transcription providers are discovered through `talk.catalog.transcription`. The current Gateway relay uses the Voice Call streaming provider config until the dedicated Talk transcription config surface is added.

View File

@@ -22,9 +22,9 @@ it only posts to the channel when it calls `message(action="send")`. Set
`messages.visibleReplies: "automatic"` to keep direct-chat final replies on the
legacy automatic delivery path.
Codex heartbeat turns also get the `heartbeat_respond` tool by default, so the
agent can record whether the wake should stay quiet or notify without encoding
that control flow in final text.
Codex heartbeat turns also get `heartbeat_respond` in the searchable OpenClaw
tool catalog by default, so the agent can record whether the wake should stay
quiet or notify without encoding that control flow in final text.
Heartbeat-specific initiative guidance is sent as a Codex collaboration-mode
developer instruction on the heartbeat turn itself. Ordinary chat turns restore
@@ -563,9 +563,11 @@ openclaw migrate apply codex --yes
```
The Codex migration provider copies skills into the current OpenClaw agent
workspace. Codex native plugins, hooks, and config files are reported or archived
for manual review instead of being activated automatically, because they can
execute commands, expose MCP servers, or carry credentials.
workspace. For source-installed `openai-curated` Codex plugins, migration also
calls Codex app-server `plugin/install` and records explicit native plugin
config under `plugins.entries.codex.config.codexPlugins`. Codex config files,
hooks, and cached plugin bundles that are not source-installed curated plugins
remain report-only manual-review items.
Auth is selected in this order:
@@ -606,19 +608,30 @@ If a deployment needs additional environment isolation, add those variables to
`appServer.clearEnv` only affects the spawned Codex app-server child process.
Codex dynamic tools default to the `native-first` profile. In that mode,
OpenClaw does not expose dynamic tools that duplicate Codex-native workspace
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
`update_plan`. OpenClaw integration tools such as messaging, sessions, media,
cron, browser, nodes, gateway, `heartbeat_respond`, and `web_search` remain
available.
Codex dynamic tools default to the `native-first` profile and `searchable`
loading. In that mode, OpenClaw does not expose dynamic tools that duplicate
Codex-native workspace operations: `read`, `write`, `edit`, `apply_patch`,
`exec`, `process`, and `update_plan`. Remaining OpenClaw integration tools such
as messaging, sessions, media, cron, browser, nodes, gateway,
`heartbeat_respond`, and `web_search` are available through Codex tool search
under the `openclaw` namespace, keeping the initial model context smaller.
`sessions_yield` and message-tool-only source replies stay direct because those
are turn-control contracts. Heartbeat collaboration instructions tell Codex to
search for `heartbeat_respond` before ending a heartbeat turn when the tool is
not already loaded.
Set `codexDynamicToolsLoading: "direct"` only when connecting to a custom Codex
app-server that cannot search deferred dynamic tools or when debugging the full
tool payload.
Supported top-level Codex plugin fields:
| Field | Default | Meaning |
| -------------------------- | ---------------- | ----------------------------------------------------------------------------------------- |
| `codexDynamicToolsProfile` | `"native-first"` | Use `"openclaw-compat"` to expose the full OpenClaw dynamic tool set to Codex app-server. |
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. |
Supported `appServer` fields:
@@ -674,6 +687,106 @@ Environment overrides remain available for local testing:
preferred for repeatable deployments because it keeps the plugin behavior in the
same reviewed file as the rest of the Codex harness setup.
## Native Codex plugins
Native Codex plugin support uses Codex app-server's own app and plugin
capabilities in the same Codex thread as the OpenClaw harness turn. OpenClaw
does not translate Codex plugins into synthetic `codex_plugin_*` OpenClaw
dynamic tools. That keeps plugin calls in the native Codex transcript and avoids
starting a second ephemeral Codex thread for each plugin invocation.
Codex plugins only work when the selected OpenClaw agent runtime is the native
Codex harness. The `codexPlugins` config has no effect on Pi runs, normal
OpenAI provider runs, ACP conversation bindings, or other harnesses, because
those paths do not create Codex app-server threads with native `apps` config.
V1 support is intentionally narrow:
- Only `openai-curated` plugins that were already installed in the source Codex
app-server inventory are migration-eligible.
- Migration writes explicit plugin identities with `marketplaceName` and
`pluginName`; it does not write local `marketplacePath` cache paths.
- `codexPlugins.enabled` is the global enablement switch. There is no
`plugins["*"]` wildcard and no config key that grants arbitrary install
authority.
- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config files
are preserved in the migration report for manual review.
Example migrated config:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
},
},
},
},
}
```
Thread app config is computed when OpenClaw establishes a Codex harness session
or replaces a stale Codex thread binding. It is not recomputed on every turn.
After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so
future Codex harness sessions start with the updated app set.
OpenClaw reads Codex app inventory through app-server `app/list`, caches it for
one hour, and refreshes stale or missing entries asynchronously. A plugin app is
exposed only when OpenClaw can map it back to the migrated plugin through stable
ownership: an exact app id from plugin detail, a known MCP server name, or
unique stable metadata. Display-name-only or ambiguous ownership is excluded
until the next inventory refresh proves ownership.
Plugin-owned app tools use Codex's native app configuration. OpenClaw injects a
restrictive `config.apps` patch for the Codex thread: `_default` is disabled and
only apps owned by enabled migrated plugins are enabled. OpenClaw sets
app-level `destructive_enabled` from the effective global/per-plugin
`allow_destructive_actions` policy and lets Codex enforce destructive tool
metadata from its native app tool annotations. Plugin apps are emitted with
`open_world_enabled: true`; OpenClaw does not expose a separate plugin
open-world policy knob. OpenClaw does not maintain per-plugin destructive
tool-name deny lists. Tool approval mode is prompted by default for plugin
apps, because OpenClaw does not have an interactive app-elicitation UI in this
same-thread path.
Destructive plugin elicitations fail closed by default:
- Global `allow_destructive_actions` defaults to `false`.
- Per-plugin `allow_destructive_actions` overrides the global policy for that
plugin.
- When policy is `false`, OpenClaw returns a deterministic decline.
- When policy is `true`, OpenClaw auto-accepts only safe schemas it can map to
an approval response, such as a boolean approve field.
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
id, or an unsafe elicitation schema declines instead of prompting.
Common diagnostics:
- `auth_required`: migration installed the plugin but one of its apps still
needs authentication. The explicit plugin entry is written disabled until you
reauthorize and enable it.
- `marketplace_missing` or `plugin_missing`: the target Codex app-server cannot
see the expected `openai-curated` marketplace or plugin.
- `app_inventory_missing` or `app_inventory_stale`: app readiness came from an
empty or stale cache; OpenClaw schedules an async refresh and excludes plugin
apps until ownership/readiness is known.
- `app_ownership_ambiguous`: app inventory only matched by display name, so the
app is not exposed to the Codex thread.
## Computer use
Computer Use is covered in its own setup guide:

View File

@@ -46,6 +46,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| --- | --- |
| `plugin-sdk/channel-core` | `defineChannelPluginEntry`, `defineSetupPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase` |
| `plugin-sdk/config-schema` | Root `openclaw.json` Zod schema export (`OpenClawSchema`) |
| `plugin-sdk/json-schema-runtime` | Cached JSON Schema validation helper for plugin-owned schemas |
| `plugin-sdk/channel-setup` | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` |
| `plugin-sdk/setup` | Shared setup wizard helpers, allowlist prompts, setup status builders |
| `plugin-sdk/setup-runtime` | `createPatchedAccountSetupAdapter`, `createEnvPatchedAccountSetupAdapter`, `createSetupInputPresenceValidator`, `noteChannelLookupFailure`, `noteChannelLookupSummary`, `promptResolvedAllowFrom`, `splitSetupEntries`, `createAllowlistSetupWizardProxy`, `createDelegatedSetupWizardProxy` |
@@ -264,6 +265,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio helper exports |

View File

@@ -398,9 +398,10 @@ Gateway relay transport, which keeps provider credentials on the Gateway.
For maintainer live verification, run
`OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts`.
The Google leg mints the same constrained Live API token shape used by Control
UI Talk, opens the browser WebSocket endpoint, sends the initial setup payload,
and waits for `setupComplete`.
The smoke also covers OpenAI backend/WebRTC paths; the Google leg mints the same
constrained Live API token shape used by Control UI Talk, opens the browser
WebSocket endpoint, sends the initial setup payload, and waits for
`setupComplete`.
## Advanced configuration

View File

@@ -641,15 +641,28 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
| Setting | Config path | Default |
|---------|------------|---------|
| Model | `plugins.entries.voice-call.config.realtime.providers.openai.model` | `gpt-realtime-1.5` |
| Model | `plugins.entries.voice-call.config.realtime.providers.openai.model` | `gpt-realtime-2` |
| Voice | `...openai.voice` | `alloy` |
| Temperature | `...openai.temperature` | `0.8` |
| Temperature (Azure deployment bridge) | `...openai.temperature` | `0.8` |
| VAD threshold | `...openai.vadThreshold` | `0.5` |
| Silence duration | `...openai.silenceDurationMs` | `500` |
| API key | `...openai.apiKey` | Falls back to `OPENAI_API_KEY` |
Available built-in Realtime voices for `gpt-realtime-2`: `alloy`, `ash`,
`ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, `marin`, `cedar`.
OpenAI recommends `marin` and `cedar` for the best Realtime quality. This
is a separate set from the Text-to-speech voices above; do not assume a TTS
voice such as `fable`, `nova`, or `onyx` is valid for Realtime sessions.
<Note>
Supports Azure OpenAI via `azureEndpoint` and `azureDeployment` config keys for backend realtime bridges. Supports bidirectional tool calling. Uses G.711 u-law audio format.
Backend OpenAI realtime bridges use the GA Realtime WebSocket session shape, which does not accept `session.temperature`. Azure OpenAI deployments remain available via `azureEndpoint` and `azureDeployment` and keep the deployment-compatible session shape. Supports bidirectional tool calling and G.711 u-law audio.
</Note>
<Note>
Realtime voice is selected when the session is created. OpenAI allows most
session fields to change later, but the voice cannot be changed after the
model has emitted audio in that session. OpenClaw currently exposes the
built-in Realtime voice ids as strings.
</Note>
<Note>
@@ -657,9 +670,8 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
ephemeral client secret and a direct browser WebRTC SDP exchange against the
OpenAI Realtime API. Maintainer live verification is available with
`OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts`;
the OpenAI leg mints a client secret in Node, generates a browser SDP offer
with fake microphone media, posts it to OpenAI, and applies the SDP answer
without logging secrets.
the OpenAI legs verify both the backend WebSocket bridge and the browser
WebRTC SDP exchange without logging secrets.
</Note>
</Accordion>

View File

@@ -56,7 +56,8 @@ Exec approvals are enforced locally on the execution host:
- Gateway-authenticated callers are trusted operators for that Gateway.
- Paired nodes extend that trusted operator capability onto the node host.
- Exec approvals reduce accidental execution risk, but are **not** a per-user auth boundary.
- Exec approvals reduce accidental execution risk, but are **not** a per-user auth boundary or filesystem read-only policy.
- Once approved, a command can mutate files according to the selected host or sandbox filesystem permissions.
- Approved node-host runs bind canonical execution context: canonical cwd, exact argv, env binding when present, and pinned executable path when applicable.
- For shell scripts and direct interpreter/runtime file invocations, OpenClaw also tries to bind one concrete local file operand. If that bound file changes after approval but before execution, the run is denied instead of executing drifted content.
- File binding is intentionally best-effort, **not** a complete semantic model of every interpreter/runtime loader path. If approval mode cannot identify exactly one concrete local file to bind, it refuses to mint an approval-backed run instead of pretending full coverage.

View File

@@ -6,8 +6,9 @@ read_when:
title: "Exec tool"
---
Run shell commands in the workspace. Supports foreground + background execution via `process`.
If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
Run shell commands in the workspace. `exec` is a mutating shell surface: commands can create, edit, or delete files wherever the selected host or sandbox filesystem permits. Disabling OpenClaw filesystem tools such as `write`, `edit`, or `apply_patch` does not make `exec` read-only.
Supports foreground + background execution via `process`. If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
Background sessions are scoped per agent; `process` only sees sessions from the same agent.
## Parameters

View File

@@ -300,7 +300,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
}
```
</Tab>
<Tab title="Safe execution (no file modifications)">
<Tab title="Shell execution with filesystem tools disabled">
```json
{
"tools": {
@@ -309,6 +309,11 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
}
}
```
<Warning>
This policy disables OpenClaw filesystem tools, but `exec` is still a shell and can write files wherever the selected host or sandbox filesystem allows. For a read-only agent, deny `exec` and `process`, or combine shell access with sandbox filesystem controls such as `agents.defaults.sandbox.workspaceAccess: "ro"` or `"none"`.
</Warning>
</Tab>
<Tab title="Communication-only">
```json

View File

@@ -175,7 +175,7 @@ Imported themes are stored only in the current browser profile. They are not wri
In the Chat composer, the Talk control is the waves button next to the microphone dictation button. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`.
Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets.
Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI backend WebSocket bridge, OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets.
</Accordion>
<Accordion title="Stop and abort">

View File

@@ -346,6 +346,28 @@ describe("active-memory plugin", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
});
it("reports session status off when the current agent is outside the active-memory allowlist (#78986)", async () => {
api.pluginConfig = {
agents: ["sandbox"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const statusResult = await registeredCommands["active-memory"].handler({
channel: "webchat",
isAuthorizedSender: true,
sessionKey: "agent:main:main",
args: "status",
commandBody: "/active-memory status",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
});
expect(statusResult.text).toBe("Active Memory: off for this session.");
});
it("supports an explicit global active-memory config toggle", async () => {
const command = registeredCommands["active-memory"];
@@ -762,6 +784,35 @@ describe("active-memory plugin", () => {
});
});
it("uses messageProvider not Google Chat space id for embedded recall (#78918)", async () => {
api.pluginConfig = {
agents: ["main"],
allowedChatTypes: ["direct"],
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what did we decide?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:googlechat:default:direct:spaces/khfx4yaaaae",
messageProvider: "googlechat",
channelId: "spaces/khfx4yaaaae",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({ messageChannel: "googlechat" }),
);
expect(result).toEqual({
prependContext: expect.stringContaining(
"Untrusted context (metadata, do not treat as instructions or commands):",
),
});
});
it("runs for explicit sessions when explicit chat types are explicitly allowed", async () => {
api.pluginConfig = {
agents: ["main"],

View File

@@ -540,14 +540,16 @@ function resolveRecallRunChannelContext(params: {
messageChannel?: string;
messageProvider?: string;
} {
const isRunnableChannelName = (channel: string) =>
!channel.includes(":") && !channel.includes("/");
const explicitChannel = normalizeOptionalString(params.channelId);
const explicitProvider = normalizeOptionalString(params.messageProvider);
// A channelId that contains ":" is a scoped conversation id (e.g. Telegram
// forum-topic "-100123:topic:77"), not a runnable channel name. Using it as
// the embedded recall run's channel causes bundled-plugin dirName validation
// to throw because ":" is not allowed in directory names (#76704).
// forum-topic "-100123:topic:77") or "/" (e.g. Google Chat "spaces/...") is
// not a runnable channel name. Using it as the embedded recall run's channel
// causes bundled-plugin dirName validation to throw (#76704, #78918).
const runnableExplicitChannel =
explicitChannel && !explicitChannel.includes(":") ? explicitChannel : undefined;
explicitChannel && isRunnableChannelName(explicitChannel) ? explicitChannel : undefined;
const trustedExplicitChannel =
runnableExplicitChannel && runnableExplicitChannel !== explicitProvider
? runnableExplicitChannel
@@ -599,12 +601,12 @@ function resolveRecallRunChannelContext(params: {
const rawStrongEntryChannel =
normalizeOptionalString(sessionEntry?.lastChannel) ??
normalizeOptionalString(sessionEntry?.channel);
// Channel IDs containing ":" are scoped conversation IDs (e.g. QQ c2c
// "c2c:10D4F7C2..."), not runnable channel names. The same guard that
// Channel IDs containing ":" or "/" are scoped conversation IDs, not
// runnable channel names. The same guard that
// applies to explicit channelId (#76704) must also apply to channels
// read from the session store (#77396).
const strongEntryChannel =
rawStrongEntryChannel && !rawStrongEntryChannel.includes(":")
rawStrongEntryChannel && isRunnableChannelName(rawStrongEntryChannel)
? rawStrongEntryChannel
: undefined;
const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider);
@@ -2861,6 +2863,10 @@ export default definePluginEntry({
text: "Active Memory: session toggle unavailable because this command has no session context.",
};
}
const commandAgentId = resolveStatusUpdateAgentId({ sessionKey });
if (!isEnabledForAgent(config, commandAgentId)) {
return { text: "Active Memory: off for this session." };
}
if (action === "status") {
const disabled = await isSessionActiveMemoryDisabled({ api, sessionKey });
return {

View File

@@ -38,9 +38,32 @@ describe("browser url pattern matching", () => {
});
it("matches glob patterns", () => {
expect(matchBrowserUrlPattern("*", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("**/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://other.com/a")).toBe(false);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/app/dash")).toBe(
false,
);
expect(matchBrowserUrlPattern("https://example.com/**", "https://example.com/app/dash")).toBe(
true,
);
});
it("treats URL punctuation as literal in wildcard patterns", () => {
expect(
matchBrowserUrlPattern(
"https://example.com/download?file=*",
"https://example.com/download?file=report.pdf",
),
).toBe(true);
expect(
matchBrowserUrlPattern(
"https://example.com/download?file=*",
"https://example.com/downloadXfile=report.pdf",
),
).toBe(false);
expect(matchBrowserUrlPattern("http://[::1]:*/**", "http://[::1]:9222/json/list")).toBe(true);
});
it("rejects empty patterns", () => {

View File

@@ -1,3 +1,22 @@
function wildcardPatternToRegExp(pattern: string): RegExp {
let source = "^";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index] ?? "";
if (char === "*") {
if (pattern[index + 1] === "*") {
source += ".*";
index += 1;
} else {
source += "[^/]*";
}
continue;
}
source += char.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&");
}
source += "$";
return new RegExp(source, "u");
}
export function matchBrowserUrlPattern(pattern: string, url: string): boolean {
const trimmedPattern = pattern.trim();
if (!trimmedPattern) {
@@ -6,10 +25,11 @@ export function matchBrowserUrlPattern(pattern: string, url: string): boolean {
if (trimmedPattern === url) {
return true;
}
if (trimmedPattern === "*") {
return true;
}
if (trimmedPattern.includes("*")) {
const escaped = trimmedPattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
return regex.test(url);
return wildcardPatternToRegExp(trimmedPattern).test(url);
}
return url.includes(trimmedPattern);
}

View File

@@ -36,8 +36,8 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) {
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
}
@@ -63,6 +63,7 @@ describe("byteplus video generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual(
expect.objectContaining({
taskId: "task_123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -127,7 +128,7 @@ async function downloadBytePlusVideo(params: {
return {
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}

View File

@@ -12,7 +12,7 @@
"@lit/context": "^1.1.6",
"chokidar": "^5.0.0",
"lit": "^3.3.2",
"typebox": "^1.0.58",
"typebox": "1.1.37",
"ws": "^8.20.0"
},
"openclaw": {

View File

@@ -29,7 +29,7 @@ export default definePluginEntry({
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerMigrationProvider(buildCodexMigrationProvider());
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {

View File

@@ -38,6 +38,11 @@
"enum": ["native-first", "openclaw-compat"],
"default": "native-first"
},
"codexDynamicToolsLoading": {
"type": "string",
"enum": ["searchable", "direct"],
"default": "searchable"
},
"codexDynamicToolsExclude": {
"type": "array",
"items": { "type": "string" },
@@ -91,6 +96,42 @@
}
}
},
"codexPlugins": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"allow_destructive_actions": {
"type": "boolean",
"default": false
},
"plugins": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"marketplaceName": {
"type": "string",
"enum": ["openai-curated"]
},
"pluginName": {
"type": "string"
},
"allow_destructive_actions": {
"type": "boolean"
}
}
}
}
}
},
"appServer": {
"type": "object",
"additionalProperties": false,
@@ -161,6 +202,11 @@
"help": "Select which OpenClaw dynamic tools are exposed to Codex app-server. native-first omits tools Codex already owns.",
"advanced": true
},
"codexDynamicToolsLoading": {
"label": "Dynamic Tools Loading",
"help": "Use searchable to defer OpenClaw dynamic tools behind Codex tool search, or direct to expose them in the initial context.",
"advanced": true
},
"codexDynamicToolsExclude": {
"label": "Dynamic Tool Excludes",
"help": "Additional OpenClaw dynamic tool names to omit from Codex app-server turns.",
@@ -224,6 +270,26 @@
"help": "MCP server name exposed by the Computer Use plugin.",
"advanced": true
},
"codexPlugins": {
"label": "Native Codex Plugins",
"help": "Controls native Codex plugin availability for Codex harness turns.",
"advanced": true
},
"codexPlugins.enabled": {
"label": "Enable Native Plugins",
"help": "Expose explicit migrated Codex plugin entries to Codex harness turns.",
"advanced": true
},
"codexPlugins.allow_destructive_actions": {
"label": "Allow Destructive Plugin Actions",
"help": "Default policy for plugin app write or destructive action elicitations. Defaults to false.",
"advanced": true
},
"codexPlugins.plugins": {
"label": "Migrated Plugin Entries",
"help": "Explicit migration-authored plugin entries. The wildcard key * is not supported.",
"advanced": true
},
"appServer": {
"label": "App Server",
"help": "Runtime controls for connecting to Codex app-server.",

View File

@@ -9,10 +9,9 @@
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.73.0",
"@openai/codex": "0.128.0",
"@openai/codex": "0.129.0-alpha.15",
"ajv": "^8.20.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
"ws": "^8.20.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import { CodexAppInventoryCache, buildCodexAppInventoryCacheKey } from "./app-inventory-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex app inventory cache", () => {
it("returns missing while scheduling one coalesced app/list refresh", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (_method: "app/list", params: v2.AppsListParams) => {
return {
data: [app(params.cursor ? "app-2" : "app-1")],
nextCursor: params.cursor ? null : "next",
} satisfies v2.AppsListResponse;
});
const key = buildCodexAppInventoryCacheKey({ codexHome: "/codex", authProfileId: "work" });
const read = cache.read({ key, request, nowMs: 0 });
expect(read.state).toBe("missing");
expect(read.refreshScheduled).toBe(true);
const snapshot = await cache.refreshNow({ key, request, nowMs: 0 });
expect(snapshot.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]);
expect(request).toHaveBeenCalledTimes(2);
const fresh = cache.read({ key, request, nowMs: 50 });
expect(fresh.state).toBe("fresh");
expect(fresh.refreshScheduled).toBe(false);
expect(fresh.snapshot?.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]);
});
it("uses stale inventory for the current read while refreshing asynchronously", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 10 });
const request = vi.fn(async () => {
return {
data: [app(`app-${request.mock.calls.length}`)],
nextCursor: null,
} satisfies v2.AppsListResponse;
});
const key = "runtime";
await cache.refreshNow({ key, request, nowMs: 0 });
const stale = cache.read({ key, request, nowMs: 11 });
expect(stale.state).toBe("stale");
expect(stale.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]);
expect(stale.refreshScheduled).toBe(true);
const refreshed = await cache.refreshNow({ key, request, nowMs: 11 });
expect(refreshed.apps.map((item) => item.id)).toEqual(["app-2"]);
});
it("records refresh errors without discarding the last successful snapshot", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 1 });
const key = "runtime";
await cache.refreshNow({
key,
nowMs: 0,
request: async () => ({ data: [app("app-1")], nextCursor: null }),
});
await expect(
cache.refreshNow({
key,
nowMs: 2,
request: async () => {
throw new Error("app list failed");
},
}),
).rejects.toThrow("app list failed");
const read = cache.read({
key,
nowMs: 2,
request: async () => ({ data: [app("app-2")], nextCursor: null }),
});
expect(read.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]);
expect(read.diagnostic?.message).toBe("app list failed");
});
it("forces a post-install refresh past an older in-flight app/list", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 1_000 });
const key = "runtime";
let resolveStale: ((response: v2.AppsListResponse) => void) | undefined;
let resolveFresh: ((response: v2.AppsListResponse) => void) | undefined;
const request = vi.fn(
async (_method: "app/list", params: v2.AppsListParams): Promise<v2.AppsListResponse> => {
expect(params.forceRefetch).toBe(request.mock.calls.length === 2);
return await new Promise((resolve) => {
if (request.mock.calls.length === 1) {
resolveStale = resolve;
} else {
resolveFresh = resolve;
}
});
},
);
const staleRead = cache.read({ key, request, nowMs: 0 });
expect(staleRead.state).toBe("missing");
expect(staleRead.refreshScheduled).toBe(true);
cache.invalidate(key, "plugin installed", 1);
const forcedRead = cache.read({ key, request, nowMs: 1, forceRefetch: true });
expect(forcedRead.state).toBe("missing");
expect(forcedRead.refreshScheduled).toBe(true);
expect(request).toHaveBeenCalledTimes(2);
const forced = cache.refreshNow({ key, request, nowMs: 1 });
resolveFresh?.({ data: [app("fresh-app")], nextCursor: null });
await expect(forced).resolves.toMatchObject({
apps: [expect.objectContaining({ id: "fresh-app" })],
});
resolveStale?.({ data: [app("stale-app")], nextCursor: null });
await Promise.resolve();
const freshRead = cache.read({ key, request, nowMs: 2 });
expect(freshRead.state).toBe("fresh");
expect(freshRead.snapshot?.apps.map((item) => item.id)).toEqual(["fresh-app"]);
});
});
function app(id: string): v2.AppInfo {
return {
id,
name: id,
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: true,
pluginDisplayNames: [],
};
}

View File

@@ -0,0 +1,225 @@
import type { v2 } from "./protocol.js";
export const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000;
export type CodexAppInventoryRequest = (
method: "app/list",
params: v2.AppsListParams,
) => Promise<v2.AppsListResponse>;
export type CodexAppInventoryCacheKeyInput = {
codexHome?: string;
endpoint?: string;
authProfileId?: string;
accountId?: string;
envApiKeyFingerprint?: string;
appServerVersion?: string;
};
export type CodexAppInventoryCacheDiagnostic = {
message: string;
atMs: number;
};
export type CodexAppInventorySnapshot = {
key: string;
apps: v2.AppInfo[];
fetchedAtMs: number;
expiresAtMs: number;
revision: number;
lastError?: CodexAppInventoryCacheDiagnostic;
};
export type CodexAppInventoryReadState = "fresh" | "stale" | "missing";
export type CodexAppInventoryCacheRead = {
state: CodexAppInventoryReadState;
key: string;
revision: number;
snapshot?: CodexAppInventorySnapshot;
refreshScheduled: boolean;
diagnostic?: CodexAppInventoryCacheDiagnostic;
};
type CacheEntry = CodexAppInventorySnapshot & {
invalidated: boolean;
};
type RefreshParams = {
key: string;
request: CodexAppInventoryRequest;
nowMs?: number;
forceRefetch?: boolean;
};
export class CodexAppInventoryCache {
private readonly ttlMs: number;
private readonly entries = new Map<string, CacheEntry>();
private readonly inFlight = new Map<string, Promise<CodexAppInventorySnapshot>>();
private readonly refreshTokens = new Map<string, number>();
private readonly diagnostics = new Map<string, CodexAppInventoryCacheDiagnostic>();
private revision = 0;
constructor(options: { ttlMs?: number } = {}) {
this.ttlMs = options.ttlMs ?? CODEX_APP_INVENTORY_CACHE_TTL_MS;
}
read(params: RefreshParams): CodexAppInventoryCacheRead {
const nowMs = params.nowMs ?? Date.now();
const entry = this.entries.get(params.key);
if (!entry) {
const refreshScheduled = this.scheduleRefresh(params);
return {
state: "missing",
key: params.key,
revision: this.revision,
refreshScheduled,
...(this.diagnostics.get(params.key)
? { diagnostic: this.diagnostics.get(params.key) }
: {}),
};
}
const state: CodexAppInventoryReadState =
entry.invalidated || entry.expiresAtMs <= nowMs ? "stale" : "fresh";
const refreshScheduled =
state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params);
return {
state,
key: params.key,
revision: entry.revision,
snapshot: stripEntryState(entry),
refreshScheduled,
...(entry.lastError ? { diagnostic: entry.lastError } : {}),
};
}
refreshNow(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
return this.refresh(params);
}
invalidate(key: string, reason: string, nowMs = Date.now()): number {
this.revision += 1;
const diagnostic = { message: reason, atMs: nowMs };
const entry = this.entries.get(key);
if (entry) {
entry.invalidated = true;
entry.lastError = diagnostic;
entry.revision = this.revision;
} else {
this.diagnostics.set(key, diagnostic);
}
return this.revision;
}
clear(): void {
this.entries.clear();
this.inFlight.clear();
this.refreshTokens.clear();
this.diagnostics.clear();
this.revision = 0;
}
getRevision(): number {
return this.revision;
}
private scheduleRefresh(params: RefreshParams): boolean {
if (this.inFlight.has(params.key) && !params.forceRefetch) {
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;
}
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);
try {
return await promise;
} finally {
if (this.inFlight.get(params.key) === promise) {
this.inFlight.delete(params.key);
}
}
}
private async refreshUncoalesced(
params: RefreshParams,
refreshToken: number,
): Promise<CodexAppInventorySnapshot> {
const nowMs = params.nowMs ?? Date.now();
try {
const apps = await listAllApps(params.request, params.forceRefetch ?? false);
this.revision += 1;
const snapshot: CodexAppInventorySnapshot = {
key: params.key,
apps,
fetchedAtMs: nowMs,
expiresAtMs: nowMs + this.ttlMs,
revision: this.revision,
};
if (this.refreshTokens.get(params.key) === refreshToken) {
this.entries.set(params.key, { ...snapshot, invalidated: false });
this.diagnostics.delete(params.key);
}
return snapshot;
} catch (error) {
const diagnostic = {
message: error instanceof Error ? error.message : String(error),
atMs: nowMs,
};
this.diagnostics.set(params.key, diagnostic);
const entry = this.entries.get(params.key);
if (entry) {
entry.lastError = diagnostic;
}
throw error;
}
}
}
export const defaultCodexAppInventoryCache = new CodexAppInventoryCache();
export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyInput): string {
return JSON.stringify({
codexHome: input.codexHome ?? null,
endpoint: input.endpoint ?? null,
authProfileId: input.authProfileId ?? null,
accountId: input.accountId ?? null,
envApiKeyFingerprint: input.envApiKeyFingerprint ?? null,
appServerVersion: input.appServerVersion ?? null,
});
}
async function listAllApps(
request: CodexAppInventoryRequest,
forceRefetch: boolean,
): Promise<v2.AppInfo[]> {
const apps: v2.AppInfo[] = [];
let cursor: string | null | undefined;
do {
const response = await request("app/list", {
cursor,
limit: 100,
forceRefetch,
});
apps.push(...response.data);
cursor = response.nextCursor;
} while (cursor);
return apps;
}
function stripEntryState(entry: CacheEntry): CodexAppInventorySnapshot {
const { invalidated: _invalidated, ...snapshot } = entry;
return snapshot;
}

View File

@@ -11,6 +11,7 @@ import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerHomeDir,
resolveCodexAppServerNativeHomeDir,
} from "./auth-bridge.js";
@@ -355,6 +356,116 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("fingerprints resolved API-key auth-profile secrets without exposing them", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
key: "first-secret-key",
},
});
const first = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
key: "second-secret-key",
},
});
const second = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
expect(first).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/);
expect(second).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/);
expect(second).not.toBe(first);
expect(first).not.toContain("first-secret-key");
expect(second).not.toContain("second-secret-key");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("fingerprints API-key auth-profile secret refs", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
keyRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TEST_KEY" },
},
});
vi.stubEnv("OPENAI_CODEX_TEST_KEY", "first-ref-secret");
const first = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
vi.stubEnv("OPENAI_CODEX_TEST_KEY", "second-ref-secret");
const second = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
expect(first).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/);
expect(second).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/);
expect(second).not.toBe(first);
expect(first).not.toContain("first-ref-secret");
expect(second).not.toContain("second-ref-secret");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("fingerprints token auth-profile secret refs", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "token",
provider: "openai-codex",
tokenRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TEST_TOKEN" },
email: "codex@example.test",
},
});
vi.stubEnv("OPENAI_CODEX_TEST_TOKEN", "first-ref-token");
const first = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
vi.stubEnv("OPENAI_CODEX_TEST_TOKEN", "second-ref-token");
const second = await resolveCodexAppServerAuthAccountCacheKey({
agentDir,
authProfileId: "openai-codex:work",
});
expect(first).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/);
expect(second).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/);
expect(second).not.toBe(first);
expect(first).not.toContain("first-ref-token");
expect(second).not.toContain("second-ref-token");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex OAuth profile through app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
@@ -10,6 +11,7 @@ import {
resolvePersistedAuthProfileOwnerAgentDir,
saveAuthProfileStore,
type AuthProfileCredential,
type AuthProfileStore,
type OAuthCredential,
} from "openclaw/plugin-sdk/agent-runtime";
import type { CodexAppServerClient } from "./client.js";
@@ -93,6 +95,94 @@ export function resolveCodexAppServerAuthProfileIdForAgent(params: {
});
}
export async function resolveCodexAppServerAuthAccountCacheKey(params: {
authProfileId?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: AuthProfileOrderConfig;
}): Promise<string | undefined> {
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {});
const store =
params.authProfileStore ?? ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const profileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
config: params.config,
});
if (!profileId) {
return undefined;
}
const credential = store.profiles[profileId];
if (!credential || !isCodexAppServerAuthProvider(credential.provider, params.config)) {
return undefined;
}
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
store,
profileId,
agentDir,
});
const apiKey = resolved?.apiKey?.trim();
return apiKey
? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintApiKeyAuthProfileCacheKey(apiKey)}`
: resolveChatgptAccountId(profileId, credential);
}
if (credential.type === "token") {
const resolved = await resolveApiKeyForProfile({
store,
profileId,
agentDir,
});
const accessToken = resolved?.apiKey?.trim();
return accessToken
? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintTokenAuthProfileCacheKey(accessToken)}`
: resolveChatgptAccountId(profileId, credential);
}
return resolveChatgptAccountId(profileId, credential);
}
export function resolveCodexAppServerEnvApiKeyCacheKey(params: {
startOptions: Pick<CodexAppServerStartOptions, "transport" | "env" | "clearEnv">;
baseEnv?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): string | undefined {
if (params.startOptions.transport !== "stdio") {
return undefined;
}
const env = resolveCodexAppServerSpawnEnv(
params.startOptions,
params.baseEnv ?? process.env,
params.platform ?? process.platform,
);
const apiKey = readFirstNonEmptyEnvEntry(env, CODEX_APP_SERVER_API_KEY_ENV_VARS);
if (!apiKey) {
return undefined;
}
const hash = createHash("sha256");
hash.update("openclaw:codex:app-server-env-api-key:v1");
hash.update("\0");
hash.update(apiKey.key);
hash.update("\0");
hash.update(apiKey.value);
return `${apiKey.key}:sha256:${hash.digest("hex")}`;
}
function fingerprintApiKeyAuthProfileCacheKey(apiKey: string): string {
const hash = createHash("sha256");
hash.update("openclaw:codex:app-server-auth-profile-api-key:v1");
hash.update("\0");
hash.update(apiKey);
return `api_key:sha256:${hash.digest("hex")}`;
}
function fingerprintTokenAuthProfileCacheKey(accessToken: string): string {
const hash = createHash("sha256");
hash.update("openclaw:codex:app-server-auth-profile-token:v1");
hash.update("\0");
hash.update(accessToken);
return `token:sha256:${hash.digest("hex")}`;
}
export function resolveCodexAppServerHomeDir(agentDir: string): string {
return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME);
}
@@ -367,10 +457,17 @@ function withClearedEnvironmentVariables(
}
function readFirstNonEmptyEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined {
return readFirstNonEmptyEnvEntry(env, keys)?.value;
}
function readFirstNonEmptyEnvEntry(
env: NodeJS.ProcessEnv,
keys: readonly string[],
): { key: string; value: string } | undefined {
for (const key of keys) {
const value = env[key]?.trim();
if (value) {
return value;
return { key, value };
}
}
return undefined;

View File

@@ -3,10 +3,13 @@ import { describe, expect, it } from "vitest";
import {
CODEX_APP_SERVER_CONFIG_KEYS,
CODEX_COMPUTER_USE_CONFIG_KEYS,
CODEX_PLUGIN_ENTRY_CONFIG_KEYS,
CODEX_PLUGINS_CONFIG_KEYS,
codexAppServerStartOptionsKey,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
resolveCodexPluginsPolicy,
} from "./config.js";
describe("Codex app-server config", () => {
@@ -144,14 +147,81 @@ describe("Codex app-server config", () => {
expect(
readCodexPluginConfig({
codexDynamicToolsProfile: "openclaw-compat",
codexDynamicToolsLoading: "direct",
codexDynamicToolsExclude: ["custom_tool"],
}),
).toMatchObject({
codexDynamicToolsProfile: "openclaw-compat",
codexDynamicToolsLoading: "direct",
codexDynamicToolsExclude: ["custom_tool"],
});
});
it("parses native Codex plugin policy without treating wildcard as supported config", () => {
const config = readCodexPluginConfig({
appServer: { mode: "guardian" },
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
allow_destructive_actions: true,
},
slack: {
enabled: false,
marketplaceName: "openai-curated",
pluginName: "slack",
},
},
},
});
expect(config.appServer?.mode).toBe("guardian");
expect(config.codexPlugins?.enabled).toBe(true);
const policy = resolveCodexPluginsPolicy(config);
expect(policy).toEqual({
configured: true,
enabled: true,
allowDestructiveActions: false,
pluginPolicies: [
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
},
{
configKey: "slack",
marketplaceName: "openai-curated",
pluginName: "slack",
enabled: false,
allowDestructiveActions: false,
},
],
});
});
it("rejects non-curated native plugin identities", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
plugins: {
gmail: {
marketplaceName: "custom-market",
pluginName: "gmail",
},
},
},
});
expect(config.codexPlugins).toBeUndefined();
expect(resolveCodexPluginsPolicy(config).pluginPolicies).toEqual([]);
});
it("treats configured and environment commands as explicit overrides", () => {
expect(
resolveCodexAppServerRuntimeOptions({
@@ -390,6 +460,10 @@ describe("Codex app-server config", () => {
properties: {
appServer: { properties: Record<string, unknown> };
computerUse: { properties: Record<string, unknown> };
codexPlugins: {
properties: Record<string, unknown>;
additionalProperties: boolean;
};
};
};
uiHints: Record<string, unknown>;
@@ -409,6 +483,21 @@ describe("Codex app-server config", () => {
for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) {
expect(manifest.uiHints[`computerUse.${key}`]).toBeTruthy();
}
const codexPluginsProperties = manifest.configSchema.properties.codexPlugins;
const codexPluginsManifestKeys = Object.keys(codexPluginsProperties.properties).toSorted();
expect(codexPluginsManifestKeys).toEqual([...CODEX_PLUGINS_CONFIG_KEYS].toSorted());
expect(codexPluginsProperties.additionalProperties).toBe(false);
for (const key of CODEX_PLUGINS_CONFIG_KEYS) {
expect(manifest.uiHints[`codexPlugins.${key}`]).toBeTruthy();
}
const pluginEntryProperties = (
codexPluginsProperties.properties.plugins as {
additionalProperties: { properties: Record<string, unknown> };
}
).additionalProperties.properties;
expect(Object.keys(pluginEntryProperties).toSorted()).toEqual(
[...CODEX_PLUGIN_ENTRY_CONFIG_KEYS].toSorted(),
);
});
it("does not schema-default mode-derived policy fields", async () => {

View File

@@ -1,5 +1,5 @@
import { createHmac, randomBytes } from "node:crypto";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
const START_OPTIONS_KEY_SECRET = randomBytes(32);
@@ -7,10 +7,25 @@ const START_OPTIONS_KEY_SECRET = randomBytes(32);
type CodexAppServerTransportMode = "stdio" | "websocket";
type CodexAppServerPolicyMode = "yolo" | "guardian";
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
export type CodexAppServerEffectiveApprovalPolicy =
| CodexAppServerApprovalPolicy
| {
granular: {
mcp_elicitations: boolean;
rules: boolean;
sandbox_approval: boolean;
request_permissions?: boolean;
skill_approval?: boolean;
};
};
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
type CodexDynamicToolsProfile = "native-first" | "openclaw-compat";
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean;
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
export type CodexComputerUseConfig = {
enabled?: boolean;
@@ -34,6 +49,34 @@ export type ResolvedCodexComputerUseConfig = {
marketplaceName?: string;
};
export type CodexPluginEntryConfig = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: CodexPluginDestructivePolicy;
};
export type CodexPluginsConfig = {
enabled?: boolean;
allow_destructive_actions?: CodexPluginDestructivePolicy;
plugins?: Record<string, CodexPluginEntryConfig>;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
pluginName: string;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
};
export type ResolvedCodexPluginsPolicy = {
configured: boolean;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
pluginPolicies: ResolvedCodexPluginPolicy[];
};
export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
@@ -50,7 +93,7 @@ export type CodexAppServerRuntimeOptions = {
start: CodexAppServerStartOptions;
requestTimeoutMs: number;
turnCompletionIdleTimeoutMs: number;
approvalPolicy: CodexAppServerApprovalPolicy;
approvalPolicy: CodexAppServerEffectiveApprovalPolicy;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
@@ -58,12 +101,14 @@ export type CodexAppServerRuntimeOptions = {
export type CodexPluginConfig = {
codexDynamicToolsProfile?: CodexDynamicToolsProfile;
codexDynamicToolsLoading?: CodexDynamicToolsLoading;
codexDynamicToolsExclude?: string[];
discovery?: {
enabled?: boolean;
timeoutMs?: number;
};
computerUse?: CodexComputerUseConfig;
codexPlugins?: CodexPluginsConfig;
appServer?: {
mode?: CodexAppServerPolicyMode;
transport?: CodexAppServerTransportMode;
@@ -112,6 +157,19 @@ export const CODEX_COMPUTER_USE_CONFIG_KEYS = [
"mcpServerName",
] as const;
export const CODEX_PLUGINS_CONFIG_KEYS = [
"enabled",
"allow_destructive_actions",
"plugins",
] as const;
export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
"enabled",
"marketplaceName",
"pluginName",
"allow_destructive_actions",
] as const;
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
@@ -127,6 +185,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsProfileSchema = z.enum(["native-first", "openclaw-compat"]);
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : resolveServiceTier(value)),
@@ -134,9 +193,27 @@ const codexAppServerServiceTierSchema = z
)
.optional();
const codexPluginEntryConfigSchema = z
.object({
enabled: z.boolean().optional(),
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
pluginName: z.string().trim().min(1).optional(),
allow_destructive_actions: z.boolean().optional(),
})
.strict();
const codexPluginsConfigSchema = z
.object({
enabled: z.boolean().optional(),
allow_destructive_actions: z.boolean().optional(),
plugins: z.record(z.string(), codexPluginEntryConfigSchema).optional(),
})
.strict();
const codexPluginConfigSchema = z
.object({
codexDynamicToolsProfile: codexDynamicToolsProfileSchema.optional(),
codexDynamicToolsLoading: codexDynamicToolsLoadingSchema.optional(),
codexDynamicToolsExclude: z.array(z.string()).optional(),
discovery: z
.object({
@@ -158,6 +235,7 @@ const codexPluginConfigSchema = z
})
.strict()
.optional(),
codexPlugins: z.unknown().optional(),
appServer: z
.object({
mode: codexAppServerPolicyModeSchema.optional(),
@@ -183,7 +261,44 @@ const codexPluginConfigSchema = z
export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
const parsed = codexPluginConfigSchema.safeParse(value);
return parsed.success ? parsed.data : {};
if (!parsed.success) {
return {};
}
const { codexPlugins: rawCodexPlugins, ...config } = parsed.data;
const plugins = codexPluginsConfigSchema.safeParse(rawCodexPlugins);
if (!plugins.success) {
return config;
}
return { ...config, ...(plugins.data ? { codexPlugins: plugins.data } : {}) };
}
export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodexPluginsPolicy {
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
const configured = config !== undefined;
const enabled = config?.enabled === true;
const allowDestructiveActions = config?.allow_destructive_actions ?? false;
const pluginPolicies = Object.entries(config?.plugins ?? {})
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
return [];
}
return [
{
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: entry.pluginName,
enabled: enabled && entry.enabled !== false,
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,
},
];
})
.toSorted((left, right) => left.configKey.localeCompare(right.configKey));
return {
configured,
enabled,
allowDestructiveActions,
pluginPolicies,
};
}
export function resolveCodexAppServerRuntimeOptions(
@@ -350,6 +465,35 @@ export function codexSandboxPolicyForTurn(
};
}
export function withMcpElicitationsApprovalPolicy(
policy: CodexAppServerEffectiveApprovalPolicy,
): CodexAppServerEffectiveApprovalPolicy {
if (typeof policy !== "string") {
return {
granular: {
...policy.granular,
mcp_elicitations: true,
},
};
}
if (policy === "never") {
return {
granular: {
mcp_elicitations: true,
rules: false,
sandbox_approval: false,
},
};
}
return {
granular: {
mcp_elicitations: true,
rules: true,
sandbox_approval: true,
},
};
}
function resolveTransport(value: unknown): CodexAppServerTransportMode {
return value === "websocket" ? "websocket" : "stdio";
}

View File

@@ -14,7 +14,10 @@ import {
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
createCodexDynamicToolBridge,
} from "./dynamic-tools.js";
import type { JsonValue } from "./protocol.js";
function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
@@ -85,6 +88,85 @@ afterEach(() => {
});
describe("createCodexDynamicToolBridge", () => {
it("defers OpenClaw dynamic tools behind Codex tool search by default", () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({ name: "web_search" }),
createTool({ name: "message" }),
createTool({ name: HEARTBEAT_RESPONSE_TOOL_NAME }),
createTool({ name: "sessions_yield" }),
],
signal: new AbortController().signal,
});
const webSearch = bridge.specs.find((tool) => tool.name === "web_search");
const message = bridge.specs.find((tool) => tool.name === "message");
const heartbeat = bridge.specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
const sessionsYield = bridge.specs.find((tool) => tool.name === "sessions_yield");
expect(webSearch).toEqual(
expect.objectContaining({
name: "web_search",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
}),
);
expect(message).toEqual(
expect.objectContaining({
name: "message",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
}),
);
expect(heartbeat).toEqual(
expect.objectContaining({
name: HEARTBEAT_RESPONSE_TOOL_NAME,
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
}),
);
expect(sessionsYield).not.toHaveProperty("namespace");
expect(sessionsYield).not.toHaveProperty("deferLoading");
});
it("keeps configured direct tools in the initial Codex tool context", () => {
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message" }), createTool({ name: "web_search" })],
signal: new AbortController().signal,
directToolNames: ["message"],
});
expect(bridge.specs).toEqual([
expect.objectContaining({
name: "message",
}),
expect.objectContaining({
name: "web_search",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
}),
]);
expect(bridge.specs[0]).not.toHaveProperty("namespace");
expect(bridge.specs[0]).not.toHaveProperty("deferLoading");
});
it("can expose all dynamic tools directly for compatibility", () => {
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "web_search" }), createTool({ name: "message" })],
signal: new AbortController().signal,
loading: "direct",
});
expect(bridge.specs).toEqual([
expect.objectContaining({ name: "web_search" }),
expect.objectContaining({ name: "message" }),
]);
expect(bridge.specs).toEqual([
expect.not.objectContaining({ namespace: expect.any(String) }),
expect.not.objectContaining({ namespace: expect.any(String) }),
]);
});
it.each([
{ toolName: "tts", mediaUrl: "/tmp/reply.opus", audioAsVoice: true },
{ toolName: "image_generate", mediaUrl: "/tmp/generated.png" },

View File

@@ -17,6 +17,7 @@ import {
type MessagingToolSend,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { CodexDynamicToolsLoading } from "./config.js";
import {
type CodexDynamicToolCallOutputContentItem,
type CodexDynamicToolCallParams,
@@ -53,10 +54,16 @@ export type CodexDynamicToolBridge = {
};
};
export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
export function createCodexDynamicToolBridge(params: {
tools: AnyAgentTool[];
signal: AbortSignal;
hookContext?: CodexDynamicToolHookContext;
loading?: CodexDynamicToolsLoading;
directToolNames?: Iterable<string>;
}): CodexDynamicToolBridge {
const toolResultHookContext = toToolResultHookContext(params.hookContext);
const tools = params.tools.map((tool) =>
@@ -79,13 +86,19 @@ export function createCodexDynamicToolBridge(params: {
});
const legacyExtensionRunner =
createCodexAppServerToolResultExtensionRunner(toolResultHookContext);
const directToolNames = new Set([
...ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES,
...(params.directToolNames ?? []),
]);
return {
specs: tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: toJsonValue(tool.parameters),
})),
specs: tools.map((tool) =>
createCodexDynamicToolSpec({
tool,
loading: params.loading ?? "searchable",
directToolNames,
}),
),
telemetry,
handleToolCall: async (call, options) => {
const tool = toolMap.get(call.tool);
@@ -176,6 +189,26 @@ export function createCodexDynamicToolBridge(params: {
};
}
function createCodexDynamicToolSpec(params: {
tool: AnyAgentTool;
loading: CodexDynamicToolsLoading;
directToolNames: ReadonlySet<string>;
}): CodexDynamicToolSpec {
const base = {
name: params.tool.name,
description: params.tool.description,
inputSchema: toJsonValue(params.tool.parameters),
};
if (params.loading === "direct" || params.directToolNames.has(params.tool.name)) {
return base;
}
return {
...base,
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
};
}
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {

View File

@@ -73,6 +73,73 @@ function buildCurrentCodexApprovalElicitation() {
};
}
function buildPluginApprovalElicitation(overrides: Record<string, unknown> = {}) {
return {
threadId: "thread-1",
turnId: "turn-1",
serverName: "google-calendar-mcp",
mode: "form",
message: "Approve app action?",
_meta: {
app_id: "google-calendar-app",
},
requestedSchema: {
type: "object",
properties: {
approve: {
type: "boolean",
title: "Approve this app action",
},
},
required: ["approve"],
},
...overrides,
};
}
function createPluginAppPolicyContext(
params: {
allowDestructiveActions?: boolean;
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
} = {},
) {
const apps = params.apps ?? [
{
appId: "google-calendar-app",
pluginName: "google-calendar",
mcpServerNames: ["google-calendar-mcp"],
},
];
return {
fingerprint: "plugin-policy-1",
apps: Object.fromEntries(
apps.map((app) => [
app.appId,
{
configKey: app.pluginName,
marketplaceName: "openai-curated" as const,
pluginName: app.pluginName,
allowDestructiveActions: params.allowDestructiveActions ?? false,
mcpServerNames: app.mcpServerNames,
},
]),
),
pluginAppIds: Object.fromEntries(
apps.map((app) => [app.pluginName, appsForPlugin(apps, app.pluginName)]),
),
};
}
function appsForPlugin(
apps: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>,
pluginName: string,
): string[] {
return apps
.filter((app) => app.pluginName === pluginName)
.map((app) => app.appId)
.toSorted();
}
describe("Codex app-server elicitation bridge", () => {
beforeEach(() => {
mockCallGatewayTool.mockReset();
@@ -449,6 +516,170 @@ describe("Codex app-server elicitation bridge", () => {
});
});
it("declines plugin app elicitations when destructive actions are disabled", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: false }),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("accepts safely mapped plugin app elicitations when destructive actions are enabled", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({
action: "accept",
content: { approve: true },
_meta: null,
});
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("declines plugin app elicitations that are missing active turn correlation", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation({ turnId: null }),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("does not answer plugin app elicitations for a different active turn", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation({ turnId: "turn-2" }),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toBeUndefined();
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("declines plugin app elicitations with ambiguous server ownership", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation({
serverName: "shared-mcp",
_meta: {},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
apps: [
{
appId: "calendar-app-1",
pluginName: "google-calendar",
mcpServerNames: ["shared-mcp"],
},
{
appId: "calendar-app-2",
pluginName: "google-calendar",
mcpServerNames: ["shared-mcp"],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("declines plugin app elicitations that only match display names", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation({
serverName: "unknown-mcp",
_meta: {
connector_name: "Google Calendar",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("declines plugin-scoped elicitations when policy context is missing", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("declines plugin app elicitations with unmappable schemas", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation({
requestedSchema: {
type: "object",
properties: {
template: {
type: "string",
enum: ["simple", "detailed"],
},
},
required: ["template"],
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("keeps unrelated MCP approval elicitations on the existing approval bridge", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-unrelated", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-unrelated", decision: "allow-once" });
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildCurrentCodexApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("ignores unscoped approval elicitations without the active thread id", async () => {
const { turnId, serverName, mode, message, _meta, requestedSchema } =
buildCurrentCodexApprovalElicitation();

View File

@@ -10,6 +10,10 @@ import {
type AppServerApprovalOutcome,
waitForPluginApprovalDecision,
} from "./plugin-approval-roundtrip.js";
import type {
PluginAppPolicyContext,
PluginAppPolicyContextEntry,
} from "./plugin-thread-config.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
type ApprovalPropertyContext = {
@@ -25,12 +29,26 @@ type BridgeableApprovalElicitation = {
meta: JsonObject;
};
type PluginElicitationResolution =
| { kind: "not_plugin" }
| { kind: "matched"; entry: PluginAppPolicyContextEntry }
| { kind: "decline"; reason: string };
const MCP_TOOL_APPROVAL_KIND = "mcp_tool_call";
const MCP_TOOL_APPROVAL_KIND_KEY = "codex_approval_kind";
const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY = "connector_name";
const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY = "tool_title";
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY = "tool_description";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY = "tool_params_display";
const PLUGIN_APP_ID_META_KEYS = ["app_id", "appId", "codex_app_id", "codexAppId"];
const PLUGIN_NAME_META_KEYS = ["plugin_name", "pluginName", "codex_plugin_name", "codexPluginName"];
const PLUGIN_CONFIG_KEY_META_KEYS = ["config_key", "configKey", "codex_config_key"];
const PLUGIN_MARKETPLACE_NAME_META_KEYS = [
"marketplace_name",
"marketplaceName",
"codex_marketplace_name",
"codexMarketplaceName",
];
const MAX_DISPLAY_PARAM_ENTRIES = 8;
const MAX_DISPLAY_PARAM_VALUE_LENGTH = 120;
const MAX_DISPLAY_VALUE_ARRAY_ITEMS = 8;
@@ -59,12 +77,35 @@ export async function handleCodexAppServerElicitationRequest(params: {
paramsForRun: EmbeddedRunAttemptParams;
threadId: string;
turnId: string;
pluginAppPolicyContext?: PluginAppPolicyContext;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined;
if (!matchesCurrentTurn(requestParams, params.threadId, params.turnId)) {
if (!requestParams) {
return undefined;
}
if (!matchesCurrentThread(requestParams, params.threadId)) {
return undefined;
}
if (turnIdMismatches(requestParams, params.turnId)) {
return undefined;
}
const pluginResolution = resolvePluginElicitation({
requestParams,
pluginAppPolicyContext: params.pluginAppPolicyContext,
});
if (pluginResolution.kind !== "not_plugin") {
if (pluginResolution.kind === "decline") {
logPluginElicitationDecline(pluginResolution.reason, requestParams);
return declineElicitationResponse();
}
if (!hasExactTurnId(requestParams, params.turnId)) {
logPluginElicitationDecline("missing_active_turn", requestParams);
return declineElicitationResponse();
}
return buildPluginPolicyElicitationResponse(pluginResolution.entry, requestParams);
}
const approvalPrompt = readBridgeableApprovalElicitation(requestParams);
if (!approvalPrompt) {
return undefined;
@@ -79,23 +120,174 @@ export async function handleCodexAppServerElicitationRequest(params: {
return buildElicitationResponse(approvalPrompt.requestedSchema, approvalPrompt.meta, outcome);
}
function matchesCurrentTurn(
requestParams: JsonObject | undefined,
threadId: string,
turnId: string,
): boolean {
function matchesCurrentThread(requestParams: JsonObject | undefined, threadId: string): boolean {
if (!requestParams) {
return false;
}
const requestThreadId = readString(requestParams, "threadId");
if (requestThreadId !== threadId) {
return requestThreadId === threadId;
}
function turnIdMismatches(requestParams: JsonObject | undefined, turnId: string): boolean {
const rawTurnId = requestParams?.turnId;
return rawTurnId !== null && rawTurnId !== undefined && rawTurnId !== turnId;
}
function hasExactTurnId(requestParams: JsonObject | undefined, turnId: string): boolean {
return requestParams?.turnId === turnId;
}
function resolvePluginElicitation(params: {
requestParams: JsonObject | undefined;
pluginAppPolicyContext?: PluginAppPolicyContext;
}): PluginElicitationResolution {
const requestParams = params.requestParams;
if (!requestParams) {
return { kind: "not_plugin" };
}
const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {};
const context = params.pluginAppPolicyContext;
const entries = context ? Object.values(context.apps) : [];
const appId =
readFirstString(meta, PLUGIN_APP_ID_META_KEYS) ??
readFirstString(requestParams, PLUGIN_APP_ID_META_KEYS);
if (appId) {
if (!context) {
return { kind: "decline", reason: "missing_policy_context" };
}
const entry = context.apps[appId];
return uniquePluginMatch(entry ? [entry] : [], "app_id");
}
const serverName = readString(requestParams, "serverName");
if (serverName && context) {
const matches = entries.filter((entry) => entry.mcpServerNames.includes(serverName));
if (matches.length > 0) {
return uniquePluginMatch(matches, "server_name");
}
}
const metadataResolution = resolvePluginStableMetadataMatch({
meta,
requestParams,
entries,
context,
});
if (metadataResolution.kind !== "not_plugin") {
return metadataResolution;
}
if (context && hasDisplayNameOnlyPluginMatch(meta, entries)) {
return { kind: "decline", reason: "display_name_only" };
}
return { kind: "not_plugin" };
}
function resolvePluginStableMetadataMatch(params: {
meta: JsonObject;
requestParams: JsonObject;
entries: PluginAppPolicyContextEntry[];
context?: PluginAppPolicyContext;
}): PluginElicitationResolution {
const pluginName =
readFirstString(params.meta, PLUGIN_NAME_META_KEYS) ??
readFirstString(params.requestParams, PLUGIN_NAME_META_KEYS);
const configKey =
readFirstString(params.meta, PLUGIN_CONFIG_KEY_META_KEYS) ??
readFirstString(params.requestParams, PLUGIN_CONFIG_KEY_META_KEYS);
const marketplaceName =
readFirstString(params.meta, PLUGIN_MARKETPLACE_NAME_META_KEYS) ??
readFirstString(params.requestParams, PLUGIN_MARKETPLACE_NAME_META_KEYS);
if (!pluginName && !configKey) {
return { kind: "not_plugin" };
}
if (!params.context) {
return { kind: "decline", reason: "missing_policy_context" };
}
const matches = params.entries.filter((entry) => {
if (marketplaceName && entry.marketplaceName !== marketplaceName) {
return false;
}
if (pluginName && entry.pluginName !== pluginName) {
return false;
}
if (configKey && entry.configKey !== configKey) {
return false;
}
return true;
});
return uniquePluginMatch(matches, "metadata");
}
function uniquePluginMatch(
matches: PluginAppPolicyContextEntry[],
source: string,
): PluginElicitationResolution {
if (matches.length === 1 && matches[0]) {
return { kind: "matched", entry: matches[0] };
}
return {
kind: "decline",
reason: matches.length === 0 ? `${source}_not_enabled` : `${source}_ambiguous`,
};
}
function hasDisplayNameOnlyPluginMatch(
meta: JsonObject,
entries: PluginAppPolicyContextEntry[],
): boolean {
const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY);
if (!connectorName) {
return false;
}
const rawTurnId = requestParams.turnId;
if (rawTurnId !== null && rawTurnId !== undefined && rawTurnId !== turnId) {
return false;
const normalized = normalizePluginIdentityText(connectorName);
return entries.some(
(entry) =>
normalizePluginIdentityText(entry.pluginName) === normalized ||
normalizePluginIdentityText(entry.configKey) === normalized,
);
}
function normalizePluginIdentityText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPluginPolicyElicitationResponse(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,
): JsonValue {
if (!entry.allowDestructiveActions) {
logPluginElicitationDecline("destructive_actions_disabled", requestParams);
return declineElicitationResponse();
}
return true;
if (
readString(requestParams, "mode") !== "form" ||
!isJsonObject(requestParams.requestedSchema)
) {
logPluginElicitationDecline("unsupported_schema", requestParams);
return declineElicitationResponse();
}
const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {};
const response = buildElicitationResponse(requestParams.requestedSchema, meta, "approved-once");
if (isJsonObject(response) && response.action === "accept") {
return response;
}
logPluginElicitationDecline("unmappable_schema", requestParams);
return declineElicitationResponse();
}
function declineElicitationResponse(): JsonValue {
return { action: "decline", content: null, _meta: null };
}
function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void {
embeddedAgentLog.debug("codex plugin elicitation declined", {
reason,
serverName: readString(requestParams, "serverName"),
mode: readString(requestParams, "mode"),
});
}
function readBridgeableApprovalElicitation(
@@ -555,3 +747,13 @@ function readString(record: JsonObject | undefined, key: string): string | undef
const value = record?.[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function readFirstString(record: JsonObject | undefined, keys: string[]): string | undefined {
for (const key of keys) {
const value = readString(record, key);
if (value) {
return value;
}
}
return undefined;
}

View File

@@ -0,0 +1,319 @@
import { describe, expect, it, vi } from "vitest";
import { CodexAppInventoryCache } from "./app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
import {
ensureCodexAppsSubstrateConfig,
ensureCodexPluginActivation,
upsertTomlBoolean,
} from "./plugin-activation.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin activation", () => {
it("skips plugin/install when the migrated plugin is already active", async () => {
const calls: string[] = [];
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
request: async (method) => {
calls.push(method);
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: true,
reason: "already_active",
installAttempted: false,
});
expect(calls).toEqual(["plugin/list"]);
});
it("can reinstall an already active plugin when migration explicitly applies it", async () => {
const calls: string[] = [];
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
installEvenIfActive: true,
request: async (method, params) => {
calls.push(method);
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
expect(params).toEqual({
marketplacePath: "/marketplaces/openai-curated",
pluginName: "google-calendar",
});
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: true,
reason: "already_active",
installAttempted: true,
});
expect(calls).toEqual([
"plugin/list",
"plugin/install",
"plugin/list",
"skills/list",
"hooks/list",
"config/mcpServer/reload",
]);
});
it("installs a migration-authorized local curated plugin and refreshes runtime state", async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const appCache = new CodexAppInventoryCache();
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
appCache,
appCacheKey: "runtime",
request: async (method, params) => {
calls.push({ method, params });
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed: false, enabled: false }),
]);
}
if (method === "plugin/install") {
expect(params).toEqual({
marketplacePath: "/marketplaces/openai-curated",
pluginName: "google-calendar",
});
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
expect(params).toMatchObject({ forceReload: true });
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
expect(params).toMatchObject({ forceRefetch: true });
return { data: [], nextCursor: null } satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: true,
reason: "installed",
installAttempted: true,
});
expect(calls.map((call) => call.method)).toEqual([
"plugin/list",
"plugin/install",
"plugin/list",
"skills/list",
"hooks/list",
"config/mcpServer/reload",
"app/list",
]);
expect(appCache.getRevision()).toBeGreaterThan(0);
});
it("keeps activation fail-closed when post-install app inventory refresh fails", async () => {
const appCache = new CodexAppInventoryCache();
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
appCache,
appCacheKey: "runtime",
request: async (method) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed: false, enabled: false }),
]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
throw new Error("app/list unavailable");
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: true,
reason: "installed",
installAttempted: true,
});
expect(result.diagnostics).toContainEqual({
message: "Codex app inventory refresh skipped: app/list unavailable",
});
expect(appCache.getRevision()).toBeGreaterThan(0);
});
it("reports post-install runtime refresh failures without hiding the install attempt", async () => {
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
request: async (method) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed: false, enabled: false }),
]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
throw new Error("skills/list unavailable");
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: false,
reason: "refresh_failed",
installAttempted: true,
});
expect(result.diagnostics).toContainEqual({
message: "Codex plugin runtime refresh failed after install: skills/list unavailable",
});
});
it("installs from a remote curated marketplace when no local marketplace path is present", async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
request: async (method, params) => {
calls.push({ method, params });
if (method === "plugin/list") {
return {
...pluginList([pluginSummary("google-calendar", { installed: false, enabled: false })]),
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: null,
interface: null,
plugins: [pluginSummary("google-calendar", { installed: false, enabled: false })],
},
],
} satisfies v2.PluginListResponse;
}
if (method === "plugin/install") {
expect(params).toEqual({
remoteMarketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
});
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
},
});
expect(result).toMatchObject({
ok: true,
reason: "installed",
installAttempted: true,
});
expect(calls.map((call) => call.method)).toEqual([
"plugin/list",
"plugin/install",
"plugin/list",
"skills/list",
"hooks/list",
"config/mcpServer/reload",
]);
});
it("upserts native apps substrate config without clobbering other toml", async () => {
const existing = 'model = "gpt-5.5"\n\n[features]\nother = true\n';
expect(upsertTomlBoolean(existing, "features", "apps", true)).toBe(
'model = "gpt-5.5"\n\n[features]\nother = true\napps = true\n',
);
const writes: Array<{ path: string; content: string }> = [];
const result = await ensureCodexAppsSubstrateConfig({
codexHome: "/codex-home",
readFile: vi.fn(async () => existing),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (filePath, content) => {
writes.push({ path: String(filePath), content: String(content) });
}),
});
expect(result).toEqual({ changed: true, configPath: "/codex-home/config.toml" });
expect(writes[0]?.content).toContain("[features]\nother = true\napps = true");
expect(writes[0]?.content).toContain("[apps._default]\nenabled = true");
});
});
function identity(pluginName: string): ResolvedCodexPluginPolicy {
return {
configKey: pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName,
enabled: true,
allowDestructiveActions: false,
};
}
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
return {
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: "/marketplaces/openai-curated",
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,
name: id,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
...overrides,
};
}

View File

@@ -0,0 +1,275 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
type CodexAppInventoryCache,
type CodexAppInventoryRequest,
} from "./app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
import {
findOpenAiCuratedPluginSummary,
pluginReadParams,
type CodexPluginMarketplaceRef,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import type { v2 } from "./protocol.js";
export type CodexPluginActivationReason =
| "already_active"
| "installed"
| "disabled"
| "marketplace_missing"
| "plugin_missing"
| "auth_required"
| "refresh_failed";
export type CodexPluginActivationDiagnostic = {
message: string;
};
export type CodexPluginActivationResult = {
identity: ResolvedCodexPluginPolicy;
ok: boolean;
reason: CodexPluginActivationReason;
installAttempted: boolean;
marketplace?: CodexPluginMarketplaceRef;
installResponse?: v2.PluginInstallResponse;
diagnostics: CodexPluginActivationDiagnostic[];
};
export type EnsureCodexPluginActivationParams = {
identity: ResolvedCodexPluginPolicy;
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
installEvenIfActive?: boolean;
};
export type CodexPluginRuntimeRefreshResult = {
diagnostics: CodexPluginActivationDiagnostic[];
};
export async function ensureCodexPluginActivation(
params: EnsureCodexPluginActivationParams,
): Promise<CodexPluginActivationResult> {
if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) {
return activationFailure(params.identity, "marketplace_missing", {
message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.",
});
}
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
if (!resolved) {
return activationFailure(params.identity, "plugin_missing", {
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
});
}
if (resolved.summary.installed && resolved.summary.enabled && !params.installEvenIfActive) {
return {
identity: params.identity,
ok: true,
reason: "already_active",
installAttempted: false,
marketplace: resolved.marketplace,
diagnostics: [],
};
}
const installResponse = (await params.request(
"plugin/install",
pluginReadParams(
resolved.marketplace,
params.identity.pluginName,
) satisfies v2.PluginInstallParams,
)) as v2.PluginInstallResponse;
const refreshDiagnostics: CodexPluginActivationDiagnostic[] = [];
let refreshFailed = false;
try {
const refreshResult = await refreshCodexPluginRuntimeState({
request: params.request,
appCache: params.appCache,
appCacheKey: params.appCacheKey,
});
refreshDiagnostics.push(...refreshResult.diagnostics);
} catch (error) {
refreshFailed = true;
refreshDiagnostics.push({
message: `Codex plugin runtime refresh failed after install: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
const authRequired = installResponse.appsNeedingAuth.length > 0;
return {
identity: params.identity,
ok: !authRequired && !refreshFailed,
reason: refreshFailed
? "refresh_failed"
: authRequired
? "auth_required"
: resolved.summary.installed && resolved.summary.enabled
? "already_active"
: "installed",
installAttempted: true,
marketplace: resolved.marketplace,
installResponse,
diagnostics: [
...refreshDiagnostics,
...installResponse.appsNeedingAuth.map((app) => ({
message: `${app.name} requires app authentication before plugin tools are exposed.`,
})),
],
};
}
export async function refreshCodexPluginRuntimeState(params: {
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
}): Promise<CodexPluginRuntimeRefreshResult> {
const diagnostics: CodexPluginActivationDiagnostic[] = [];
await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams);
await params.request("skills/list", {
cwds: [],
forceReload: true,
} satisfies v2.SkillsListParams);
try {
await params.request("hooks/list", {
cwds: [],
} satisfies v2.HooksListParams);
} catch (error) {
diagnostics.push({
message: `Codex hooks refresh skipped: ${error instanceof Error ? error.message : String(error)}`,
});
}
await params.request("config/mcpServer/reload", undefined);
if (params.appCache && params.appCacheKey) {
params.appCache.invalidate(params.appCacheKey, "Codex plugin activation changed app inventory");
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as v2.AppsListResponse;
try {
await params.appCache.refreshNow({
key: params.appCacheKey,
request,
forceRefetch: true,
});
} catch (error) {
diagnostics.push({
message: `Codex app inventory refresh skipped: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
}
return { diagnostics };
}
export async function ensureCodexAppsSubstrateConfig(params: {
codexHome: string;
readFile?: (filePath: string, encoding: "utf8") => Promise<string>;
writeFile?: (filePath: string, content: string, encoding: "utf8") => Promise<void>;
mkdir?: (dirPath: string, options: { recursive: true }) => Promise<unknown>;
}): Promise<{ changed: boolean; configPath: string }> {
const readFile = params.readFile ?? ((filePath, encoding) => fs.readFile(filePath, encoding));
const writeFile =
params.writeFile ??
((filePath, content, encoding) => fs.writeFile(filePath, content, encoding));
const mkdir = params.mkdir ?? ((dirPath, options) => fs.mkdir(dirPath, options));
const configPath = path.join(params.codexHome, "config.toml");
let current = "";
try {
current = await readFile(configPath, "utf8");
} catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
const next = upsertTomlBoolean(
upsertTomlBoolean(current, "features", "apps", true),
"apps._default",
"enabled",
true,
);
if (next === current) {
return { changed: false, configPath };
}
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, next, "utf8");
return { changed: true, configPath };
}
export function upsertTomlBoolean(
source: string,
section: string,
key: string,
value: boolean,
): string {
const lines = source.replace(/\r\n/g, "\n").split("\n");
if (lines.length > 0 && lines.at(-1) === "") {
lines.pop();
}
const sectionHeaderPattern = new RegExp(`^\\s*\\[${escapeRegExp(section)}\\]\\s*(?:#.*)?$`);
const anySectionPattern = /^\s*\[[^\]]+\]\s*(?:#.*)?$/;
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
const desiredLine = `${key} = ${value ? "true" : "false"}`;
const sectionStart = lines.findIndex((line) => sectionHeaderPattern.test(line));
if (sectionStart === -1) {
const nextLines = [...lines];
if (nextLines.length > 0 && nextLines.at(-1)?.trim()) {
nextLines.push("");
}
nextLines.push(`[${section}]`, desiredLine);
return `${nextLines.join("\n")}\n`;
}
let sectionEnd = lines.length;
for (let index = sectionStart + 1; index < lines.length; index += 1) {
if (anySectionPattern.test(lines[index] ?? "")) {
sectionEnd = index;
break;
}
}
for (let index = sectionStart + 1; index < sectionEnd; index += 1) {
if (keyPattern.test(lines[index] ?? "")) {
if (lines[index] === desiredLine) {
return `${lines.join("\n")}\n`;
}
const nextLines = [...lines];
nextLines[index] = desiredLine;
return `${nextLines.join("\n")}\n`;
}
}
const nextLines = [...lines];
nextLines.splice(sectionEnd, 0, desiredLine);
return `${nextLines.join("\n")}\n`;
}
function activationFailure(
identity: ResolvedCodexPluginPolicy,
reason: CodexPluginActivationReason,
diagnostic: CodexPluginActivationDiagnostic,
): CodexPluginActivationResult {
return {
identity,
ok: false,
reason,
installAttempted: false,
diagnostics: [diagnostic],
};
}
function isEnoent(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,346 @@
import { describe, expect, it } from "vitest";
import { CodexAppInventoryCache } from "./app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js";
import { findOpenAiCuratedPluginSummary, readCodexPluginInventory } from "./plugin-inventory.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin inventory", () => {
it("returns enabled migrated curated plugins with stable owned app ids", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const calls: string[] = [];
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
slack: {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "slack",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method, params) => {
calls.push(method);
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed: true, enabled: true }),
pluginSummary("slack", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
expect(params).toMatchObject({
marketplacePath: "/marketplaces/openai-curated",
pluginName: "google-calendar",
});
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(inventory.records).toHaveLength(1);
expect(inventory.records[0]).toMatchObject({
policy: { pluginName: "google-calendar" },
summary: { installed: true, enabled: true },
appOwnership: "proven",
ownedAppIds: ["google-calendar-app"],
apps: [{ id: "google-calendar-app", accessible: true, enabled: true }],
});
expect(calls).toEqual(["plugin/list", "plugin/read"]);
});
it("matches namespaced curated plugin ids by normalized path segment", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("github-app", true)],
nextCursor: null,
}),
});
const listed = pluginList([
pluginSummary("openai-curated/github", {
name: "GitHub",
installed: true,
enabled: true,
}),
]);
expect(findOpenAiCuratedPluginSummary(listed, "github")?.summary.id).toBe(
"openai-curated/github",
);
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
github: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "github",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method, params) => {
if (method === "plugin/list") {
return listed;
}
if (method === "plugin/read") {
expect(params).toMatchObject({
marketplacePath: "/marketplaces/openai-curated",
pluginName: "github",
});
return pluginDetail("github", [appSummary("github-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(inventory.records).toHaveLength(1);
expect(inventory.records[0]).toMatchObject({
policy: { pluginName: "github" },
summary: { id: "openai-curated/github", installed: true, enabled: true },
appOwnership: "proven",
ownedAppIds: ["github-app"],
});
expect(inventory.diagnostics).not.toContainEqual(
expect.objectContaining({ code: "plugin_missing" }),
);
});
it("fails closed when plugin detail apps are absent from app inventory", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [],
nextCursor: null,
}),
});
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/list") {
return pluginList([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]).toMatchObject({
appOwnership: "proven",
authRequired: true,
ownedAppIds: ["google-calendar-app"],
apps: [
{
id: "google-calendar-app",
accessible: false,
enabled: false,
needsAuth: true,
},
],
});
});
it("marks display-name-only app matches ambiguous instead of exposing app ids", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [
{
...appInfo("calendar-app", true),
pluginDisplayNames: ["Google Calendar"],
},
],
nextCursor: null,
}),
});
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
readPluginDetails: false,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", {
name: "Google Calendar",
installed: true,
enabled: true,
}),
]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(inventory.records[0]?.appOwnership).toBe("ambiguous");
expect(inventory.records[0]?.ownedAppIds).toEqual([]);
expect(inventory.diagnostics).toContainEqual(
expect.objectContaining({ code: "app_ownership_ambiguous" }),
);
});
it("fails closed when the app inventory cache is missing", async () => {
const appCache = new CodexAppInventoryCache();
const inventory = await readCodexPluginInventory({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request: async (method) => {
if (method === "app/list") {
return { data: [], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([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.appInventory?.state).toBe("missing");
expect(inventory.records[0]?.ownedAppIds).toEqual(["google-calendar-app"]);
expect(inventory.records[0]?.apps).toEqual([]);
expect(inventory.diagnostics).toContainEqual(
expect.objectContaining({ code: "app_inventory_missing" }),
);
});
});
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
return {
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: "/marketplaces/openai-curated",
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,
name: id,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
...overrides,
};
}
function pluginDetail(pluginName: string, apps: v2.AppSummary[]): v2.PluginReadResponse {
return {
plugin: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
marketplacePath: "/marketplaces/openai-curated",
summary: pluginSummary(pluginName, { installed: true, enabled: true }),
description: null,
skills: [],
apps,
mcpServers: [],
},
};
}
function appSummary(id: string): v2.AppSummary {
return {
id,
name: id,
description: null,
installUrl: null,
needsAuth: false,
};
}
function appInfo(id: string, accessible: boolean): v2.AppInfo {
return {
id,
name: id,
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: accessible,
isEnabled: true,
pluginDisplayNames: [],
};
}

View File

@@ -0,0 +1,346 @@
import {
type CodexAppInventoryCache,
type CodexAppInventoryCacheRead,
type CodexAppInventoryRequest,
} from "./app-inventory-cache.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
resolveCodexPluginsPolicy,
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import type { v2 } from "./protocol.js";
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
export type CodexPluginMarketplaceRef = {
name: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
path?: string;
remoteMarketplaceName?: string;
};
export type CodexPluginInventoryDiagnosticCode =
| "disabled"
| "marketplace_missing"
| "plugin_missing"
| "plugin_disabled"
| "plugin_detail_unavailable"
| "app_inventory_missing"
| "app_inventory_stale"
| "app_ownership_ambiguous";
export type CodexPluginInventoryDiagnostic = {
code: CodexPluginInventoryDiagnosticCode;
plugin?: ResolvedCodexPluginPolicy;
message: string;
};
export type CodexPluginOwnedApp = {
id: string;
name: string;
accessible: boolean;
enabled: boolean;
needsAuth: boolean;
};
export type CodexPluginInventoryRecord = {
policy: ResolvedCodexPluginPolicy;
summary: v2.PluginSummary;
detail?: v2.PluginDetail;
activationRequired: boolean;
authRequired: boolean;
appOwnership: "proven" | "ambiguous" | "none";
ownedAppIds: string[];
apps: CodexPluginOwnedApp[];
};
export type CodexPluginInventory = {
policy: ResolvedCodexPluginsPolicy;
marketplace?: CodexPluginMarketplaceRef;
records: CodexPluginInventoryRecord[];
diagnostics: CodexPluginInventoryDiagnostic[];
appInventory?: CodexAppInventoryCacheRead;
};
export type ReadCodexPluginInventoryParams = {
pluginConfig?: unknown;
policy?: ResolvedCodexPluginsPolicy;
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
nowMs?: number;
readPluginDetails?: boolean;
};
export async function readCodexPluginInventory(
params: ReadCodexPluginInventoryParams,
): Promise<CodexPluginInventory> {
const policy = params.policy ?? resolveCodexPluginsPolicy(params.pluginConfig);
if (!policy.enabled) {
return {
policy,
records: [],
diagnostics: [
{
code: "disabled",
message: "Native Codex plugin support is disabled.",
},
],
};
}
const appInventory = readCachedAppInventory(params);
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return {
policy,
records: [],
diagnostics: policy.pluginPolicies
.filter((pluginPolicy) => pluginPolicy.enabled)
.map((pluginPolicy) => ({
code: "marketplace_missing",
plugin: pluginPolicy,
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
})),
...(appInventory ? { appInventory } : {}),
};
}
const marketplace = marketplaceRef(marketplaceEntry);
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
const records: CodexPluginInventoryRecord[] = [];
if (appInventory?.state === "missing") {
diagnostics.push({
code: "app_inventory_missing",
message: "Cached Codex app inventory is missing; plugin apps are excluded for this setup.",
});
} else if (appInventory?.state === "stale") {
diagnostics.push({
code: "app_inventory_stale",
message: "Cached Codex app inventory is stale; using stale app readiness and refreshing.",
});
}
for (const pluginPolicy of policy.pluginPolicies) {
if (!pluginPolicy.enabled) {
continue;
}
const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName);
if (!summary) {
diagnostics.push({
code: "plugin_missing",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
});
continue;
}
const detail = await readPluginDetail(params, marketplace, pluginPolicy, diagnostics);
const ownedAppIds =
detail?.apps
.map((app) => app.id)
.filter(Boolean)
.toSorted() ?? [];
const appOwnership = resolveAppOwnership({
detail,
appInventory,
summary,
});
if (appOwnership === "ambiguous") {
diagnostics.push({
code: "app_ownership_ambiguous",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} has only display-name app matches; apps are not exposed until ownership is stable.`,
});
}
if (summary.installed && !summary.enabled) {
diagnostics.push({
code: "plugin_disabled",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} is installed in Codex but disabled.`,
});
}
const apps = resolveOwnedApps({
detail,
appInventory,
});
records.push({
policy: pluginPolicy,
summary,
...(detail ? { detail } : {}),
activationRequired: !summary.installed || !summary.enabled,
authRequired: apps.some((app) => app.needsAuth || !app.accessible),
appOwnership,
ownedAppIds,
apps,
});
}
return {
policy,
marketplace,
records,
diagnostics,
...(appInventory ? { appInventory } : {}),
};
}
export function findOpenAiCuratedPluginSummary(
listed: v2.PluginListResponse,
pluginName: string,
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return undefined;
}
const summary = findPluginSummary(marketplaceEntry, pluginName);
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
}
export function pluginReadParams(
marketplace: CodexPluginMarketplaceRef,
pluginName: string,
): v2.PluginReadParams {
return {
...(marketplace.path ? { marketplacePath: marketplace.path } : {}),
...(marketplace.remoteMarketplaceName
? { remoteMarketplaceName: marketplace.remoteMarketplaceName }
: {}),
pluginName,
};
}
function readCachedAppInventory(
params: ReadCodexPluginInventoryParams,
): CodexAppInventoryCacheRead | undefined {
if (!params.appCache || !params.appCacheKey) {
return undefined;
}
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as v2.AppsListResponse;
return params.appCache.read({
key: params.appCacheKey,
request,
nowMs: params.nowMs,
});
}
async function readPluginDetail(
params: ReadCodexPluginInventoryParams,
marketplace: CodexPluginMarketplaceRef,
pluginPolicy: ResolvedCodexPluginPolicy,
diagnostics: CodexPluginInventoryDiagnostic[],
): Promise<v2.PluginDetail | undefined> {
if (params.readPluginDetails === false) {
return undefined;
}
try {
const response = (await params.request(
"plugin/read",
pluginReadParams(marketplace, pluginPolicy.pluginName),
)) as v2.PluginReadResponse;
return response.plugin;
} catch (error) {
diagnostics.push({
code: "plugin_detail_unavailable",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} detail unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
});
return undefined;
}
}
function resolveAppOwnership(params: {
detail?: v2.PluginDetail;
appInventory?: CodexAppInventoryCacheRead;
summary: v2.PluginSummary;
}): "proven" | "ambiguous" | "none" {
if (params.detail && params.detail.apps.length > 0) {
return "proven";
}
const apps = params.appInventory?.snapshot?.apps ?? [];
const displayMatches = apps.filter((app) =>
app.pluginDisplayNames.some((displayName) => displayName === params.summary.name),
);
return displayMatches.length > 0 ? "ambiguous" : "none";
}
function resolveOwnedApps(params: {
detail?: v2.PluginDetail;
appInventory?: CodexAppInventoryCacheRead;
}): CodexPluginOwnedApp[] {
const detailApps = params.detail?.apps ?? [];
if (detailApps.length === 0) {
return [];
}
if (params.appInventory?.state === "missing") {
return [];
}
const appInfoById = new Map(
(params.appInventory?.snapshot?.apps ?? []).map((app) => [app.id, app] as const),
);
return detailApps
.map((app) => {
const info = appInfoById.get(app.id);
if (!info) {
return {
id: app.id,
name: app.name,
accessible: false,
enabled: false,
needsAuth: true,
};
}
return {
id: app.id,
name: app.name,
accessible: info.isAccessible,
enabled: info.isEnabled,
needsAuth: app.needsAuth || !info.isAccessible,
};
})
.toSorted((left, right) => left.id.localeCompare(right.id));
}
function findPluginSummary(
marketplace: v2.PluginMarketplaceEntry,
pluginName: string,
): v2.PluginSummary | undefined {
return marketplace.plugins.find(
(plugin) =>
plugin.name === pluginName ||
plugin.id === pluginName ||
plugin.id === `${pluginName}@${marketplace.name}` ||
pluginNameFromPluginId(plugin.id, marketplace.name) === pluginName,
);
}
function pluginNameFromPluginId(pluginId: string, marketplaceName: string): string | undefined {
const trimmed = pluginId.trim();
if (!trimmed) {
return undefined;
}
const marketplaceSuffix = `@${marketplaceName}`;
const withoutMarketplaceSuffix = trimmed.endsWith(marketplaceSuffix)
? trimmed.slice(0, -marketplaceSuffix.length)
: trimmed;
return withoutMarketplaceSuffix.split("/").at(-1)?.trim() || undefined;
}
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
return {
name: CODEX_PLUGINS_MARKETPLACE_NAME,
...(marketplace.path ? { path: marketplace.path } : {}),
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
};
}

View File

@@ -0,0 +1,732 @@
import { describe, expect, it, vi } from "vitest";
import { CodexAppInventoryCache } from "./app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js";
import {
buildCodexPluginThreadConfig,
buildCodexPluginThreadConfigInputFingerprint,
isCodexPluginThreadBindingStale,
mergeCodexThreadConfigs,
shouldBuildCodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin thread config", () => {
it("builds restrictive app config for accessible migrated plugin apps", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
"google-calendar-app": {
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "prompt",
},
},
});
expect(config.policyContext.apps["google-calendar-app"]).toEqual({
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
mcpServerNames: ["google-calendar"],
});
expect(config.diagnostics).toEqual([]);
});
it("maps destructive app access from global and per-plugin policy", async () => {
const pluginOverrideDisabled = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allow_destructive_actions: false,
},
},
},
});
const disabledApps = pluginOverrideDisabled.configPatch?.apps as
| Record<string, unknown>
| undefined;
expect(disabledApps?.["google-calendar-app"]).toMatchObject({
enabled: true,
destructive_enabled: false,
open_world_enabled: true,
});
expect(disabledApps?.["google-calendar-app"]).not.toHaveProperty("default_tools_enabled");
expect(disabledApps?.["google-calendar-app"]).not.toHaveProperty("tools");
expect(
pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(false);
const pluginOverrideEnabled = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allow_destructive_actions: true,
},
},
},
});
const enabledApps = pluginOverrideEnabled.configPatch?.apps as
| Record<string, unknown>
| undefined;
expect(enabledApps?.["google-calendar-app"]).toMatchObject({
enabled: true,
destructive_enabled: true,
});
expect(
pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(true);
});
it("builds a restrictive app config when native plugin support is disabled", async () => {
expect(
shouldBuildCodexPluginThreadConfig({
codexPlugins: { enabled: false },
}),
).toBe(true);
const config = await buildCodexPluginThreadConfig({
pluginConfig: { codexPlugins: { enabled: false } },
appCacheKey: "runtime",
request: async (method) => {
throw new Error(`unexpected request ${method}`);
},
});
expect(config.enabled).toBe(false);
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.diagnostics).toEqual([]);
expect(config.policyContext.apps).toEqual({});
});
it("does not let per-plugin enablement override disabled native plugin support", async () => {
expect(
shouldBuildCodexPluginThreadConfig({
codexPlugins: {
enabled: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
}),
).toBe(true);
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCacheKey: "runtime",
request: async (method) => {
throw new Error(`unexpected request ${method}`);
},
});
expect(config.enabled).toBe(false);
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toEqual({});
expect(config.diagnostics).toEqual([]);
});
it("waits for the initial app inventory before exposing plugin apps", async () => {
const appCache = new CodexAppInventoryCache();
const request = vi.fn(async (method: string) => {
if (method === "app/list") {
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([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}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request,
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
"google-calendar-app": {
enabled: true,
destructive_enabled: false,
open_world_enabled: true,
default_tools_approval_mode: "prompt",
},
},
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
pluginName: "google-calendar",
});
expect(config.diagnostics).toEqual([]);
expect(request.mock.calls.filter(([method]) => method === "app/list")).toHaveLength(1);
});
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
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/list") {
return pluginList([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(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toEqual({});
expect(config.diagnostics).toContainEqual(
expect.objectContaining({
code: "app_not_ready",
message: "google-calendar-app is not accessible or enabled for google-calendar.",
}),
);
});
it("re-reads app readiness after re-enabling an installed plugin", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true, false)],
nextCursor: null,
}),
});
let enabled = false;
const appListParams: v2.AppsListParams[] = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "plugin/install") {
enabled = true;
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return {
data: [appInfo("google-calendar-app", true, enabled)],
nextCursor: null,
} satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request,
});
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
destructive_enabled: false,
open_world_enabled: true,
},
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
pluginName: "google-calendar",
});
expect(config.diagnostics).toEqual([]);
expect(request.mock.calls.map(([method]) => method)).toContain("plugin/install");
expect(request.mock.calls.filter(([method]) => method === "app/list").length).toBeGreaterThan(
0,
);
expect(appListParams.some((params) => params.forceRefetch)).toBe(true);
});
it("surfaces critical post-install refresh failures and keeps plugin apps disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
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/list") {
return pluginList([
pluginSummary("google-calendar", { installed: false, enabled: false }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
throw new Error("skills/list unavailable");
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toEqual({});
expect(config.diagnostics).toContainEqual(
expect.objectContaining({
code: "plugin_activation_failed",
message: expect.stringContaining("skills/list unavailable"),
}),
);
});
it("fails closed when the initial app inventory refresh fails", async () => {
const appCache = new CodexAppInventoryCache();
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request: async (method) => {
if (method === "app/list") {
throw new Error("app/list unavailable");
}
if (method === "plugin/list") {
return pluginList([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(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toEqual({});
expect(config.diagnostics).toContainEqual(
expect.objectContaining({ code: "app_inventory_missing" }),
);
});
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
const appCache = new CodexAppInventoryCache();
const first = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: { codexPlugins: { enabled: true } },
appCacheKey: "runtime-a",
});
await appCache.refreshNow({
key: "runtime-a",
request: async () => ({ data: [], nextCursor: null }),
});
const second = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: { codexPlugins: { enabled: true } },
appCacheKey: "runtime-a",
});
const third = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: { codexPlugins: { enabled: true } },
appCacheKey: "runtime-b",
});
expect(second).toBe(first);
expect(third).not.toBe(second);
});
it("uses app-level destructive policy for plugins without OpenClaw tool-name knowledge", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("github-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
github: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "github",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("github", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("github", [appSummary("github-app")], ["github"]);
}
throw new Error(`unexpected request ${method}`);
},
});
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
expect(apps?.["github-app"]).toEqual({
enabled: true,
destructive_enabled: false,
open_world_enabled: true,
default_tools_approval_mode: "prompt",
});
expect(apps?.["github-app"]).not.toHaveProperty("tools");
});
it("merges app config with native hook config", () => {
expect(
mergeCodexThreadConfigs(
{ "features.codex_hooks": true, hooks: { PreToolUse: [] } },
{ apps: { _default: { enabled: false } } },
),
).toEqual({
"features.codex_hooks": true,
hooks: { PreToolUse: [] },
apps: { _default: { enabled: false } },
});
});
it("marks missing and changed plugin app bindings stale only when relevant", () => {
expect(
isCodexPluginThreadBindingStale({
codexPluginsEnabled: true,
currentInputFingerprint: "input-2",
}),
).toBe(true);
expect(
isCodexPluginThreadBindingStale({
codexPluginsEnabled: true,
bindingFingerprint: "config-1",
bindingInputFingerprint: "input-1",
currentInputFingerprint: "input-2",
hasBindingPolicyContext: true,
}),
).toBe(true);
expect(
isCodexPluginThreadBindingStale({
codexPluginsEnabled: true,
bindingFingerprint: "config-1",
bindingInputFingerprint: "input-1",
currentInputFingerprint: "input-1",
hasBindingPolicyContext: true,
}),
).toBe(false);
expect(
isCodexPluginThreadBindingStale({
codexPluginsEnabled: false,
bindingFingerprint: "config-1",
bindingInputFingerprint: "input-1",
hasBindingPolicyContext: true,
}),
).toBe(true);
});
});
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
return {
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: "/marketplaces/openai-curated",
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,
name: id,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
...overrides,
};
}
function pluginDetail(
pluginName: string,
apps: v2.AppSummary[],
mcpServers: string[] = [],
): v2.PluginReadResponse {
return {
plugin: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
marketplacePath: "/marketplaces/openai-curated",
summary: pluginSummary(pluginName, { installed: true, enabled: true }),
description: null,
skills: [],
apps,
mcpServers,
},
};
}
function appSummary(id: string): v2.AppSummary {
return {
id,
name: id,
description: null,
installUrl: null,
needsAuth: false,
};
}
function appInfo(id: string, accessible: boolean, enabled = true): v2.AppInfo {
return {
id,
name: id,
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: accessible,
isEnabled: enabled,
pluginDisplayNames: [],
};
}
async function buildReadyGoogleCalendarThreadConfig(
pluginConfig: unknown,
): Promise<Awaited<ReturnType<typeof buildCodexPluginThreadConfig>>> {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
return buildCodexPluginThreadConfig({
pluginConfig,
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([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}`);
},
});
}

View File

@@ -0,0 +1,389 @@
import crypto from "node:crypto";
import {
defaultCodexAppInventoryCache,
type CodexAppInventoryCache,
type CodexAppInventoryRequest,
} from "./app-inventory-cache.js";
import {
resolveCodexPluginsPolicy,
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import {
ensureCodexPluginActivation,
type CodexPluginActivationResult,
} from "./plugin-activation.js";
import {
readCodexPluginInventory,
type CodexPluginInventory,
type CodexPluginInventoryDiagnostic,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import type { JsonObject, JsonValue } from "./protocol.js";
export type PluginAppPolicyContextEntry = {
configKey: string;
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"];
pluginName: string;
allowDestructiveActions: boolean;
mcpServerNames: string[];
};
export type PluginAppPolicyContext = {
fingerprint: string;
apps: Record<string, PluginAppPolicyContextEntry>;
pluginAppIds: Record<string, string[]>;
};
export type CodexPluginThreadConfigDiagnostic =
| CodexPluginInventoryDiagnostic
| {
code: "plugin_activation_failed" | "app_not_ready";
plugin?: ResolvedCodexPluginPolicy;
message: string;
};
export type CodexPluginThreadConfig = {
enabled: boolean;
configPatch?: JsonObject;
fingerprint: string;
inputFingerprint: string;
policyContext: PluginAppPolicyContext;
inventory?: CodexPluginInventory;
diagnostics: CodexPluginThreadConfigDiagnostic[];
};
export type BuildCodexPluginThreadConfigParams = {
pluginConfig?: unknown;
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey: string;
nowMs?: number;
};
const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 1;
const CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION = 1;
export function shouldBuildCodexPluginThreadConfig(pluginConfig?: unknown): boolean {
return resolveCodexPluginsPolicy(pluginConfig).configured;
}
export function buildCodexPluginThreadConfigInputFingerprint(params: {
pluginConfig?: unknown;
appCacheKey?: string;
}): string {
const policy = resolveCodexPluginsPolicy(params.pluginConfig);
return fingerprintJson({
version: CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION,
policy: policyFingerprint(policy),
appCacheKey: params.appCacheKey ?? null,
});
}
export async function buildCodexPluginThreadConfig(
params: BuildCodexPluginThreadConfigParams,
): Promise<CodexPluginThreadConfig> {
const appCache = params.appCache ?? defaultCodexAppInventoryCache;
let inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: params.pluginConfig,
appCacheKey: params.appCacheKey,
});
const policy = resolveCodexPluginsPolicy(params.pluginConfig);
if (!policy.enabled) {
return emptyPluginThreadConfig({
enabled: false,
inputFingerprint,
configPatch: buildDisabledAppsConfigPatch(),
});
}
let inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
policy,
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
nowMs: params.nowMs,
});
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache);
inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
policy,
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
nowMs: params.nowMs,
});
inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: params.pluginConfig,
appCacheKey: params.appCacheKey,
});
}
const activationDiagnostics: CodexPluginThreadConfigDiagnostic[] = [];
const activationResults: CodexPluginActivationResult[] = [];
for (const record of inventory.records) {
if (!record.activationRequired) {
continue;
}
const activation = await ensureCodexPluginActivation({
identity: record.policy,
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
});
activationResults.push(activation);
if (!activation.ok) {
activationDiagnostics.push({
code: "plugin_activation_failed",
plugin: record.policy,
message: activation.diagnostics.map((item) => item.message).join(" ") || activation.reason,
});
}
}
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
await refreshAppInventoryNow(params, appCache, { forceRefetch: true });
inventory = await readCodexPluginInventory({
pluginConfig: params.pluginConfig,
policy,
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
nowMs: params.nowMs,
});
inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: params.pluginConfig,
appCacheKey: params.appCacheKey,
});
}
const diagnostics: CodexPluginThreadConfigDiagnostic[] = [
...inventory.diagnostics,
...activationDiagnostics,
];
const apps: JsonObject = {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
};
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
for (const record of inventory.records) {
if (record.activationRequired) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (!activation?.ok) {
continue;
}
}
if (record.appOwnership !== "proven") {
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of record.apps) {
if (!app.accessible || !app.enabled) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
});
continue;
}
const appConfig: JsonObject = {
enabled: true,
destructive_enabled: record.policy.allowDestructiveActions,
open_world_enabled: true,
default_tools_approval_mode: "prompt",
};
apps[app.id] = appConfig;
policyApps[app.id] = {
configKey: record.policy.configKey,
marketplaceName: record.policy.marketplaceName,
pluginName: record.policy.pluginName,
allowDestructiveActions: record.policy.allowDestructiveActions,
mcpServerNames: [...(record.detail?.mcpServers ?? [])].toSorted(),
};
}
}
const configPatch = { apps };
const policyContext = buildPluginAppPolicyContext(policyApps, pluginAppIds);
return {
enabled: true,
configPatch,
fingerprint: fingerprintJson({
version: CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION,
inputFingerprint,
configPatch,
policyContext,
}),
inputFingerprint,
policyContext,
inventory,
diagnostics,
};
}
export function mergeCodexThreadConfigs(
...configs: Array<JsonObject | undefined>
): JsonObject | undefined {
let merged: JsonObject | undefined;
for (const config of configs) {
if (!config) {
continue;
}
merged = mergeJsonObjects(merged ?? {}, config);
}
return merged && Object.keys(merged).length > 0 ? merged : undefined;
}
export function isCodexPluginThreadBindingStale(params: {
codexPluginsEnabled: boolean;
bindingFingerprint?: string;
bindingInputFingerprint?: string;
currentInputFingerprint?: string;
hasBindingPolicyContext?: boolean;
}): boolean {
if (!params.codexPluginsEnabled) {
return Boolean(
params.bindingFingerprint || params.bindingInputFingerprint || params.hasBindingPolicyContext,
);
}
if (
!params.bindingFingerprint ||
!params.bindingInputFingerprint ||
!params.hasBindingPolicyContext
) {
return true;
}
return params.bindingInputFingerprint !== params.currentInputFingerprint;
}
function emptyPluginThreadConfig(params: {
enabled: boolean;
inputFingerprint: string;
configPatch?: JsonObject;
}): CodexPluginThreadConfig {
const policyContext = buildPluginAppPolicyContext({}, {});
return {
enabled: params.enabled,
fingerprint: fingerprintJson({
version: CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION,
inputFingerprint: params.inputFingerprint,
configPatch: params.configPatch ?? null,
policyContext,
}),
inputFingerprint: params.inputFingerprint,
...(params.configPatch ? { configPatch: params.configPatch } : {}),
policyContext,
diagnostics: [],
};
}
function buildDisabledAppsConfigPatch(): JsonObject {
return {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
};
}
function buildPluginAppPolicyContext(
apps: Record<string, PluginAppPolicyContextEntry>,
pluginAppIds: Record<string, string[]>,
): PluginAppPolicyContext {
return {
fingerprint: fingerprintJson({ version: 1, apps, pluginAppIds }),
apps,
pluginAppIds,
};
}
function shouldWaitForInitialAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
return Boolean(
params.appCacheKey &&
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
inventory.appInventory?.state === "missing",
);
}
async function refreshAppInventoryNow(
params: BuildCodexPluginThreadConfigParams,
appCache: CodexAppInventoryCache,
options: { forceRefetch?: boolean } = {},
): Promise<void> {
const appCacheKey = params.appCacheKey;
if (!appCacheKey) {
return;
}
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as Awaited<ReturnType<CodexAppInventoryRequest>>;
try {
await appCache.refreshNow({
key: appCacheKey,
request,
nowMs: params.nowMs,
forceRefetch: options.forceRefetch,
});
} catch {
// Keep the thread fail-closed if app/list refresh is unavailable.
}
}
function policyFingerprint(policy: ResolvedCodexPluginsPolicy): JsonValue {
return {
enabled: policy.enabled,
allowDestructiveActions: policy.allowDestructiveActions,
plugins: policy.pluginPolicies.map((plugin) => ({
configKey: plugin.configKey,
marketplaceName: plugin.marketplaceName,
pluginName: plugin.pluginName,
enabled: plugin.enabled,
allowDestructiveActions: plugin.allowDestructiveActions,
})),
};
}
function mergeJsonObjects(left: JsonObject, right: JsonObject): JsonObject {
const merged: JsonObject = { ...left };
for (const [key, value] of Object.entries(right)) {
const existing = merged[key];
merged[key] =
isPlainJsonObject(existing) && isPlainJsonObject(value)
? mergeJsonObjects(existing, value)
: value;
}
return merged;
}
function isPlainJsonObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function fingerprintJson(value: JsonValue): string {
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
}
function stableStringify(value: JsonValue | undefined): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View File

@@ -75,7 +75,7 @@ export type CodexThreadStartParams = JsonObject & {
cwd?: string;
model?: string;
modelProvider?: string | null;
approvalPolicy?: string;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: CodexSandboxPolicy;
serviceTier?: CodexServiceTier | null;
@@ -108,7 +108,7 @@ export type CodexTurnStartParams = JsonObject & {
input?: CodexUserInput[];
cwd?: string;
model?: string;
approvalPolicy?: string;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandboxPolicy?: CodexSandboxPolicy;
serviceTier?: CodexServiceTier | null;
@@ -258,32 +258,115 @@ export type CodexLoginAccountParams =
};
export type CodexPluginSummary = {
id?: string;
name?: string;
id: string;
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;
needsAuth: boolean;
};
export type CodexPluginDetail = {
summary: CodexPluginSummary;
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[];
};
export type CodexPluginListResponse = {
marketplaces: CodexPluginMarketplaceEntry[];
marketplaceLoadErrors?: JsonValue[];
featuredPluginIds?: string[];
};
export type CodexPluginReadResponse = {
plugin: CodexPluginDetail;
};
export type CodexPluginListParams = {
cwds: string[];
};
export type CodexPluginReadParams = {
marketplacePath?: string;
remoteMarketplaceName?: string;
pluginName: string;
};
export type CodexPluginInstallParams = CodexPluginReadParams;
export type CodexPluginInstallResponse = {
authPolicy: string;
appsNeedingAuth: CodexAppSummary[];
};
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[];
};
export type CodexAppsListParams = {
cursor?: string | null;
limit?: number;
forceRefetch?: boolean;
};
export type CodexAppsListResponse = {
data: CodexAppInfo[];
nextCursor?: string | null;
};
export type CodexSkillsListParams = {
cwds: string[];
forceReload?: boolean;
};
export type CodexSkillsListResponse = {
data: JsonValue[];
nextCursor?: string | null;
};
export type CodexHooksListParams = {
cwds: string[];
};
export type CodexHooksListResponse = {
data: JsonValue[];
nextCursor?: string | null;
};
export type CodexMcpServerStatus = {
name: string;
tools: JsonObject;
@@ -296,6 +379,26 @@ export type CodexListMcpServerStatusResponse = {
export type CodexRequestObject = Record<string, unknown>;
export declare namespace v2 {
export type AppInfo = CodexAppInfo;
export type AppSummary = CodexAppSummary;
export type AppsListParams = CodexAppsListParams;
export type AppsListResponse = CodexAppsListResponse;
export type HooksListParams = CodexHooksListParams;
export type HooksListResponse = CodexHooksListResponse;
export type PluginDetail = CodexPluginDetail;
export type PluginInstallParams = CodexPluginInstallParams;
export type PluginInstallResponse = CodexPluginInstallResponse;
export type PluginListParams = CodexPluginListParams;
export type PluginListResponse = CodexPluginListResponse;
export type PluginMarketplaceEntry = CodexPluginMarketplaceEntry;
export type PluginReadParams = CodexPluginReadParams;
export type PluginReadResponse = CodexPluginReadResponse;
export type PluginSummary = CodexPluginSummary;
export type SkillsListParams = CodexSkillsListParams;
export type SkillsListResponse = CodexSkillsListResponse;
}
type CodexAppServerRequestParamsOverride = {
"thread/start": CodexThreadStartParams;
};
@@ -304,11 +407,19 @@ type CodexAppServerRequestResultMap = {
initialize: CodexInitializeResponse;
"account/rateLimits/read": JsonValue;
"account/read": CodexGetAccountResponse;
"app/list": CodexAppsListResponse;
"config/mcpServer/reload": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;
"hooks/list": CodexHooksListResponse;
"marketplace/add": JsonValue;
"mcpServerStatus/list": CodexListMcpServerStatusResponse;
"model/list": CodexModelListResponse;
"plugin/install": CodexPluginInstallResponse;
"plugin/list": CodexPluginListResponse;
"plugin/read": CodexPluginReadResponse;
"review/start": JsonValue;
"skills/list": JsonValue;
"skills/list": CodexSkillsListResponse;
"thread/compact/start": JsonValue;
"thread/list": JsonValue;
"thread/resume": CodexThreadResumeResponse;

File diff suppressed because it is too large Load Diff

View File

@@ -41,9 +41,16 @@ import {
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
buildCodexAppInventoryCacheKey,
defaultCodexAppInventoryCache,
} from "./app-inventory-cache.js";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerEnvApiKeyCacheKey,
resolveCodexAppServerHomeDir,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
@@ -59,7 +66,9 @@ import {
import { ensureCodexComputerUse } from "./computer-use.js";
import {
readCodexPluginConfig,
resolveCodexPluginsPolicy,
resolveCodexAppServerRuntimeOptions,
withMcpElicitationsApprovalPolicy,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
} from "./config.js";
@@ -76,6 +85,11 @@ import {
buildCodexNativeHookRelayConfig,
CODEX_NATIVE_HOOK_RELAY_EVENTS,
} from "./native-hook-relay.js";
import {
buildCodexPluginThreadConfig,
buildCodexPluginThreadConfigInputFingerprint,
shouldBuildCodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import {
assertCodexTurnStartResponse,
readCodexDynamicToolCallParams,
@@ -356,6 +370,50 @@ function toCodexTextInput(text: string): CodexUserInput {
return { type: "text", text, text_elements: [] };
}
function resolveCodexPluginAppCacheEndpoint(appServer: CodexAppServerRuntimeOptions): string {
return JSON.stringify({
transport: appServer.start.transport,
command: appServer.start.command,
args: appServer.start.args,
url: appServer.start.url ?? null,
credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start),
});
}
function fingerprintCodexPluginAppCacheCredentials(
startOptions: CodexAppServerRuntimeOptions["start"],
): string | null {
const authToken = startOptions.authToken ?? "";
const headers = Object.entries(startOptions.headers)
.map(([key, value]) => [key.toLowerCase(), value] as const)
.toSorted(([left], [right]) => left.localeCompare(right));
if (!authToken && headers.length === 0) {
return null;
}
const hash = createHash("sha256");
hash.update("openclaw:codex:plugin-app-cache-credentials:v1");
hash.update("\0");
hash.update(authToken);
for (const [key, value] of headers) {
hash.update("\0");
hash.update(key);
hash.update("\0");
hash.update(value);
}
return `sha256:${hash.digest("hex")}`;
}
function resolveCodexPluginAppCacheCodexHome(
appServer: CodexAppServerRuntimeOptions,
agentDir: string,
): string | undefined {
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
if (configuredCodexHome) {
return configuredCodexHome;
}
return appServer.start.transport === "stdio" ? resolveCodexAppServerHomeDir(agentDir) : undefined;
}
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: {
@@ -376,6 +434,7 @@ export async function runCodexAppServerAttempt(
const attemptClientFactory = resolveCodexAppServerClientFactory();
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
let pluginAppServer: CodexAppServerRuntimeOptions = appServer;
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
configuredEvents: options.nativeHookRelay?.events,
appServer,
@@ -433,6 +492,17 @@ export async function runCodexAppServerAttempt(
sessionKey: sandboxSessionKey,
...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}),
};
const startupAuthAccountCacheKey = await resolveCodexAppServerAuthAccountCacheKey({
authProfileId: startupAuthProfileId,
authProfileStore: params.authProfileStore,
agentDir,
config: params.config,
});
const startupEnvApiKeyCacheKey = startupAuthProfileId
? undefined
: resolveCodexAppServerEnvApiKeyCacheKey({
startOptions: appServer.start,
});
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
@@ -453,6 +523,8 @@ export async function runCodexAppServerAttempt(
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: runAbortController.signal,
loading: pluginConfig.codexDynamicToolsLoading ?? "searchable",
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
hookContext: {
agentId: sessionAgentId,
config: params.config,
@@ -602,6 +674,36 @@ export async function runCodexAppServerAttempt(
? buildCodexNativeHookRelayDisabledConfig()
: undefined;
const threadConfig = nativeHookRelayConfig;
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
const pluginAppCacheKey = buildCodexAppInventoryCacheKey({
codexHome: resolveCodexPluginAppCacheCodexHome(appServer, agentDir),
endpoint: resolveCodexPluginAppCacheEndpoint(appServer),
authProfileId: startupAuthProfileId,
accountId: startupAuthAccountCacheKey,
envApiKeyFingerprint: startupEnvApiKeyCacheKey,
});
const pluginThreadConfigInputFingerprint = pluginThreadConfigEnabled
? buildCodexPluginThreadConfigInputFingerprint({
pluginConfig,
appCacheKey: pluginAppCacheKey,
})
: undefined;
const resolvedPluginPolicy = pluginThreadConfigEnabled
? resolveCodexPluginsPolicy(pluginConfig)
: undefined;
const enabledPluginConfigKeys = resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
.filter((plugin) => plugin.enabled)
.map((plugin) => plugin.configKey)
.toSorted()
: undefined;
pluginAppServer =
resolvedPluginPolicy?.enabled === true
? {
...appServer,
approvalPolicy: withMcpElicitationsApprovalPolicy(appServer.approvalPolicy),
}
: appServer;
({ client, thread } = await withCodexStartupTimeout({
timeoutMs: params.timeoutMs,
timeoutFloorMs: options.startupTimeoutFloorMs,
@@ -628,9 +730,27 @@ export async function runCodexAppServerAttempt(
params: runtimeParams,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer,
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
config: threadConfig,
pluginThreadConfig: pluginThreadConfigEnabled
? {
enabled: true,
inputFingerprint: pluginThreadConfigInputFingerprint,
enabledPluginConfigKeys,
build: () =>
buildCodexPluginThreadConfig({
pluginConfig,
request: (method, requestParams) =>
startupClient.request(method, requestParams, {
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),
}
: undefined,
});
return { client: startupClient, thread: startupThread };
};
@@ -1005,6 +1125,7 @@ export async function runCodexAppServerAttempt(
paramsForRun: params,
threadId: thread.threadId,
turnId,
pluginAppPolicyContext: thread.pluginAppPolicyContext,
signal: runAbortController.signal,
});
}
@@ -1131,7 +1252,7 @@ export async function runCodexAppServerAttempt(
buildTurnStartParams(params, {
threadId: thread.threadId,
cwd: effectiveWorkspace,
appServer,
appServer: pluginAppServer,
promptText: promptBuild.prompt,
}),
{ timeoutMs: params.timeoutMs, signal: runAbortController.signal },
@@ -2134,6 +2255,7 @@ export const __testing = {
filterCodexDynamicToolsForAllowlist,
filterToolsForVisionInputs,
handleDynamicToolCallWithTimeout,
resolveCodexPluginAppCacheEndpoint,
resolveOpenClawCodingToolsSessionKeys,
shouldForceMessageTool,
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {

View File

@@ -60,6 +60,69 @@ describe("codex app-server session binding", () => {
await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy();
});
it("round-trips plugin app policy context with app ids as record keys", async () => {
const sessionFile = path.join(tempDir, "session.json");
const pluginAppPolicyContext = {
fingerprint: "plugin-policy-1",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: true,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
};
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
pluginAppPolicyContext,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("rejects old plugin app policy entries that duplicate the app id", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(
resolveCodexAppServerBindingPath(sessionFile),
`${JSON.stringify({
schemaVersion: 1,
threadId: "thread-123",
sessionFile,
cwd: tempDir,
pluginAppPolicyContext: {
fingerprint: "plugin-policy-1",
apps: {
"google-calendar-app": {
appId: "google-calendar-app",
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
allowDestructiveActions: true,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
},
createdAt: "2026-05-03T00:00:00.000Z",
updatedAt: "2026-05-03T00:00:00.000Z",
})}\n`,
);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.pluginAppPolicyContext).toBeUndefined();
});
it("does not persist public OpenAI as the provider for Codex-native auth bindings", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(

View File

@@ -6,7 +6,12 @@ import {
resolveProviderIdForAuth,
type AuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
type CodexAppServerApprovalPolicy,
type CodexAppServerSandboxMode,
} from "./config.js";
import type { PluginAppPolicyContext } from "./plugin-thread-config.js";
import type { CodexServiceTier } from "./protocol.js";
const CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER = "openai-codex";
@@ -34,6 +39,9 @@ export type CodexAppServerThreadBinding = {
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
dynamicToolsFingerprint?: string;
pluginAppsFingerprint?: string;
pluginAppsInputFingerprint?: string;
pluginAppPolicyContext?: PluginAppPolicyContext;
createdAt: string;
updatedAt: string;
};
@@ -83,6 +91,13 @@ export async function readCodexAppServerBinding(
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
: undefined,
pluginAppsFingerprint:
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
pluginAppsInputFingerprint:
typeof parsed.pluginAppsInputFingerprint === "string"
? parsed.pluginAppsInputFingerprint
: undefined,
pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext),
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
@@ -119,6 +134,9 @@ export async function writeCodexAppServerBinding(
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
createdAt: binding.createdAt ?? now,
updatedAt: now,
};
@@ -128,6 +146,63 @@ export async function writeCodexAppServerBinding(
);
}
function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.fingerprint !== "string") {
return undefined;
}
const apps = record.apps;
if (!apps || typeof apps !== "object" || Array.isArray(apps)) {
return undefined;
}
const parsedApps: PluginAppPolicyContext["apps"] = {};
for (const [appId, rawEntry] of Object.entries(apps)) {
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
return undefined;
}
const entry = rawEntry as Record<string, unknown>;
if (
"appId" in entry ||
typeof entry.configKey !== "string" ||
entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof entry.pluginName !== "string" ||
typeof entry.allowDestructiveActions !== "boolean" ||
!Array.isArray(entry.mcpServerNames) ||
entry.mcpServerNames.some((serverName) => typeof serverName !== "string")
) {
return undefined;
}
parsedApps[appId] = {
configKey: entry.configKey,
marketplaceName: entry.marketplaceName,
pluginName: entry.pluginName,
allowDestructiveActions: entry.allowDestructiveActions,
mcpServerNames: entry.mcpServerNames,
};
}
const parsedPluginAppIds: PluginAppPolicyContext["pluginAppIds"] = {};
const rawPluginAppIds = record.pluginAppIds;
if (rawPluginAppIds && (typeof rawPluginAppIds !== "object" || Array.isArray(rawPluginAppIds))) {
return undefined;
}
if (rawPluginAppIds && typeof rawPluginAppIds === "object") {
for (const [configKey, appIds] of Object.entries(rawPluginAppIds)) {
if (!Array.isArray(appIds) || appIds.some((appId) => typeof appId !== "string")) {
return undefined;
}
parsedPluginAppIds[configKey] = appIds;
}
}
return {
fingerprint: record.fingerprint,
apps: parsedApps,
pluginAppIds: parsedPluginAppIds,
};
}
export async function clearCodexAppServerBinding(sessionFile: string): Promise<void> {
try {
await fs.unlink(resolveCodexAppServerBindingPath(sessionFile));

View File

@@ -9,6 +9,11 @@ import {
import { isModernCodexModel } from "../../provider.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js";
import {
isCodexPluginThreadBindingStale,
mergeCodexThreadConfigs,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import {
assertCodexThreadResumeResponse,
assertCodexThreadStartResponse,
@@ -32,6 +37,13 @@ import {
type CodexAppServerThreadBinding,
} from "./session-binding.js";
export type CodexPluginThreadConfigProvider = {
enabled: boolean;
inputFingerprint?: string;
enabledPluginConfigKeys?: readonly string[];
build: () => Promise<CodexPluginThreadConfig>;
};
export async function startOrResumeThread(params: {
client: CodexAppServerClient;
params: EmbeddedRunAttemptParams;
@@ -40,14 +52,50 @@ export async function startOrResumeThread(params: {
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile, {
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
let preserveExistingBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
if (binding?.threadId) {
let pluginBindingStale = isCodexPluginThreadBindingStale({
codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false,
bindingFingerprint: binding.pluginAppsFingerprint,
bindingInputFingerprint: binding.pluginAppsInputFingerprint,
currentInputFingerprint: params.pluginThreadConfig?.inputFingerprint,
hasBindingPolicyContext: Boolean(binding.pluginAppPolicyContext),
});
if (
!pluginBindingStale &&
shouldRecheckRecoverablePluginBinding({
binding,
pluginThreadConfig: params.pluginThreadConfig,
})
) {
try {
prebuiltPluginThreadConfig = await params.pluginThreadConfig?.build();
pluginBindingStale =
prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint;
} catch (error) {
embeddedAgentLog.warn("codex app-server plugin app config recovery check failed", {
error,
threadId: binding.threadId,
});
}
}
if (pluginBindingStale) {
embeddedAgentLog.debug("codex app-server plugin app config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
}
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -110,6 +158,9 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
createdAt: binding.createdAt,
},
{
@@ -126,6 +177,9 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
};
} catch (error) {
if (isCodexAppServerConnectionClosedError(error)) {
@@ -139,6 +193,10 @@ export async function startOrResumeThread(params: {
}
}
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
: undefined;
const config = mergeCodexThreadConfigs(params.config, pluginThreadConfig?.configPatch);
const response = assertCodexThreadStartResponse(
await params.client.request(
"thread/start",
@@ -147,7 +205,7 @@ export async function startOrResumeThread(params: {
dynamicTools: params.dynamicTools,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: params.config,
config,
}),
),
);
@@ -169,6 +227,9 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
createdAt,
},
{
@@ -187,11 +248,36 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
createdAt,
updatedAt: createdAt,
};
}
function shouldRecheckRecoverablePluginBinding(params: {
binding: CodexAppServerThreadBinding;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
}): boolean {
if (!params.pluginThreadConfig?.enabled) {
return false;
}
if (
!params.binding.pluginAppsFingerprint ||
!params.binding.pluginAppsInputFingerprint ||
params.binding.pluginAppsInputFingerprint !== params.pluginThreadConfig.inputFingerprint
) {
return false;
}
const policyContext = params.binding.pluginAppPolicyContext;
if (!policyContext) {
return false;
}
const expectedPluginConfigKeys = params.pluginThreadConfig.enabledPluginConfigKeys ?? [];
return Object.keys(policyContext.apps).length === 0 || expectedPluginConfigKeys.length > 0;
}
export function buildThreadStartParams(
params: EmbeddedRunAttemptParams,
options: {
@@ -299,6 +385,7 @@ export function buildTurnCollaborationMode(
function buildHeartbeatCollaborationInstructions(): string {
return [
"This is an OpenClaw heartbeat turn. Apply these instructions only to this heartbeat wake; ordinary chat turns should stay in Codex Default mode.",
"When you are ready to end the heartbeat, prefer the structured `heartbeat_respond` tool so OpenClaw can record the wake outcome and notification decision. If `heartbeat_respond` is not already available and `tool_search` is available, search for `heartbeat_respond`, load it, then call it. Use `notify=false` when nothing should visibly interrupt the user.",
CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY,
].join("\n\n");
}

View File

@@ -1,3 +1,3 @@
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.128.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.129.0-alpha.15";

View File

@@ -224,6 +224,8 @@ async function attachExistingThread(params: {
{ timeoutMs: runtime.requestTimeoutMs },
);
const thread = response.thread;
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
@@ -236,7 +238,7 @@ async function attachExistingThread(params: {
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
@@ -290,6 +292,8 @@ async function createThread(params: {
},
{ timeoutMs: runtime.requestTimeoutMs },
);
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
@@ -302,7 +306,7 @@ async function createThread(params: {
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},

View File

@@ -1,8 +1,17 @@
import path from "node:path";
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import {
applyMigrationManualItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
MIGRATION_REASON_TARGET_EXISTS,
summarizeMigrationItems,
writeMigrationConfigPath,
} from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
withCachedMigrationConfigRuntime,
writeMigrationReport,
} from "openclaw/plugin-sdk/migration-runtime";
import type {
@@ -11,21 +20,62 @@ import type {
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
type ResolvedCodexPluginPolicy,
} from "../app-server/config.js";
import {
ensureCodexPluginActivation,
type CodexPluginActivationResult,
} from "../app-server/plugin-activation.js";
import type { v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import { buildCodexMigrationPlan } from "./plan.js";
import {
buildCodexPluginsConfigValue,
CODEX_PLUGIN_CONFIG_ITEM_ID,
CODEX_PLUGIN_CONFIG_PATH,
hasCodexPluginConfigConflict,
readCodexPluginMigrationConfigEntry,
type CodexPluginMigrationConfigEntry,
} from "./plan.js";
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
class CodexPluginConfigConflictError extends Error {
constructor(readonly reason: string) {
super(reason);
this.name = "CodexPluginConfigConflictError";
}
}
export async function applyCodexMigrationPlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
runtime?: MigrationProviderContext["runtime"];
}): Promise<MigrationApplyResult> {
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
const items: MigrationItem[] = [];
const runtime = withCachedMigrationConfigRuntime(
params.ctx.runtime ?? params.runtime,
params.ctx.config,
);
const applyCtx = { ...params.ctx, runtime };
for (const item of plan.items) {
if (item.status !== "planned") {
items.push(item);
continue;
}
if (item.action === "archive") {
if (item.id === CODEX_PLUGIN_CONFIG_ITEM_ID) {
items.push(await applyCodexPluginConfigItem(applyCtx, item, items));
} else if (item.kind === "plugin" && item.action === "install") {
items.push(await applyCodexPluginInstallItem(applyCtx, item));
} else if (item.kind === "manual") {
items.push(applyMigrationManualItem(item));
} else if (item.action === "archive") {
items.push(await archiveMigrationItem(item, reportDir));
} else {
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
@@ -41,3 +91,190 @@ export async function applyCodexMigrationPlan(params: {
await writeMigrationReport(result, { title: "Codex Migration Report" });
return result;
}
async function applyCodexPluginInstallItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
const policy = readCodexPluginPolicy(item);
if (!policy) {
return {
...markMigrationItemError(item, "invalid Codex plugin migration item"),
details: { ...item.details, code: "invalid_plugin_item" },
};
}
try {
const result = await ensureCodexPluginActivation({
identity: policy,
installEvenIfActive: true,
request: async (method, requestParams) =>
await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: 60_000,
config: ctx.config,
}),
});
defaultCodexAppInventoryCache.clear();
const baseDetails = {
...item.details,
code: result.reason,
activationReason: result.reason,
...codexPluginActivationReportState(result),
installAttempted: result.installAttempted,
diagnostics: result.diagnostics.map((diagnostic) => diagnostic.message),
};
if (result.ok) {
return {
...item,
status: "migrated",
...(result.reason === "already_active" ? { reason: "already active" } : {}),
details: baseDetails,
};
}
if (result.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON) {
return {
...item,
status: "skipped",
reason: CODEX_PLUGIN_AUTH_REQUIRED_REASON,
details: {
...baseDetails,
appsNeedingAuth: sanitizeAppsNeedingAuth(result.installResponse?.appsNeedingAuth ?? []),
},
};
}
return {
...item,
status: "error",
reason: result.reason,
details: baseDetails,
};
} catch (error) {
return {
...item,
status: "error",
reason: error instanceof Error ? error.message : String(error),
details: {
...item.details,
code: "plugin_install_failed",
},
};
}
}
async function applyCodexPluginConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,
appliedItems: readonly MigrationItem[],
): Promise<MigrationItem> {
const entries = appliedItems
.map(readAppliedPluginConfigEntry)
.filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined);
if (entries.length === 0) {
return markMigrationItemSkipped(item, "no selected Codex plugins");
}
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return markMigrationItemError(item, "config runtime unavailable");
}
const currentConfig = configApi.current() as MigrationProviderContext["config"];
const value = buildCodexPluginsConfigValue(entries, { config: currentConfig });
if (!ctx.overwrite && hasCodexPluginConfigConflict(currentConfig, value)) {
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
}
try {
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
if (!ctx.overwrite && hasCodexPluginConfigConflict(draft, value)) {
throw new CodexPluginConfigConflictError(MIGRATION_REASON_TARGET_EXISTS);
}
writeMigrationConfigPath(draft as Record<string, unknown>, CODEX_PLUGIN_CONFIG_PATH, value);
},
});
return {
...item,
status: "migrated",
details: {
...item.details,
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
};
} catch (error) {
if (error instanceof CodexPluginConfigConflictError) {
return markMigrationItemConflict(item, error.reason);
}
return markMigrationItemError(item, error instanceof Error ? error.message : String(error));
}
}
function readAppliedPluginConfigEntry(
item: MigrationItem,
): CodexPluginMigrationConfigEntry | undefined {
if (item.status === "migrated") {
return readCodexPluginMigrationConfigEntry(item, true);
}
if (
item.status === "skipped" &&
item.reason !== CODEX_PLUGIN_NOT_SELECTED_REASON &&
item.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON
) {
return readCodexPluginMigrationConfigEntry(item, false);
}
return undefined;
}
function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy | undefined {
const configKey = item.details?.configKey;
const marketplaceName = item.details?.marketplaceName;
const pluginName = item.details?.pluginName;
if (
typeof configKey !== "string" ||
marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof pluginName !== "string"
) {
return undefined;
}
return {
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName,
enabled: true,
allowDestructiveActions: false,
};
}
function codexPluginActivationReportState(result: CodexPluginActivationResult): {
installed?: boolean;
enabled?: boolean;
} {
switch (result.reason) {
case "already_active":
case "installed":
return { installed: true, enabled: true };
case "auth_required":
return { installed: true, enabled: false };
case "disabled":
case "marketplace_missing":
case "plugin_missing":
return { installed: false, enabled: false };
case "refresh_failed":
return { installed: true, enabled: false };
}
const exhaustiveReason: never = result.reason;
return exhaustiveReason;
}
function sanitizeAppsNeedingAuth(apps: readonly v2.AppSummary[]): Array<{
id: string;
name: string;
needsAuth: boolean;
}> {
return apps.map((app) => ({
id: app.id,
name: app.name,
needsAuth: app.needsAuth,
}));
}

View File

@@ -2,7 +2,9 @@ import path from "node:path";
import {
createMigrationItem,
createMigrationManualItem,
hasMigrationConfigPatchConflict,
MIGRATION_REASON_TARGET_EXISTS,
readMigrationConfigPath,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import type {
@@ -10,10 +12,33 @@ import type {
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import { exists, sanitizeName } from "./helpers.js";
import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js";
import {
discoverCodexSource,
hasCodexSource,
type CodexPluginSource,
type CodexSkillSource,
} from "./source.js";
import { resolveCodexMigrationTargets } from "./targets.js";
export const CODEX_PLUGIN_CONFIG_ITEM_ID = "config:codex-plugins";
export const CODEX_PLUGIN_CONFIG_PATH = ["plugins", "entries", "codex"] as const;
const CODEX_PLUGIN_ENABLED_PATH = ["plugins", "entries", "codex", "enabled"] as const;
const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [
"plugins",
"entries",
"codex",
"config",
"codexPlugins",
] as const;
export type CodexPluginMigrationConfigEntry = {
configKey: string;
pluginName: string;
enabled: boolean;
};
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
const base = sanitizeName(skill.name) || "codex-skill";
if ((counts.get(base) ?? 0) <= 1) {
@@ -67,6 +92,176 @@ async function buildSkillItems(params: {
return items;
}
function uniquePluginConfigKey(
plugin: CodexPluginSource,
counts: Map<string, number>,
usedCounts: Map<string, number>,
): string {
const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin";
const total = counts.get(base) ?? 0;
if (total <= 1) {
return base;
}
const next = (usedCounts.get(base) ?? 0) + 1;
usedCounts.set(base, next);
return sanitizeName(`${base}-${next}`) || base;
}
function buildPluginItems(plugins: readonly CodexPluginSource[]): MigrationItem[] {
const baseCounts = new Map<string, number>();
for (const plugin of plugins.filter((entry) => entry.migratable)) {
const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin";
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
}
const usedCounts = new Map<string, number>();
let manualIndex = 0;
const items: MigrationItem[] = [];
for (const plugin of plugins) {
if (
plugin.migratable &&
plugin.marketplaceName === CODEX_PLUGINS_MARKETPLACE_NAME &&
plugin.pluginName
) {
const configKey = uniquePluginConfigKey(plugin, baseCounts, usedCounts);
items.push(
createMigrationItem({
id: `plugin:${configKey}`,
kind: "plugin",
action: "install",
source: plugin.source,
target: `plugins.entries.codex.config.codexPlugins.plugins.${configKey}`,
message: `Install Codex plugin "${plugin.pluginName}" in the OpenClaw-managed Codex app-server runtime.`,
details: {
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: plugin.pluginName,
sourceInstalled: plugin.installed === true,
sourceEnabled: plugin.enabled === true,
},
}),
);
continue;
}
manualIndex += 1;
items.push(
createMigrationManualItem({
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
source: plugin.source,
message:
plugin.message ??
`Codex native plugin "${plugin.name}" was found but not activated automatically.`,
recommendation:
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
}),
);
}
return items;
}
export function readCodexPluginMigrationConfigEntry(
item: MigrationItem,
enabled: boolean,
): CodexPluginMigrationConfigEntry | undefined {
const configKey = item.details?.configKey;
const marketplaceName = item.details?.marketplaceName;
const pluginName = item.details?.pluginName;
if (
item.kind !== "plugin" ||
item.action !== "install" ||
typeof configKey !== "string" ||
marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof pluginName !== "string"
) {
return undefined;
}
return { configKey, pluginName, enabled };
}
function readExistingAllowDestructiveActions(
config: MigrationProviderContext["config"],
): boolean | undefined {
const value = readMigrationConfigPath(config as Record<string, unknown>, [
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
"allow_destructive_actions",
]);
return typeof value === "boolean" ? value : undefined;
}
export function buildCodexPluginsConfigValue(
entries: readonly CodexPluginMigrationConfigEntry[],
params: { config?: MigrationProviderContext["config"] } = {},
): Record<string, unknown> {
const plugins = Object.fromEntries(
entries
.toSorted((a, b) => a.configKey.localeCompare(b.configKey))
.map((entry) => [
entry.configKey,
{
enabled: entry.enabled,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: entry.pluginName,
},
]),
);
return {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions:
params.config === undefined
? false
: (readExistingAllowDestructiveActions(params.config) ?? false),
plugins,
},
},
};
}
export function hasCodexPluginConfigConflict(
config: MigrationProviderContext["config"],
value: Record<string, unknown>,
): boolean {
const enabled = readMigrationConfigPath(
config as Record<string, unknown>,
CODEX_PLUGIN_ENABLED_PATH,
);
if (enabled !== undefined && enabled !== true) {
return true;
}
const nativeConfig = (value.config as Record<string, unknown> | undefined)?.codexPlugins;
return hasMigrationConfigPatchConflict(config, CODEX_PLUGIN_NATIVE_CONFIG_PATH, nativeConfig);
}
function buildPluginConfigItem(
ctx: MigrationProviderContext,
pluginItems: readonly MigrationItem[],
): MigrationItem | undefined {
const entries = pluginItems
.map((item) => readCodexPluginMigrationConfigEntry(item, true))
.filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined);
if (entries.length === 0) {
return undefined;
}
const value = buildCodexPluginsConfigValue(entries, { config: ctx.config });
const conflict = !ctx.overwrite && hasCodexPluginConfigConflict(ctx.config, value);
return createMigrationItem({
id: CODEX_PLUGIN_CONFIG_ITEM_ID,
kind: "config",
action: "merge",
target: "plugins.entries.codex.config.codexPlugins",
status: conflict ? "conflict" : "planned",
reason: conflict ? MIGRATION_REASON_TARGET_EXISTS : undefined,
message:
"Enable OpenClaw's Codex plugin integration and record migrated source-installed curated plugins.",
details: {
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
});
}
export async function buildCodexMigrationPlan(
ctx: MigrationProviderContext,
): Promise<MigrationPlan> {
@@ -85,16 +280,11 @@ export async function buildCodexMigrationPlan(
overwrite: ctx.overwrite,
})),
);
for (const [index, plugin] of source.plugins.entries()) {
items.push(
createMigrationManualItem({
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${index + 1}`,
source: plugin.source,
message: `Codex native plugin "${plugin.name}" was found but not activated automatically.`,
recommendation:
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
}),
);
const pluginItems = buildPluginItems(source.plugins);
items.push(...pluginItems);
const pluginConfigItem = buildPluginConfigItem(ctx, pluginItems);
if (pluginConfigItem) {
items.push(pluginConfigItem);
}
for (const archivePath of source.archivePaths) {
items.push(
@@ -118,7 +308,12 @@ export async function buildCodexMigrationPlan(
: []),
...(source.plugins.length > 0
? [
"Codex native plugins are reported for manual review only. OpenClaw does not auto-activate plugin bundles, hooks, MCP servers, or apps from another Codex home.",
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
]
: []),
...(source.pluginDiscoveryError
? [
`Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`,
]
: []),
...(source.archivePaths.length > 0
@@ -136,7 +331,7 @@ export async function buildCodexMigrationPlan(
warnings,
nextSteps: [
"Run openclaw doctor after applying the migration.",
"Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.",
"Review skipped or auth-required Codex plugin/config/hook items before exposing them in OpenClaw sessions.",
],
metadata: {
agentDir: targets.agentDir,

View File

@@ -2,9 +2,17 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol.js";
import { buildCodexMigrationProvider } from "./provider.js";
const appServerRequest = vi.hoisted(() => vi.fn());
vi.mock("../app-server/request.js", () => ({
requestCodexAppServerJson: appServerRequest,
}));
const tempRoots = new Set<string>();
const logger = {
@@ -31,15 +39,20 @@ function makeContext(params: {
workspaceDir: string;
overwrite?: boolean;
reportDir?: string;
config?: MigrationProviderContext["config"];
runtime?: MigrationProviderContext["runtime"];
}): MigrationProviderContext {
return {
config: {
agents: {
defaults: {
workspace: params.workspaceDir,
config:
params.config ??
({
agents: {
defaults: {
workspace: params.workspaceDir,
},
},
},
} as MigrationProviderContext["config"],
} as MigrationProviderContext["config"]),
runtime: params.runtime,
source: params.source,
stateDir: params.stateDir,
overwrite: params.overwrite,
@@ -84,6 +97,7 @@ async function createCodexFixture(): Promise<{
afterEach(async () => {
vi.unstubAllEnvs();
appServerRequest.mockReset();
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
}
@@ -91,6 +105,10 @@ afterEach(async () => {
});
describe("buildCodexMigrationProvider", () => {
beforeEach(() => {
appServerRequest.mockRejectedValue(new Error("codex app-server unavailable"));
});
it("plans Codex skills while keeping plugins and native config explicit", async () => {
const fixture = await createCodexFixture();
const provider = buildCodexMigrationProvider();
@@ -145,8 +163,54 @@ describe("buildCodexMigrationProvider", () => {
expect.arrayContaining([expect.objectContaining({ id: "skill:system-skill" })]),
);
expect(plan.warnings).toEqual(
expect.arrayContaining([expect.stringContaining("cached plugin bundles")]),
);
});
it("plans source-installed curated plugins without installing during dry-run", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockResolvedValueOnce(
pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]),
);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(appServerRequest).toHaveBeenCalledTimes(1);
expect(appServerRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugin/list",
requestParams: { cwds: [] },
}),
);
expect(appServerRequest).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "plugin/install" }),
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.stringContaining("Codex native plugins are reported for manual review only"),
expect.objectContaining({
id: "plugin:google-calendar",
kind: "plugin",
action: "install",
status: "planned",
details: expect.objectContaining({
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
}),
}),
expect.objectContaining({
id: "config:codex-plugins",
kind: "config",
action: "merge",
status: "planned",
}),
]),
);
});
@@ -184,6 +248,381 @@ describe("buildCodexMigrationProvider", () => {
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined();
});
it("installs selected curated plugins during apply and writes codexPlugins config", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: { sandbox: "workspace-write" },
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir,
config: configState,
}),
);
expect(appServerRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugin/install",
requestParams: {
marketplacePath: "/marketplaces/openai-curated",
pluginName: "google-calendar",
},
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:google-calendar",
status: "migrated",
reason: "already active",
details: expect.objectContaining({
code: "already_active",
installAttempted: true,
}),
}),
expect.objectContaining({
id: "config:codex-plugins",
status: "migrated",
}),
]),
);
expect(configState.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: {
appServer: { sandbox: "workspace-write" },
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
});
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).not.toHaveProperty("*");
});
it("does not merge migrated plugin config over existing codexPlugins without overwrite", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
slack: {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "slack",
},
},
},
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "config:codex-plugins",
status: "conflict",
reason: "target exists",
}),
]),
);
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({
allow_destructive_actions: true,
plugins: {
slack: {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "slack",
},
},
});
const codexPlugins = configState.plugins?.entries?.codex?.config?.codexPlugins as
| { plugins?: Record<string, unknown> }
| undefined;
expect(codexPlugins?.plugins).not.toHaveProperty("google-calendar");
});
it("preserves existing destructive plugin policy when overwrite is explicit", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {},
},
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
overwrite: true,
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "config:codex-plugins",
status: "migrated",
}),
]),
);
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({
enabled: true,
allow_destructive_actions: true,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("records auth-required plugin installs as disabled explicit config entries", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
return {
authPolicy: "ON_USE",
appsNeedingAuth: [
{
id: "google-calendar",
name: "Google Calendar",
description: "Calendar",
installUrl: "https://example.invalid/auth",
needsAuth: true,
},
],
} satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:google-calendar",
status: "skipped",
reason: "auth_required",
details: expect.objectContaining({
code: "auth_required",
appsNeedingAuth: [
{
id: "google-calendar",
name: "Google Calendar",
needsAuth: true,
},
],
}),
}),
]),
);
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({
enabled: true,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("does not write config entries for failed plugin installs", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/install") {
throw new Error("install failed");
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:google-calendar",
status: "error",
reason: "install failed",
}),
expect.objectContaining({
id: "config:codex-plugins",
status: "skipped",
reason: "no selected Codex plugins",
}),
]),
);
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined();
});
it("reports existing skill targets as conflicts unless overwrite is set", async () => {
const fixture = await createCodexFixture();
await writeFile(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md"));
@@ -217,3 +656,61 @@ describe("buildCodexMigrationProvider", () => {
);
});
});
function createConfigRuntime(
configState: MigrationProviderContext["config"],
): MigrationProviderContext["runtime"] {
type Runtime = NonNullable<MigrationProviderContext["runtime"]>;
type MutateConfigFileParams = Parameters<Runtime["config"]["mutateConfigFile"]>[0];
type MutateConfigFileResult = Awaited<ReturnType<Runtime["config"]["mutateConfigFile"]>>;
return {
config: {
current: () => configState,
mutateConfigFile: async (params: MutateConfigFileParams): Promise<MutateConfigFileResult> => {
const result = await params.mutate(configState, {
snapshot: {} as never,
previousHash: null,
});
return {
path: "/tmp/openclaw.json",
previousHash: null,
snapshot: {} as never,
nextConfig: configState,
afterWrite: { mode: "auto" },
followUp: { mode: "auto", requiresRestart: false },
result,
};
},
},
} as unknown as MigrationProviderContext["runtime"];
}
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
return {
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: "/marketplaces/openai-curated",
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,
name: id,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
...overrides,
};
}

View File

@@ -1,9 +1,17 @@
import type { MigrationPlan, MigrationProviderPlugin } from "openclaw/plugin-sdk/plugin-entry";
import type {
MigrationPlan,
MigrationProviderContext,
MigrationProviderPlugin,
} from "openclaw/plugin-sdk/plugin-entry";
import { applyCodexMigrationPlan } from "./apply.js";
import { buildCodexMigrationPlan } from "./plan.js";
import { discoverCodexSource, hasCodexSource } from "./source.js";
export function buildCodexMigrationProvider(): MigrationProviderPlugin {
export function buildCodexMigrationProvider(
params: {
runtime?: MigrationProviderContext["runtime"];
} = {},
): MigrationProviderPlugin {
return {
id: "codex",
label: "Codex",
@@ -22,7 +30,7 @@ export function buildCodexMigrationProvider(): MigrationProviderPlugin {
},
plan: buildCodexMigrationPlan,
async apply(ctx, plan?: MigrationPlan) {
return await applyCodexMigrationPlan({ ctx, plan });
return await applyCodexMigrationPlan({ ctx, plan, runtime: params.runtime });
},
};
}

View File

@@ -1,6 +1,9 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import {
exists,
isDirectory,
@@ -19,10 +22,17 @@ export type CodexSkillSource = {
sourceLabel: string;
};
type CodexPluginSource = {
export type CodexPluginSource = {
name: string;
source: string;
manifestPath: string;
sourceKind: "app-server" | "cache";
migratable: boolean;
manifestPath?: string;
marketplaceName?: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
pluginName?: string;
installed?: boolean;
enabled?: boolean;
message?: string;
};
type CodexArchiveSource = {
@@ -42,6 +52,7 @@ type CodexSource = {
hooksPath?: string;
skills: CodexSkillSource[];
plugins: CodexPluginSource[];
pluginDiscoveryError?: string;
archivePaths: CodexArchiveSource[];
};
@@ -104,7 +115,15 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
const manifest = await readJsonObject(manifestPath);
const manifestName = typeof manifest.name === "string" ? manifest.name.trim() : "";
const name = manifestName || path.basename(dir);
discovered.set(dir, { name, source: dir, manifestPath });
discovered.set(dir, {
name,
source: dir,
manifestPath,
sourceKind: "cache",
migratable: false,
message:
"Cached Codex plugin bundle found. Review manually unless the plugin is also installed in the source Codex app-server inventory.",
});
return;
}
for (const entry of await safeReadDir(dir)) {
@@ -118,6 +137,84 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
}
async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
plugins: CodexPluginSource[];
error?: string;
}> {
try {
const response = await requestCodexAppServerJson<v2.PluginListResponse>({
method: "plugin/list",
requestParams: { cwds: [] } satisfies v2.PluginListParams,
timeoutMs: 60_000,
startOptions: {
transport: "stdio",
command: "codex",
commandSource: "config",
args: ["app-server", "--listen", "stdio://"],
headers: {},
env: {
CODEX_HOME: codexHome,
HOME: path.dirname(codexHome),
},
},
});
const marketplace = response.marketplaces.find(
(entry) => entry.name === CODEX_PLUGINS_MARKETPLACE_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): CodexPluginSource | undefined => {
const pluginName = pluginNameFromSummary(plugin);
if (!pluginName) {
return undefined;
}
return {
name: plugin.name,
pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`,
sourceKind: "app-server",
migratable: true,
installed: plugin.installed,
enabled: plugin.enabled,
};
})
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined)
.toSorted((a, b) => (a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name));
return { plugins };
} catch (error) {
return {
plugins: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
const candidates = [summary.id, summary.name];
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)
: trimmed;
const pathSegment = withoutMarketplaceSuffix.split("/").at(-1)?.trim();
const normalized = pathSegment?.toLowerCase().replaceAll(/\s+/gu, "-");
if (normalized) {
return normalized;
}
}
return undefined;
}
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
const codexSkillsDir = path.join(codexHome, "skills");
@@ -133,7 +230,19 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
root: agentsSkillsDir,
sourceLabel: "personal AgentSkill",
});
const plugins = await discoverPluginDirs(codexHome);
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome);
const sourcePluginNames = new Set(
sourcePluginDiscovery.plugins.flatMap((plugin) =>
plugin.pluginName ? [plugin.pluginName] : [],
),
);
const cachedPlugins = (await discoverPluginDirs(codexHome)).filter((plugin) => {
const normalizedName = sanitizePluginName(plugin.name);
return !sourcePluginNames.has(normalizedName);
});
const plugins = [...sourcePluginDiscovery.plugins, ...cachedPlugins].toSorted((a, b) =>
a.source.localeCompare(b.source),
);
const archivePaths: CodexArchiveSource[] = [];
if (await exists(configPath)) {
archivePaths.push({
@@ -167,6 +276,7 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
...((await exists(hooksPath)) ? { hooksPath } : {}),
skills,
plugins,
...(sourcePluginDiscovery.error ? { pluginDiscoveryError: sourcePluginDiscovery.error } : {}),
archivePaths,
};
}
@@ -174,3 +284,7 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
export function hasCodexSource(source: CodexSource): boolean {
return source.confidence !== "low";
}
function sanitizePluginName(value: string): string {
return value.trim().toLowerCase().replaceAll(/\s+/gu, "-");
}

View File

@@ -69,11 +69,17 @@ export function buildCodexHarnessPromptSnapshot(params: {
export function createCodexDynamicToolSpecsForPromptSnapshot(params: {
tools: AnyAgentTool[];
pluginConfig?: Pick<CodexPluginConfig, "codexDynamicToolsProfile" | "codexDynamicToolsExclude">;
pluginConfig?: Pick<
CodexPluginConfig,
"codexDynamicToolsProfile" | "codexDynamicToolsLoading" | "codexDynamicToolsExclude"
>;
directToolNames?: Iterable<string>;
}): CodexDynamicToolSpec[] {
const profiledTools = applyCodexDynamicToolProfile(params.tools, params.pluginConfig ?? {});
return createCodexDynamicToolBridge({
tools: profiledTools,
signal: new AbortController().signal,
loading: params.pluginConfig?.codexDynamicToolsLoading ?? "searchable",
directToolNames: params.directToolNames,
}).specs;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
isProviderApiKeyConfigured,
type AuthProfileStore,
@@ -304,25 +305,10 @@ async function readJsonResponse<T>(params: {
}
}
function inferFileExtension(params: { fileName?: string; mimeType?: string }): string {
const normalizedMime = normalizeOptionalLowercaseString(params.mimeType);
if (normalizedMime?.includes("jpeg")) {
return "jpg";
}
if (normalizedMime?.includes("png")) {
return "png";
}
if (normalizedMime?.includes("webm")) {
return "webm";
}
if (normalizedMime?.includes("mp4")) {
return "mp4";
}
if (normalizedMime?.includes("mpeg")) {
return "mp3";
}
if (normalizedMime?.includes("wav")) {
return "wav";
function resolveFileExtension(params: { fileName?: string; mimeType?: string }): string {
const extension = extensionForMime(params.mimeType);
if (extension) {
return extension.slice(1);
}
const fileName = params.fileName?.trim();
if (!fileName) {
@@ -356,7 +342,7 @@ async function uploadInputImage(params: {
"image",
new Blob([toBlobBytes(params.image.buffer)], { type: params.image.mimeType }),
normalizeOptionalString(params.image.fileName) ||
`input.${inferFileExtension({ mimeType: params.image.mimeType })}`,
`input.${resolveFileExtension({ mimeType: params.image.mimeType })}`,
);
form.set("type", "input");
form.set("overwrite", "true");
@@ -823,7 +809,7 @@ export async function runComfyWorkflow(params: {
mimeType: downloaded.mimeType,
fileName:
originalName ||
`${params.capability}-${assetIndex}.${inferFileExtension({ mimeType: downloaded.mimeType })}`,
`${params.capability}-${assetIndex}.${resolveFileExtension({ mimeType: downloaded.mimeType })}`,
nodeId: output.nodeId,
});
}

View File

@@ -83,4 +83,31 @@ describe("deepinfra video generation provider", () => {
});
expect(release).toHaveBeenCalledOnce();
});
it("names base64 WebM data URL outputs from the MIME type", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
video_url: `data:video/webm;base64,${Buffer.from("webm-data").toString("base64")}`,
request_id: "req_webm",
inference_status: { status: "succeeded" },
}),
},
release: vi.fn(async () => {}),
});
const provider = buildDeepInfraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "deepinfra",
model: "deepinfra/Pixverse/Pixverse-T2V",
prompt: "A WebM data URL",
cfg: {},
});
expect(result.videos[0]).toMatchObject({
mimeType: "video/webm",
fileName: "video-1.webm",
});
expect(result.videos[0]?.buffer).toEqual(Buffer.from("webm-data"));
});
});

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -65,7 +66,7 @@ function parseVideoDataUrl(url: string): GeneratedVideoAsset | undefined {
return undefined;
}
const mimeType = match[1] ?? "video/mp4";
const ext = mimeType.includes("webm") ? "webm" : "mp4";
const ext = extensionForMime(mimeType)?.slice(1) ?? "mp4";
return {
buffer: Buffer.from(match[2] ?? "", "base64"),
mimeType,

View File

@@ -1520,6 +1520,55 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("exports model failover spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "model.failover",
sessionId: "session-1",
lane: "main",
fromProvider: "anthropic",
fromModel: "claude-opus-4-6",
toProvider: "openai",
toModel: "gpt-5.4",
reason: "overloaded",
suspended: true,
cascadeDepth: 1,
});
await flushDiagnosticEvents();
const failoverCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.model.failover",
);
expect(failoverCall?.[1]).toMatchObject({
attributes: {
"openclaw.provider": "anthropic",
"openclaw.model": "claude-opus-4-6",
"openclaw.failover.to_provider": "openai",
"openclaw.failover.to_model": "gpt-5.4",
"openclaw.failover.reason": "overloaded",
"openclaw.failover.suspended": true,
"openclaw.failover.cascade_depth": 1,
"openclaw.lane": "main",
},
startTime: expect.any(Number),
});
expect(failoverCall?.[1]).toEqual({
attributes: expect.not.objectContaining({
"openclaw.sessionId": expect.anything(),
"openclaw.sessionKey": expect.anything(),
}),
startTime: expect.any(Number),
});
const span = telemetryState.spans.find(
(candidate) => candidate.name === "openclaw.model.failover",
);
expect(span?.end).toHaveBeenCalledWith(expect.any(Number));
await service.stop?.(ctx);
});
test("maps model call APIs to GenAI operation names and error type", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

View File

@@ -83,6 +83,7 @@ type ModelCallLifecycleDiagnosticEvent = Extract<
DiagnosticEventPayload,
{ type: "model.call.completed" | "model.call.error" }
>;
type ModelFailoverDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "model.failover" }>;
type HarnessRunDiagnosticEvent = Extract<
DiagnosticEventPayload,
{ type: "harness.run.started" | "harness.run.completed" | "harness.run.error" }
@@ -1844,6 +1845,44 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
span.end(evt.ts);
};
const recordModelFailover = (
evt: ModelFailoverDiagnosticEvent,
metadata: DiagnosticEventMetadata,
) => {
if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number | boolean> = {
"openclaw.failover.reason": lowCardinalityAttr(evt.reason, "unknown"),
};
if (evt.fromProvider) {
spanAttrs["openclaw.provider"] = evt.fromProvider;
}
if (evt.fromModel) {
spanAttrs["openclaw.model"] = evt.fromModel;
}
if (evt.toProvider) {
spanAttrs["openclaw.failover.to_provider"] = evt.toProvider;
}
if (evt.toModel) {
spanAttrs["openclaw.failover.to_model"] = evt.toModel;
}
if (evt.lane) {
spanAttrs["openclaw.lane"] = lowCardinalityAttr(evt.lane, "unknown");
}
if (evt.suspended !== undefined) {
spanAttrs["openclaw.failover.suspended"] = evt.suspended;
}
if (evt.cascadeDepth !== undefined) {
spanAttrs["openclaw.failover.cascade_depth"] = evt.cascadeDepth;
}
const span = spanWithDuration("openclaw.model.failover", spanAttrs, 0, {
parentContext: activeTrustedParentContext(evt, metadata),
endTimeMs: evt.ts,
});
span.end(evt.ts);
};
const modelCallMetricAttrs = (evt: ModelCallLifecycleDiagnosticEvent) => ({
"openclaw.provider": evt.provider,
"openclaw.model": evt.model,
@@ -2421,6 +2460,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
case "payload.large":
return;
case "model.failover":
recordModelFailover(evt, metadata);
return;
}
} catch (err) {
ctx.logger.error(

View File

@@ -1,18 +1,11 @@
import { resolveLanguage } from "@pierre/diffs";
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { DiffViewerPayload } from "./types.js";
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
type DiffPayloadFile = FileContents | FileDiffMetadata;
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export async function normalizeSupportedLanguageHint(
value?: string,
): Promise<SupportedLanguages | undefined> {

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Static, Type } from "typebox";
@@ -34,19 +35,6 @@ const MAX_TITLE_BYTES = 1_024;
const MAX_PATH_BYTES = 2_048;
const MAX_LANG_BYTES = 128;
function stringEnum<T extends readonly string[]>(
values: T,
description: string,
options: { deprecated?: boolean } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
description,
...options,
});
}
const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
@@ -76,17 +64,23 @@ const DiffsToolSchema = Type.Object(
}),
),
mode: Type.Optional(
stringEnum(
DIFF_MODES,
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
),
stringEnum(DIFF_MODES, {
description:
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
}),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
layout: Type.Optional(
stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print."),
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "File quality preset: standard, hq, or print.",
}),
),
fileFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }),
),
fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
fileScale: Type.Optional(
Type.Number({
description: "Optional rendered-file device scale factor override (1-4).",
@@ -103,13 +97,15 @@ const DiffsToolSchema = Type.Object(
),
/** @deprecated Use fileQuality. */
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality.", {
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "Deprecated alias for fileQuality.",
deprecated: true,
}),
),
/** @deprecated Use fileFormat. */
imageFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.", {
stringEnum(DIFF_OUTPUT_FORMATS, {
description: "Deprecated alias for fileFormat.",
deprecated: true,
}),
),

View File

@@ -1,6 +1,7 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
@@ -81,7 +82,7 @@ export function createDiscordDraftPreviewController(params: {
previewToolProgressEnabled,
});
let previewToolProgressSuppressed = false;
let previewToolProgressLines: string[] = [];
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let reasoningProgressRawText = "";
let lastReasoningProgressLine: string | undefined;
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
@@ -156,7 +157,10 @@ export function createDiscordDraftPreviewController(params: {
}
await progressDraftGate.startNow();
},
async pushToolProgress(line?: string, options?: { toolName?: string }) {
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
) {
if (!draftStream) {
return;
}
@@ -166,19 +170,24 @@ export function createDiscordDraftPreviewController(params: {
) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (isEmptyDiscordProgressLine(line)) {
return;
}
const normalized = normalizeProgressLineIdentity(line);
if (!normalized) {
return;
}
const progressLine: string | ChannelProgressDraftLine =
typeof line === "object" && line !== undefined ? line : normalized;
if (discordStreamMode !== "progress") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
return;
}
const previous = previewToolProgressLines.at(-1);
const previous = normalizeProgressLineIdentity(previewToolProgressLines.at(-1));
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
previewToolProgressLines = [...previewToolProgressLines, progressLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
const previewText = formatChannelProgressDraftText({
@@ -194,15 +203,19 @@ export function createDiscordDraftPreviewController(params: {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
const previous = previewToolProgressLines.at(-1);
const previous = normalizeProgressLineIdentity(previewToolProgressLines.at(-1));
if (previous !== normalized) {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
previewToolProgressLines = [...previewToolProgressLines, progressLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (shouldStartDiscordProgressDraftNow(line)) {
await progressDraftGate.startNow();
} else {
await progressDraftGate.noteWork();
}
if (alreadyStarted && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
@@ -392,3 +405,23 @@ function mergeReasoningProgressText(current: string, incoming: string): string {
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text);
}
function normalizeProgressLineIdentity(
line: string | ChannelProgressDraftLine | undefined,
): string {
const text = typeof line === "string" ? line : line?.text;
return text?.replace(/\s+/g, " ").trim() ?? "";
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
if (!line || typeof line === "string") {
return false;
}
return line.toolName === "apply_patch" && !line.detail && !line.status;
}
function shouldStartDiscordProgressDraftNow(
line: string | ChannelProgressDraftLine | undefined,
): boolean {
return typeof line === "object" && line?.kind === "patch" && Boolean(line.detail);
}

View File

@@ -127,6 +127,10 @@ type DispatchInboundParams = {
phase?: string;
summary?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
}) => Promise<void> | void;
onReplyStart?: () => Promise<void> | void;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
@@ -1652,6 +1656,66 @@ describe("processDiscordMessage draft streaming", () => {
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠 Exec\n• done");
});
it("keeps Discord progress labels as rolling lines", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "first", phase: "start" });
await params?.replyOptions?.onToolStart?.({ name: "second", phase: "start" });
await params?.replyOptions?.onToolStart?.({ name: "third", phase: "start" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
maxLines: 3,
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third");
});
it("skips empty apply_patch starts and renders the patch summary", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "apply_patch", phase: "start" });
await params?.replyOptions?.onPatchSummary?.({
phase: "end",
name: "apply_patch",
summary: "1 modified",
modified: ["extensions/discord/src/monitor/message-handler.draft-preview.ts"],
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n🩹 1 modified; extensions/discord/src/monitor/message-handler.draft-prev…",
);
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Apply Patch"));
});
it("shows reasoning text instead of a bare Reasoning progress line", async () => {
const draftStream = createMockDraftStreamForTest();

View File

@@ -13,8 +13,8 @@ import {
resolveChannelMessageSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-message";
import {
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-streaming";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
@@ -674,7 +674,7 @@ export async function processDiscordMessage(
await maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
await draftPreview.pushToolProgress(
formatChannelProgressDraftLineForEntry(
buildChannelProgressDraftLineForEntry(
discordConfig,
{
event: "tool",
@@ -689,7 +689,7 @@ export async function processDiscordMessage(
},
onItemEvent: async (payload) => {
await draftPreview.pushToolProgress(
formatChannelProgressDraftLineForEntry(discordConfig, {
buildChannelProgressDraftLineForEntry(discordConfig, {
event: "item",
itemKind: payload.kind,
title: payload.title,
@@ -707,7 +707,7 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
@@ -721,7 +721,7 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
@@ -736,7 +736,7 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
@@ -751,7 +751,7 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,

View File

@@ -1,12 +1,8 @@
import { stripInlineDirectiveTagsForDisplay } from "openclaw/plugin-sdk/text-runtime";
import { escapeRegExp, stripInlineDirectiveTagsForDisplay } from "openclaw/plugin-sdk/text-runtime";
const SPEECH_EMOJI_RE =
/(?:\p{Extended_Pictographic}(?:\uFE0F|\u200D|\p{Extended_Pictographic}|\p{Emoji_Modifier})*)+/gu;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripEmojiForSpeech(text: string): string {
return text
.replace(SPEECH_EMOJI_RE, " ")

View File

@@ -58,6 +58,7 @@ describe("fal video generation provider", () => {
responseUrl: string;
videoUrl: string;
bytes: string;
contentType?: string;
responseExtras?: Record<string, unknown>;
}) {
fetchGuardMock
@@ -78,7 +79,9 @@ describe("fal video generation provider", () => {
},
}),
)
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: params.bytes }));
.mockResolvedValueOnce(
releasedVideo({ contentType: params.contentType ?? "video/mp4", bytes: params.bytes }),
);
}
function getSubmitBody(): Record<string, unknown> {
@@ -119,7 +122,8 @@ describe("fal video generation provider", () => {
statusUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status",
responseUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123",
videoUrl: "https://fal.run/files/video.mp4",
bytes: "mp4-bytes",
bytes: "webm-bytes",
contentType: "video/webm",
});
const provider = buildFalVideoGenerationProvider();
@@ -158,7 +162,8 @@ describe("fal video generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.videos[0]?.mimeType).toBe("video/webm");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.videos[0]?.url).toBe("https://fal.run/files/video.mp4");
expect(result.metadata).toEqual({
requestId: "req-123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -121,7 +122,7 @@ async function downloadFalVideo(
url,
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
} finally {
await release();

View File

@@ -4,6 +4,7 @@ import { isAbsolute, resolve } from "node:path";
import { basename } from "node:path";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import type { OpenClawPluginApi } from "../runtime-api.js";
@@ -577,7 +578,7 @@ async function resolveUploadInput(
);
}
const mimeMatch = header.match(/data:([^;]+)/);
const ext = mimeMatch?.[1]?.split("/")[1] ?? "png";
const ext = extensionForMime(mimeMatch?.[1])?.slice(1) ?? "png";
// Estimate decoded byte count from base64 length BEFORE allocating the
// full buffer to avoid spiking memory on oversized payloads.
const estimatedBytes = Math.ceil((trimmedData.length * 3) / 4);

View File

@@ -137,6 +137,49 @@ describe("handleFileFetch — happy path", () => {
// Accept either.
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
});
it("detects extensionless plain text as text/plain", async () => {
const target = path.join(tmpRoot, "LICENSE");
const contents = "Permission is hereby granted\n";
await fs.writeFile(target, contents);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("text/plain");
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
});
it("does not classify extensionless binary content as text/plain", async () => {
const target = path.join(tmpRoot, "opaque");
await fs.writeFile(target, Buffer.from([0x00, 0x01, 0x02, 0xff]));
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("application/octet-stream");
});
it("sniffs binary content instead of trusting a misleading extension", async () => {
const target = path.join(tmpRoot, "image.txt");
await fs.writeFile(
target,
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52,
]),
);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("image/png");
});
});
describe("handleFileFetch — size enforcement", () => {

View File

@@ -1,15 +1,15 @@
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import path from "node:path";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import {
FsSafeError,
resolveAbsolutePathForRead,
root,
} from "openclaw/plugin-sdk/security-runtime";
import { EXTENSION_MIME } from "../shared/mime.js";
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const TEXT_SNIFF_MAX_BYTES = 8192;
type FileFetchParams = {
path?: unknown;
@@ -47,25 +47,6 @@ type FileFetchErr = {
type FileFetchResult = FileFetchOk | FileFetchErr;
function detectMimeType(filePath: string): string {
if (process.platform !== "win32") {
try {
const result = spawnSync("file", ["-b", "--mime-type", filePath], {
encoding: "utf-8",
timeout: 2000,
});
const stdout = result.stdout?.trim();
if (result.status === 0 && stdout) {
return stdout;
}
} catch {
// fall through to extension fallback
}
}
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
}
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return FILE_FETCH_DEFAULT_MAX_BYTES;
@@ -101,6 +82,39 @@ function classifyFsError(err: unknown): FileFetchErrCode {
return "READ_ERROR";
}
function isLikelyPlainText(buffer: Buffer): boolean {
if (buffer.byteLength === 0) {
return true;
}
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
if (sample.includes(0)) {
return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(sample);
} catch {
return false;
}
let controlBytes = 0;
for (const byte of sample) {
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
controlBytes += 1;
}
}
return controlBytes / sample.byteLength < 0.01;
}
async function detectFetchedFileMime(params: {
buffer: Buffer;
filePath: string;
}): Promise<string> {
const detected = await detectMime(params);
if (detected) {
return detected;
}
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
}
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
const requestedPath = params.path;
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
@@ -196,7 +210,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const base64 = buffer.toString("base64");
const mimeType = detectMimeType(opened.realPath);
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
return {
ok: true,

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
EXTENSION_MIME,
IMAGE_MIME_INLINE_SET,
TEXT_INLINE_MAX_BYTES,
TEXT_INLINE_MIME_SET,
@@ -13,6 +12,8 @@ describe("mimeFromExtension", () => {
expect(mimeFromExtension("/abs/path/bar.JPG")).toBe("image/jpeg");
expect(mimeFromExtension("doc.pdf")).toBe("application/pdf");
expect(mimeFromExtension("notes.md")).toBe("text/markdown");
expect(mimeFromExtension("trace.log")).toBe("text/plain");
expect(mimeFromExtension("bitmap.bmp")).toBe("image/bmp");
});
it("falls back to application/octet-stream for unknown extensions", () => {
@@ -28,11 +29,11 @@ describe("mimeFromExtension", () => {
describe("MIME constants", () => {
it("EXTENSION_MIME includes the v1 image set", () => {
expect(EXTENSION_MIME[".png"]).toBe("image/png");
expect(EXTENSION_MIME[".jpg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".jpeg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".webp"]).toBe("image/webp");
expect(EXTENSION_MIME[".gif"]).toBe("image/gif");
expect(mimeFromExtension("image.png")).toBe("image/png");
expect(mimeFromExtension("image.jpg")).toBe("image/jpeg");
expect(mimeFromExtension("image.jpeg")).toBe("image/jpeg");
expect(mimeFromExtension("image.webp")).toBe("image/webp");
expect(mimeFromExtension("image.gif")).toBe("image/gif");
});
it("IMAGE_MIME_INLINE_SET is the inline-renderable image set", () => {
@@ -50,6 +51,7 @@ describe("MIME constants", () => {
expect(TEXT_INLINE_MIME_SET.has("text/markdown")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("application/json")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/csv")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/xml")).toBe(true);
});
it("TEXT_INLINE_MAX_BYTES is the documented 8KB cap", () => {

View File

@@ -1,28 +1,4 @@
import path from "node:path";
// Single source of truth for extension→MIME mapping. Used by all four
// handlers/tools so adding a new extension lands everywhere at once.
export const EXTENSION_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
".pdf": "application/pdf",
".txt": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".html": "text/html",
".xml": "application/xml",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
};
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
// MIME types we treat as inline-displayable images for vision-capable models.
// Note: heic/heif are detectable but not all providers can render them, so we
@@ -43,11 +19,11 @@ export const TEXT_INLINE_MIME_SET = new Set([
"text/html",
"application/json",
"application/xml",
"text/xml",
]);
export const TEXT_INLINE_MAX_BYTES = 8 * 1024;
export function mimeFromExtension(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
}

View File

@@ -1,3 +1,4 @@
import { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import {
jsonResult,
@@ -7,19 +8,6 @@ import {
import { Type } from "typebox";
import { runFirecrawlScrape } from "./firecrawl-client.js";
function optionalStringEnum<const T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Optional(
Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
}),
);
}
const FirecrawlScrapeToolSchema = Type.Object(
{
url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }),

View File

@@ -4071,7 +4071,7 @@ describe("google-meet plugin", () => {
const provider: RealtimeVoiceProviderPlugin = {
id: "openai",
label: "OpenAI",
defaultModel: "gpt-realtime-1.5",
defaultModel: "gpt-realtime-2",
autoSelectOrder: 1,
resolveConfig: ({ rawConfig }) => rawConfig,
isConfigured: () => true,
@@ -4302,7 +4302,7 @@ describe("google-meet plugin", () => {
const provider: RealtimeVoiceProviderPlugin = {
id: "openai",
label: "OpenAI",
defaultModel: "gpt-realtime-1.5",
defaultModel: "gpt-realtime-2",
autoSelectOrder: 1,
resolveConfig: ({ rawConfig }) => rawConfig,
isConfigured: () => true,

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
GoogleMeetConfig,
@@ -127,10 +128,6 @@ function resolveProbeTimeoutMs(input: number | undefined, fallback: number): num
return Math.min(Math.trunc(input), 120_000);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isManagedChromeBrowserSession(session: GoogleMeetSession): boolean {
return Boolean(
(session.transport === "chrome" || session.transport === "chrome-node") &&

View File

@@ -1,4 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "../config.js";
import {
asBrowserTabs,
@@ -71,10 +72,6 @@ export function isGoogleMeetBrowserManualActionError(
return error instanceof GoogleMeetBrowserManualActionError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatBrowserAutomationError(error: unknown): string {
if (error instanceof Error) {
return error.message;

View File

@@ -4,6 +4,7 @@ import {
startGatewayClientWhenEventLoopReady,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "./config.js";
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
@@ -30,13 +31,6 @@ type VoiceCallMeetJoinResult = {
introSent: boolean;
};
function sleep(ms: number): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createConnectedGatewayClient(
config: GoogleMeetConfig,
): Promise<VoiceCallGatewayClient> {

View File

@@ -1,4 +1,5 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -193,7 +194,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
return null;
}
const mimeType = inline?.mimeType ?? inline?.mime_type ?? DEFAULT_OUTPUT_MIME;
const extension = mimeType.includes("jpeg") ? "jpg" : (mimeType.split("/")[1] ?? "png");
const extension = extensionForMime(mimeType)?.slice(1) ?? "png";
imageIndex += 1;
return {
buffer: Buffer.from(data, "base64"),

View File

@@ -9,8 +9,7 @@
"type": "module",
"dependencies": {
"gaxios": "7.1.4",
"google-auth-library": "10.6.2",
"zod": "^4.4.3"
"google-auth-library": "10.6.2"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",

View File

@@ -9,7 +9,7 @@ import {
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import type { GoogleChatAccountConfig } from "./types.config.js";
type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";

View File

@@ -1,8 +1,8 @@
import { messagingApi } from "@line/bot-sdk";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { resolveLineAccount } from "./accounts.js";
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
@@ -113,7 +113,7 @@ export async function uploadRichMenuImage(
const contentType =
media.contentType === "image/png" || media.contentType === "image/jpeg"
? media.contentType
: normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
: mimeTypeFromFilePath(imagePath) === "image/png"
? "image/png"
: "image/jpeg";

View File

@@ -5,7 +5,6 @@
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
"dependencies": {
"ajv": "^8.20.0",
"typebox": "1.1.37"
},
"devDependencies": {

View File

@@ -1,31 +1,5 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("ajv", () => ({
default: class MockAjv {
compile(schema: unknown) {
return (value: unknown) => {
if (
schema &&
typeof schema === "object" &&
!Array.isArray(schema) &&
(schema as { properties?: Record<string, { type?: string }> }).properties?.foo?.type ===
"string"
) {
const ok = typeof (value as { foo?: unknown })?.foo === "string";
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = ok
? undefined
: [{ instancePath: "/foo", message: "must be string" }];
return ok;
}
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = undefined;
return true;
};
}
errors?: Array<{ instancePath: string; message: string }>;
},
}));
vi.mock("../api.js", async () => {
const actual = await vi.importActual<typeof import("../api.js")>("../api.js");
return {
@@ -35,7 +9,6 @@ vi.mock("../api.js", async () => {
});
afterAll(() => {
vi.doUnmock("ajv");
vi.doUnmock("../api.js");
vi.resetModules();
});
@@ -159,6 +132,45 @@ describe("llm-task tool (json-only)", () => {
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("validates caller schemas with repeated $id independently across calls", async () => {
const tool = createLlmTaskTool(fakeApi());
(runEmbeddedPiAgent as any)
.mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
})
.mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ count: 1 }) }],
});
await expect(
tool.execute("id", {
prompt: "return foo",
schema: {
$id: "https://example.test/llm-task-result",
type: "object",
properties: { foo: { type: "string" } },
required: ["foo"],
additionalProperties: false,
},
}),
).resolves.toMatchObject({ details: { json: { foo: "bar" } } });
await expect(
tool.execute("id", {
prompt: "return count",
schema: {
$id: "https://example.test/llm-task-result",
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
additionalProperties: false,
},
}),
).resolves.toMatchObject({ details: { json: { count: 1 } } });
});
it("throws on invalid json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},

View File

@@ -1,13 +1,14 @@
import path from "node:path";
import Ajv from "ajv";
import { buildModelAliasIndex, resolveModelRefFromString } from "openclaw/plugin-sdk/agent-runtime";
import {
type JsonSchemaObject,
validateJsonSchemaValue,
} from "openclaw/plugin-sdk/json-schema-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "../api.js";
import type { OpenClawPluginApi } from "../api.js";
const AjvCtor = Ajv as unknown as typeof import("ajv").default;
function stripCodeFences(s: string): string {
const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
@@ -293,17 +294,14 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
const schema = params.schema;
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
const ajv = new AjvCtor({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
const ok = validate(parsed);
if (!ok) {
const msg =
validate.errors
?.map(
(e: { instancePath?: string; message?: string }) =>
`${e.instancePath || "<root>"} ${e.message || "invalid"}`,
)
.join("; ") ?? "invalid";
const validation = validateJsonSchemaValue({
schema: schema as JsonSchemaObject,
cacheKey: "llm-task.result",
value: parsed,
cache: false,
});
if (!validation.ok) {
const msg = validation.errors.map((error) => error.text).join("; ") || "invalid";
throw new Error(`LLM JSON did not match schema: ${msg}`);
}
}

View File

@@ -633,6 +633,9 @@ describe("Matrix auth/config live surfaces", () => {
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
expect(validateMatrixHomeserverUrl("http://[::ffff:127.0.0.1]:8008")).toBe(
"http://[::ffff:127.0.0.1]:8008",
);
});
it("accepts internal http homeservers only when private-network access is enabled", () => {

View File

@@ -1,56 +1 @@
import net from "node:net";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function normalizeHost(host: string): string {
const normalized = normalizeLowercaseStringOrEmpty(host).replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}
function isPrivateIpv4(host: string): boolean {
const parts = host.split(".").map((part) => Number(part));
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false;
}
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function isPrivateIpv6(host: string): boolean {
if (host === "::1") {
return true;
}
if (host === "::" || host.startsWith("ff")) {
return false;
}
return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:");
}
export function isPrivateOrLoopbackHost(host: string): boolean {
const normalized = normalizeHost(host);
if (!normalized) {
return false;
}
if (normalized === "localhost") {
return true;
}
const family = net.isIP(normalized);
if (family === 4) {
return isPrivateIpv4(normalized);
}
if (family === 6) {
return isPrivateIpv6(normalized);
}
return false;
}
export { isPrivateOrLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -1,4 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
@@ -62,10 +63,6 @@ function resolveMatrixUserLocalpart(userId: string): string | null {
return trimmed.slice(1, colonIndex).trim() || null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveMatrixMentionPrefixCandidates(params: {
userId?: string | null;
displayName?: string | null;

View File

@@ -1,3 +1,4 @@
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
fetchWithSsrFGuard,
ssrfPolicyFromPrivateNetworkOptIn,
@@ -488,10 +489,6 @@ function readErrorCode(error: unknown): string | undefined {
return undefined;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function createMattermostPost(
client: MattermostClient,
params: {

View File

@@ -475,6 +475,7 @@ describe("createMattermostInteractionHandler", () => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const req = {
destroyed: false,
method: params.method ?? "POST",
headers: params.headers ?? {},
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
@@ -484,6 +485,18 @@ describe("createMattermostInteractionHandler", () => {
listeners.set(event, existing);
return this;
},
removeListener(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
listeners.set(
event,
existing.filter((entry) => entry !== handler),
);
return this;
},
destroy() {
this.destroyed = true;
return this;
},
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
req.emitTest = (event: string, ...args: unknown[]) => {

View File

@@ -7,7 +7,12 @@ import {
} from "openclaw/plugin-sdk/text-runtime";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
import {
isTrustedProxyAddress,
readRequestBodyWithLimit,
resolveClientIp,
type OpenClawConfig,
} from "./runtime-api.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
@@ -353,35 +358,9 @@ export function buildButtonProps(params: {
// ── Request body reader ────────────────────────────────────────────────
function readInteractionBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
const timer = setTimeout(() => {
req.destroy();
reject(new Error("Request body read timeout"));
}, INTERACTION_BODY_TIMEOUT_MS);
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
req.destroy();
clearTimeout(timer);
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
return readRequestBodyWithLimit(req, {
maxBytes: INTERACTION_MAX_BODY_BYTES,
timeoutMs: INTERACTION_BODY_TIMEOUT_MS,
});
}

View File

@@ -1,3 +1,4 @@
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
fetchMattermostUserTeams,
@@ -22,10 +23,6 @@ import {
} from "./slash-commands.js";
import { activateSlashCommands } from "./slash-state.js";
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function buildSlashCommands(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;

Some files were not shown because too many files have changed in this diff Show More