mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
61 Commits
codex/pr-8
...
v2026.5.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1246255d0 | ||
|
|
bdbdd17b90 | ||
|
|
4acf72cf03 | ||
|
|
e126e74a58 | ||
|
|
a2abb63f6e | ||
|
|
a25650e044 | ||
|
|
13c44a2432 | ||
|
|
1ffb4126dc | ||
|
|
2b7e1ab1e4 | ||
|
|
738a8f574e | ||
|
|
410e009d54 | ||
|
|
80c9015b56 | ||
|
|
c612dd9529 | ||
|
|
a97399a25c | ||
|
|
87d6b868b7 | ||
|
|
e309a8732e | ||
|
|
b28a70369d | ||
|
|
c5140a09e1 | ||
|
|
3c3cef1785 | ||
|
|
7c606f834c | ||
|
|
fec979cf96 | ||
|
|
b258035a8d | ||
|
|
f6c919cb9a | ||
|
|
f810103509 | ||
|
|
2aa4dc03c1 | ||
|
|
a0a2eeefb9 | ||
|
|
4182fbaad0 | ||
|
|
ab9893a4f5 | ||
|
|
d7472ab015 | ||
|
|
95434cd497 | ||
|
|
a90a5fc4d1 | ||
|
|
cc46ca9bee | ||
|
|
9fd79d7b69 | ||
|
|
b251a74b1c | ||
|
|
fdb6e92ff5 | ||
|
|
7f0fc0bab4 | ||
|
|
8f212d0b6f | ||
|
|
b86c387d6c | ||
|
|
23dc2bfcd8 | ||
|
|
985bc40711 | ||
|
|
eab66220f8 | ||
|
|
22a6717e11 | ||
|
|
a4743ad180 | ||
|
|
1df4df6eed | ||
|
|
ca8bc5500d | ||
|
|
930046df04 | ||
|
|
03e4b035f1 | ||
|
|
6115eada6d | ||
|
|
84a2060a64 | ||
|
|
bc6090502c | ||
|
|
d3a8a45119 | ||
|
|
b12cd4358d | ||
|
|
1824464bf2 | ||
|
|
6eebba3920 | ||
|
|
7b544a7976 | ||
|
|
441041f92d | ||
|
|
7284608461 | ||
|
|
56d96b3b8d | ||
|
|
41bf26ede3 | ||
|
|
6820d18160 | ||
|
|
e6fb7aa1a8 |
@@ -433,6 +433,10 @@ jobs:
|
||||
add_profile_suite native-live-extensions-media-music-google "full"
|
||||
add_profile_suite native-live-extensions-media-music-minimax "full"
|
||||
add_profile_suite native-live-extensions-media-video "full"
|
||||
add_profile_suite native-live-extensions-media-video-a "full"
|
||||
add_profile_suite native-live-extensions-media-video-b "full"
|
||||
add_profile_suite native-live-extensions-media-video-c "full"
|
||||
add_profile_suite native-live-extensions-media-video-d "full"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2198,6 +2202,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-anthropic' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-anthropic-')) || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-opencode-go' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-opencode-go-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
@@ -2414,6 +2419,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
@@ -2602,6 +2608,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-extensions-media-video' && startsWith(matrix.suite_id, 'native-live-extensions-media-video-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
run: |
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -6,17 +6,32 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime SDK helper, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
- Codex harness: keep auth-profile-backed media tools such as `image_generate` available when OpenAI auth lives in the agent's auth-profile store instead of environment variables.
|
||||
- WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.
|
||||
- fix(memory-wiki): require admin scope for ingest [AI]. (#80897) Thanks @pgondhi987.
|
||||
- memory-wiki: require write scope for Obsidian search [AI]. (#80904) Thanks @pgondhi987.
|
||||
- Build: skip copied metadata for bundled plugins that are excluded from build entries, preventing update/status rebuilds from advertising missing QQ Bot runtime files. (#80925)
|
||||
- Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual `└─ ` prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.
|
||||
- Auto-reply: surface a visible error when the configured model backend fails and fallback produces no visible reply, while preserving intentional silent turns and side-effect-only deliveries. (#80917) Thanks @dutifulbob.
|
||||
- Agents/exec: skip redundant heartbeat wake-ups for subagent session exec completions, preventing spurious LLM invocations on parent sessions. Fixes #66748. (#66749) Thanks @ggzeng.
|
||||
- Provider streams: keep OpenAI-compatible SSE and JSON fallback streams draining across split chunks and fail Azure Responses streams with a bounded first-event diagnostic instead of stalling. Refs #80926. (#80927) Thanks @galiniliev and @CaptainTimon.
|
||||
- Agents: rewrite generic provider internal errors with support request IDs into user-friendly transient error copy. (#49401) Thanks @y471823206.
|
||||
- WhatsApp: finish handling pending debounced inbound messages before closing the socket. (#81246) Thanks @mcaxtr.
|
||||
- Telegram: preserve supported HTML tags in visible replies and durable mirrors so formatted messages render correctly instead of degrading to escaped text. (#80977) Thanks @obviyus.
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/OpenAI HTTP: honor `max_completion_tokens` and `max_tokens` on inbound `/v1/chat/completions` requests so client-provided token caps reach the upstream provider via `streamParams.maxTokens`, with `max_completion_tokens` taking precedence when both are sent. Thanks @Lellansin.
|
||||
- Models/OpenAI CLI auth: make `openclaw models auth login --provider openai` start the ChatGPT/Codex account login by default, while `--method api-key` remains the explicit OpenAI API-key setup path.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside explicit SDK OAuth auth-result config patches, so provider helpers emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside SDK OAuth auth-result default config patches, so helper-built provider auth flows emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids returned by direct `openclaw models auth login --set-default` provider auth flows before writing config, so Gemini testing targets `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows when API-key onboarding only reapplies the agent default, so emitted config keeps testing `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: canonicalize provider-qualified retired Gemini 3 Pro Preview refs during Google forward-compatible model resolution, so emitted config uses `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize proxy-prefixed retired Gemini 3 Pro Preview catalog rows, so emitted configs use `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside per-agent model overrides before writing config, so agent-specific config emits `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Docs/subagents: document `agents.defaults.subagents.announceTimeoutMs` in the sub-agent and configuration references. (#75509) Thanks @akrimm702.
|
||||
- Cron: add direct `cron.get`, `openclaw cron get <id>`, and agent-tool `get` support for inspecting one stored cron job by id. (#75117) Thanks @samzong.
|
||||
- Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC.
|
||||
@@ -85,6 +100,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction: read post-compaction AGENTS.md refresh context from the queued run workspace instead of the runner process cwd, so CLI-backed follow-up turns re-inject the correct workspace startup rules after compaction. Fixes #70541. (#75532) Thanks @vyctorbrzezowski.
|
||||
- Agents/read tool: treat positive offsets beyond EOF as empty ranges instead of surfacing the upstream read error, so stale pagination cursors no longer crash tool calls while unrelated read failures still fail loud. Fixes #62466. (#75536) Thanks @vyctorbrzezowski.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview refs left in Google API-key onboarding model allowlists and fallbacks, so setup-emitted config keeps testing `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
|
||||
- Telegram/context: bound selected topic context to the active session so messages from before `/new` or `/reset` are not replayed into later turns. (#80848) Thanks @VACInc.
|
||||
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids when resolving exact configured proxy-provider refs, so `kilocode/google/gemini-3-pro-preview` resolves to `kilocode/google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- CLI: strip generic OSC terminal escape payloads from sanitized output fields, preventing clipboard/title escape bodies from leaking into commitment tables and other terminal-safe text. Thanks @shakkernerd.
|
||||
- Codex app-server: match connector-backed plugin approval elicitations by stable connector id so enabled destructive actions no longer fall through to display-name-only rejection.
|
||||
@@ -221,6 +237,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong.
|
||||
- Gateway/mobile: reuse bootstrap-issued device-token scopes on handoff reconnects and surface device-token scope mismatches separately from token mismatches while preserving full shared-token dashboard/native sessions. Fixes #79292. Thanks @BunsDev.
|
||||
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
|
||||
- Plugins/install: retry managed npm plugin installs without npm alias overrides after npm's `Invalid comparator: npm:` failure, so older npm versions can install official plugins instead of aborting. (#80539) Thanks @rubencu.
|
||||
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
|
||||
- Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump.
|
||||
- CLI/agents: surface durable message delivery status from `sendDurableMessageBatch` in `deliverAgentCommandResult` and `openclaw agent --json --deliver`, preserving suppressed hook outcomes as terminal no-retry results while exposing partial and failed sends for automation. Supersedes #53961 and #57755. Thanks @Kaspre.
|
||||
@@ -322,6 +339,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn.
|
||||
- Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.
|
||||
- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.
|
||||
- Plugin SDK/Windows: enable the native require fast path for root `openclaw/plugin-sdk` dist aliases instead of forcing Jiti transforms. (#80878) Thanks @medns.
|
||||
|
||||
## 2026.5.9
|
||||
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -116,20 +116,25 @@ ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# Reinstall production dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-runtime-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
echo "==> runtime-assets: install prod dependencies" && \
|
||||
rm -rf node_modules && \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
echo "==> runtime-assets: refresh bundled plugin registry" && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
echo "==> runtime-assets: prune non-selected plugin dist" && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
echo "==> runtime-assets: remove dist type and sourcemap files" && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
echo "==> runtime-assets: check package dist imports" && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
# ── Runtime base image ──────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
|
||||
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
|
||||
## Uncomment the lines below to enable sandbox isolation
|
||||
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
||||
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
||||
@@ -112,6 +113,7 @@ services:
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
|
||||
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f26833e053032e3da94025c8a5a8cb62dcddd275797b527440a19be5886a4783 plugin-sdk-api-baseline.json
|
||||
429fe1d6d119379b914bf84b15705233dc8d2d9e1a8131bb28ea19b19afbe6a0 plugin-sdk-api-baseline.jsonl
|
||||
bc50c46011011a907e4a2e55f0608fe2624a8f34d2a9363853f3cc80c58f2971 plugin-sdk-api-baseline.json
|
||||
fe317db84516318f0fdf885af7ce34783b7b6f47bd612f2557cb7aad46f493f3 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -395,7 +395,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
Outbound text uses Telegram `parse_mode: "HTML"`.
|
||||
|
||||
- Markdown-ish text is rendered to Telegram-safe HTML.
|
||||
- Raw model HTML is escaped to reduce Telegram parse failures.
|
||||
- Supported Telegram HTML tags are preserved; unsupported HTML is escaped.
|
||||
- If Telegram rejects parsed HTML, OpenClaw retries as plain text.
|
||||
|
||||
Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`.
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 codex --plugin google-calendar --verify-plugin-apps --dry-run
|
||||
openclaw migrate hermes --dry-run
|
||||
openclaw migrate hermes
|
||||
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
@@ -59,6 +60,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
<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. When omitted, interactive Codex migrations show a native Codex plugin checkbox selector and non-interactive migrations keep all planned plugins. This only applies to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory.
|
||||
</ParamField>
|
||||
<ParamField path="--verify-plugin-apps" type="boolean">
|
||||
Codex only. Force a fresh source Codex app-server `app/list` traversal before planning native plugin activation. Off by default to keep migration planning fast.
|
||||
</ParamField>
|
||||
<ParamField path="--no-backup" type="boolean">
|
||||
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
|
||||
</ParamField>
|
||||
@@ -125,7 +129,8 @@ your personal Codex CLI state by default.
|
||||
Running `openclaw migrate codex` in an interactive terminal previews the full
|
||||
plan, then opens checkbox selectors before the final apply confirmation. Skill
|
||||
copy items are prompted first. Use `Toggle all on` or `Toggle all off` for bulk
|
||||
selection; planned skills start checked, conflict skills start unchecked, and
|
||||
selection. Press Space to toggle rows, or press Enter to activate the highlighted
|
||||
row and continue. Planned skills start checked, conflict skills start unchecked, and
|
||||
`Skip for now` skips skill copies for this run while still continuing to plugin
|
||||
selection. When source-installed curated Codex plugins are migratable and
|
||||
`--plugin` was not supplied, migration then prompts for native Codex plugin
|
||||
@@ -156,17 +161,36 @@ openclaw migrate apply codex --yes --plugin google-calendar
|
||||
- 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.
|
||||
app-server `plugin/list`. Planning reads `plugin/read` for each enabled
|
||||
installed plugin. App-backed plugins require the source Codex app-server
|
||||
account response to be a ChatGPT subscription account; non-ChatGPT or missing
|
||||
account responses are skipped with `codex_subscription_required`. By default,
|
||||
migration does not call source `app/list`, so app-backed plugins that pass the
|
||||
account gate are planned without source app accessibility verification, and
|
||||
account lookup transport failures skip with `codex_account_unavailable`. Pass
|
||||
`--verify-plugin-apps` when you want migration to force a fresh source
|
||||
`app/list` snapshot and require every owned app to be present, enabled, and
|
||||
accessible before planning native activation. In that mode, account lookup
|
||||
transport failures fall through to source app inventory verification. The
|
||||
source app inventory snapshot is kept in memory for the current process; it
|
||||
is not written to migration output or target config. Disabled plugins,
|
||||
unreadable plugin details, subscription-gated source accounts, and, when
|
||||
verification is requested, missing apps, disabled apps, inaccessible apps, or
|
||||
source app inventory failures become manual skipped items with typed reasons
|
||||
instead of target config entries.
|
||||
Apply calls app-server `plugin/install` for each selected eligible 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 `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
|
||||
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, cached
|
||||
plugin bundles that are not source-installed curated plugins, and source-installed
|
||||
plugins that fail the source subscription gate are not activated automatically.
|
||||
When `--verify-plugin-apps` is set, plugins that fail the source app-inventory
|
||||
gate are also skipped. They are copied or reported in the migration report for
|
||||
manual review.
|
||||
|
||||
For migrated source-installed curated plugins, apply writes:
|
||||
@@ -178,7 +202,13 @@ For migrated source-installed curated plugins, apply writes:
|
||||
`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
|
||||
paths. Source-side subscription failures are reported on manual items with typed
|
||||
reasons such as `codex_subscription_required`, `codex_account_unavailable`,
|
||||
`plugin_disabled`, or `plugin_read_unavailable`. With `--verify-plugin-apps`,
|
||||
source app-inventory failures can also appear as `app_inaccessible`,
|
||||
`app_disabled`, `app_missing`, or `app_inventory_unavailable`. Skipped plugins
|
||||
are not written to target config.
|
||||
Target-side 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.
|
||||
|
||||
@@ -201,6 +201,10 @@ Set `stream: true` to receive Server-Sent Events (SSE):
|
||||
- `tool_choice`: `"auto"`, `"none"`
|
||||
- `messages[*].role: "tool"` follow-up turns
|
||||
- `messages[*].tool_call_id` for binding tool results back to a prior tool call
|
||||
- `max_completion_tokens`: number; per-call cap for total completion tokens (reasoning tokens included). Current OpenAI Chat Completions field name; preferred when both `max_completion_tokens` and `max_tokens` are sent.
|
||||
- `max_tokens`: number; legacy alias accepted for backwards compatibility. Ignored when `max_completion_tokens` is also present.
|
||||
|
||||
When either field is set, the value is forwarded to the upstream provider via the agent stream-param channel. The actual wire field name sent to the upstream provider is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, and `max_tokens` for providers that only accept the legacy name (such as Mistral and Chutes).
|
||||
|
||||
### Unsupported variants
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ Preview migration from the source Codex home:
|
||||
openclaw migrate codex --dry-run
|
||||
```
|
||||
|
||||
Use strict source app verification when you want migration to check source app
|
||||
accessibility before planning native plugin activation:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run --verify-plugin-apps
|
||||
```
|
||||
|
||||
Apply the migration when the plan looks right:
|
||||
|
||||
```bash
|
||||
@@ -87,8 +94,19 @@ The integration has three separate states:
|
||||
- Accessible: Codex app-server confirms the plugin's app entries are available
|
||||
for the active account and can be mapped to the migrated plugin identity.
|
||||
|
||||
Migration is the durable install/eligibility step. Runtime app inventory is the
|
||||
accessibility check. Codex harness session setup then computes a restrictive
|
||||
Migration is the durable install/eligibility step. During planning, OpenClaw
|
||||
reads source Codex `plugin/read` details and checks that the source Codex
|
||||
app-server account response is a ChatGPT subscription account. Non-ChatGPT or
|
||||
missing account responses skip app-backed plugins with
|
||||
`codex_subscription_required`. By default, migration does not call source
|
||||
`app/list`; app-backed source plugins that pass the account gate are planned
|
||||
without source app accessibility verification, and account lookup transport
|
||||
failures skip with `codex_account_unavailable`. With `--verify-plugin-apps`,
|
||||
migration takes a fresh source `app/list` snapshot and requires every owned app
|
||||
to be present, enabled, and accessible before planning native activation. In
|
||||
that mode, account lookup transport failures fall through to the source
|
||||
app-inventory gate. Runtime app inventory is the target-session accessibility
|
||||
check after migration. Codex harness session setup then computes a restrictive
|
||||
thread app config for the enabled and accessible plugin apps.
|
||||
|
||||
Thread app config is computed when OpenClaw establishes a Codex harness session
|
||||
@@ -100,6 +118,12 @@ V1 is intentionally narrow:
|
||||
|
||||
- Only `openai-curated` plugins that were already installed in the source Codex
|
||||
app-server inventory are migration-eligible.
|
||||
- App-backed source plugins must pass the migration-time subscription gate.
|
||||
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
|
||||
accounts plus, in verification mode, inaccessible, disabled, missing source
|
||||
apps or source app-inventory refresh failures are reported as skipped manual
|
||||
items instead of enabled config entries. Unreadable plugin details are skipped
|
||||
before the source app-inventory gate.
|
||||
- Migration writes explicit plugin identities with `marketplaceName` and
|
||||
`pluginName`; it does not write local `marketplacePath` cache paths.
|
||||
- `codexPlugins.enabled` is the global enablement switch.
|
||||
@@ -111,7 +135,18 @@ V1 is intentionally narrow:
|
||||
## App inventory and ownership
|
||||
|
||||
OpenClaw reads Codex app inventory through app-server `app/list`, caches it for
|
||||
one hour, and refreshes stale or missing entries asynchronously.
|
||||
one hour, and refreshes stale or missing entries asynchronously. The cache is
|
||||
in memory only; restarting the CLI or gateway drops it, and OpenClaw rebuilds it
|
||||
from the next `app/list` read.
|
||||
|
||||
Migration and runtime use separate cache keys:
|
||||
|
||||
- Source migration verification uses the source Codex home and source app-server
|
||||
start options. This runs only when `--verify-plugin-apps` is set, and it
|
||||
forces a fresh source `app/list` traversal for that planning run.
|
||||
- Target runtime setup uses the target agent's Codex app-server identity when it
|
||||
builds the Codex thread app config. Plugin activation invalidates that target
|
||||
cache key and then force-refreshes it after `plugin/install`.
|
||||
|
||||
A plugin app is exposed only when OpenClaw can map it back to the migrated
|
||||
plugin through stable ownership:
|
||||
@@ -161,6 +196,27 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
|
||||
needs authentication. The explicit plugin entry is written disabled until you
|
||||
reauthorize and enable it.
|
||||
|
||||
**`app_inaccessible`, `app_disabled`, or `app_missing`:**
|
||||
migration did not install the plugin because the source Codex app inventory did
|
||||
not show all owned apps as present, enabled, and accessible while
|
||||
`--verify-plugin-apps` was set. Reauthorize or enable the app in Codex, then
|
||||
rerun migration with `--verify-plugin-apps`.
|
||||
|
||||
**`app_inventory_unavailable`:** migration did not install the plugin because
|
||||
strict source app verification was requested and source Codex app inventory
|
||||
refresh failed. Fix source Codex app-server access or retry without
|
||||
`--verify-plugin-apps` if you accept the faster account-gated plan.
|
||||
|
||||
**`codex_subscription_required`:** migration did not install the app-backed
|
||||
plugin because the source Codex app-server account was not logged in with a
|
||||
ChatGPT subscription account. Log in to the Codex app with subscription auth,
|
||||
then rerun migration.
|
||||
|
||||
**`codex_account_unavailable`:** migration did not install the app-backed plugin
|
||||
because the source Codex app-server account could not be read. Fix source Codex
|
||||
app-server auth or rerun with `--verify-plugin-apps` if you want source app
|
||||
inventory to decide eligibility when account lookup fails.
|
||||
|
||||
**`marketplace_missing` or `plugin_missing`:** the target Codex app-server
|
||||
cannot see the expected `openai-curated` marketplace or plugin. Rerun migration
|
||||
against the target runtime or inspect Codex app-server plugin status.
|
||||
|
||||
@@ -20,7 +20,7 @@ SGLang serves open-weight models via an OpenAI-compatible HTTP API. OpenClaw con
|
||||
| Streaming usage | Yes (`supportsStreamingUsage: true`) |
|
||||
| Pricing | Marked external-free (`modelPricing.external: false`) |
|
||||
|
||||
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY` and you do not define an explicit `models.providers.sglang` entry — see [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
|
||||
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY`. Use `sglang/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom SGLang base URL. See [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -71,8 +71,10 @@ define `models.providers.sglang`, OpenClaw will query:
|
||||
and convert the returned IDs into model entries.
|
||||
|
||||
<Note>
|
||||
If you set `models.providers.sglang` explicitly, auto-discovery is skipped and
|
||||
you must define models manually.
|
||||
If you set `models.providers.sglang` explicitly, OpenClaw uses your declared
|
||||
models by default. Add `"sglang/*": {}` to `agents.defaults.models` when you
|
||||
want OpenClaw to query that configured provider's `/models` endpoint and include
|
||||
all advertised SGLang models.
|
||||
</Note>
|
||||
|
||||
## Explicit configuration (manual models)
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "vLLM"
|
||||
|
||||
vLLM can serve open-source (and some custom) models via an **OpenAI-compatible** HTTP API. OpenClaw connects to vLLM using the `openai-completions` API.
|
||||
|
||||
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth) and you do not define an explicit `models.providers.vllm` entry.
|
||||
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth). Use `vllm/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom vLLM base URL.
|
||||
|
||||
OpenClaw treats `vllm` as a local OpenAI-compatible provider that supports
|
||||
streamed usage accounting, so status/context token counts can update from
|
||||
@@ -72,7 +72,7 @@ GET http://127.0.0.1:8000/v1/models
|
||||
and converts the returned IDs into model entries.
|
||||
|
||||
<Note>
|
||||
If you set `models.providers.vllm` explicitly, auto-discovery is skipped and you must define models manually.
|
||||
If you set `models.providers.vllm` explicitly, OpenClaw uses your declared models by default. Add `"vllm/*": {}` to `agents.defaults.models` when you want OpenClaw to query that configured provider's `/models` endpoint and include all advertised vLLM models.
|
||||
</Note>
|
||||
|
||||
## Explicit configuration (manual models)
|
||||
@@ -111,6 +111,21 @@ Use explicit config when:
|
||||
}
|
||||
```
|
||||
|
||||
To keep this provider dynamic without manually listing every model, add a provider
|
||||
wildcard to the visible model catalog:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"vllm/*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -331,7 +346,7 @@ Use explicit config when:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="No models discovered">
|
||||
Auto-discovery requires `VLLM_API_KEY` to be set **and** no explicit `models.providers.vllm` config entry. If you have defined the provider manually, OpenClaw skips discovery and uses only your declared models.
|
||||
Auto-discovery requires `VLLM_API_KEY` to be set. If you have defined `models.providers.vllm`, OpenClaw uses only your declared models unless `agents.defaults.models` includes `"vllm/*": {}`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tools render as raw text">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"openclawVersion": "2026.5.12-beta.4",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,10 +20,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1 +1 @@
|
||||
6f42494c638de9f72ce783550b4a7c62912c26d47293641e588625afa06db370
|
||||
cc41ae7fab365c305d54f7a740856549aa350f59ddddd9287bc231521d6a15d2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cerebras provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,10 +27,10 @@
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -550,6 +550,25 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves native app-server auth untouched when auth bridging is disabled", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ requiresOpenaiAuth: true }));
|
||||
try {
|
||||
vi.stubEnv("OPENAI_API_KEY", "env-api-key");
|
||||
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
authProfileId: null,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ type: "apiKey" }));
|
||||
|
||||
@@ -42,7 +42,7 @@ type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): Promise<CodexAppServerStartOptions> {
|
||||
if (params.startOptions.transport !== "stdio") {
|
||||
@@ -52,6 +52,9 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
params.startOptions,
|
||||
params.agentDir,
|
||||
);
|
||||
if (params.authProfileId === null) {
|
||||
return isolatedStartOptions;
|
||||
}
|
||||
const store = ensureCodexAppServerAuthProfileStore({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
@@ -291,10 +294,13 @@ function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string
|
||||
export async function applyCodexAppServerAuthProfile(params: {
|
||||
client: CodexAppServerClient;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): Promise<void> {
|
||||
if (params.authProfileId === null) {
|
||||
return;
|
||||
}
|
||||
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
|
||||
74
extensions/codex/src/app-server/plugin-app-cache-key.ts
Normal file
74
extensions/codex/src/app-server/plugin-app-cache-key.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
buildCodexAppInventoryCacheKey,
|
||||
type CodexAppInventoryCacheKeyInput,
|
||||
} from "./app-inventory-cache.js";
|
||||
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
|
||||
import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
export type CodexPluginAppCacheKeyParams = Omit<
|
||||
CodexAppInventoryCacheKeyInput,
|
||||
"codexHome" | "endpoint"
|
||||
> & {
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">;
|
||||
agentDir?: string;
|
||||
};
|
||||
|
||||
export function buildCodexPluginAppCacheKey(params: CodexPluginAppCacheKeyParams): string {
|
||||
return buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir),
|
||||
endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer),
|
||||
authProfileId: params.authProfileId,
|
||||
accountId: params.accountId,
|
||||
envApiKeyFingerprint: params.envApiKeyFingerprint,
|
||||
appServerVersion: params.appServerVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCodexPluginAppCacheEndpoint(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
|
||||
): 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),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCodexPluginAppCacheCodexHome(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
|
||||
agentDir?: string,
|
||||
): string | undefined {
|
||||
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
|
||||
if (configuredCodexHome) {
|
||||
return configuredCodexHome;
|
||||
}
|
||||
return appServer.start.transport === "stdio" && agentDir
|
||||
? resolveCodexAppServerHomeDir(agentDir)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function fingerprintCodexPluginAppCacheCredentials(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
): 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")}`;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
|
||||
requestParams: CodexAppServerRequestParams<M>;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<CodexAppServerRequestResult<M>>;
|
||||
@@ -26,7 +27,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<T>;
|
||||
@@ -35,7 +37,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<T> {
|
||||
@@ -48,13 +51,19 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
try {
|
||||
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
client.close();
|
||||
// Wait for the child to actually exit (with a SIGKILL fallback) so
|
||||
// the parent process doesn't hang on an orphaned codex app-server.
|
||||
// The stdio bin shim does not always propagate stdin EOF to the
|
||||
// underlying codex binary, so the unref'd close() path can leave
|
||||
// the child running and keep the parent's event loop alive.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -25,20 +25,18 @@ function queueActiveRunMessageForTest(
|
||||
return queueAgentHarnessMessage(...args);
|
||||
}
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import {
|
||||
buildCodexAppInventoryCacheKey,
|
||||
defaultCodexAppInventoryCache,
|
||||
} from "./app-inventory-cache.js";
|
||||
import {
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
resolveCodexAppServerHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
createCodexDynamicToolBridge,
|
||||
} from "./dynamic-tools.js";
|
||||
import * as elicitationBridge from "./elicitation-bridge.js";
|
||||
import {
|
||||
buildCodexPluginAppCacheKey,
|
||||
resolveCodexPluginAppCacheEndpoint,
|
||||
} from "./plugin-app-cache-key.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
|
||||
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
|
||||
@@ -683,6 +681,47 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes auth profiles into Codex dynamic tool construction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:api-key-backup": {
|
||||
provider: "openai",
|
||||
type: "api_key",
|
||||
key: "not-a-real-key",
|
||||
},
|
||||
},
|
||||
} satisfies EmbeddedRunAttemptParams["authProfileStore"];
|
||||
params.disableTools = false;
|
||||
params.authProfileStore = authProfileStore;
|
||||
|
||||
const factoryOptions: unknown[] = [];
|
||||
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await __testing.buildDynamicTools({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
sandboxSessionKey: params.sessionKey!,
|
||||
sandbox: null as never,
|
||||
runAbortController: new AbortController(),
|
||||
sessionAgentId: "main",
|
||||
pluginConfig: {},
|
||||
onYieldDetected: () => undefined,
|
||||
});
|
||||
|
||||
expect(factoryOptions).toHaveLength(1);
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
authProfileStore,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
|
||||
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));
|
||||
|
||||
@@ -3685,9 +3724,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
}),
|
||||
request: async () => ({
|
||||
data: [
|
||||
@@ -3887,9 +3926,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
accountId: "account-work",
|
||||
}),
|
||||
@@ -4028,9 +4067,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
envApiKeyFingerprint: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
baseEnv: { CODEX_API_KEY: "old-codex-env-key" },
|
||||
@@ -5198,7 +5237,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("keys plugin app inventory by websocket credentials without exposing them", () => {
|
||||
const first = __testing.resolveCodexPluginAppCacheEndpoint({
|
||||
const first = resolveCodexPluginAppCacheEndpoint({
|
||||
start: {
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
@@ -5207,13 +5246,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
authToken: "token-first",
|
||||
headers: { Authorization: "Bearer first" },
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
const second = __testing.resolveCodexPluginAppCacheEndpoint({
|
||||
const second = resolveCodexPluginAppCacheEndpoint({
|
||||
start: {
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
@@ -5222,11 +5256,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
authToken: "token-second",
|
||||
headers: { Authorization: "Bearer second" },
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
|
||||
@@ -41,16 +41,12 @@ import {
|
||||
import { markAuthProfileBlockedUntil, 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 { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
import {
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerAuthProfileId,
|
||||
resolveCodexAppServerAuthProfileIdForAgent,
|
||||
} from "./auth-bridge.js";
|
||||
@@ -87,6 +83,7 @@ import {
|
||||
buildCodexNativeHookRelayConfig,
|
||||
CODEX_NATIVE_HOOK_RELAY_EVENTS,
|
||||
} from "./native-hook-relay.js";
|
||||
import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
buildCodexPluginThreadConfig,
|
||||
buildCodexPluginThreadConfigInputFingerprint,
|
||||
@@ -391,50 +388,6 @@ 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;
|
||||
}
|
||||
|
||||
function restrictCodexAppServerSandboxForOpenClawSandbox(
|
||||
appServer: CodexAppServerRuntimeOptions,
|
||||
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>,
|
||||
@@ -734,9 +687,9 @@ export async function runCodexAppServerAttempt(
|
||||
: undefined;
|
||||
const threadConfig = nativeHookRelayConfig;
|
||||
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
|
||||
const pluginAppCacheKey = buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexPluginAppCacheCodexHome(appServer, agentDir),
|
||||
endpoint: resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
authProfileId: startupAuthProfileId,
|
||||
accountId: startupAuthAccountCacheKey,
|
||||
envApiKeyFingerprint: startupEnvApiKeyCacheKey,
|
||||
@@ -2229,6 +2182,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
resolvedWorkspace: input.resolvedWorkspace,
|
||||
}),
|
||||
config: params.config,
|
||||
authProfileStore: params.authProfileStore,
|
||||
abortSignal: input.runAbortController.signal,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
@@ -3155,7 +3109,6 @@ export const __testing = {
|
||||
filterToolsForVisionInputs,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
resolveDynamicToolCallTimeoutMs,
|
||||
resolveCodexPluginAppCacheEndpoint,
|
||||
restrictCodexAppServerSandboxForOpenClawSandbox,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldForceMessageTool,
|
||||
|
||||
@@ -39,6 +39,7 @@ let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerMod
|
||||
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
|
||||
async function sendInitializeResult(
|
||||
@@ -63,6 +64,7 @@ describe("shared Codex app-server client", () => {
|
||||
clearSharedCodexAppServerClient,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
createIsolatedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} = await import("./shared-client.js"));
|
||||
});
|
||||
@@ -151,6 +153,39 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.authProfileId).toBe("openai-codex:work");
|
||||
});
|
||||
|
||||
it("skips target auth resolution when native source auth is requested", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const config = { auth: { order: { "openai-codex": ["openai-codex:target"] } } };
|
||||
|
||||
const clientPromise = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
config,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
const [bridgeCall] = mocks.bridgeCodexAppServerStartOptions.mock.calls[0] ?? [];
|
||||
expect(bridgeCall).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
authProfileId: null,
|
||||
config,
|
||||
}),
|
||||
);
|
||||
const [applyCall] = mocks.applyCodexAppServerAuthProfile.mock.calls[0] ?? [];
|
||||
expect(applyCall).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
authProfileId: null,
|
||||
config,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the configured implicit auth profile before sharing a client", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
@@ -32,29 +32,34 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
export async function getSharedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: options?.authProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
authProfileId,
|
||||
agentDir,
|
||||
agentDir: usesNativeAuth ? undefined : agentDir,
|
||||
});
|
||||
if (state.key && state.key !== key) {
|
||||
clearSharedCodexAppServerClient();
|
||||
@@ -71,7 +76,7 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
});
|
||||
@@ -100,23 +105,28 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
export async function createIsolatedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: options?.authProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
@@ -126,7 +136,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
});
|
||||
|
||||
@@ -21,14 +21,22 @@ import type {
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
|
||||
import {
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerAuthProfileIdForAgent,
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
} from "../app-server/auth-bridge.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
} from "../app-server/config.js";
|
||||
import {
|
||||
ensureCodexPluginActivation,
|
||||
type CodexPluginActivationResult,
|
||||
} from "../app-server/plugin-activation.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
@@ -40,6 +48,7 @@ import {
|
||||
readCodexPluginMigrationConfigEntry,
|
||||
type CodexPluginMigrationConfigEntry,
|
||||
} from "./plan.js";
|
||||
import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
|
||||
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
|
||||
@@ -104,6 +113,8 @@ async function applyCodexPluginInstallItem(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const appCacheKey = await buildTargetCodexPluginAppCacheKey(ctx);
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: policy,
|
||||
installEvenIfActive: true,
|
||||
@@ -112,10 +123,14 @@ async function applyCodexPluginInstallItem(
|
||||
method,
|
||||
requestParams,
|
||||
timeoutMs: 60_000,
|
||||
startOptions: appServer.start,
|
||||
agentDir: resolveCodexMigrationTargets(ctx).agentDir,
|
||||
config: ctx.config,
|
||||
isolated: true,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey,
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
const baseDetails = {
|
||||
...item.details,
|
||||
code: result.reason,
|
||||
@@ -162,6 +177,38 @@ async function applyCodexPluginInstallItem(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetCodexAppServer(ctx: MigrationProviderContext) {
|
||||
return resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: readCodexPluginConfig(ctx.config),
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTargetCodexPluginAppCacheKey(ctx: MigrationProviderContext): Promise<string> {
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
agentDir: targets.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const accountId = await resolveCodexAppServerAuthAccountCacheKey({
|
||||
authProfileId,
|
||||
agentDir: targets.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const envApiKeyFingerprint = authProfileId
|
||||
? undefined
|
||||
: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
return buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir: targets.agentDir,
|
||||
authProfileId,
|
||||
accountId,
|
||||
envApiKeyFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCodexPluginConfigItem(
|
||||
ctx: MigrationProviderContext,
|
||||
item: MigrationItem,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import { exists, sanitizeName } from "./helpers.js";
|
||||
import {
|
||||
codexPluginMigrationSubscriptionWarning,
|
||||
discoverCodexSource,
|
||||
hasCodexSource,
|
||||
type CodexPluginSource,
|
||||
@@ -33,6 +34,7 @@ const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [
|
||||
"codexPlugins",
|
||||
] as const;
|
||||
const MIGRATION_REASON_PLUGIN_EXISTS = "plugin exists";
|
||||
const CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED = "not_run";
|
||||
|
||||
export type CodexPluginMigrationConfigEntry = {
|
||||
configKey: string;
|
||||
@@ -40,6 +42,13 @@ export type CodexPluginMigrationConfigEntry = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type CodexPluginMigrationBlockSkipDetails = {
|
||||
pluginName: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
apps?: NonNullable<CodexPluginSource["migrationBlock"]>["apps"];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
if ((counts.get(base) ?? 0) <= 1) {
|
||||
@@ -173,6 +182,9 @@ function buildPluginItems(
|
||||
pluginName: plugin.pluginName,
|
||||
sourceInstalled: plugin.installed === true,
|
||||
sourceEnabled: plugin.enabled === true,
|
||||
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
|
||||
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -180,6 +192,29 @@ function buildPluginItems(
|
||||
}
|
||||
|
||||
manualIndex += 1;
|
||||
if (plugin.migrationBlock && plugin.pluginName) {
|
||||
const details: CodexPluginMigrationBlockSkipDetails = {
|
||||
pluginName: plugin.pluginName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
...(plugin.migrationBlock.apps ? { apps: plugin.migrationBlock.apps } : {}),
|
||||
...(plugin.migrationBlock.error ? { error: plugin.migrationBlock.error } : {}),
|
||||
};
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
source: plugin.source,
|
||||
status: "skipped",
|
||||
reason: plugin.migrationBlock.code,
|
||||
message:
|
||||
plugin.message ??
|
||||
`Codex native plugin "${plugin.name}" was found but not activated automatically.`,
|
||||
details: { ...details },
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
items.push(
|
||||
createMigrationManualItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
|
||||
@@ -195,6 +230,10 @@ function buildPluginItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
function shouldVerifyPluginApps(ctx: MigrationProviderContext): boolean {
|
||||
return ctx.providerOptions?.verifyPluginApps === true;
|
||||
}
|
||||
|
||||
export function readCodexPluginMigrationConfigEntry(
|
||||
item: MigrationItem,
|
||||
enabled: boolean,
|
||||
@@ -345,13 +384,17 @@ function buildPluginConfigItem(
|
||||
export async function buildCodexMigrationPlan(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<MigrationPlan> {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const source = await discoverCodexSource({
|
||||
input: ctx.source,
|
||||
evaluatePluginMigrationEligibility: true,
|
||||
verifyPluginApps: shouldVerifyPluginApps(ctx),
|
||||
});
|
||||
if (!hasCodexSource(source)) {
|
||||
throw new Error(
|
||||
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
);
|
||||
}
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(
|
||||
...(await buildSkillItems({
|
||||
@@ -386,16 +429,31 @@ export async function buildCodexMigrationPlan(
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting migration targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.length > 0
|
||||
...(source.plugins.some((plugin) => plugin.migratable)
|
||||
? [
|
||||
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.some(
|
||||
(plugin) => plugin.migratable && plugin.apps && plugin.apps.length > 0,
|
||||
) && !shouldVerifyPluginApps(ctx)
|
||||
? [
|
||||
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.some((plugin) => plugin.sourceKind === "cache")
|
||||
? ["Codex 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.plugins.some(
|
||||
(plugin) => plugin.migrationBlock?.code === "codex_subscription_required",
|
||||
)
|
||||
? [codexPluginMigrationSubscriptionWarning()]
|
||||
: []),
|
||||
...(source.archivePaths.length > 0
|
||||
? [
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
|
||||
@@ -3,8 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
|
||||
const appServerRequest = vi.hoisted(() => vi.fn());
|
||||
@@ -38,6 +40,7 @@ function makeContext(params: {
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
reportDir?: string;
|
||||
config?: MigrationProviderContext["config"];
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
@@ -56,6 +59,7 @@ function makeContext(params: {
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
@@ -69,6 +73,14 @@ function findItem(items: readonly { id?: string }[], id: string) {
|
||||
return item as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function findItemByReason(items: readonly { reason?: string }[], reason: string) {
|
||||
const item = items.find((entry) => entry.reason === reason);
|
||||
if (!item) {
|
||||
throw new Error(`Expected migration item reason ${reason}`);
|
||||
}
|
||||
return item as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
||||
if (!record || typeof record !== "object") {
|
||||
throw new Error("Expected record");
|
||||
@@ -122,9 +134,28 @@ async function createCodexFixture(): Promise<{
|
||||
return { root, homeDir, codexHome, stateDir, workspaceDir };
|
||||
}
|
||||
|
||||
function sourceAppCacheKey(fixture: { codexHome: string }): string {
|
||||
return buildCodexPluginAppCacheKey({
|
||||
appServer: {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
appServerRequest.mockReset();
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -145,6 +176,7 @@ describe("buildCodexMigrationProvider", () => {
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -185,9 +217,15 @@ describe("buildCodexMigrationProvider", () => {
|
||||
|
||||
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 })]),
|
||||
);
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
@@ -195,10 +233,11 @@ describe("buildCodexMigrationProvider", () => {
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(1);
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(mockCallArg(appServerRequest), {
|
||||
method: "plugin/list",
|
||||
requestParams: { cwds: [] },
|
||||
@@ -226,6 +265,588 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips source-installed plugins whose owned apps are inaccessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [
|
||||
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
expectRecordFields(requestParams, { forceRefetch: true });
|
||||
return appsList([
|
||||
appInfo("asdk_app_readwise", {
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "app_inaccessible");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
reason: "app_inaccessible",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "readwise",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "asdk_app_readwise",
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
needsAuth: false,
|
||||
},
|
||||
]);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("plans app-backed plugins without source app/list by default", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expectRecordFields(findItem(plan.items, "config:codex-plugins"), {
|
||||
kind: "config",
|
||||
action: "merge",
|
||||
status: "planned",
|
||||
});
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Codex app-backed plugins were planned without source app accessibility verification",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and skips app-backed plugins when source Codex account is not ChatGPT subscription auth", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return {
|
||||
account: { type: "apiKey" },
|
||||
requiresOpenaiAuth: true,
|
||||
} satisfies CodexGetAccountResponse;
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "codex_subscription_required");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
reason: "codex_subscription_required",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "gmail",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "app-gmail",
|
||||
name: "Gmail",
|
||||
needsAuth: true,
|
||||
},
|
||||
]);
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Codex app-backed plugin migration requires the Codex app-server source account",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(plan.warnings).not.toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("planned for native activation")]),
|
||||
);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and skips app-backed plugins when source Codex account is missing", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return {
|
||||
account: null,
|
||||
requiresOpenaiAuth: true,
|
||||
} satisfies CodexGetAccountResponse;
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
expectRecordFields(findItemByReason(plan.items, "codex_subscription_required"), {
|
||||
reason: "codex_subscription_required",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls through to app inventory when source account read fails and app verification is requested", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
throw new Error("account unavailable");
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("app-gmail")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips app-backed plugins by default when source account read fails", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
throw new Error("account unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "codex_account_unavailable");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
reason: "codex_account_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expectRecordFields(manualItem.details, { error: "account unavailable" });
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads source plugin readiness with native source auth instead of target agent auth", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar", [
|
||||
pluginApp("app-google-calendar", { name: "Google Calendar", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("app-google-calendar")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: fixture.workspaceDir,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:target"],
|
||||
},
|
||||
},
|
||||
} as MigrationProviderContext["config"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(4);
|
||||
for (const [arg] of appServerRequest.mock.calls) {
|
||||
expect(arg).toEqual(
|
||||
expect.objectContaining({
|
||||
authProfileId: null,
|
||||
isolated: true,
|
||||
startOptions: expect.objectContaining({
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(arg).not.toHaveProperty("agentDir");
|
||||
expect(arg).not.toHaveProperty("config");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports inaccessible before missing when multiple owned apps are blocked", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [
|
||||
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
|
||||
pluginApp("asdk_app_reader", { name: "Reader", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([
|
||||
appInfo("asdk_app_readwise", {
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const manualItem = findItemByReason(plan.items, "app_inaccessible");
|
||||
expectRecordFields(manualItem, {
|
||||
reason: "app_inaccessible",
|
||||
status: "skipped",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "readwise",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "asdk_app_reader",
|
||||
name: "Reader",
|
||||
needsAuth: false,
|
||||
},
|
||||
{
|
||||
id: "asdk_app_readwise",
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
needsAuth: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("force-refreshes source app inventory once for app-backed plugins sharing a cache key", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: sourceAppCacheKey(fixture),
|
||||
request: async () => appsList([appInfo("app-google-calendar", { isAccessible: false })]),
|
||||
});
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([
|
||||
pluginSummary("google-calendar", { installed: true, enabled: true }),
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
const pluginName = (requestParams as v2.PluginReadParams).pluginName;
|
||||
return pluginRead(pluginName, [pluginApp(`app-${pluginName}`)]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
expectRecordFields(requestParams, { forceRefetch: true });
|
||||
return appsList([appInfo("app-google-calendar"), appInfo("app-gmail")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:google-calendar"), { status: "planned" });
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), { status: "planned" });
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for disabled plugins and plugin/read failures", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([
|
||||
pluginSummary("readwise", { installed: true, enabled: false }),
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
expectRecordFields(requestParams, { pluginName: "gmail" });
|
||||
throw new Error("detail unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItemByReason(plan.items, "plugin_disabled"), {
|
||||
reason: "plugin_disabled",
|
||||
status: "skipped",
|
||||
});
|
||||
expectRecordFields(findItemByReason(plan.items, "plugin_read_unavailable"), {
|
||||
reason: "plugin_read_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when app inventory refresh fails for app-backed plugins", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [pluginApp("asdk_app_readwise", { name: "Readwise" })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
throw new Error("app inventory unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItemByReason(plan.items, "app_inventory_unavailable"), {
|
||||
reason: "app_inventory_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats auth-required source apps as ready when app inventory says they are accessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("reader", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("reader", [
|
||||
pluginApp("ready-app", { name: "Ready App", needsAuth: false }),
|
||||
pluginApp("auth-app", { name: "Auth App", needsAuth: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("ready-app"), appInfo("auth-app")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pluginItem = findItem(plan.items, "plugin:reader");
|
||||
expectRecordFields(pluginItem, {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expectRecordFields(pluginItem.details, {
|
||||
configKey: "reader",
|
||||
pluginName: "reader",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies planned skills and archives native config during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const reportDir = path.join(fixture.root, "report");
|
||||
@@ -275,6 +896,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -287,6 +911,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -375,6 +1002,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
@@ -415,6 +1045,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -427,6 +1060,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -449,6 +1085,11 @@ describe("buildCodexMigrationProvider", () => {
|
||||
|
||||
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const sourceKey = sourceAppCacheKey(fixture);
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: sourceKey,
|
||||
request: async () => appsList([appInfo("source-only-app")]),
|
||||
});
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -476,6 +1117,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -488,6 +1132,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -504,6 +1151,14 @@ describe("buildCodexMigrationProvider", () => {
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
|
||||
const sourceCacheRead = defaultCodexAppInventoryCache.read({
|
||||
key: sourceKey,
|
||||
request: async () => {
|
||||
throw new Error("source app cache was cleared");
|
||||
},
|
||||
});
|
||||
expect(sourceCacheRead.state).toBe("fresh");
|
||||
expect(sourceCacheRead.snapshot?.apps.map((app) => app.id)).toEqual(["source-only-app"]);
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
@@ -545,6 +1200,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -557,6 +1215,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -596,6 +1257,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return {
|
||||
authPolicy: "ON_USE",
|
||||
@@ -619,6 +1283,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -671,6 +1338,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
throw new Error("install failed");
|
||||
}
|
||||
@@ -777,6 +1447,61 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRead(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 pluginApp(id: string, overrides: Partial<v2.AppSummary> = {}): v2.AppSummary {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: null,
|
||||
installUrl: null,
|
||||
needsAuth: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appInfo(id: string, overrides: Partial<v2.AppInfo> = {}): 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: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appsList(apps: v2.AppInfo[]): v2.AppsListResponse {
|
||||
return { data: apps, nextCursor: null };
|
||||
}
|
||||
|
||||
function chatGptAccount(): CodexGetAccountResponse {
|
||||
return {
|
||||
account: { type: "chatgpt", email: "codex@example.test", planType: "plus" },
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -18,7 +18,9 @@ export function buildCodexMigrationProvider(
|
||||
description:
|
||||
"Inventory and promote Codex CLI skills while keeping Codex native plugins and hooks explicit.",
|
||||
async detect(ctx) {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const source = await discoverCodexSource({
|
||||
input: ctx.source,
|
||||
});
|
||||
const found = hasCodexSource(source);
|
||||
return {
|
||||
found,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
defaultCodexAppInventoryCache,
|
||||
type CodexAppInventoryRequest,
|
||||
} from "../app-server/app-inventory-cache.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import type { CodexAppServerStartOptions } from "../app-server/config.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import {
|
||||
pluginReadParams,
|
||||
type CodexPluginMarketplaceRef,
|
||||
} from "../app-server/plugin-inventory.js";
|
||||
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import {
|
||||
exists,
|
||||
@@ -32,9 +42,35 @@ export type CodexPluginSource = {
|
||||
pluginName?: string;
|
||||
installed?: boolean;
|
||||
enabled?: boolean;
|
||||
apps?: CodexPluginMigrationAppFact[];
|
||||
migrationBlock?: CodexPluginMigrationBlock;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type CodexPluginMigrationBlockCode =
|
||||
| "plugin_disabled"
|
||||
| "codex_subscription_required"
|
||||
| "codex_account_unavailable"
|
||||
| "plugin_read_unavailable"
|
||||
| "app_inventory_unavailable"
|
||||
| "app_inaccessible"
|
||||
| "app_disabled"
|
||||
| "app_missing";
|
||||
|
||||
export type CodexPluginMigrationAppFact = {
|
||||
id: string;
|
||||
name: string;
|
||||
needsAuth?: boolean;
|
||||
isAccessible?: boolean;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type CodexPluginMigrationBlock = {
|
||||
code: CodexPluginMigrationBlockCode;
|
||||
apps?: CodexPluginMigrationAppFact[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CodexArchiveSource = {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -56,6 +92,26 @@ type CodexSource = {
|
||||
archivePaths: CodexArchiveSource[];
|
||||
};
|
||||
|
||||
type CodexSourceDiscoveryOptions = {
|
||||
input?: string;
|
||||
evaluatePluginMigrationEligibility?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
};
|
||||
|
||||
type SourceAppServerRequestOptions = {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
};
|
||||
|
||||
type PluginReadResult =
|
||||
| {
|
||||
ok: true;
|
||||
detail: v2.PluginDetail;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function defaultCodexHome(): string {
|
||||
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
|
||||
}
|
||||
@@ -137,27 +193,19 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
|
||||
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
async function discoverInstalledCuratedPlugins(
|
||||
codexHome: string,
|
||||
options: CodexSourceDiscoveryOptions = {},
|
||||
): Promise<{
|
||||
plugins: CodexPluginSource[];
|
||||
error?: string;
|
||||
}> {
|
||||
const startOptions = sourceCodexAppServerStartOptions(codexHome);
|
||||
const requestOptions = { startOptions };
|
||||
try {
|
||||
const response = await requestCodexAppServerJson<v2.PluginListResponse>({
|
||||
const response = await requestSourceCodexAppServerJson<v2.PluginListResponse>(requestOptions, {
|
||||
method: "plugin/list",
|
||||
requestParams: { cwds: [] } satisfies v2.PluginListParams,
|
||||
timeoutMs: 60_000,
|
||||
isolated: true,
|
||||
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,
|
||||
@@ -170,25 +218,21 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
}
|
||||
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 };
|
||||
.map((plugin) => buildInstalledPluginSource(plugin))
|
||||
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined);
|
||||
const withEligibility =
|
||||
options.evaluatePluginMigrationEligibility === true
|
||||
? await withPluginMigrationEligibility({
|
||||
plugins,
|
||||
marketplace: marketplaceRef(marketplace),
|
||||
requestOptions,
|
||||
verifyPluginApps: options.verifyPluginApps === true,
|
||||
})
|
||||
: plugins;
|
||||
const sorted = withEligibility.toSorted((a, b) =>
|
||||
(a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name),
|
||||
);
|
||||
return { plugins: sorted };
|
||||
} catch (error) {
|
||||
return {
|
||||
plugins: [],
|
||||
@@ -197,6 +241,308 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCodexAppServerStartOptions(codexHome: string): CodexAppServerStartOptions {
|
||||
return {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: path.dirname(codexHome),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSourceCodexAppServerJson<T>(
|
||||
options: SourceAppServerRequestOptions,
|
||||
params: {
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
},
|
||||
): Promise<T> {
|
||||
return await requestCodexAppServerJson<T>({
|
||||
method: params.method,
|
||||
requestParams: params.requestParams,
|
||||
timeoutMs: 60_000,
|
||||
startOptions: options.startOptions,
|
||||
authProfileId: null,
|
||||
isolated: true,
|
||||
});
|
||||
}
|
||||
|
||||
function buildInstalledPluginSource(plugin: v2.PluginSummary): CodexPluginSource | undefined {
|
||||
const pluginName = pluginNameFromSummary(plugin);
|
||||
if (!pluginName) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
|
||||
return {
|
||||
name: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
...(marketplace.path ? { path: marketplace.path } : {}),
|
||||
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function withPluginMigrationEligibility(params: {
|
||||
plugins: CodexPluginSource[];
|
||||
marketplace: CodexPluginMarketplaceRef;
|
||||
requestOptions: SourceAppServerRequestOptions;
|
||||
verifyPluginApps: boolean;
|
||||
}): Promise<CodexPluginSource[]> {
|
||||
const pending: Array<{ plugin: CodexPluginSource; apps: CodexPluginMigrationAppFact[] }> = [];
|
||||
const evaluated: CodexPluginSource[] = [];
|
||||
|
||||
for (const plugin of params.plugins) {
|
||||
if (plugin.enabled !== true) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "plugin_disabled" },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" is installed in Codex but disabled; enable it in Codex before migrating it to OpenClaw.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = await readPluginDetail(params.requestOptions, params.marketplace, plugin);
|
||||
if (!detail.ok) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "plugin_read_unavailable", error: detail.error },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" detail could not be read: ${detail.error}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (detail.detail.apps.length === 0) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const apps = detail.detail.apps
|
||||
.map(sourcePluginAppFact)
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
pending.push({ plugin, apps });
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
let sourceAccount: Awaited<ReturnType<typeof readSourceCodexAccount>> | undefined;
|
||||
try {
|
||||
sourceAccount = await readSourceCodexAccount(params.requestOptions);
|
||||
} catch (error) {
|
||||
if (!params.verifyPluginApps) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "codex_account_unavailable", apps, error: message },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but the source Codex app-server account could not be read: ${message}`,
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
}
|
||||
if (sourceAccount && sourceAccount !== "chatgpt") {
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "codex_subscription_required", apps },
|
||||
message: codexSubscriptionRequiredMessage(plugin),
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
if (!params.verifyPluginApps) {
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
apps,
|
||||
migratable: true,
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: {
|
||||
code: "app_inventory_unavailable",
|
||||
apps,
|
||||
error: message,
|
||||
},
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (!snapshot) {
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
const appInfoById = new Map(snapshot.apps.map((app) => [app.id, app] as const));
|
||||
for (const { plugin, apps: declaredApps } of pending) {
|
||||
const apps = declaredApps
|
||||
.map((app) => sourcePluginAppFactWithInventory(app, appInfoById.get(app.id)))
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
const blockCode = migrationBlockCodeForApps(apps);
|
||||
if (!blockCode) {
|
||||
evaluated.push({ ...plugin, apps, migratable: true });
|
||||
continue;
|
||||
}
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: blockCode, apps },
|
||||
message: appInventoryBlockMessage(plugin, apps, blockCode),
|
||||
});
|
||||
}
|
||||
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
async function readSourceCodexAccount(
|
||||
options: SourceAppServerRequestOptions,
|
||||
): Promise<"chatgpt" | "non_chatgpt" | "missing"> {
|
||||
const response = await requestSourceCodexAppServerJson<CodexGetAccountResponse>(options, {
|
||||
method: "account/read",
|
||||
requestParams: { refreshToken: false },
|
||||
});
|
||||
if (
|
||||
!response.account ||
|
||||
typeof response.account !== "object" ||
|
||||
Array.isArray(response.account)
|
||||
) {
|
||||
return "missing";
|
||||
}
|
||||
const type = response.account.type;
|
||||
return type === "chatgpt" ? "chatgpt" : "non_chatgpt";
|
||||
}
|
||||
|
||||
async function readPluginDetail(
|
||||
options: SourceAppServerRequestOptions,
|
||||
marketplace: CodexPluginMarketplaceRef,
|
||||
plugin: CodexPluginSource,
|
||||
): Promise<PluginReadResult> {
|
||||
try {
|
||||
const response = await requestSourceCodexAppServerJson<v2.PluginReadResponse>(options, {
|
||||
method: "plugin/read",
|
||||
requestParams: pluginReadParams(marketplace, plugin.pluginName ?? plugin.name),
|
||||
});
|
||||
return { ok: true, detail: response.plugin };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSourceAppInventory(
|
||||
options: SourceAppServerRequestOptions,
|
||||
): Promise<Awaited<ReturnType<typeof defaultCodexAppInventoryCache.refreshNow>>> {
|
||||
const key = buildCodexPluginAppCacheKey({
|
||||
appServer: { start: options.startOptions },
|
||||
});
|
||||
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
||||
await requestSourceCodexAppServerJson<v2.AppsListResponse>(options, {
|
||||
method,
|
||||
requestParams,
|
||||
});
|
||||
return await defaultCodexAppInventoryCache.refreshNow({
|
||||
key,
|
||||
request,
|
||||
forceRefetch: true,
|
||||
});
|
||||
}
|
||||
|
||||
function sourcePluginAppFact(app: v2.AppSummary): CodexPluginMigrationAppFact {
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
needsAuth: app.needsAuth,
|
||||
};
|
||||
}
|
||||
|
||||
function sourcePluginAppFactWithInventory(
|
||||
app: CodexPluginMigrationAppFact,
|
||||
info: v2.AppInfo | undefined,
|
||||
): CodexPluginMigrationAppFact {
|
||||
if (!info) {
|
||||
return app;
|
||||
}
|
||||
return {
|
||||
...app,
|
||||
isAccessible: info.isAccessible,
|
||||
isEnabled: info.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function migrationBlockCodeForApps(
|
||||
apps: readonly CodexPluginMigrationAppFact[],
|
||||
): CodexPluginMigrationBlockCode | undefined {
|
||||
if (apps.some((app) => app.isAccessible === false)) {
|
||||
return "app_inaccessible";
|
||||
}
|
||||
if (apps.some((app) => app.isEnabled === false)) {
|
||||
return "app_disabled";
|
||||
}
|
||||
if (apps.some((app) => app.isAccessible === undefined || app.isEnabled === undefined)) {
|
||||
return "app_missing";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appInventoryBlockMessage(
|
||||
plugin: CodexPluginSource,
|
||||
apps: readonly CodexPluginMigrationAppFact[],
|
||||
code: CodexPluginMigrationBlockCode,
|
||||
): string {
|
||||
const status =
|
||||
code === "app_inaccessible" ? "inaccessible" : code === "app_disabled" ? "disabled" : "missing";
|
||||
const blocking =
|
||||
apps.find((app) =>
|
||||
code === "app_inaccessible"
|
||||
? app.isAccessible === false
|
||||
: code === "app_disabled"
|
||||
? app.isEnabled === false
|
||||
: app.isAccessible === undefined || app.isEnabled === undefined,
|
||||
) ?? apps[0];
|
||||
const appLabel = blocking ? ` app "${blocking.name}"` : " an owned app";
|
||||
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns${appLabel} but the source app inventory reports it is ${status}; authenticate or enable the app in Codex before migrating it to OpenClaw.`;
|
||||
}
|
||||
|
||||
export function codexPluginMigrationSubscriptionWarning(): string {
|
||||
return "Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.";
|
||||
}
|
||||
|
||||
function codexSubscriptionRequiredMessage(plugin: CodexPluginSource): string {
|
||||
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but ${codexPluginMigrationSubscriptionWarning()}`;
|
||||
}
|
||||
|
||||
function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
|
||||
const candidates = [summary.id, summary.name];
|
||||
for (const candidate of candidates) {
|
||||
@@ -216,8 +562,14 @@ function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
|
||||
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
|
||||
export async function discoverCodexSource(
|
||||
inputOrOptions?: string | CodexSourceDiscoveryOptions,
|
||||
): Promise<CodexSource> {
|
||||
const options =
|
||||
typeof inputOrOptions === "string" || inputOrOptions === undefined
|
||||
? { input: inputOrOptions }
|
||||
: inputOrOptions;
|
||||
const codexHome = resolveHomePath(options.input?.trim() || defaultCodexHome());
|
||||
const codexSkillsDir = path.join(codexHome, "skills");
|
||||
const agentsSkillsDir = personalAgentsSkillsDir();
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
@@ -231,7 +583,7 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
root: agentsSkillsDir,
|
||||
sourceLabel: "personal AgentSkill",
|
||||
});
|
||||
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome);
|
||||
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome, options);
|
||||
const sourcePluginNames = new Set(
|
||||
sourcePluginDiscovery.plugins.flatMap((plugin) =>
|
||||
plugin.pluginName ? [plugin.pluginName] : [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepInfra provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw diagnostics Prometheus exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -31,10 +31,10 @@
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"openclawVersion": "2026.5.12-beta.4",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,7 +21,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -65,10 +65,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/document-extract-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw local document extraction plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/elevenlabs-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw ElevenLabs speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Exa plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fal-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw fal provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -17,7 +17,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -48,10 +48,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/file-transfer",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw file transfer plugin (file_fetch, dir_list, dir_fetch, file_write)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/firecrawl-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Firecrawl plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fireworks-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Fireworks provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/github-copilot-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw GitHub Copilot provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-meet",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Google Meet participant plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,7 +16,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -33,10 +33,10 @@
|
||||
"minHostVersion": ">=2026.4.20"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -118,6 +118,24 @@ describe("resolveGoogleGeminiForwardCompatModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes provider-qualified retired Gemini 3 Pro preview requests", () => {
|
||||
const model = resolveGoogleGeminiForwardCompatModel({
|
||||
providerId: "google",
|
||||
ctx: createContext({
|
||||
provider: "google",
|
||||
modelId: "google/gemini-3-pro-preview",
|
||||
models: [createTemplateModel("google", "gemini-3.1-pro-preview")],
|
||||
}),
|
||||
});
|
||||
|
||||
expectModelFields(model, {
|
||||
provider: "google",
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
api: "google-generative-ai",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Gemini CLI 3.1 clones sourced from CLI templates when both catalogs exist", () => {
|
||||
const model = resolveGoogleGeminiForwardCompatModel({
|
||||
providerId: "google-gemini-cli",
|
||||
|
||||
@@ -30,14 +30,24 @@ const GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS = ["gemini-3-flash"] as const;
|
||||
// Gemma uses the Gemini flash template as a forward-compat approximation
|
||||
// until a dedicated Gemma template is registered in the catalog.
|
||||
const GEMMA_TEMPLATE_IDS = GEMINI_3_1_FLASH_TEMPLATE_IDS;
|
||||
const GOOGLE_PROVIDER_PREFIX = "google/";
|
||||
|
||||
function normalizeGeminiProRequestId(id: string): string {
|
||||
if (id.startsWith(GOOGLE_PROVIDER_PREFIX)) {
|
||||
const modelId = id.slice(GOOGLE_PROVIDER_PREFIX.length);
|
||||
const normalizedModelId = normalizeGeminiProRequestId(modelId);
|
||||
return normalizedModelId === modelId ? id : `${GOOGLE_PROVIDER_PREFIX}${normalizedModelId}`;
|
||||
}
|
||||
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview" || id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function googleFamilyModelId(id: string): string {
|
||||
return id.startsWith(GOOGLE_PROVIDER_PREFIX) ? id.slice(GOOGLE_PROVIDER_PREFIX.length) : id;
|
||||
}
|
||||
|
||||
type GoogleForwardCompatFamily = {
|
||||
googleTemplateIds: readonly string[];
|
||||
cliTemplateIds: readonly string[];
|
||||
@@ -130,7 +140,7 @@ export function resolveGoogleGeminiForwardCompatModel(params: {
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmed = normalizeGeminiProRequestId(params.ctx.modelId.trim());
|
||||
const lower = normalizeOptionalLowercaseString(trimmed) ?? "";
|
||||
const lower = normalizeOptionalLowercaseString(googleFamilyModelId(trimmed)) ?? "";
|
||||
|
||||
let family: GoogleForwardCompatFamily;
|
||||
let patch: Partial<ProviderRuntimeModel> | undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -17,7 +17,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -75,10 +75,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/gradium-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gradium speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/groq-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Groq media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/huggingface-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Hugging Face provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/image-generation-core",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw image generation runtime package",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin using imsg on a signed-in Mac",
|
||||
"type": "module",
|
||||
@@ -40,10 +40,10 @@
|
||||
]
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
}
|
||||
},
|
||||
"pluginInspector": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/inworld-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Inworld speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/kilocode-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Kilo Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/kimi-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Kimi provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,7 +16,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -46,10 +46,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/litellm-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw LiteLLM provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lmstudio-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw LM Studio provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,10 +25,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,7 +22,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -87,10 +87,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
import { ensureMatrixCryptoRuntime, ensureMatrixSdkInstalled } from "./deps.js";
|
||||
|
||||
const logStub = vi.fn();
|
||||
|
||||
@@ -160,3 +160,47 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureMatrixSdkInstalled", () => {
|
||||
it("returns without error when all required packages resolve", async () => {
|
||||
const resolveFn = vi.fn((_id: string) => "/fake/path");
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn })).resolves.toBeUndefined();
|
||||
expect(resolveFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws actionable repair error listing every missing package", async () => {
|
||||
const resolveFn = vi.fn((_id: string) => {
|
||||
throw new Error("Cannot find module");
|
||||
});
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn })).rejects.toThrow(
|
||||
/matrix-js-sdk.*@matrix-org\/matrix-sdk-crypto-nodejs.*@matrix-org\/matrix-sdk-crypto-wasm/s,
|
||||
);
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn })).rejects.toThrow(
|
||||
/openclaw plugins update matrix/,
|
||||
);
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn })).rejects.toThrow(/openclaw doctor --fix/);
|
||||
});
|
||||
|
||||
it("lists only the packages that fail to resolve", async () => {
|
||||
const resolveFn = vi.fn((id: string) => {
|
||||
if (id === "@matrix-org/matrix-sdk-crypto-wasm") {
|
||||
throw new Error("Cannot find module");
|
||||
}
|
||||
return "/fake/path";
|
||||
});
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn })).rejects.toThrow(
|
||||
/Matrix plugin dependencies are missing: @matrix-org\/matrix-sdk-crypto-wasm\./,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invoke the install confirm prompt when packages are missing (regression: #80758)", async () => {
|
||||
const confirm = vi.fn(async () => true);
|
||||
const resolveFn = vi.fn((_id: string) => {
|
||||
throw new Error("Cannot find module");
|
||||
});
|
||||
await expect(ensureMatrixSdkInstalled({ resolveFn, confirm })).rejects.toThrow(
|
||||
/Matrix plugin dependencies are missing/,
|
||||
);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
|
||||
@@ -26,12 +25,12 @@ type MatrixCryptoRuntimeDeps = {
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
function resolveMissingMatrixPackages(): string[] {
|
||||
function resolveMissingMatrixPackages(resolveFn?: (id: string) => string): string[] {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const resolve = resolveFn ?? defaultResolveFn;
|
||||
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
|
||||
try {
|
||||
req.resolve(pkg);
|
||||
resolve(pkg);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
@@ -46,9 +45,12 @@ export function isMatrixSdkAvailable(): boolean {
|
||||
return resolveMissingMatrixPackages().length === 0;
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
function buildMatrixDepsMissingMessage(missing: string[]): string {
|
||||
const packages = missing.length > 0 ? missing.join(", ") : REQUIRED_MATRIX_PACKAGES.join(", ");
|
||||
return [
|
||||
`Matrix plugin dependencies are missing: ${packages}.`,
|
||||
"Repair this plugin with `openclaw plugins update matrix` or run `openclaw doctor --fix`.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
type CommandResult = {
|
||||
@@ -299,47 +301,14 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
}
|
||||
|
||||
export async function ensureMatrixSdkInstalled(params: {
|
||||
runtime: RuntimeEnv;
|
||||
export async function ensureMatrixSdkInstalled(params?: {
|
||||
runtime?: RuntimeEnv;
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
resolveFn?: (id: string) => string;
|
||||
}): Promise<void> {
|
||||
if (isMatrixSdkAvailable()) {
|
||||
const missing = resolveMissingMatrixPackages(params?.resolveFn);
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm(
|
||||
"Matrix requires matrix-js-sdk, @matrix-org/matrix-sdk-crypto-nodejs, and @matrix-org/matrix-sdk-crypto-wasm. Install now?",
|
||||
);
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Matrix requires matrix-js-sdk, @matrix-org/matrix-sdk-crypto-nodejs, and @matrix-org/matrix-sdk-crypto-wasm (install dependencies first).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolvePluginRoot();
|
||||
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runFixedCommandWithTimeout({
|
||||
argv: command,
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
const missing = resolveMissingMatrixPackages();
|
||||
throw new Error(
|
||||
missing.length > 0
|
||||
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
|
||||
: "Matrix dependency install completed but Matrix dependencies are still missing.",
|
||||
);
|
||||
}
|
||||
throw new Error(buildMatrixDepsMissingMessage(missing));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,7 +16,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/media-understanding-core",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw media understanding runtime package",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
@@ -14,7 +14,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import {
|
||||
listMemoryCorpusSupplements,
|
||||
resolveMemorySearchConfig,
|
||||
@@ -31,23 +32,14 @@ export const MemorySearchSchema = Type.Object({
|
||||
query: Type.String(),
|
||||
maxResults: Type.Optional(Type.Number()),
|
||||
minScore: Type.Optional(Type.Number()),
|
||||
corpus: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("memory"),
|
||||
Type.Literal("wiki"),
|
||||
Type.Literal("all"),
|
||||
Type.Literal("sessions"),
|
||||
]),
|
||||
),
|
||||
corpus: Type.Optional(stringEnum(["memory", "wiki", "all", "sessions"])),
|
||||
});
|
||||
|
||||
export const MemoryGetSchema = Type.Object({
|
||||
path: Type.String(),
|
||||
from: Type.Optional(Type.Number()),
|
||||
lines: Type.Optional(Type.Number()),
|
||||
corpus: Type.Optional(
|
||||
Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]),
|
||||
),
|
||||
corpus: Type.Optional(stringEnum(["memory", "wiki", "all"])),
|
||||
});
|
||||
|
||||
function resolveMemoryToolContext(options: MemoryToolOptions) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setMemorySearchImpl,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
import { createMemorySearchTool } from "./tools.js";
|
||||
import { MemoryGetSchema, MemorySearchSchema } from "./tools.shared.js";
|
||||
import {
|
||||
asOpenClawConfig,
|
||||
createMemorySearchToolOrThrow,
|
||||
@@ -33,6 +34,24 @@ vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) =>
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory tool schemas", () => {
|
||||
it("uses flat corpus enums for provider tool compatibility", () => {
|
||||
const searchCorpus = MemorySearchSchema.properties.corpus as {
|
||||
anyOf?: unknown;
|
||||
enum?: unknown;
|
||||
};
|
||||
const getCorpus = MemoryGetSchema.properties.corpus as {
|
||||
anyOf?: unknown;
|
||||
enum?: unknown;
|
||||
};
|
||||
|
||||
expect(searchCorpus.anyOf).toBeUndefined();
|
||||
expect(searchCorpus.enum).toEqual(["memory", "wiki", "all", "sessions"]);
|
||||
expect(getCorpus.anyOf).toBeUndefined();
|
||||
expect(getCorpus.enum).toEqual(["memory", "wiki", "all"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory_search unavailable payloads", () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryToolMockState({ searchImpl: async () => [] });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-wiki",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw persistent wiki plugin",
|
||||
"type": "module",
|
||||
@@ -14,7 +14,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/microsoft-foundry",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Microsoft Foundry provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/microsoft-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Microsoft speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/migrate-claude",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "Claude to OpenClaw migration provider",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/migrate-hermes",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "Hermes to OpenClaw migration provider",
|
||||
"type": "module",
|
||||
@@ -12,7 +12,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax provider and OAuth plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mistral-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Mistral provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/moonshot-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"private": true,
|
||||
"description": "OpenClaw Moonshot provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,7 +22,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -58,10 +58,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.4",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,7 +12,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -44,10 +44,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.4"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.4"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user