Compare commits

..

20 Commits

Author SHA1 Message Date
Josh Lehman
43ea501f38 refactor: move session/transcript runtime state to SQLite
Migrate OpenClaw session, transcript, and runtime state off per-file
.jsonl/JSON storage onto the SQLite state and agent databases, brought up to
date with current main. Channel plugins persist via the plugin-state SDK
keyed-store seam rather than the raw DB; storage-heavy plugins keep their own
SQLite stores via SDK path helpers.
2026-05-31 09:25:49 -07:00
stain lu
4b1e5b7943 fix(cli): stabilize claude auth epochs on token rotation
Stabilizes Claude CLI reusable sessions when Claude token rotation causes transient token-shaped credential reads. Local Claude CLI OAuth and token credential encodings now share the same identity-only auth-epoch, while ref-backed token auth profiles ignore refreshed token material and plaintext token profiles remain epoch-sensitive on manual token replacement.

Fixes #74312.

Proof: focused local Vitest, autoreview, Testbox-through-Crabbox tbx_01ksyrcknbt743x32x6k1s95qw, and GitHub CI run 26709864094 all passed.

Co-authored-by: stainlu <stainlu@newtype-ai.org>
2026-05-31 11:19:42 +01:00
Ted Li
92b6af76d9 fix(reply): deliver plugin binding replies
Deliver plugin-owned bound-thread replies even when the source room is configured for `message_tool` visible replies. Normal agent final text still stays private unless the agent calls `message(action=send)`.

Document the distinction in the group/channel docs and root routing policy, and keep ambient room-event plus unauthorized text-slash suppression covered by regression tests.

Fixes #87721.
2026-05-31 11:17:45 +01:00
Peter Steinberger
53a9f13cf4 chore(lint): reduce lint suppressions 2026-05-31 11:17:16 +01:00
Firas Alswihry
b2f71db7bb feat(dreaming): add report-only shadow trial runner
Adds a report-only memory-core dreaming shadow-trial runner that writes inspectable artifacts without mutating durable memory. The public helper now stores default reports under daily directories with opaque content-hash filenames, so multiple same-day trials coexist without leaking candidate text into paths.

Verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-memory.config.ts extensions/memory-core/src/dreaming-shadow-trial.test.ts --reporter=verbose --maxWorkers=1
- git diff --check
- pnpm exec oxfmt --check extensions/memory-core/src/dreaming-shadow-trial.ts extensions/memory-core/src/dreaming-shadow-trial.test.ts
- pnpm tsgo:extensions
- autoreview clean: no accepted/actionable findings
- GitHub CI run 26709794635 passed
- Real behavior proof run 26709798698 passed
- Dependency Guard run 26709794113 passed

Co-authored-by: Firas Alswihry <itzfiras@gmail.com>
2026-05-31 11:16:33 +01:00
Peter Steinberger
6fb1f386c6 perf(cli): slim agent command registration 2026-05-31 11:14:26 +01:00
Peter Steinberger
ae4ab2a41f refactor(logging): share stuck recovery session refs 2026-05-31 11:10:06 +01:00
Soham Patankar
4f3d8a57dd fix(codex): accept first-party OpenAI plugin marketplaces
Allow Codex native plugin config to target first-party OpenAI marketplaces, including openai-curated, openai-bundled, and openai-primary-runtime.

Fixes #82216.
Thanks @yaanfpv for the contribution.

Verification:
- node scripts/run-vitest.mjs test/scripts/lint-suppressions.test.ts
- pnpm build:ci-artifacts
- OPENCLAW_VITEST_MAX_WORKERS=2 node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts test/scripts/lint-suppressions.test.ts
- node scripts/run-vitest.mjs extensions/codex/src/app-server/config.test.ts extensions/codex/src/app-server/plugin-activation.test.ts extensions/codex/src/app-server/session-binding.test.ts extensions/codex/src/migration/provider.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/inbound.test.ts
- git diff --check
- ./.agents/skills/autoreview/scripts/autoreview --mode local
- GitHub PR CI on head 896640060b, including build-artifacts run 26709647050
2026-05-31 11:08:42 +01:00
Ayaan Zaidi
f454d6202f fix(agents): preserve explicit active run aborts 2026-05-31 15:31:48 +05:30
Ayaan Zaidi
1556e3c68c fix(agents): surface internal abort incomplete turns 2026-05-31 15:31:48 +05:30
Ayaan Zaidi
a4d3add6da fix(agents): classify internal aborts as non-deliverable 2026-05-31 15:31:48 +05:30
Feelw00
b4cdc33fc9 fix(logging): align diagnostic recovery dedup keys
Align diagnostic stuck-session recovery in-flight dedup with the runtime recovery key. The coordinator now dedups by logical session ref only, so a mid-flight generation bump cannot emit a phantom `session.recovery.requested` event that runtime recovery skips as already in flight.

Adds a regression test for the idle-queued stall path where a queued message bumps generation while recovery is pending.

Fixes #88010
2026-05-31 11:00:42 +01:00
Chinar Amrutkar
c2c20a0b0d fix(ui): pair sequential tool results by fallback order
Fixes #70746 by pairing nameless same-name tool results with the earliest unmatched Control UI tool card while preserving exact ID matches. Empty fallback results now count as consumed, so later results do not overwrite the first card.

Focused regression coverage covers sequential same-name calls and empty-result fallback pairing. Thanks @chinar-amrutkar.

Co-authored-by: Chinar Amrutkar <chinar.amrutkar@gmail.com>
2026-05-31 11:00:00 +01:00
Vincent Koc
a753e6bc86 fix(test): extend e2e vitest watchdog 2026-05-31 11:50:18 +02:00
tanshanshan
425a4ab2f2 chore(lint): enable object-shorthand (#81808)
* fix: narrow current-main core type guards

* fix: preserve query and test guard narrowing

* fix(copilot): align client options with sdk rename

* test(sms): type fetch mocks

* fix(sms): preserve numeric allowlist entries

* test(sms): preserve pairing send count assertion

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:46:10 +01:00
Peter Steinberger
724160b7eb docs: clarify package guard policy 2026-05-31 10:45:28 +01:00
Peter Steinberger
6699e7331a docs: document scoped mention patterns
## Summary

- Document scoped configured mention-pattern policy on the Groups page, including allow/deny mode semantics, supported conversation IDs, account-level precedence, and native-mention behavior.
- Add config UI help for `mentionPatterns.mode`, `allowIn`, and `denyIn` on Discord, Matrix, Slack, Telegram, and WhatsApp.
- Regenerate channel config/docs/plugin SDK metadata baselines for the new hint copy.

Refs #70864.

## Verification

- git diff --check
- pnpm format:docs:check
- pnpm docs:check-mdx
- pnpm docs:check-links
- pnpm config:channels:check
- pnpm config:docs:check
- pnpm plugin-sdk:api:check
- node scripts/run-vitest.mjs src/config/schema.hints.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local

## Real behavior proof

Behavior addressed: Documentation and config UI metadata for scoped configured mention-pattern policy.
Real environment tested: Local OpenClaw checkout on macOS.
Exact steps or command run after this patch: The verification commands listed above.
Evidence after fix: Docs formatting, MDX, link audit, generated config/channel/API baselines, and config hint tests passed; autoreview reported no accepted/actionable findings.
Observed result after fix: The Groups page now explains how to scope `messages.groupChat.mentionPatterns` with `channels.<channel>.mentionPatterns`, and config metadata exposes field help for the supported channels.
What was not tested: Live Discord, Matrix, Slack, Telegram, or WhatsApp inbound messages; this PR is documentation/config metadata only and follows the already-landed runtime behavior from #70864.
2026-05-31 10:44:20 +01:00
Vincent Koc
b0625bdd1c fix(agents): strip malformed arg-value suffixes
Strip malformed terminal `</arg_value>>` suffixes from selected agent read/path and exec routing arguments before validation.

This keeps valid literal `</arg_value>` text intact, preserves payload fields such as write content and edit replacements, and prevents read/exec failures caused by malformed tool XML suffixes.

Fixes #48780.
Thanks @vincentkoc for the original fix.

Verification:
- `node scripts/run-vitest.mjs src/agents/agent-tools.params.test.ts src/agents/agent-tools.read.arg-value-suffix.test.ts src/agents/agent-tools.read.workspace-root-guard.test.ts src/agents/agent-tools.workspace-only-false.test.ts src/agents/bash-tools.exec.path.test.ts src/agents/bash-tools.exec-foreground-failures.test.ts`
- `node_modules/.bin/oxfmt --check src/agents/agent-tools.params.ts src/agents/agent-tools.params.test.ts src/agents/bash-tools.exec.path.test.ts`
- `node scripts/run-oxlint.mjs src/agents/agent-tools.params.ts src/agents/agent-tools.params.test.ts src/agents/bash-tools.exec.path.test.ts`
- `pnpm check:test-types`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub Actions green on PR head `f1d8026352`.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 10:44:12 +01:00
stain lu
4ca22b95bc test(plugins): cover Link agent wallet bundle shape (#75181)
* test(plugins): cover Link agent wallet bundle shape

* test(plugins): add bundle fixture helpers

* test(plugins): align Link manifest fixture expectation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:43:12 +01:00
Peter Steinberger
3950605561 chore(lint): tighten lint exception coverage 2026-05-31 10:42:59 +01:00
3097 changed files with 169835 additions and 133792 deletions

View File

@@ -8,7 +8,26 @@
},
"rules": {
"curly": "error",
"eslint/no-underscore-dangle": "error",
"eslint/no-underscore-dangle": [
"error",
{
"allow": [
"__openclaw",
"__test",
"__testing",
"__resetUsageFormatCachesForTest",
"_createdAt",
"_default",
"_getActiveHandles",
"_getActiveRequests",
"_registerProvider",
"_resetActiveManagedProxyStateForTests",
"_resetIMessageShortIdMemoryForTest",
"_resetIMessageShortIdState",
"_setGitHubCopilotDeviceFlowFetchGuardForTesting"
]
}
],
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",
@@ -218,13 +237,6 @@
"**/node_modules/**"
],
"overrides": [
{
"files": ["src/security/**"],
"rules": {
"eslint/no-warning-comments": "off",
"oxc/no-map-spread": "off"
}
},
{
"files": [
"**/*.test.ts",

View File

@@ -201,6 +201,7 @@ Skills own workflows; root owns hard policy and routing.
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
- Release/package guards: no hard-coded retired-package denylists; use generic artifact/dependency checks or fix build source.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
@@ -220,7 +221,7 @@ Skills own workflows; root owns hard policy and routing.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
- `message_tool_only`: visible source reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Never auto-publish private final.
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
- Rebrand/migration/config warnings: run `openclaw doctor`.
- Never edit `node_modules`.

View File

@@ -410,21 +410,76 @@ Docs: https://docs.openclaw.ai
### Fixes
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
@@ -433,6 +488,7 @@ Docs: https://docs.openclaw.ai
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
@@ -444,11 +500,16 @@ Docs: https://docs.openclaw.ai
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
- Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.
@@ -1366,6 +1427,8 @@ Docs: https://docs.openclaw.ai
- Voice Call/Telnyx: add realtime media-streaming call support for conversational voice calls. (#81024) Thanks @dynamite-bud.
- Dependencies: add release dependency evidence reports, npm advisory gating, and PR dependency-change awareness so maintainers can review dependency risk before and during releases. Thanks @joshavant.
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
- Cron/state: store runtime schedule state and run history in the shared SQLite state database; `openclaw doctor --fix` imports legacy `jobs-state.json` and `cron/runs/*.jsonl` files.
- Gateway/state: store device identity/auth, bootstrap tokens, device and node pairing ledgers, channel pairing requests/allowlists, inferred commitments, subagent run records, TUI restore pointers, auth routing state, OpenRouter model cache, web push subscriptions/VAPID keys, APNs registrations, and update-check state in the shared SQLite state database; `openclaw doctor --fix` imports and removes the legacy JSON files.
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
- Codex: add node-backed Codex CLI session listing and binding so an OpenClaw conversation can continue an existing Codex CLI session running on a paired node.
@@ -1545,6 +1608,7 @@ Docs: https://docs.openclaw.ai
- Subagents/maintenance: preserve pending subagent registry sessions during session-store cleanup, pruning, and disk-budget enforcement so in-flight subagent runs are not deleted by background maintenance before they complete. (#81498) Thanks @ai-hpc.
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
- Maintainer tooling: clarify which pnpm test/check commands are safe locally versus inside Codex worktrees, routing linked-worktree gates through node wrappers and Crabbox/Testbox.
- Gateway/sessions: remove the automatic cron session reaper and retired `cron.sessionRetention`; session rows are retained for explicit reset/delete flows while cron run-log pruning remains under `cron.runLog`.
- Auto-reply: preserve same-key ordering when debounced inbound work falls back to immediate flushes, so follow-up turns cannot overtake an active buffered flush.
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
@@ -1743,7 +1807,7 @@ Docs: https://docs.openclaw.ai
- Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip.
- Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11.
- Agents/harness: skip tool-result middleware validation when no handler is registered, and sanitize incoming tool result `details` (functions, symbols, bigints, cycles, oversized payloads) before middleware sees them. Tool emitters legitimately produce raw dependency payloads on `details`, and the harness owes any registered middleware a JSON-safe view of that payload; otherwise a no-op middleware (e.g. bundled `tokenjuice` on the `pi` runtime) causes the validator to reject every tool result and silently substitute a failure sentinel, dropping outbound Discord messages, exec output, cron results, and any other tool whose payload carries non-serializable values. Thanks @solomonneas.
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
- Runtime/install: raise the supported Node 22 floor to `22.19+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
- Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel.
- Discord/voice: add realtime `/vc` modes so Discord voice channels can run as STT/TTS, a realtime talk buffer with the OpenClaw agent brain, or a bidi realtime session with `openclaw_agent_consult`.
- Discord/voice: add bounded realtime gateway logs for voice channel joins, realtime model/voice selection, transcripts, consult routing/answers, and playback start, allow OpenAI realtime Discord sessions to disable input-triggered response interruption for echo-heavy rooms while keeping explicit Discord barge-in available for new and already-active speakers, and allow voice turns to target an existing Discord channel agent session.

View File

@@ -759,6 +759,7 @@ public struct AgentParams: Codable, Sendable {
public let sessioneffects: AnyCodable?
public let sourcereplydeliverymode: AnyCodable?
public let disablemessagetool: Bool?
public let initialvfsentries: [[String: AnyCodable]]?
public let voicewaketrigger: String?
public let idempotencykey: String
public let label: String?
@@ -800,6 +801,7 @@ public struct AgentParams: Codable, Sendable {
sessioneffects: AnyCodable?,
sourcereplydeliverymode: AnyCodable?,
disablemessagetool: Bool?,
initialvfsentries: [[String: AnyCodable]]?,
voicewaketrigger: String?,
idempotencykey: String,
label: String?)
@@ -840,6 +842,7 @@ public struct AgentParams: Codable, Sendable {
self.sessioneffects = sessioneffects
self.sourcereplydeliverymode = sourcereplydeliverymode
self.disablemessagetool = disablemessagetool
self.initialvfsentries = initialvfsentries
self.voicewaketrigger = voicewaketrigger
self.idempotencykey = idempotencykey
self.label = label
@@ -882,6 +885,7 @@ public struct AgentParams: Codable, Sendable {
case sessioneffects = "sessionEffects"
case sourcereplydeliverymode = "sourceReplyDeliveryMode"
case disablemessagetool = "disableMessageTool"
case initialvfsentries = "initialVfsEntries"
case voicewaketrigger = "voiceWakeTrigger"
case idempotencykey = "idempotencyKey"
case label
@@ -1598,12 +1602,12 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let configuredagentsonly: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let configuredagentsonly: Bool?
public let search: String?
public init(
@@ -1612,12 +1616,12 @@ public struct SessionsListParams: Codable, Sendable {
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
configuredagentsonly: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String? = nil,
configuredagentsonly: Bool?,
search: String?)
{
self.limit = limit
@@ -1625,12 +1629,12 @@ public struct SessionsListParams: Codable, Sendable {
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.configuredagentsonly = configuredagentsonly
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.configuredagentsonly = configuredagentsonly
self.search = search
}
@@ -1640,50 +1644,16 @@ public struct SessionsListParams: Codable, Sendable {
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case configuredagentsonly = "configuredAgentsOnly"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case configuredagentsonly = "configuredAgentsOnly"
case search
}
}
public struct SessionsCleanupParams: Codable, Sendable {
public let agent: String?
public let allagents: Bool?
public let enforce: Bool?
public let activekey: String?
public let fixmissing: Bool?
public let fixdmscope: Bool?
public init(
agent: String?,
allagents: Bool?,
enforce: Bool?,
activekey: String?,
fixmissing: Bool?,
fixdmscope: Bool?)
{
self.agent = agent
self.allagents = allagents
self.enforce = enforce
self.activekey = activekey
self.fixmissing = fixmissing
self.fixdmscope = fixdmscope
}
private enum CodingKeys: String, CodingKey {
case agent
case allagents = "allAgents"
case enforce
case activekey = "activeKey"
case fixmissing = "fixMissing"
case fixdmscope = "fixDmScope"
}
}
public struct SessionsPreviewParams: Codable, Sendable {
public let keys: [String]
public let limit: Int?

View File

@@ -1,4 +1,4 @@
8162a661edc183008a336db265a092acc90762c6c547b1383ef14fd0d381dea5 config-baseline.json
5ee177382cf32c2816dca0a4e67cd6c01df1045d600b21a6e9c11639ddb10ce8 config-baseline.core.json
7a7aba829deb8b54047b5c9e7dd3f3c9eade721e2f728db339c4ea99b77a162e config-baseline.channel.json
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json
e903d5e935a075ad4fa4446964871b0347636e159a3db2fbcfe036bd303c074c config-baseline.json
6a46df8f096703bc8bb0e4bc7f6d8e9cfce1d760c61ff1bd0c20ab7fe274004a config-baseline.core.json
507acac5476b823f93bec2f6ab0061d023fbaca80e0d981fb916f9c6436f1f2a config-baseline.channel.json
9279edd18923a2da92d38f2894f4881932189b974c2cb6a7d057a0f43f96b413 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
34d396bf8f1b2963884256e87c8879a378e2ce7c8064ae0c30d734085a305dd6 plugin-sdk-api-baseline.json
bf3e94dcccaf169811990dfd058a16ace8ce2ab44ea9eac6042b525b6d8baf5f plugin-sdk-api-baseline.jsonl
9a3ee218fb45e9dd0d4e98c59f9ea640f66983e8d6c35fa17ccb35866c039bce plugin-sdk-api-baseline.json
b257d1adbe8fdbe31c418bbbf4246a6aa26d305a776905db2a3a7e2284ede3d1 plugin-sdk-api-baseline.jsonl

View File

@@ -40,9 +40,10 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
## How cron works
- Cron runs **inside the Gateway** process (not inside the model).
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
- Job definitions, runtime execution state, and run history persist in the shared SQLite state database at `~/.openclaw/state/openclaw.sqlite`, so restarts do not lose schedules.
- Legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once by `openclaw doctor --fix` and renamed with a `.migrated` suffix.
- The optional `cron.store` path is now a legacy import namespace and display hint, not a runtime JSON writer. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
- If legacy import finds malformed `jobs.json` rows, valid jobs continue importing and the malformed raw rows are preserved in SQLite quarantine state for later repair or review.
- All cron executions create [background task](/automation/tasks) records.
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
- One-shot jobs (`--at`) auto-delete after success by default.
@@ -459,7 +460,9 @@ Model override note:
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
Cron data is keyed by the resolved `cron.store` value inside the shared SQLite state database. That value is a logical store key and legacy import path, not a runtime JSON write path. SQLite stores job definitions, pending slots, active markers, last-run metadata, run history, and the schedule identity used to invalidate stale pending slots after a job update.
Run `openclaw doctor --fix` once after upgrading from an older version so doctor can import and archive legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files. After import, existing stores no longer drive active cron jobs; future cron changes should go through the CLI or Gateway API.
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
@@ -471,7 +474,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
</Accordion>
<Accordion title="Maintenance">
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
`cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs. Session rows are SQLite-backed and are not age/count-pruned.
</Accordion>
</AccordionGroup>

View File

@@ -2,6 +2,7 @@
summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/Signal/Slack/Telegram/WhatsApp/Zalo)"
read_when:
- Changing group chat behavior or mention gating
- Scoping mentionPatterns to specific group conversations
title: "Groups"
sidebarTitle: "Groups"
---
@@ -57,6 +58,8 @@ For direct chats and any other source event, use `messages.visibleReplies: "mess
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, the prompt does not define a `NO_REPLY` contract. Doing nothing visible simply means not calling the message tool.
Plugin-owned conversation bindings are the exception. Once a plugin binds a thread and claims the inbound turn, the plugin's returned reply is the visible binding response; it does not need `message(action=send)`. That reply is plugin runtime output, not private model final text.
Typing indicators are still sent for direct group requests. Ambient always-on room events, when enabled, stay strict and quiet unless the agent calls the message tool.
Sessions suppress verbose tool/progress summaries by default. Use `/verbose on`
@@ -360,10 +363,89 @@ Replying to a bot message counts as an implicit mention when the channel support
}
```
## Scope configured mention patterns
Configured `mentionPatterns` are regex fallback triggers. Use them when the
platform does not expose a native bot mention, or when you want plain text such
as `openclaw:` to count as a mention. Native platform mentions are separate:
when Discord, Slack, Telegram, Matrix, or another channel can prove the message
explicitly mentioned the bot, that native mention still triggers even if
configured regex patterns are denied.
By default, configured mention patterns apply everywhere that channel passes
provider and conversation facts into mention detection. To keep broad patterns
from waking the agent in every group, scope them per channel with
`channels.<channel>.mentionPatterns`.
Use `mode: "deny"` when regex mention patterns should be off by default for a
channel, then opt in specific rooms with `allowIn`:
```json5
{
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b", "\\bops bot\\b"],
},
},
channels: {
slack: {
mentionPatterns: {
mode: "deny",
allowIn: ["C0123OPS"],
},
},
},
}
```
Use the default `mode: "allow"` (or omit `mode`) when regex mention patterns
should apply broadly, then turn them off in noisy rooms with `denyIn`:
```json5
{
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
channels: {
telegram: {
mentionPatterns: {
denyIn: ["-1001234567890", "-1001234567890:topic:42"],
},
},
},
}
```
Policy resolution:
| Field | Effect |
| --------------- | --------------------------------------------------------------------------------------------------------------------- |
| `mode: "allow"` | Regex mention patterns are enabled unless the conversation ID is in `denyIn`. This is the default. |
| `mode: "deny"` | Regex mention patterns are disabled unless the conversation ID is in `allowIn`. |
| `allowIn` | Conversation IDs where regex mention patterns are enabled in deny mode. |
| `denyIn` | Conversation IDs where regex mention patterns are disabled. `denyIn` wins over `allowIn` if both include the same ID. |
Supported scoped regex policy today:
| Channel | IDs used in `allowIn` / `denyIn` |
| -------- | ------------------------------------------------------------ |
| Discord | Discord channel IDs. |
| Matrix | Matrix room IDs. |
| Slack | Slack channel IDs. |
| Telegram | Group chat IDs, or `chatId:topic:threadId` for forum topics. |
| WhatsApp | WhatsApp conversation IDs such as `123@g.us`. |
Account-level channel configs can set the same policy under
`channels.<channel>.accounts.<accountId>.mentionPatterns` when that channel
supports multiple accounts. Account policy takes precedence over the top-level
channel policy for that account.
<AccordionGroup>
<Accordion title="Mention gating notes">
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Surfaces that provide explicit mentions still pass; configured regex patterns are a fallback.
- `channels.<channel>.mentionPatterns.mode: "deny"` disables configured mention patterns by default for that channel; opt selected conversations back in with `allowIn`.
- `channels.<channel>.mentionPatterns.denyIn` disables configured mention patterns for specific conversation IDs while native platform @mentions still pass.
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).

View File

@@ -118,7 +118,7 @@ Skipped runs are tracked separately from execution errors. They do not affect re
For isolated jobs that target a local configured model provider, cron runs a lightweight provider preflight before starting the agent turn. Loopback, private-network, and `.local` `api: "ollama"` providers are probed at `/api/tags`; local OpenAI-compatible providers such as vLLM, SGLang, and LM Studio are probed at `/models`. If the endpoint is unreachable, the run is recorded as `skipped` and retried on a later schedule; matching dead endpoints are cached for 5 minutes to avoid many jobs hammering the same local server.
Note: cron jobs, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
Note: cron job definitions, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported and removed by `openclaw doctor --fix`; malformed legacy rows are preserved in SQLite quarantine state during import. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
### Manual runs
@@ -196,10 +196,10 @@ Cron does not classify final-output prose or approval-looking refusal phrases as
## Retention
Retention and pruning are controlled in config:
Cron run-log retention is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per job. `cron.runLog.maxBytes` remains accepted for compatibility with older file-backed run logs.
- Session rows are SQLite-backed and are not pruned by age/count maintenance.
## Migrating older jobs

View File

@@ -222,6 +222,10 @@ Target-side auth-required installs are reported on the affected plugin item with
Their explicit config entries are written disabled until you reauthorize and
enable them. Other install failures are item-scoped `error` results.
The native Codex plugin config also accepts first-party `openai-bundled` and
`openai-primary-runtime` marketplace identities, but migration does not
auto-discover or install them from source state.
If Codex app-server plugin inventory is unavailable during planning, migration
falls back to cached bundle advisory items instead of failing the whole
migration.

View File

@@ -39,7 +39,7 @@ Probes are real requests (may consume tokens and trigger rate limits).
Use `--agent <id>` to inspect a configured agent's model/auth state. When omitted,
the command uses `OPENCLAW_AGENT_DIR` if set, otherwise the
configured default agent.
Probe rows can come from auth profiles, env credentials, or `models.json`.
Probe rows can come from auth profiles, env credentials, or the stored model catalog.
For OpenAI ChatGPT/Codex OAuth troubleshooting, `openclaw models status`,
`openclaw models auth list --provider openai`, and
`openclaw config get agents.defaults.model --json` are the quickest way to

View File

@@ -103,69 +103,11 @@ JSON examples:
## Repair
Run maintenance now (instead of waiting for the next write cycle):
```bash
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --agent work --dry-run
openclaw sessions cleanup --all-agents --dry-run
openclaw sessions cleanup --enforce
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123"
openclaw sessions cleanup --dry-run --fix-dm-scope
openclaw sessions cleanup --json
```
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
- `--dry-run`: preview how many entries would be pruned/capped without writing.
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
- `--fix-missing`: remove entries whose transcript files are missing or header-only/empty, even if they would not normally age/count out yet.
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
- `--active-key <key>`: protect a specific active key from disk-budget eviction. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance.
- `--agent <id>`: run cleanup for one configured agent store.
- `--all-agents`: run cleanup for all configured agent stores.
- `--store <path>`: run against a specific `sessions.json` file.
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
When a Gateway is reachable, non-dry-run cleanup for configured agent stores is
sent through the Gateway so it shares the same session-store writer as runtime
traffic. Use `--store <path>` for explicit offline repair of a store file.
`openclaw sessions cleanup --all-agents --dry-run --json`:
```json
{
"allAgents": true,
"mode": "warn",
"dryRun": true,
"stores": [
{
"agentId": "main",
"storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json",
"beforeCount": 120,
"afterCount": 80,
"missing": 0,
"dmScopeRetired": 0,
"pruned": 40,
"capped": 0
},
{
"agentId": "work",
"storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json",
"beforeCount": 18,
"afterCount": 18,
"missing": 0,
"dmScopeRetired": 0,
"pruned": 0,
"capped": 0
}
]
}
```
Legacy JSON import belongs to `openclaw doctor --fix`. Runtime commands do not
prune, cap, import, or rewrite session databases. If doctor reports session rows
whose transcript events are missing, rerun doctor to import any remaining legacy
sources; if the source transcript is gone, reset or delete the affected session
explicitly.
Related:

View File

@@ -127,8 +127,9 @@ See [Sandboxing](/gateway/sandboxing) and [Multi-Agent Sandbox & Tools](/tools/m
Configure logging before the delegate handles any real data:
- Cron run history: OpenClaw shared SQLite state database
- Session transcripts: `~/.openclaw/agents/delegate/sessions`
- Cron run history: `~/.openclaw/state/openclaw.sqlite`
- Session rows and transcripts:
`~/.openclaw/agents/delegate/agent/openclaw-agent.sqlite`
- Identity provider audit logs (Exchange, Google Workspace)
All delegate actions flow through OpenClaw's session store. For compliance, ensure these logs are retained and reviewed.

View File

@@ -119,6 +119,14 @@ stays separate from `MEMORY.md` and that the agent does not claim the candidate
was promoted. It does not add production shadow-trial behavior or change the
deep-phase promotion engine.
The `memory-core` shadow-trial runner keeps that same report-only contract for
code paths that need a stable artifact. It accepts the candidate, trial prompt,
baseline outcome, candidate outcome, verdict, reason, risk flags, and evidence
references, then writes a report with `promotion action: report-only`. Helpful
verdicts map to a `promote` recommendation, neutral verdicts map to `defer`, and
harmful verdicts map to `reject`; none of those recommendations writes to
`MEMORY.md` or applies deep-phase promotion.
## Scheduling
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.

View File

@@ -790,10 +790,12 @@ Group messages default to **require mention** (metadata mention or safe regex pa
Visible replies are controlled separately. Normal group, channel, and internal WebChat direct requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Opt into `messages.visibleReplies: "message_tool"` or `messages.groupChat.visibleReplies: "message_tool"` when visible output should only post after the agent calls `message(action=send)`. If the model returns final text without calling the message tool in an opted-in tool-only mode, that final text stays private and the gateway verbose log records suppressed payload metadata.
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. Some weaker models can answer final text but fail to understand that source-visible output must be sent with `message(action=send)`. For those models, use `"automatic"` so the final assistant turn is the visible reply path. If the session log shows assistant text with `didSendViaMessagingTool: false`, the model produced private final text instead of calling the message tool. Switch to a stronger tool-calling model for that channel, inspect the gateway verbose log for the suppressed payload summary, or set `messages.groupChat.visibleReplies: "automatic"` to use visible final replies for every group/channel request.
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. Some weaker models can answer final text but fail to understand that source-visible output must be sent with `message(action=send)`. For those models, use `"automatic"` so the final assistant turn is the visible reply path. If the gateway verbose log or SQLite transcript shows assistant text with `didSendViaMessagingTool: false`, the model produced private final text instead of calling the message tool. Switch to a stronger tool-calling model for that channel, inspect the gateway verbose log for the suppressed payload summary, or set `messages.groupChat.visibleReplies: "automatic"` to use visible final replies for every group/channel request.
If the message tool is unavailable under the active tool policy, OpenClaw falls back to automatic visible replies instead of silently suppressing the response. `openclaw doctor` warns about this mismatch.
This rule applies to normal agent final text. Plugin-owned conversation bindings use the owning plugin's returned reply as the visible response for claimed bound-thread turns; the plugin does not need to call `message(action=send)` for those binding replies.
**Troubleshooting: group @mention triggers typing then silence (no error)**
Symptom: a group/channel @mention shows the typing indicator and the gateway log reports `dispatch complete (queuedFinal=false, replies=0)`, but no message lands in the room. DMs to the same agent reply normally.

View File

@@ -316,7 +316,8 @@ conversation bindings, or any non-Codex harness.
migrated plugin entry when global `codexPlugins.enabled` is also true.
Default: `true` for explicit entries.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.marketplaceName`:
stable marketplace identity. V1 only supports `"openai-curated"`.
stable marketplace identity. V1 supports `"openai-curated"`,
`"openai-bundled"`, and `"openai-primary-runtime"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.pluginName`: stable
Codex plugin identity from migration, for example `"google-calendar"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
@@ -1284,9 +1285,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
}
```
- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable.
- `runLog.maxBytes`: accepted for compatibility with older file-backed cron run logs. Default: `2_000_000` bytes.
- `runLog.keepLines`: newest SQLite run-history rows retained per job. Default: `2000`.
- `runLog.maxBytes`: approximate max serialized SQLite run-log bytes per job before pruning. Default: `2_000_000` bytes.
- `runLog.keepLines`: newest rows retained when run-log pruning is triggered. Default: `2000`.
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhook`: deprecated legacy migration fallback URL (http/https). Runtime does not read it; doctor can use it to translate legacy `notify: true` cron jobs into per-job `delivery.mode = "webhook"` plus `delivery.to`.

View File

@@ -429,8 +429,7 @@ candidate contains redacted secret placeholders such as `***`.
}
```
- `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable).
- `runLog`: prune retained cron run-history rows per job. `maxBytes` remains accepted for older file-backed run logs.
- `runLog`: prune SQLite cron run history by approximate serialized size (`maxBytes`) and retained rows.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
</Accordion>

View File

@@ -385,8 +385,22 @@ That stages grounded durable candidates into the short-term dreaming store while
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
</Accordion>
<Accordion title="3c. Session lock cleanup">
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks that are still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
<Accordion title="Legacy runtime JSON imports">
Doctor checks for older runtime JSON ledgers that are now stored in
`~/.openclaw/state/openclaw.sqlite`. In `--fix` mode it imports each legacy
file into SQLite and removes the file after a successful import.
Current imports include:
- `identity/device.json`
- `identity/device-auth.json`
- `devices/bootstrap.json`
- `devices/pending.json` and `devices/paired.json`
- `nodes/pending.json` and `nodes/paired.json`
- `push/web-push-subscriptions.json`
- `push/vapid-keys.json`
- `push/apns-registrations.json`
</Accordion>
<Accordion title="3c. Legacy session file cleanup">
Doctor treats old session JSON/JSONL trees as migration inputs. In `--fix` / `--repair` mode it imports supported legacy rows into the per-agent SQLite database, verifies the resulting database state, and can remove obsolete file-era sidecars after a successful import. Runtime session writes no longer depend on lock files or whole-file rewrite queues.

View File

@@ -296,9 +296,8 @@ replacement. Gateway startup does not generate bundled-plugin dependency trees.
For full persistence details on VM deployments, see
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
**Disk growth hotspots:** watch `media/`, session JSONL files, the shared
SQLite state database, installed plugin package roots, and rolling file logs
under `/tmp/openclaw/`.
**Disk growth hotspots:** watch `media/`, the shared SQLite state database,
installed plugin package roots, and rolling file logs under `/tmp/openclaw/`.
### Shell helpers (optional)

View File

@@ -38,14 +38,14 @@ All Codex harness settings live under `plugins.entries.codex.config`.
Supported top-level fields:
| Field | Default | Meaning |
| -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
| Field | Default | Meaning |
| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
## App-server transport

View File

@@ -526,7 +526,7 @@ Supported top-level Codex plugin fields:
| -------------------------- | -------------- | ---------------------------------------------------------------------------------------- |
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. |
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. |
Supported `appServer` fields:

View File

@@ -3,7 +3,7 @@ summary: "Configure migrated native Codex plugins for Codex-mode OpenClaw agents
title: "Native Codex plugins"
read_when:
- You want Codex-mode OpenClaw agents to use native Codex plugins
- You are migrating source-installed openai-curated Codex plugins
- You are configuring first-party Codex plugin marketplaces
- You are troubleshooting codexPlugins, app inventory, destructive actions, or plugin app diagnostics
---
@@ -22,7 +22,9 @@ Use this page after the base [Codex harness](/plugins/codex-harness) is working.
- The selected OpenClaw agent runtime must be the native Codex harness.
- `plugins.entries.codex.enabled` must be true.
- `plugins.entries.codex.config.codexPlugins.enabled` must be true.
- V1 supports only `openai-curated` plugins that migration observed as
- V1 supports first-party Codex plugin marketplaces: `openai-curated`,
`openai-bundled`, and `openai-primary-runtime`.
- Migration only auto-discovers `openai-curated` plugins that it observed as
source-installed in the source Codex home.
- The target Codex app-server must be able to see the expected marketplace,
plugin, and app inventory.
@@ -52,9 +54,11 @@ Apply the migration when the plan looks right:
openclaw migrate apply codex --yes
```
Migration writes explicit `codexPlugins` entries for eligible plugins and calls
Codex app-server `plugin/install` for selected plugins. A typical migrated
config looks like this:
Migration writes explicit `codexPlugins` entries for eligible curated plugins
and calls Codex app-server `plugin/install` for selected plugins. Explicit
config may also reference Codex's bundled and primary-runtime first-party
marketplaces when the target app-server inventory exposes those plugin apps. A
typical migrated config looks like this:
```json5
{
@@ -146,8 +150,10 @@ up the updated app set.
V1 is intentionally narrow:
- Runtime config accepts `openai-curated`, `openai-bundled`, and
`openai-primary-runtime` plugin identities.
- Only `openai-curated` plugins that were already installed in the source Codex
app-server inventory are migration-eligible.
app-server inventory are migration-eligible for automatic migration.
- 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
@@ -160,7 +166,9 @@ V1 is intentionally narrow:
- There is no `plugins["*"]` wildcard and no config key that grants arbitrary
install authority.
- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config files
are preserved in the migration report for manual review.
are preserved in the migration report for manual review. Bundled and
primary-runtime first-party plugins can still be added manually through
explicit `codexPlugins` config.
## App inventory and ownership
@@ -248,8 +256,10 @@ 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.
cannot see the expected first-party marketplace or plugin. Rerun migration
against the target runtime, inspect Codex app-server plugin status, or confirm
the explicit `marketplaceName` is one of `openai-curated`, `openai-bundled`, or
`openai-primary-runtime`.
**`app_inventory_missing` or `app_inventory_stale`:** app readiness came from an
empty or stale cache. OpenClaw schedules an async refresh and excludes plugin

View File

@@ -0,0 +1,23 @@
---
summary: "Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh."
read_when:
- You are installing, configuring, or auditing the skill-workshop plugin
title: "Skill Workshop plugin"
---
# Skill Workshop plugin
Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh.
## Distribution
- Package: `@openclaw/skill-workshop`
- Install route: included in OpenClaw
## Surface
contracts: tools
## Related docs
- [skill-workshop](/plugins/skill-workshop)

View File

@@ -586,6 +586,7 @@ releases.
| `plugin-sdk/reply-reference` | Reply reference planning | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Reply chunk helpers | Text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session row helpers | SQLite-backed session row, session-key, updated-at, and transcript row helpers |
| `plugin-sdk/sqlite-runtime` | SQLite helpers | Focused database open/path helpers for first-party runtime and migration tests |
| `plugin-sdk/state-paths` | State path helpers | Config, credentials, migration, and explicit operator-file path helpers; runtime state and caches belong in SQLite stores |
| `plugin-sdk/routing` | Routing/session-key helpers | `resolveAgentRoute`, `buildAgentSessionKey`, `resolveDefaultAgentBoundAccountId`, session-key normalization helpers |
| `plugin-sdk/status-helpers` | Channel status helpers | Channel/account status summary builders, runtime-state defaults, issue metadata helpers |
@@ -653,7 +654,8 @@ releases.
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports; new code should use `memory-core-host-engine-session-transcripts` for SQLite transcript indexing helpers |
| `plugin-sdk/memory-core-host-engine-session-transcripts` | Memory host SQLite session transcript engine | Memory host SQLite session transcript indexing exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |
| `plugin-sdk/memory-core-host-query` | Memory host query helpers | Memory host query helpers |

View File

@@ -27,6 +27,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema` |
| `plugin-sdk/config-schema` | `OpenClawSchema` |
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
| `plugin-sdk/provider-ai` | OpenClaw-owned provider stream/model/message types plus simple streaming helpers used by bundled provider plugins |
| `plugin-sdk/provider-ai-oauth` | OpenClaw-owned OAuth helper facade for provider runtime code |
| `plugin-sdk/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
@@ -238,6 +240,7 @@ and pairing-path families.
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | SQLite-backed session row, session-key, updated-at, and transcript row helpers |
| `plugin-sdk/sqlite-runtime` | Focused SQLite database open/path helpers for first-party runtime and migration tests |
| `plugin-sdk/cron-store-runtime` | SQLite cron store load/save helpers |
| `plugin-sdk/state-paths` | Config, credentials, migration, and explicit operator-file path helpers; runtime state and caches belong in SQLite stores |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
@@ -294,6 +297,7 @@ and pairing-path families.
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | SQLite session row helpers without broad config writes, maintenance imports, or raw database openers |
| `plugin-sdk/sqlite-runtime` | Focused SQLite database helpers without session-row helper imports |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
@@ -348,7 +352,8 @@ and pairing-path families.
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports; use `memory-core-host-engine-session-transcripts` for SQLite transcript indexing helpers |
| `plugin-sdk/memory-core-host-engine-session-transcripts` | Memory host SQLite session transcript indexing exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |
| `plugin-sdk/memory-core-host-query` | Memory host query helpers |

View File

@@ -0,0 +1,713 @@
---
summary: "Experimental capture of reusable procedures as workspace skills with review, approval, quarantine, and hot skill refresh"
title: "Skill workshop plugin"
read_when:
- You want agents to turn corrections or reusable procedures into workspace skills
- You are configuring procedural skill memory
- You are debugging skill_workshop tool behavior
- You are deciding whether to enable automatic skill creation
---
Skill Workshop is **experimental**. It is disabled by default, its capture
heuristics and reviewer prompts may change between releases, and automatic
writes should be used only in trusted workspaces after reviewing pending-mode
output first.
Skill Workshop is procedural memory for workspace skills. It lets an agent turn
reusable workflows, user corrections, hard-won fixes, and recurring pitfalls
into `SKILL.md` files under:
```text
<workspace>/skills/<skill-name>/SKILL.md
```
This is different from long-term memory:
- **Memory** stores facts, preferences, entities, and past context.
- **Skills** store reusable procedures the agent should follow on future tasks.
- **Skill Workshop** is the bridge from a useful turn to a durable workspace
skill, with safety checks and optional approval.
Skill Workshop is useful when the agent learns a procedure such as:
- how to validate externally sourced animated GIF assets
- how to replace screenshot assets and verify dimensions
- how to run a repo-specific QA scenario
- how to debug a recurring provider failure
- how to repair a stale local workflow note
It is not intended for:
- facts like "the user likes blue"
- broad autobiographical memory
- raw transcript archiving
- secrets, credentials, or hidden prompt text
- one-off instructions that will not repeat
## Default state
The bundled plugin is **experimental** and **disabled by default** unless it is
explicitly enabled in `plugins.entries.skill-workshop`.
The plugin manifest does not set `enabledByDefault: true`. The `enabled: true`
default inside the plugin config schema applies only after the plugin entry has
already been selected and loaded.
Experimental means:
- the plugin is supported enough for opt-in testing and dogfooding
- proposal storage, reviewer thresholds, and capture heuristics can evolve
- pending approval is the recommended starting mode
- auto apply is for trusted personal/workspace setups, not shared or hostile
input-heavy environments
## Enable
Minimal safe config:
```json5
{
plugins: {
entries: {
"skill-workshop": {
enabled: true,
config: {
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "hybrid",
},
},
},
},
}
```
With this config:
- the `skill_workshop` tool is available
- explicit reusable corrections are queued as pending proposals
- threshold-based reviewer passes can propose skill updates
- no skill file is written until a pending proposal is applied
Use automatic writes only in trusted workspaces:
```json5
{
plugins: {
entries: {
"skill-workshop": {
enabled: true,
config: {
autoCapture: true,
approvalPolicy: "auto",
reviewMode: "hybrid",
},
},
},
},
}
```
`approvalPolicy: "auto"` still uses the same scanner and quarantine path. It
does not apply proposals with critical findings.
## Configuration
| Key | Default | Range / values | Meaning |
| -------------------- | ----------- | ------------------------------------------- | -------------------------------------------------------------------- |
| `enabled` | `true` | boolean | Enables the plugin after the plugin entry is loaded. |
| `autoCapture` | `true` | boolean | Enables post-turn capture/review on successful agent turns. |
| `approvalPolicy` | `"pending"` | `"pending"`, `"auto"` | Queue proposals or write safe proposals automatically. |
| `reviewMode` | `"hybrid"` | `"off"`, `"heuristic"`, `"llm"`, `"hybrid"` | Chooses explicit correction capture, LLM reviewer, both, or neither. |
| `reviewInterval` | `15` | `1..200` | Run reviewer after this many successful turns. |
| `reviewMinToolCalls` | `8` | `1..500` | Run reviewer after this many observed tool calls. |
| `reviewTimeoutMs` | `45000` | `5000..180000` | Timeout for the embedded reviewer run. |
| `maxPending` | `50` | `1..200` | Max pending/quarantined proposals kept per workspace. |
| `maxSkillBytes` | `40000` | `1024..200000` | Max generated skill/support file size. |
Recommended profiles:
```json5
// Conservative: explicit tool use only, no automatic capture.
{
autoCapture: false,
approvalPolicy: "pending",
reviewMode: "off",
}
```
```json5
// Review-first: capture automatically, but require approval.
{
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "hybrid",
}
```
```json5
// Trusted automation: write safe proposals immediately.
{
autoCapture: true,
approvalPolicy: "auto",
reviewMode: "hybrid",
}
```
```json5
// Low-cost: no reviewer LLM call, only explicit correction phrases.
{
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "heuristic",
}
```
## Capture paths
Skill Workshop has three capture paths.
### Tool suggestions
The model can call `skill_workshop` directly when it sees a reusable procedure
or when the user asks it to save/update a skill.
This is the most explicit path and works even with `autoCapture: false`.
### Heuristic capture
When `autoCapture` is enabled and `reviewMode` is `heuristic` or `hybrid`, the
plugin scans successful turns for explicit user correction phrases:
- `next time`
- `from now on`
- `remember to`
- `make sure to`
- `always ... use/check/verify/record/save/prefer`
- `prefer ... when/for/instead/use`
- `when asked`
The heuristic creates a proposal from the latest matching user instruction. It
uses topic hints to choose skill names for common workflows:
- animated GIF tasks -> `animated-gif-workflow`
- screenshot or asset tasks -> `screenshot-asset-workflow`
- QA or scenario tasks -> `qa-scenario-workflow`
- GitHub PR tasks -> `github-pr-workflow`
- fallback -> `learned-workflows`
Heuristic capture is intentionally narrow. It is for clear corrections and
repeatable process notes, not for general transcript summarization.
### LLM reviewer
When `autoCapture` is enabled and `reviewMode` is `llm` or `hybrid`, the plugin
runs a compact embedded reviewer after thresholds are reached.
The reviewer receives:
- the recent transcript text, capped to the last 12,000 characters
- up to 12 existing workspace skills
- up to 2,000 characters from each existing skill
- JSON-only instructions
The reviewer has no tools:
- `disableTools: true`
- `toolsAllow: []`
- `disableMessageTool: true`
The reviewer returns either `{ "action": "none" }` or one proposal. The `action` field is `create`, `append`, or `replace` - prefer `append`/`replace` when a relevant skill already exists; use `create` only when no existing skill fits.
Example `create`:
```json
{
"action": "create",
"skillName": "media-asset-qa",
"title": "Media Asset QA",
"reason": "Reusable animated media acceptance workflow",
"description": "Validate externally sourced animated media before product use.",
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution.\n- Store a local approved copy.\n- Verify in product UI before final reply."
}
```
`append` adds `section` + `body`. `replace` swaps `oldText` for `newText` in the named skill.
## Proposal lifecycle
Every generated update becomes a proposal with:
- `id`
- `createdAt`
- `updatedAt`
- `workspaceDir`
- optional `agentId`
- optional `sessionId`
- `skillName`
- `title`
- `reason`
- `source`: `tool`, `agent_end`, or `reviewer`
- `status`
- `change`
- optional `scanFindings`
- optional `quarantineReason`
Proposal statuses:
- `pending` - waiting for approval
- `applied` - written to `<workspace>/skills`
- `rejected` - rejected by operator/model
- `quarantined` - blocked by critical scanner findings
State is stored per workspace under the Gateway state directory:
```text
<stateDir>/skill-workshop/<workspace-hash>.json
```
Pending and quarantined proposals are deduplicated by skill name and change
payload. The store keeps the newest pending/quarantined proposals up to
`maxPending`.
## Tool reference
The plugin registers one agent tool:
```text
skill_workshop
```
### `status`
Count proposals by state for the active workspace.
```json
{ "action": "status" }
```
Result shape:
```json
{
"workspaceDir": "/path/to/workspace",
"pending": 1,
"quarantined": 0,
"applied": 3,
"rejected": 0
}
```
### `list_pending`
List pending proposals.
```json
{ "action": "list_pending" }
```
To list another status:
```json
{ "action": "list_pending", "status": "applied" }
```
Valid `status` values:
- `pending`
- `applied`
- `rejected`
- `quarantined`
### `list_quarantine`
List quarantined proposals.
```json
{ "action": "list_quarantine" }
```
Use this when automatic capture appears to do nothing and the logs mention
`skill-workshop: quarantined <skill>`.
### `inspect`
Fetch a proposal by id.
```json
{
"action": "inspect",
"id": "proposal-id"
}
```
### `suggest`
Create a proposal. With `approvalPolicy: "pending"` (default), this queues instead of writing.
```json
{
"action": "suggest",
"skillName": "animated-gif-workflow",
"title": "Animated GIF Workflow",
"reason": "User established reusable GIF validation rules.",
"description": "Validate animated GIF assets before using them.",
"body": "## Workflow\n\n- Verify the URL resolves to image/gif.\n- Confirm it has multiple frames.\n- Record attribution and license.\n- Avoid hotlinking when a local asset is needed."
}
```
<AccordionGroup>
<Accordion title="Request immediate write in auto mode (apply: true)">
```json
{
"action": "suggest",
"apply": true,
"skillName": "animated-gif-workflow",
"description": "Validate animated GIF assets before using them.",
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution."
}
```
With `approvalPolicy: "pending"`, `apply: true` still queues the proposal. Review it, then use
the `apply` action after approval.
</Accordion>
<Accordion title="Force pending under auto policy (apply: false)">
```json
{
"action": "suggest",
"apply": false,
"skillName": "screenshot-asset-workflow",
"description": "Screenshot replacement workflow.",
"body": "## Workflow\n\n- Verify dimensions.\n- Optimize the PNG.\n- Run the relevant gate."
}
```
</Accordion>
<Accordion title="Append to a named section">
```json
{
"action": "suggest",
"skillName": "qa-scenario-workflow",
"section": "Workflow",
"description": "QA scenario workflow.",
"body": "- For media QA, verify generated assets render and pass final assertions."
}
```
</Accordion>
<Accordion title="Replace exact text">
```json
{
"action": "suggest",
"skillName": "github-pr-workflow",
"oldText": "- Check the PR.",
"newText": "- Check unresolved review threads, CI status, linked issues, and changed files before deciding."
}
```
</Accordion>
</AccordionGroup>
### `apply`
Apply a pending proposal.
With `approvalPolicy: "pending"`, this action asks for operator approval before writing the
workspace skill.
```json
{
"action": "apply",
"id": "proposal-id"
}
```
`apply` refuses quarantined proposals:
```text
quarantined proposal cannot be applied
```
### `reject`
Mark a proposal rejected.
```json
{
"action": "reject",
"id": "proposal-id"
}
```
### `write_support_file`
Write a supporting file inside an existing or proposed skill directory.
Allowed top-level support directories:
- `references/`
- `templates/`
- `scripts/`
- `assets/`
Example:
```json
{
"action": "write_support_file",
"skillName": "release-workflow",
"relativePath": "references/checklist.md",
"body": "# Release Checklist\n\n- Run release docs.\n- Verify changelog.\n"
}
```
Support files are workspace-scoped, path-checked, byte-limited by
`maxSkillBytes`, scanned, and written atomically.
## Skill writes
Skill Workshop writes only under:
```text
<workspace>/skills/<normalized-skill-name>/
```
Skill names are normalized:
- lowercased
- non `[a-z0-9_-]` runs become `-`
- leading/trailing non-alphanumerics are removed
- max length is 80 characters
- final name must match `[a-z0-9][a-z0-9_-]{1,79}`
For `create`:
- if the skill does not exist, Skill Workshop writes a new `SKILL.md`
- if it already exists, Skill Workshop appends the body to `## Workflow`
For `append`:
- if the skill exists, Skill Workshop appends to the requested section
- if it does not exist, Skill Workshop creates a minimal skill then appends
For `replace`:
- the skill must already exist
- `oldText` must be present exactly
- only the first exact match is replaced
All writes are atomic and refresh the in-memory skills snapshot immediately, so
the new or updated skill can become visible without a Gateway restart.
## Safety model
Skill Workshop has a safety scanner on generated `SKILL.md` content and support
files.
Critical findings quarantine proposals:
| Rule id | Blocks content that... |
| -------------------------------------- | --------------------------------------------------------------------- |
| `prompt-injection-ignore-instructions` | tells the agent to ignore prior/higher instructions |
| `prompt-injection-system` | references system prompts, developer messages, or hidden instructions |
| `prompt-injection-tool` | encourages bypassing tool permission/approval |
| `shell-pipe-to-shell` | includes `curl`/`wget` piped into `sh`, `bash`, or `zsh` |
| `secret-exfiltration` | appears to send env/process env data over the network |
Warn findings are retained but do not block by themselves:
| Rule id | Warns on... |
| -------------------- | -------------------------------- |
| `destructive-delete` | broad `rm -rf` style commands |
| `unsafe-permissions` | `chmod 777` style permission use |
Quarantined proposals:
- keep `scanFindings`
- keep `quarantineReason`
- appear in `list_quarantine`
- cannot be applied through `apply`
To recover from a quarantined proposal, create a new safe proposal with the
unsafe content removed. Do not edit the store JSON by hand.
## Prompt guidance
When enabled, Skill Workshop injects a short prompt section that tells the agent
to use `skill_workshop` for durable procedural memory.
The guidance emphasizes:
- procedures, not facts/preferences
- user corrections
- non-obvious successful procedures
- recurring pitfalls
- stale/thin/wrong skill repair through append/replace
- saving reusable procedure after long tool loops or hard fixes
- short imperative skill text
- no transcript dumps
The write mode text changes with `approvalPolicy`:
- pending mode: queue suggestions; use `apply` after explicit approval
- auto mode: apply safe workspace-skill updates unless `apply: false` queues instead
## Costs and runtime behavior
Heuristic capture does not call a model.
LLM review uses an embedded run on the active/default agent model. It is
threshold-based so it does not run on every turn by default.
The reviewer:
- uses the same configured provider/model context when available
- falls back to runtime agent defaults
- has `reviewTimeoutMs`
- uses lightweight bootstrap context
- has no tools
- writes nothing directly
- can only emit a proposal that goes through the normal scanner and
approval/quarantine path
If the reviewer fails, times out, or returns invalid JSON, the plugin logs a
warning/debug message and skips that review pass.
## Operating patterns
Use Skill Workshop when the user says:
- "next time, do X"
- "from now on, prefer Y"
- "make sure to verify Z"
- "save this as a workflow"
- "this took a while; remember the process"
- "update the local skill for this"
Good skill text:
```markdown
## Workflow
- Verify the GIF URL resolves to `image/gif`.
- Confirm the file has multiple frames.
- Record source URL, license, and attribution.
- Store a local copy when the asset will ship with the product.
- Verify the local asset renders in the target UI before final reply.
```
Poor skill text:
```markdown
The user asked about a GIF and I searched two websites. Then one was blocked by
Cloudflare. The final answer said to check attribution.
```
Reasons the poor version should not be saved:
- transcript-shaped
- not imperative
- includes noisy one-off details
- does not tell the next agent what to do
## Debugging
Check whether the plugin is loaded:
```bash
openclaw plugins list --enabled
```
Check proposal counts from an agent/tool context:
```json
{ "action": "status" }
```
Inspect pending proposals:
```json
{ "action": "list_pending" }
```
Inspect quarantined proposals:
```json
{ "action": "list_quarantine" }
```
Common symptoms:
| Symptom | Likely cause | Check |
| ------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Tool is unavailable | Plugin entry is not enabled | `plugins.entries.skill-workshop.enabled` and `openclaw plugins list` |
| No automatic proposal appears | `autoCapture: false`, `reviewMode: "off"`, or thresholds not met | Config, proposal status, Gateway logs |
| Heuristic did not capture | User wording did not match correction patterns | Use explicit `skill_workshop.suggest` or enable LLM reviewer |
| Reviewer did not create a proposal | Reviewer returned `none`, invalid JSON, or timed out | Gateway logs, `reviewTimeoutMs`, thresholds |
| Proposal is not applied | `approvalPolicy: "pending"` | `list_pending`, then `apply` |
| Proposal disappeared from pending | Duplicate proposal reused, max pending pruning, or was applied/rejected/quarantined | `status`, `list_pending` with status filters, `list_quarantine` |
| Skill file exists but model misses it | Skill snapshot not refreshed or skill gating excludes it | `openclaw skills` status and workspace skill eligibility |
Relevant logs:
- `skill-workshop: queued <skill>`
- `skill-workshop: applied <skill>`
- `skill-workshop: quarantined <skill>`
- `skill-workshop: heuristic capture skipped: ...`
- `skill-workshop: reviewer skipped: ...`
- `skill-workshop: reviewer found no update`
## QA scenarios
Repo-backed QA scenarios:
- `qa/scenarios/plugins/skill-workshop-animated-gif-autocreate.md`
- `qa/scenarios/plugins/skill-workshop-pending-approval.md`
- `qa/scenarios/plugins/skill-workshop-reviewer-autonomous.md`
Run the deterministic coverage:
```bash
pnpm openclaw qa suite \
--scenario skill-workshop-animated-gif-autocreate \
--scenario skill-workshop-pending-approval \
--concurrency 1
```
Run reviewer coverage:
```bash
pnpm openclaw qa suite \
--scenario skill-workshop-reviewer-autonomous \
--concurrency 1
```
The reviewer scenario is intentionally separate because it enables
`reviewMode: "llm"` and exercises the embedded reviewer pass.
## When not to enable auto apply
Avoid `approvalPolicy: "auto"` when:
- the workspace contains sensitive procedures
- the agent is working on untrusted input
- skills are shared across a broad team
- you are still tuning prompts or scanner rules
- the model frequently handles hostile web/email content
Use pending mode first. Switch to auto mode only after reviewing the kind of
skills the agent proposes in that workspace.
## Related docs
- [Skills](/tools/skills)
- [Plugins](/tools/plugin)
- [Testing](/reference/test)

View File

@@ -127,8 +127,8 @@ runs, reset or delete any intentionally stale session explicitly.
Isolated cron runs also create session entries/transcripts. Session rows use the
same SQLite session tables as other rows:
- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables).
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per cron job (default: `2000`). `cron.runLog.maxBytes` remains accepted for older file-backed run logs.
- Legacy cron session imports happen through `openclaw doctor --fix`.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune SQLite cron run history (defaults: `2_000_000` approximate serialized bytes and `2000` rows per job).
When cron force-creates a new isolated run session, it sanitizes the previous
`cron:<jobId>` session entry before writing the new row. It carries safe

View File

@@ -25,8 +25,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
- When a visible assistant message was truncated in `chat.history`, Control UI can open a side reader and fetch the full display-normalized entry on demand through `chat.message.get` without increasing the default history payload.
- `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
- Compaction entries render as an explicit compacted-history divider. The divider explains that the compacted transcript is preserved as a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore from that compacted view when their permissions allow it.
- `chat.history` follows the active SQLite transcript branch, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
- Compaction entries render as an explicit compacted-history divider. The divider explains that earlier turns are preserved in a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore the pre-compaction view when their permissions allow it.
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
- Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key.
- Workspace startup files and pending `BOOTSTRAP.md` instructions are supplied through the agent system prompt's Project Context, not copied into the WebChat user message. Bootstrap truncation only adds a concise system-prompt recovery notice; detailed counts and config knobs stay on diagnostic surfaces.
@@ -50,11 +50,11 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
WebChat has two separate data paths:
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, the embedded OpenClaw runtime persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
- The per-agent SQLite transcript is the durable model/runtime transcript. For normal agent runs, OpenClaw persists model-visible `user`, `assistant`, and `toolResult` messages through its transcript store. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session transcript.
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal embedded agent turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
- `chat.history` reads the stored transcript rows and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the transcript rows contain the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
- `chat.message.get` uses the same transcript branch and display projection rules as `chat.history`, including active-agent scoping, but targets one transcript entry by `messageId` and returns an honest unavailable reason when the full content can no longer be returned.
Normal agent-run final answers should be durable because the embedded runtime writes the assistant `message_end`. Any fallback that mirrors a delivered final payload into the transcript must first avoid duplicating an assistant turn that the embedded runtime already wrote.

View File

@@ -46,7 +46,7 @@ describe("acpx plugin", () => {
createAcpxRuntimeServiceMock.mockReturnValue(service);
const api = {
pluginConfig: { stateDir: "/tmp/acpx" },
pluginConfig: { timeoutSeconds: 30 },
registerService: vi.fn(),
on: vi.fn(),
};
@@ -71,7 +71,7 @@ describe("acpx plugin", () => {
const on = vi.fn();
const api = createTestPluginApi({
pluginConfig: { stateDir: "/tmp/acpx" },
pluginConfig: { timeoutSeconds: 30 },
registerService: vi.fn(),
on,
});

View File

@@ -17,7 +17,8 @@
},
"stateDir": {
"type": "string",
"minLength": 1
"deprecated": true,
"description": "Legacy option accepted for compatibility and ignored; ACPX state follows the OpenClaw state directory."
},
"probeAgent": {
"type": "string",
@@ -49,10 +50,6 @@
"type": "number",
"minimum": 0
},
"probeAgent": {
"type": "string",
"minLength": 1
},
"mcpServers": {
"type": "object",
"additionalProperties": {
@@ -101,10 +98,6 @@
"label": "Default Working Directory",
"help": "Default working directory for embedded ACP session operations when not set per session."
},
"stateDir": {
"label": "State Directory",
"help": "Directory used for embedded ACP session state and persistence."
},
"permissionMode": {
"label": "Permission Mode",
"help": "Default permission policy for embedded ACP runtime prompts."

View File

@@ -0,0 +1,64 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
export type AcpRuntimeTurn = import("../runtime-api.js").AcpRuntimeTurn;
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
export type AcpRuntimeTurnResult = import("../runtime-api.js").AcpRuntimeTurnResult;
export type AcpAgentRegistry = {
resolve(agent: string): string | undefined;
list(): string[];
};
export type AcpSessionRecord = Record<string, unknown>;
export type AcpSessionStore = {
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
save(record: AcpSessionRecord): Promise<void>;
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
probeAgent?: string;
mcpServers?: unknown;
permissionMode?: unknown;
nonInteractivePermissions?: unknown;
timeoutMs?: number;
probeAgent?: string;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
startTurn(input: AcpRuntimeTurnInput): AcpRuntimeTurn;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: {
handle?: AcpRuntimeHandle;
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: {
handle: AcpRuntimeHandle;
reason?: string;
discardPersistentState?: boolean;
}): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

View File

@@ -34,24 +34,22 @@ function restoreEnv(name: keyof typeof previousEnv): void {
}
}
function generatedCodexPaths(stateDir: string): {
function generatedCodexPaths(wrapperRoot: string): {
configPath: string;
wrapperPath: string;
} {
const baseDir = path.join(stateDir, "acpx");
const codexHome = path.join(baseDir, "codex-home");
const codexHome = path.join(wrapperRoot, "codex-home");
return {
configPath: path.join(codexHome, "config.toml"),
wrapperPath: path.join(baseDir, "codex-acp-wrapper.mjs"),
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
};
}
function generatedClaudePaths(stateDir: string): {
function generatedClaudePaths(wrapperRoot: string): {
wrapperPath: string;
} {
const baseDir = path.join(stateDir, "acpx");
return {
wrapperPath: path.join(baseDir, "claude-agent-acp-wrapper.mjs"),
wrapperPath: path.join(wrapperRoot, "claude-agent-acp-wrapper.mjs"),
};
}
@@ -100,9 +98,9 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("installs an isolated Codex ACP wrapper without synthesizing auth from canonical OpenClaw OAuth", async () => {
const root = await makeTempDir();
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const generatedClaude = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
const generatedClaude = generatedClaudePaths(wrapperRoot);
const installedBinPath = path.join(
root,
"node_modules",
@@ -119,7 +117,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
@@ -133,11 +131,11 @@ describe("prepareAcpxCodexAuthConfig", () => {
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
});
it("keeps generated wrappers usable when chmod is rejected by the state filesystem", async () => {
it("keeps generated wrappers usable when chmod is rejected by the wrapper filesystem", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generatedCodex = generatedCodexPaths(stateDir);
const generatedClaude = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generatedCodex = generatedCodexPaths(wrapperRoot);
const generatedClaude = generatedClaudePaths(wrapperRoot);
const chmodError = Object.assign(new Error("operation not permitted"), { code: "EPERM" });
const chmodSpy = vi.spyOn(fs, "chmod").mockRejectedValue(chmodError);
const pluginConfig = resolveAcpxPluginConfig({
@@ -147,7 +145,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
});
expect(chmodSpy).toHaveBeenCalledWith(generatedCodex.wrapperPath, 0o755);
@@ -160,8 +158,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("falls back to the current Codex ACP package range when the local adapter is unavailable", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
@@ -169,7 +167,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => undefined,
});
@@ -181,8 +179,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("falls back to the patched Claude ACP package when the local adapter is unavailable", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedClaudePaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
@@ -190,7 +188,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledClaudeAcpBinPath: async () => undefined,
});
@@ -203,8 +201,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("uses the bundled Codex ACP dependency by default when it is installed", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
@@ -212,7 +210,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
});
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
@@ -223,8 +221,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("keeps the orphaned wrapper alive long enough to force-kill the child process group", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
@@ -232,7 +230,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
});
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
@@ -254,8 +252,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedClaudePaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
@@ -263,7 +261,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
});
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
@@ -274,8 +272,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("launches the locally installed Codex ACP bin with isolated CODEX_HOME", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(
installedBinPath,
@@ -289,7 +287,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
@@ -308,14 +306,14 @@ describe("prepareAcpxCodexAuthConfig", () => {
);
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
expect(launched.argv).toStrictEqual([]);
const expectedCodexHome = await fs.realpath(path.join(stateDir, "acpx", "codex-home"));
const expectedCodexHome = await fs.realpath(path.join(wrapperRoot, "codex-home"));
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
});
it("launches the locally installed Claude ACP bin without going through npm", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedClaudePaths(wrapperRoot);
const installedBinPath = path.join(root, "claude-agent-acp-bin.js");
await fs.writeFile(
installedBinPath,
@@ -329,7 +327,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledClaudeAcpBinPath: async () => installedBinPath,
});
@@ -349,8 +347,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
@@ -395,7 +393,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => undefined,
});
@@ -430,12 +428,12 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("copies only trusted Codex project declarations into the isolated Codex home", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const stateDir = path.join(root, "state");
const wrapperRoot = path.join(root, "wrapper");
const explicitProject = path.join(root, "explicit project");
const inlineProject = path.join(root, "inline-project");
const mapProject = path.join(root, "map-project");
const untrustedProject = path.join(root, "untrusted-project");
const generated = generatedCodexPaths(stateDir);
const generated = generatedCodexPaths(wrapperRoot);
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "config.toml"),
@@ -457,7 +455,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => undefined,
});
@@ -474,8 +472,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("normalizes an explicitly configured Codex ACP command to the local wrapper", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedCodexPaths(wrapperRoot);
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "config.toml"),
@@ -495,7 +493,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
});
@@ -514,8 +512,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("normalizes an explicitly configured Claude ACP npx command to the local wrapper", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedClaudePaths(stateDir);
const wrapperRoot = path.join(root, "wrapper");
const generated = generatedClaudePaths(wrapperRoot);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {
agents: {
@@ -529,7 +527,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
});
@@ -590,7 +588,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot: stateDir,
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
});
@@ -608,7 +606,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
).rejects.toMatchObject({ code: 1 });
const log = await fs.readFile(
path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.lease-secret.log"),
path.join(stateDir, "codex-acp-wrapper.stderr.lease-secret.log"),
"utf8",
);
expect(log).toContain("token=[REDACTED]");
@@ -631,12 +629,12 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(log).not.toContain("private-secret-body");
expect(log).not.toContain("truncated-private-secret");
expect(log).not.toContain("tail-secret-1234567890");
await expectPathMissing(path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.log"));
await expectPathMissing(path.join(stateDir, "codex-acp-wrapper.stderr.log"));
});
it("leaves a custom Claude agent command alone", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const wrapperRoot = path.join(root, "wrapper");
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {
agents: {
@@ -650,7 +648,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
});
@@ -659,7 +657,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
it("does not normalize custom Claude commands that only mention the package name", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const wrapperRoot = path.join(root, "wrapper");
const command =
"node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.4 --flag";
const pluginConfig = resolveAcpxPluginConfig({
@@ -675,7 +673,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
wrapperRoot,
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
});

View File

@@ -697,13 +697,13 @@ function buildClaudeAcpWrapperCommand(wrapperPath: string, configuredCommand?: s
export async function prepareAcpxCodexAuthConfig(params: {
pluginConfig: ResolvedAcpxPluginConfig;
stateDir: string;
wrapperRoot: string;
logger?: unknown;
resolveInstalledCodexAcpBinPath?: () => Promise<string | undefined>;
resolveInstalledClaudeAcpBinPath?: () => Promise<string | undefined>;
}): Promise<ResolvedAcpxPluginConfig> {
void params.logger;
const codexBaseDir = path.join(params.stateDir, "acpx");
const codexBaseDir = params.wrapperRoot;
await prepareIsolatedCodexHome({
baseDir: codexBaseDir,
workspaceDir: params.pluginConfig.cwd,

View File

@@ -23,6 +23,7 @@ export type AcpxMcpServer = {
export type AcpxPluginConfig = {
cwd?: string;
/** @deprecated Ignored; ACPX state now follows OpenClaw's state directory. */
stateDir?: string;
probeAgent?: string;
permissionMode?: AcpxPermissionMode;
@@ -38,7 +39,6 @@ export type AcpxPluginConfig = {
export type ResolvedAcpxPluginConfig = {
cwd: string;
stateDir: string;
probeAgent?: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
@@ -78,7 +78,7 @@ const McpServerConfigSchema = z.object({
export const AcpxPluginConfigSchema = z.strictObject({
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
stateDir: z.string({ error: "stateDir must be a string" }).optional(),
probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(),
permissionMode: z
.enum(ACPX_PERMISSION_MODES, {

View File

@@ -16,7 +16,7 @@ function expectedMcpServerArgs(params: { sourceEntry: string; distEntry: string
}
describe("embedded acpx plugin config", () => {
it("resolves workspace stateDir and cwd by default", () => {
it("resolves workspace cwd by default", () => {
const workspaceDir = path.resolve("/tmp/openclaw-acpx");
const resolved = resolveAcpxPluginConfig({
rawConfig: undefined,
@@ -24,7 +24,6 @@ describe("embedded acpx plugin config", () => {
});
expect(resolved.cwd).toBe(workspaceDir);
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
expect(resolved.permissionMode).toBe("approve-reads");
expect(resolved.nonInteractivePermissions).toBe("fail");
expect(resolved.timeoutSeconds).toBe(120);
@@ -42,6 +41,17 @@ describe("embedded acpx plugin config", () => {
expect(resolved.timeoutSeconds).toBe(300);
});
it("accepts legacy stateDir config without changing the resolved cwd", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
stateDir: "/tmp/legacy-acpx-state",
},
workspaceDir: "/tmp/openclaw-acpx",
});
expect(resolved.cwd).toBe("/tmp/openclaw-acpx");
});
it("keeps explicit probeAgent config", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
@@ -169,8 +179,8 @@ describe("embedded acpx plugin config", () => {
expect(server).toEqual({
command: process.execPath,
args: expectedMcpServerArgs({
sourceEntry: "src/mcp/plugin-tools-serve.ts",
distEntry: "dist/mcp/plugin-tools-serve.js",
sourceEntry: "src/mcp/plugin-tools-serve.ts",
}),
});
});
@@ -187,8 +197,8 @@ describe("embedded acpx plugin config", () => {
expect(server).toEqual({
command: process.execPath,
args: expectedMcpServerArgs({
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
distEntry: "dist/mcp/openclaw-tools-serve.js",
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
}),
});
});
@@ -216,7 +226,9 @@ describe("embedded acpx plugin config", () => {
},
stateDir: {
type: "string",
minLength: 1,
deprecated: true,
description:
"Legacy option accepted for compatibility and ignored; ACPX state follows the OpenClaw state directory.",
},
permissionMode: {
type: "string",

View File

@@ -235,7 +235,6 @@ export function resolveAcpxPluginConfig(params: {
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
const fallbackCwd = workspaceDir;
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
const mcpServers = resolveConfiguredMcpServers({
@@ -262,7 +261,6 @@ export function resolveAcpxPluginConfig(params: {
return {
cwd,
stateDir,
probeAgent,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:

View File

@@ -1,15 +1,14 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
import { withOpenClawTestState } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it } from "vitest";
import {
createAcpxProcessLeaseStore,
OPENCLAW_ACPX_LEASE_ID_ARG,
OPENCLAW_ACPX_LEASE_ID_ENV,
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
OPENCLAW_GATEWAY_INSTANCE_ID_ENV,
withAcpxLeaseEnvironment,
type AcpxProcessLease,
withAcpxLeaseEnvironment,
} from "./process-lease.js";
function makeLease(index: number): AcpxProcessLease {
@@ -27,19 +26,37 @@ function makeLease(index: number): AcpxProcessLease {
}
describe("createAcpxProcessLeaseStore", () => {
afterEach(() => {
resetPluginStateStoreForTests();
});
it("serializes concurrent lease saves without dropping records", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-acpx-leases-"));
try {
const store = createAcpxProcessLeaseStore({ stateDir });
await withOpenClawTestState({ label: "acpx-leases" }, async () => {
const store = createAcpxProcessLeaseStore();
await Promise.all(Array.from({ length: 25 }, (_, index) => store.save(makeLease(index))));
const leases = await store.listOpen("gateway-test");
expect(leases.map((lease) => lease.leaseId).toSorted()).toEqual(
Array.from({ length: 25 }, (_, index) => `lease-${index}`).toSorted(),
);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
});
it("deletes terminal leases so long-running gateways do not hit the plugin state cap", async () => {
await withOpenClawTestState({ label: "acpx-leases-terminal-prune" }, async () => {
const store = createAcpxProcessLeaseStore();
for (let index = 0; index < 1050; index += 1) {
await store.save(makeLease(index));
await store.markState(`lease-${index}`, "closed");
}
await store.save(makeLease(1051));
expect(await store.load("lease-0")).toBeUndefined();
expect((await store.listOpen("gateway-test")).map((lease) => lease.leaseId)).toEqual([
"lease-1051",
]);
});
});
});

View File

@@ -1,7 +1,5 @@
import { randomUUID, createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
export const OPENCLAW_ACPX_LEASE_ID_ENV = "OPENCLAW_ACPX_LEASE_ID";
export const OPENCLAW_GATEWAY_INSTANCE_ID_ENV = "OPENCLAW_GATEWAY_INSTANCE_ID";
@@ -30,12 +28,23 @@ export type AcpxProcessLeaseStore = {
markState(leaseId: string, state: AcpxProcessLeaseState): Promise<void>;
};
type LeaseFile = {
type LeaseStoreEntry = {
version: 1;
leases: AcpxProcessLease[];
lease: AcpxProcessLease;
};
const LEASE_FILE = "process-leases.json";
const ACPX_PLUGIN_ID = "acpx";
const PROCESS_LEASES_NAMESPACE = "process-leases";
const PROCESS_LEASES_MAX_ENTRIES = 900;
const leaseStore = createPluginStateKeyedStore<LeaseStoreEntry>(ACPX_PLUGIN_ID, {
namespace: PROCESS_LEASES_NAMESPACE,
maxEntries: PROCESS_LEASES_MAX_ENTRIES,
});
function isTerminalLeaseState(state: AcpxProcessLeaseState): boolean {
return state === "closed" || state === "lost";
}
function normalizeLease(value: unknown): AcpxProcessLease | undefined {
if (typeof value !== "object" || value === null) {
@@ -69,53 +78,52 @@ function normalizeLease(value: unknown): AcpxProcessLease | undefined {
};
}
async function readLeaseFile(filePath: string): Promise<LeaseFile> {
const { value } = await readJsonFileWithFallback<Partial<LeaseFile>>(filePath, {
version: 1,
leases: [],
});
const leases = Array.isArray(value.leases)
? value.leases.map(normalizeLease).filter((lease): lease is AcpxProcessLease => Boolean(lease))
: [];
return { version: 1, leases };
}
function writeLeaseFile(filePath: string, value: LeaseFile): Promise<void> {
return writeJsonFileAtomically(filePath, value);
}
export function createAcpxProcessLeaseStore(params: { stateDir: string }): AcpxProcessLeaseStore {
const filePath = path.join(params.stateDir, LEASE_FILE);
export function createAcpxProcessLeaseStore(): AcpxProcessLeaseStore {
let updateQueue: Promise<void> = Promise.resolve();
async function readStoredLeases(): Promise<AcpxProcessLease[]> {
const entries = await leaseStore.entries();
return entries
.map((entry) => normalizeLease(entry.value.lease))
.filter((lease): lease is AcpxProcessLease => !!lease);
}
async function update(
mutator: (leases: AcpxProcessLease[]) => AcpxProcessLease[],
): Promise<void> {
const run = updateQueue.then(async () => {
await fs.mkdir(params.stateDir, { recursive: true });
const current = await readLeaseFile(filePath);
await writeLeaseFile(filePath, {
version: 1,
leases: mutator(current.leases),
});
const current = await readStoredLeases();
const next = mutator(current).filter((lease) => !isTerminalLeaseState(lease.state));
const nextIds = new Set(next.map((lease) => lease.leaseId));
await Promise.all([
...current
.filter((lease) => !nextIds.has(lease.leaseId))
.map((lease) => leaseStore.delete(lease.leaseId)),
...next.map((lease) =>
leaseStore.register(lease.leaseId, {
version: 1,
lease,
}),
),
]);
});
updateQueue = run.catch(() => {});
await run;
}
async function readCurrent(): Promise<LeaseFile> {
async function readCurrent(): Promise<AcpxProcessLease[]> {
await updateQueue;
return await readLeaseFile(filePath);
return await readStoredLeases();
}
return {
async load(leaseId) {
const current = await readCurrent();
return current.leases.find((lease) => lease.leaseId === leaseId);
return current.find((lease) => lease.leaseId === leaseId);
},
async listOpen(gatewayInstanceId) {
const current = await readCurrent();
return current.leases.filter(
return current.filter(
(lease) =>
(lease.state === "open" || lease.state === "closing") &&
(!gatewayInstanceId || lease.gatewayInstanceId === gatewayInstanceId),

View File

@@ -1,7 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
resetPluginStateStoreForTests,
seedPluginStateEntriesForTests,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AcpRuntimeError,
type AcpRuntime,
@@ -9,7 +14,7 @@ import {
type AcpRuntimeTurn,
} from "../runtime-api.js";
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
import { AcpxRuntime, testing, type AcpSessionStore } from "./runtime.js";
import { AcpxRuntime, createSqliteSessionStore, testing, type AcpSessionStore } from "./runtime.js";
type TestSessionStore = {
load(sessionId: string): Promise<Record<string, unknown> | undefined>;
@@ -24,6 +29,7 @@ const CODEX_ACP_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_ACP_WRAPPER_COMMAND} ${OPE
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node "${path.resolve(
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
)}"`;
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
function makeRuntime(
baseStore: TestSessionStore,
@@ -142,6 +148,67 @@ describe("AcpxRuntime fresh reset wrapper", () => {
vi.restoreAllMocks();
});
afterEach(() => {
if (ORIGINAL_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
resetPluginBlobStoreForTests();
});
it("keys SQLite session records by acpxRecordId before display name", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-session-store-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
resetPluginBlobStoreForTests();
const store = createSqliteSessionStore();
const record = {
name: "agent:codex:acp:oneshot",
sessionKey: "agent:codex:acp:oneshot",
acpxRecordId: "agent:codex:acp:oneshot:run-1",
};
try {
await store.save(record as never);
await expect(store.load(record.acpxRecordId)).resolves.toMatchObject({
acpxRecordId: record.acpxRecordId,
});
await expect(store.load(record.name)).resolves.toBeUndefined();
} finally {
resetPluginBlobStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("persists a runtime session above the keyed-state value cap", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-session-store-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
resetPluginBlobStoreForTests();
const store = createSqliteSessionStore();
const largeTranscript = "x".repeat(70_000);
const acpxRecordId = "agent:codex:acp:persistent:run-large";
try {
await store.save({
name: "agent:codex:acp:persistent",
sessionKey: "agent:codex:acp:persistent",
acpxRecordId,
events: [{ type: "assistant", text: largeTranscript }],
} as never);
await expect(store.load(acpxRecordId)).resolves.toMatchObject({
acpxRecordId,
events: [{ type: "assistant", text: largeTranscript }],
});
} finally {
resetPluginBlobStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("rejects unsupported runtime session modes with a clear AcpRuntimeError (issue #73071)", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),

View File

@@ -6,7 +6,6 @@ import {
AcpxRuntime as BaseAcpxRuntime,
createAcpRuntime,
createAgentRegistry,
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
type AcpAgentRegistry,
@@ -19,6 +18,7 @@ import {
type AcpRuntimeTurnResult,
} from "acpx/runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { createPluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { AcpRuntimeError, type AcpRuntime, type AcpRuntimeErrorCode } from "../runtime-api.js";
@@ -60,14 +60,30 @@ type OpenClawLeaseSessionMetadata = {
};
function withOpenClawManagedTurnTimeout<T extends object>(input: T): T & { timeoutMs: 0 } {
// OpenClaw owns ACP turn deadlines. acpx treats timeout after partial agent
// output as a completed turn, which can mark background work done early.
// OpenClaw owns ACP turn deadlines; delegate timeouts can mark partial turns done early.
return {
...input,
timeoutMs: 0,
};
}
const ACPX_SESSION_STORE_PLUGIN_ID = "acpx";
const ACPX_SESSION_STORE_NAMESPACE = "runtime-sessions";
const ACPX_SESSION_STORE_MAX_ENTRIES = 10_000;
type StoredAcpSessionRecordMetadata = {
schemaVersion: 1;
bytes: number;
};
const acpxSessionStore = createPluginBlobStore<StoredAcpSessionRecordMetadata>(
ACPX_SESSION_STORE_PLUGIN_ID,
{
namespace: ACPX_SESSION_STORE_NAMESPACE,
maxEntries: ACPX_SESSION_STORE_MAX_ENTRIES,
},
);
function withOpenClawLeaseSessionMetadata<T extends object>(
record: T,
metadata: OpenClawLeaseSessionMetadata,
@@ -141,6 +157,65 @@ function readSessionRecordName(record: unknown): string {
return typeof name === "string" ? name.trim() : "";
}
function resolveAcpSessionRecordKey(record: unknown): string {
if (typeof record !== "object" || record === null) {
return "";
}
const fields = record as {
acpxRecordId?: unknown;
name?: unknown;
sessionKey?: unknown;
id?: unknown;
sessionId?: unknown;
};
for (const value of [
fields.acpxRecordId,
fields.name,
fields.sessionKey,
fields.id,
fields.sessionId,
]) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function normalizeAcpSessionStoreKey(sessionId: string): string {
return sessionId.trim();
}
function parseStoredAcpSessionRecord(blob: Buffer): AcpLoadedSessionRecord {
const parsed = JSON.parse(blob.toString("utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return undefined;
}
return parsed as AcpLoadedSessionRecord;
}
export function createSqliteSessionStore(): AcpSessionStore {
return {
async load(sessionId: string): Promise<AcpLoadedSessionRecord> {
const key = normalizeAcpSessionStoreKey(sessionId);
const entry = key ? await acpxSessionStore.lookup(key) : undefined;
return entry ? parseStoredAcpSessionRecord(entry.blob) : undefined;
},
async save(record: AcpSessionRecord): Promise<void> {
const key = resolveAcpSessionRecordKey(record);
if (!key) {
throw new Error("Cannot save ACPX session without a stable session key.");
}
const payload = Buffer.from(JSON.stringify(record), "utf8");
await acpxSessionStore.register(
key,
{ schemaVersion: 1, bytes: payload.byteLength },
payload,
);
},
};
}
function readRecordAgentCommand(record: unknown): string | undefined {
if (typeof record !== "object" || record === null) {
return undefined;
@@ -1217,7 +1292,6 @@ export {
ACPX_BACKEND_ID,
createAcpRuntime,
createAgentRegistry,
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
};

View File

@@ -2,6 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import {
createPluginStateKeyedStore,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
@@ -36,7 +40,7 @@ const { reapStaleOpenClawOwnedAcpxOrphansMock } = vi.hoisted(() => ({
}),
),
}));
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createSqliteSessionStoreMock } =
vi.hoisted(() => ({
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
return {
@@ -60,7 +64,7 @@ const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionSt
};
}),
createAgentRegistryMock: vi.fn(() => ({})),
createFileSessionStoreMock: vi.fn(() => ({})),
createSqliteSessionStoreMock: vi.fn(() => ({})),
}));
vi.mock("../runtime-api.js", () => ({
@@ -77,7 +81,7 @@ vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: acpxRuntimeConstructorMock,
createAgentRegistry: createAgentRegistryMock,
createFileSessionStore: createFileSessionStoreMock,
createSqliteSessionStore: createSqliteSessionStoreMock,
}));
vi.mock("./codex-auth-bridge.js", () => ({
@@ -91,13 +95,36 @@ vi.mock("./process-reaper.js", () => ({
import { getAcpRuntimeBackend } from "../runtime-api.js";
import type { OpenClawPluginServiceContext } from "../runtime-api.js";
import { createAcpxRuntimeService, resolveAcpxTimerTimeoutMs } from "./service.js";
import { createAcpxProcessLeaseStore } from "./process-lease.js";
import {
ACPX_GATEWAY_INSTANCE_KEY,
ACPX_GATEWAY_INSTANCE_NAMESPACE,
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
createAcpxRuntimeService,
resolveAcpxTimerTimeoutMs,
resolveAcpxWrapperRoot,
} from "./service.js";
type GatewayInstanceRecord = {
version: 1;
id: string;
createdAt: number;
};
const gatewayInstanceStore = createPluginStateKeyedStore<GatewayInstanceRecord>(
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
{
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
maxEntries: 1,
},
);
const tempDirs: string[] = [];
const previousEnv = {
OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE: process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE,
OPENCLAW_SKIP_ACPX_RUNTIME: process.env.OPENCLAW_SKIP_ACPX_RUNTIME,
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
function restoreEnv(name: keyof typeof previousEnv): void {
@@ -122,19 +149,24 @@ afterEach(async () => {
reapStaleOpenClawOwnedAcpxOrphansMock.mockClear();
acpxRuntimeConstructorMock.mockClear();
createAgentRegistryMock.mockClear();
createFileSessionStoreMock.mockClear();
createSqliteSessionStoreMock.mockClear();
restoreEnv("OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE");
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME");
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME_PROBE");
restoreEnv("OPENCLAW_STATE_DIR");
resetPluginStateStoreForTests();
await fs.rm(resolveAcpxWrapperRoot(), { recursive: true, force: true });
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
function createServiceContext(workspaceDir: string): OpenClawPluginServiceContext {
const stateDir = path.join(workspaceDir, ".openclaw-plugin-state");
process.env.OPENCLAW_STATE_DIR = stateDir;
return {
workspaceDir,
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
stateDir,
config: {},
logger: {
info: vi.fn(),
@@ -180,11 +212,7 @@ function createStartupTraceRecorder() {
}
function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Array<unknown>> } }) {
const [call] = runtimeFactory.mock.calls;
if (!call) {
throw new Error("Expected runtimeFactory to be called");
}
const [input] = call;
const input = runtimeFactory.mock.calls[0]?.[0];
if (typeof input !== "object" || input === null) {
throw new Error("Expected runtimeFactory to be called with an options object");
}
@@ -196,6 +224,14 @@ function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Arr
};
}
async function writeGatewayInstanceIdFixture(id: string): Promise<void> {
await gatewayInstanceStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
version: 1,
id,
createdAt: Date.now(),
});
}
describe("createAcpxRuntimeService", () => {
it("caps configured timeout seconds to timer-safe milliseconds", () => {
expect(resolveAcpxTimerTimeoutMs(0.001)).toBe(1);
@@ -223,24 +259,19 @@ describe("createAcpxRuntimeService", () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
const workspaceDir = await makeTempDir();
const stateDir = path.join(workspaceDir, "custom-state");
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {
await fs.access(stateDir);
});
const probeAvailability = vi.fn(async () => {});
const runtime = createMockRuntime({
doctor: async () => ({ ok: true, message: "ok" }),
isHealthy: () => false,
probeAvailability,
});
const service = createAcpxRuntimeService({
pluginConfig: { stateDir },
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
await fs.access(stateDir);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
@@ -304,9 +335,9 @@ describe("createAcpxRuntimeService", () => {
expect(trace.measured).toEqual([
"config.resolve",
"gateway-instance-id",
"config.prepare-codex-auth",
"filesystem.prepare",
"gateway-instance-id",
"process-leases.reap",
"runtime.create",
"backend.register",
@@ -334,27 +365,21 @@ describe("createAcpxRuntimeService", () => {
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
await fs.mkdir(path.join(ctx.stateDir, "acpx"), { recursive: true });
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
await fs.writeFile(
path.join(ctx.stateDir, "acpx", "process-leases.json"),
JSON.stringify({
version: 1,
leases: [
{
leaseId: "lease-1",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot: path.join(ctx.stateDir, "acpx"),
wrapperPath: path.join(ctx.stateDir, "acpx", "codex-acp-wrapper.mjs"),
rootPid: 101,
commandHash: "hash",
startedAt: 1,
state: "open",
},
],
}),
);
const wrapperRoot = resolveAcpxWrapperRoot();
const processLeaseStore = createAcpxProcessLeaseStore();
await fs.mkdir(wrapperRoot, { recursive: true });
await writeGatewayInstanceIdFixture("gw-test");
await processLeaseStore.save({
leaseId: "lease-1",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot,
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
rootPid: 101,
commandHash: "hash",
startedAt: 1,
state: "open",
});
cleanupOpenClawOwnedAcpxProcessTreeMock.mockResolvedValueOnce({
inspectedPids: [101, 102],
terminatedPids: [101, 102],
@@ -370,7 +395,7 @@ describe("createAcpxRuntimeService", () => {
rootPid: 101,
expectedLeaseId: "lease-1",
expectedGatewayInstanceId: "gw-test",
wrapperRoot: path.join(ctx.stateDir, "acpx"),
wrapperRoot,
deps: processCleanupDeps,
});
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
@@ -378,33 +403,48 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("scopes generated wrapper roots by state dir and gateway instance", async () => {
const stateDirA = path.join(await makeTempDir(), "state");
const stateDirB = path.join(await makeTempDir(), "state");
const rootA1 = resolveAcpxWrapperRoot({
gatewayInstanceId: "gw-a",
stateDir: stateDirA,
});
const rootA2 = resolveAcpxWrapperRoot({
gatewayInstanceId: "gw-b",
stateDir: stateDirA,
});
const rootB1 = resolveAcpxWrapperRoot({
gatewayInstanceId: "gw-a",
stateDir: stateDirB,
});
expect(rootA1).not.toBe(rootA2);
expect(rootA1).not.toBe(rootB1);
expect(path.dirname(path.dirname(rootA1))).toBe(resolveAcpxWrapperRoot());
});
it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
const wrapperRoot = path.join(ctx.stateDir, "acpx");
const wrapperRoot = resolveAcpxWrapperRoot();
const processLeaseStore = createAcpxProcessLeaseStore();
await fs.mkdir(wrapperRoot, { recursive: true });
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
await fs.writeFile(
path.join(wrapperRoot, "process-leases.json"),
JSON.stringify({
version: 1,
leases: [
{
leaseId: "lease-pending",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot,
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
rootPid: 0,
commandHash: "hash",
startedAt: 1,
state: "open",
},
],
}),
);
await writeGatewayInstanceIdFixture("gw-test");
await processLeaseStore.save({
leaseId: "lease-pending",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot,
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
rootPid: 0,
commandHash: "hash",
startedAt: 1,
state: "open",
});
reapStaleOpenClawOwnedAcpxOrphansMock.mockResolvedValueOnce({
inspectedPids: [201, 202],
terminatedPids: [201, 202],
@@ -422,10 +462,7 @@ describe("createAcpxRuntimeService", () => {
deps: processCleanupDeps,
});
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
const leaseFile = JSON.parse(
await fs.readFile(path.join(wrapperRoot, "process-leases.json"), "utf8"),
);
expect(leaseFile.leases[0].state).toBe("closed");
await expect(processLeaseStore.load("lease-pending")).resolves.toBeUndefined();
await service.stop?.(ctx);
});

View File

@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { inspect } from "node:util";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import type {
AcpRuntime,
OpenClawPluginService,
@@ -38,6 +40,23 @@ type AcpxRuntimeLike = AcpRuntime & {
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
const SKIP_RUNTIME_PROBE_ENV = "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE";
const ACPX_BACKEND_ID = "acpx";
export const ACPX_GATEWAY_INSTANCE_PLUGIN_ID = "acpx";
export const ACPX_GATEWAY_INSTANCE_NAMESPACE = "gateway-instance";
export const ACPX_GATEWAY_INSTANCE_KEY = "current";
type AcpxGatewayInstanceRecord = {
version: 1;
id: string;
createdAt: number;
};
const gatewayInstanceStore = createPluginStateKeyedStore<AcpxGatewayInstanceRecord>(
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
{
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
maxEntries: 1,
},
);
type AcpxRuntimeModule = typeof import("./runtime.js");
let runtimeModulePromise: Promise<AcpxRuntimeModule> | null = null;
@@ -56,6 +75,30 @@ type CreateAcpxRuntimeServiceParams = {
processCleanupDeps?: AcpxProcessCleanupDeps;
};
function sanitizeWrapperRootSegment(value: string, fallback: string): string {
const segment = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
return segment || fallback;
}
function hashWrapperRootStateDir(stateDir: string): string {
return createHash("sha256").update(path.resolve(stateDir)).digest("hex").slice(0, 16);
}
export function resolveAcpxWrapperRoot(params?: {
gatewayInstanceId: string;
stateDir: string;
}): string {
const baseRoot = path.join(resolvePreferredOpenClawTmpDir(), "acpx");
if (!params) {
return baseRoot;
}
return path.join(
baseRoot,
hashWrapperRootStateDir(params.stateDir),
sanitizeWrapperRootSegment(params.gatewayInstanceId, "gateway"),
);
}
function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
runtimeModulePromise ??= import("./runtime.js");
return runtimeModulePromise;
@@ -82,9 +125,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
openclawGatewayInstanceId: params.gatewayInstanceId,
openclawProcessLeaseStore: params.processLeaseStore,
openclawWrapperRoot: params.wrapperRoot,
sessionStore: module.createFileSessionStore({
stateDir: params.pluginConfig.stateDir,
}),
sessionStore: module.createSqliteSessionStore(),
agentRegistry: module.createAgentRegistry({
overrides: params.pluginConfig.agents,
}),
@@ -228,21 +269,17 @@ async function withStartupProbeTimeout<T>(params: {
}
}
async function resolveGatewayInstanceId(stateDir: string): Promise<string> {
const filePath = path.join(stateDir, "gateway-instance-id");
try {
const existing = (await fs.readFile(filePath, "utf8")).trim();
if (existing) {
return existing;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
async function resolveGatewayInstanceId(): Promise<string> {
const existing = await gatewayInstanceStore.lookup(ACPX_GATEWAY_INSTANCE_KEY);
if (existing?.version === 1 && existing.id.trim()) {
return existing.id;
}
const next = randomUUID();
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(filePath, `${next}\n`, { mode: 0o600 });
await gatewayInstanceStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
version: 1,
id: next,
createdAt: Date.now(),
});
return next;
}
@@ -319,22 +356,24 @@ export function createAcpxRuntimeService(
...basePluginConfig,
probeAgent: basePluginConfig.probeAgent ?? resolveAllowedAgentsProbeAgent(ctx),
};
const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
resolveGatewayInstanceId(),
);
const wrapperRoot = resolveAcpxWrapperRoot({
gatewayInstanceId,
stateDir: ctx.stateDir,
});
const pluginConfig = await measureAcpxStartup(ctx, "config.prepare-codex-auth", () =>
prepareAcpxCodexAuthConfig({
pluginConfig: effectiveBasePluginConfig,
stateDir: ctx.stateDir,
wrapperRoot,
logger: ctx.logger,
}),
);
const wrapperRoot = path.join(ctx.stateDir, "acpx");
await measureAcpxStartup(ctx, "filesystem.prepare", async () => {
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
await fs.mkdir(wrapperRoot, { recursive: true });
});
const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
resolveGatewayInstanceId(ctx.stateDir),
);
const processLeaseStore = createAcpxProcessLeaseStore({ stateDir: wrapperRoot });
const processLeaseStore = createAcpxProcessLeaseStore();
const startupReap = await measureAcpxStartup(ctx, "process-leases.reap", () =>
reapOpenAcpxProcessLeases({
gatewayInstanceId,

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import * as readline from "node:readline";
import {
deleteSqliteSessionTranscript,
loadSqliteSessionTranscriptBoundedEvents,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
DEFAULT_PROVIDER,
parseModelRef,
@@ -23,15 +23,9 @@ import {
resolvePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import {
asOptionalRecord as asRecord,
normalizeOptionalString,
normalizeStringEntries,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_AGENT_ID = "main";
@@ -47,7 +41,6 @@ const DEFAULT_MIN_TIMEOUT_MS = 250;
const DEFAULT_SETUP_GRACE_TIMEOUT_MS = 0;
const DEFAULT_QUERY_MODE = "recent" as const;
const DEFAULT_QMD_SEARCH_MODE = "search" as const;
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
const ACTIVE_MEMORY_RECALL_LANE = "active-memory";
const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3;
const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
@@ -88,7 +81,6 @@ const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([
"web_search",
"write",
]);
const TOGGLE_STATE_FILE = "session-toggles.json";
const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000;
const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000;
const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024;
@@ -162,7 +154,6 @@ type ActiveRecallPluginConfig = {
circuitBreakerMaxTimeouts?: number;
circuitBreakerCooldownMs?: number;
persistTranscripts?: boolean;
transcriptDir?: string;
qmd?: {
searchMode?: ActiveMemoryQmdSearchMode;
};
@@ -203,7 +194,6 @@ type ResolvedActiveRecallPluginConfig = {
circuitBreakerMaxTimeouts: number;
circuitBreakerCooldownMs: number;
persistTranscripts: boolean;
transcriptDir: string;
qmd: {
searchMode: ActiveMemoryQmdSearchMode;
};
@@ -263,10 +253,20 @@ type TranscriptReadLimits = {
maxBytes?: number;
};
type TranscriptScope = {
agentId: string;
sessionId: string;
};
type TranscriptScopeTracker = {
current?: TranscriptScope;
scopes: TranscriptScope[];
};
type RecallSubagentResult = {
rawReply: string;
resultStatus?: "failed" | "unavailable";
transcriptPath?: string;
transcriptScope?: TranscriptScope;
searchDebug?: ActiveMemorySearchDebug;
};
@@ -286,45 +286,41 @@ type CachedActiveRecallResult = {
};
type ActiveMemoryChatType = "direct" | "group" | "channel" | "explicit";
type ActiveMemoryToggleStore = {
sessions?: Record<string, { disabled?: boolean; updatedAt?: number }>;
type ActiveMemorySessionEntry = {
chatType?: unknown;
groupId?: unknown;
nativeChannelId?: unknown;
nativeDirectUserId?: unknown;
deliveryContext?: {
channel?: unknown;
to?: unknown;
};
};
type AsyncLock = <T>(task: () => Promise<T>) => Promise<T>;
type ActiveMemorySessionToggleEntry = {
version: 1;
disabled: true;
updatedAt: number;
};
const sessionToggleStore = createPluginStateKeyedStore<ActiveMemorySessionToggleEntry>(
"active-memory",
{
namespace: "session-toggles",
maxEntries: 50_000,
},
);
const toggleStoreLocks = new Map<string, AsyncLock>();
let lastActiveRecallCacheSweepAt = 0;
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS;
function createAsyncLock(): AsyncLock {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(task: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await task();
} finally {
release?.();
}
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function withToggleStoreLock<T>(statePath: string, task: () => Promise<T>): Promise<T> {
let withLock = toggleStoreLocks.get(statePath);
if (!withLock) {
withLock = createAsyncLock();
toggleStoreLocks.set(statePath, withLock);
}
return withLock(task);
}
type ActiveMemoryThinkingLevel =
| "off"
| "minimal"
@@ -408,17 +404,6 @@ function clampInt(value: number | undefined, fallback: number, min: number, max:
return Math.max(min, Math.min(max, Math.floor(value as number)));
}
function normalizeTranscriptDir(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
if (!raw) {
return DEFAULT_TRANSCRIPT_DIR;
}
const normalized = raw.replace(/\\/g, "/");
const parts = normalized.split("/").map((part) => part.trim());
const safeParts = parts.filter((part) => part.length > 0 && part !== "." && part !== "..");
return safeParts.length > 0 ? path.join(...safeParts) : DEFAULT_TRANSCRIPT_DIR;
}
function normalizeChatIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@@ -429,15 +414,15 @@ function normalizeChatIdList(value: unknown): string[] {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim().toLowerCase();
if (!trimmed) {
const normalized = normalizeConversationIdValue(entry);
if (!normalized) {
continue;
}
if (seen.has(trimmed)) {
if (seen.has(normalized)) {
continue;
}
seen.add(trimmed);
out.push(trimmed);
seen.add(normalized);
out.push(normalized);
}
return out;
}
@@ -499,42 +484,6 @@ function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean {
return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false;
}
function resolveSafeTranscriptDir(baseSessionsDir: string, transcriptDir: string): string {
const normalized = transcriptDir.trim();
if (!normalized || normalized.includes(":") || path.isAbsolute(normalized)) {
return path.resolve(baseSessionsDir, DEFAULT_TRANSCRIPT_DIR);
}
const resolvedBase = path.resolve(baseSessionsDir);
const candidate = path.resolve(resolvedBase, normalized);
if (!isPathInside(resolvedBase, candidate)) {
return path.resolve(resolvedBase, DEFAULT_TRANSCRIPT_DIR);
}
return candidate;
}
function toSafeTranscriptAgentDirName(agentId: string): string {
const encoded = encodeURIComponent(agentId.trim());
return encoded ? encoded : "unknown-agent";
}
function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: string): string {
return path.join(
api.runtime.state.resolveStateDir(),
"plugins",
"active-memory",
"transcripts",
"agents",
toSafeTranscriptAgentDirName(agentId),
);
}
function requireTransientWorkspaceDir(tempDir: string | undefined): string {
if (!tempDir) {
throw new Error("Active memory transient workspace was not initialized.");
}
return tempDir;
}
function resolveCanonicalSessionKeyFromSessionId(params: {
api: OpenClawPluginApi;
agentId: string;
@@ -578,6 +527,31 @@ function resolveCanonicalSessionKeyFromSessionId(params: {
}
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeActiveMemoryChatType(value: unknown): ActiveMemoryChatType | undefined {
if (value === "direct" || value === "group" || value === "channel" || value === "explicit") {
return value;
}
return undefined;
}
function normalizeConversationIdValue(value: unknown): string | undefined {
const trimmed = normalizeOptionalString(value)?.toLowerCase();
if (!trimmed) {
return undefined;
}
for (const prefix of ["room:", "group:", "channel:", "direct:", "dm:", "user:"]) {
if (trimmed.startsWith(prefix)) {
const withoutPrefix = trimmed.slice(prefix.length).trim();
return withoutPrefix || undefined;
}
}
return trimmed;
}
function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string {
return `runtime toolsAllow: ${toolsAllow.join(", ")}`;
}
@@ -672,81 +646,25 @@ function resolveRecallRunChannelContext(params: {
sessionKey: resolvedSessionKey,
});
const rawStrongEntryChannel =
normalizeOptionalString(sessionEntry?.lastChannel) ??
normalizeOptionalString(sessionEntry?.deliveryContext?.channel) ??
normalizeOptionalString(sessionEntry?.channel);
// Channel IDs containing ":" or "/" are scoped conversation IDs, not
// runnable channel names. The same guard that
// applies to explicit channelId (#76704) must also apply to channels
// read from the session store (#77396).
// read from SQLite session rows (#77396).
const strongEntryChannel =
rawStrongEntryChannel && isRunnableChannelName(rawStrongEntryChannel)
? rawStrongEntryChannel
: undefined;
const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider);
return resolveReturnValue({
resolvedChannel: strongEntryChannel ?? weakEntryChannel,
resolvedChannelStrength: strongEntryChannel
? "strong"
: weakEntryChannel
? "weak"
: undefined,
resolvedChannel: strongEntryChannel,
resolvedChannelStrength: strongEntryChannel ? "strong" : undefined,
});
} catch {
return resolveReturnValue({});
}
}
function resolveToggleStatePath(api: OpenClawPluginApi): string {
return path.join(
api.runtime.state.resolveStateDir(),
"plugins",
"active-memory",
TOGGLE_STATE_FILE,
);
}
async function readToggleStore(statePath: string): Promise<ActiveMemoryToggleStore> {
try {
const raw = await fs.readFile(statePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
const sessions = (parsed as { sessions?: unknown }).sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return {};
}
const nextSessions: NonNullable<ActiveMemoryToggleStore["sessions"]> = {};
for (const [sessionKey, value] of Object.entries(sessions)) {
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const disabled = (value as { disabled?: unknown }).disabled === true;
const updatedAt =
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
? (value as { updatedAt: number }).updatedAt
: undefined;
if (disabled) {
nextSessions[sessionKey] = { disabled, updatedAt };
}
}
return Object.keys(nextSessions).length > 0 ? { sessions: nextSessions } : {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
return {};
}
}
async function writeToggleStore(statePath: string, store: ActiveMemoryToggleStore): Promise<void> {
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(store, null, 2)}\n`,
tempPrefix: ".active-memory",
});
}
async function isSessionActiveMemoryDisabled(params: {
api: OpenClawPluginApi;
sessionKey?: string;
@@ -756,8 +674,8 @@ async function isSessionActiveMemoryDisabled(params: {
return false;
}
try {
const store = await readToggleStore(resolveToggleStatePath(params.api));
return store.sessions?.[sessionKey]?.disabled === true;
const entry = await sessionToggleStore.lookup(sessionKey);
return entry?.disabled === true;
} catch (error) {
params.api.logger.debug?.(
`active-memory: failed to read session toggle (${error instanceof Error ? error.message : String(error)})`,
@@ -771,17 +689,15 @@ async function setSessionActiveMemoryDisabled(params: {
sessionKey: string;
disabled: boolean;
}): Promise<void> {
const statePath = resolveToggleStatePath(params.api);
await withToggleStoreLock(statePath, async () => {
const store = await readToggleStore(statePath);
const sessions = { ...store.sessions };
if (params.disabled) {
sessions[params.sessionKey] = { disabled: true, updatedAt: Date.now() };
} else {
delete sessions[params.sessionKey];
}
await writeToggleStore(statePath, Object.keys(sessions).length > 0 ? { sessions } : {});
});
if (params.disabled) {
await sessionToggleStore.register(params.sessionKey, {
version: 1,
disabled: true,
updatedAt: Date.now(),
});
return;
}
await sessionToggleStore.delete(params.sessionKey);
}
function resolveCommandSessionKey(params: {
@@ -932,7 +848,6 @@ function normalizePluginConfig(
600_000,
),
persistTranscripts: raw.persistTranscripts === true,
transcriptDir: normalizeTranscriptDir(raw.transcriptDir),
qmd: {
searchMode: resolveQmdSearchMode(qmd?.searchMode),
},
@@ -1194,15 +1109,20 @@ function isEligibleInteractiveSession(ctx: {
function resolveChatType(ctx: {
sessionKey?: string;
messageProvider?: string;
channelId?: string;
mainKey?: string;
sessionEntry?: ActiveMemorySessionEntry;
}): ActiveMemoryChatType | undefined {
const rawSessionKey = ctx.sessionKey?.trim();
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
const sessionKey = (baseSessionKey ?? rawSessionKey)?.trim().toLowerCase();
const storedChatType = normalizeActiveMemoryChatType(ctx.sessionEntry?.chatType);
if (storedChatType) {
return storedChatType;
}
const sessionKey = ctx.sessionKey?.trim().toLowerCase();
if (sessionKey) {
if (sessionKey.startsWith("agent:") && sessionKey.split(":")[2] === "explicit") {
return "explicit";
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
if (sessionKey.includes(":direct:")) {
return "direct";
}
if (sessionKey.includes(":dm:")) {
return "direct";
}
if (sessionKey.includes(":group:")) {
return "group";
@@ -1210,21 +1130,17 @@ function resolveChatType(ctx: {
if (sessionKey.includes(":channel:")) {
return "channel";
}
if (sessionKey.includes(":direct:") || sessionKey.includes(":dm:")) {
if (sessionKey.includes(":explicit:")) {
return "explicit";
}
if (/^agent:[^:]+:explicit$/.test(sessionKey)) {
return "explicit";
}
if (/^agent:[^:]+:main:thread:/.test(sessionKey)) {
return "direct";
}
const mainKey = ctx.mainKey?.trim().toLowerCase() || "main";
const agentSessionParts = sessionKey.split(":");
if (
agentSessionParts.length === 3 &&
agentSessionParts[0] === "agent" &&
(agentSessionParts[2] === mainKey || agentSessionParts[2] === "main")
) {
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
const channelId = (ctx.channelId ?? "").trim();
if (provider && provider !== "webchat" && channelId) {
return "direct";
}
if (/^agent:[^:]+:main$/.test(sessionKey) && provider && provider !== "webchat") {
return "direct";
}
}
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
@@ -1239,8 +1155,7 @@ function isAllowedChatType(
ctx: {
sessionKey?: string;
messageProvider?: string;
channelId?: string;
mainKey?: string;
sessionEntry?: ActiveMemorySessionEntry;
},
): boolean {
const chatType = resolveChatType(ctx);
@@ -1250,63 +1165,47 @@ function isAllowedChatType(
return config.allowedChatTypes.includes(chatType);
}
/**
* Best-effort extraction of the conversation id (peer id) embedded in an
* agent-scoped session key, using shared session-key utilities so we
* stay aligned with the canonical key shapes produced by
* `buildAgentPeerSessionKey` / `resolveThreadSessionKeys`.
*
* Supported shapes (after stripping the optional `:thread:<id>` suffix):
* - agent:<agentId>:direct:<peerId> (dmScope=per-peer)
* - agent:<agentId>:<channel>:direct:<peerId> (dmScope=per-channel-peer)
* - agent:<agentId>:<channel>:<accountId>:direct:<peerId> (dmScope=per-account-channel-peer)
* - agent:<agentId>:<channel>:group:<peerId> (group)
* - agent:<agentId>:<channel>:channel:<peerId> (channel)
*
* The legacy `dm` token is also accepted for backwards compatibility.
*
* Returns undefined for sessions that do not embed a peer id (for
* example dmScope=main `agent:<agentId>:<mainKey>` sessions, or any
* non-canonical session key shape).
*/
function resolveConversationId(ctx: {
sessionKey?: string;
messageProvider?: string;
sessionEntry?: ActiveMemorySessionEntry;
}): string | undefined {
const rawSessionKey = ctx.sessionKey?.trim();
const storedChatType = normalizeActiveMemoryChatType(ctx.sessionEntry?.chatType);
if (storedChatType === "direct") {
const id =
normalizeConversationIdValue(ctx.sessionEntry?.nativeDirectUserId) ??
normalizeConversationIdValue(ctx.sessionEntry?.deliveryContext?.to);
if (id) {
return id;
}
}
if (storedChatType === "group" || storedChatType === "channel") {
const id =
normalizeConversationIdValue(ctx.sessionEntry?.groupId) ??
normalizeConversationIdValue(ctx.sessionEntry?.nativeChannelId) ??
normalizeConversationIdValue(ctx.sessionEntry?.deliveryContext?.to);
if (id) {
return id;
}
}
return resolveConversationIdFromSessionKey(ctx.sessionKey);
}
function resolveConversationIdFromSessionKey(sessionKey: string | undefined): string | undefined {
const rawSessionKey = sessionKey?.trim();
if (!rawSessionKey) {
return undefined;
}
// Strip generic `:thread:<id>` suffix first so threaded sessions match
// the same conversation id as their non-threaded parent. Provider-
// specific topic ids (e.g. Telegram/Feishu) that are baked into the
// peer id by the channel adapter are preserved.
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
const baseKey = (baseSessionKey ?? rawSessionKey).trim();
if (!baseKey) {
return undefined;
}
const parsed = parseAgentSessionKey(baseKey);
const baseSessionKey = parseThreadSessionSuffix(rawSessionKey).baseSessionKey ?? rawSessionKey;
const parsed = parseAgentSessionKey(baseSessionKey);
if (!parsed) {
return undefined;
}
const restParts = parsed.rest.split(":").filter(Boolean);
if (restParts.length < 2) {
// `agent:<agentId>:<mainKey>` (dmScope=main) lands here — there is
// no embedded peer id to filter against.
return undefined;
}
// Walk left-to-right until we hit the first chat-type marker. Every
// canonical peer key terminates with `<chatType>:<peerId...>`, so the
// tail after the first marker is the conversation id we want.
for (let index = 0; index < restParts.length - 1; index += 1) {
const token = restParts[index];
if (token === "direct" || token === "dm" || token === "group" || token === "channel") {
const tail = restParts
.slice(index + 1)
.join(":")
.trim();
return tail || undefined;
const parts = parsed.rest.split(":").filter(Boolean);
for (let index = 0; index < parts.length - 1; index += 1) {
const kind = parts[index];
if (kind === "direct" || kind === "dm" || kind === "group" || kind === "channel") {
return normalizeConversationIdValue(parts.slice(index + 1).join(":"));
}
}
return undefined;
@@ -1327,6 +1226,7 @@ function isAllowedChatId(
ctx: {
sessionKey?: string;
messageProvider?: string;
sessionEntry?: ActiveMemorySessionEntry;
},
): boolean {
const hasAllowlist = config.allowedChatIds.length > 0;
@@ -1611,7 +1511,6 @@ async function persistPluginStatusLines(params: {
await params.api.runtime.agent.session.patchSessionEntry({
agentId,
sessionKey,
preserveActivity: true,
update: (existing) => {
const previousEntries = Array.isArray(existing.pluginDebugEntries)
? existing.pluginDebugEntries
@@ -1673,52 +1572,64 @@ function resolveTranscriptReadLimits(
};
}
async function streamBoundedTranscriptJsonl(params: {
sessionFile: string;
async function streamBoundedTranscriptEvents(params: {
transcriptScope: TranscriptScope;
limits?: TranscriptReadLimits;
onRecord: (record: unknown) => boolean | void;
}): Promise<void> {
const limits = resolveTranscriptReadLimits(params.limits);
try {
const stats = await fs.stat(params.sessionFile);
if (!stats.isFile() || stats.size > limits.maxBytes) {
return;
}
} catch {
return;
}
const stream = fsSync.createReadStream(params.sessionFile, {
encoding: "utf8",
});
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let seenLines = 0;
try {
for await (const line of rl) {
seenLines += 1;
if (seenLines > limits.maxLines) {
const events = loadSqliteSessionTranscriptBoundedEvents({
...params.transcriptScope,
maxBytes: limits.maxBytes,
maxEvents: limits.maxLines,
});
for (const { event } of events) {
if (params.onRecord(event)) {
break;
}
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
if (params.onRecord(JSON.parse(trimmed) as unknown)) {
break;
}
} catch {}
}
} catch {
// Treat transcript recovery as best-effort on timeout/abort paths.
} finally {
rl.close();
stream.destroy();
}
}
function deleteTransientRecallTranscript(transcriptScope: TranscriptScope | undefined): void {
if (!transcriptScope) {
return;
}
try {
deleteSqliteSessionTranscript(transcriptScope);
} catch {
// Best-effort cleanup; recall results should not fail because transcript cleanup did.
}
}
function rememberTranscriptScope(
tracker: TranscriptScopeTracker,
scope: TranscriptScope | undefined,
): void {
if (!scope) {
return;
}
tracker.current = scope;
if (
!tracker.scopes.some(
(known) => known.agentId === scope.agentId && known.sessionId === scope.sessionId,
)
) {
tracker.scopes.push(scope);
}
}
function resolveRunTranscriptScope(
fallback: TranscriptScope,
result: { meta?: { agentMeta?: { sessionId?: string } } },
): TranscriptScope {
const sessionId = result.meta?.agentMeta?.sessionId?.trim();
return sessionId ? { ...fallback, sessionId } : fallback;
}
function extractActiveMemorySearchDebugFromSessionRecord(
value: unknown,
): ActiveMemorySearchDebug | undefined {
@@ -1794,12 +1705,12 @@ function extractTerminalMemorySearchResultFromSessionRecord(
}
async function readActiveMemorySearchDebug(
sessionFile: string,
transcriptScope: TranscriptScope,
limits?: TranscriptReadLimits,
): Promise<ActiveMemorySearchDebug | undefined> {
let found: ActiveMemorySearchDebug | undefined;
await streamBoundedTranscriptJsonl({
sessionFile,
await streamBoundedTranscriptEvents({
transcriptScope,
limits,
onRecord: (record) => {
const debug = extractActiveMemorySearchDebugFromSessionRecord(record);
@@ -1812,12 +1723,12 @@ async function readActiveMemorySearchDebug(
}
async function readTerminalMemorySearchResult(
sessionFile: string,
transcriptScope: TranscriptScope,
limits?: TranscriptReadLimits,
): Promise<TerminalMemorySearchResult | undefined> {
let found: TerminalMemorySearchResult | undefined;
await streamBoundedTranscriptJsonl({
sessionFile,
await streamBoundedTranscriptEvents({
transcriptScope,
limits,
onRecord: (record) => {
const result = extractTerminalMemorySearchResultFromSessionRecord(record);
@@ -1832,7 +1743,7 @@ async function readTerminalMemorySearchResult(
}
function watchTerminalMemorySearchResult(params: {
getSessionFile: () => string | undefined;
getTranscriptScope: () => TranscriptScope | undefined;
abortSignal: AbortSignal;
}): TerminalMemorySearchWatch {
let stopped = false;
@@ -1871,8 +1782,10 @@ function watchTerminalMemorySearchResult(params: {
}
inFlight = true;
try {
const sessionFile = params.getSessionFile();
const result = sessionFile ? await readTerminalMemorySearchResult(sessionFile) : undefined;
const transcriptScope = params.getTranscriptScope();
const result = transcriptScope
? await readTerminalMemorySearchResult(transcriptScope)
: undefined;
if (result) {
finish(result);
return;
@@ -1958,17 +1871,17 @@ function extractAssistantTextFromSessionRecord(value: unknown): string {
}
async function readPartialAssistantText(
sessionFile: string | undefined,
transcriptScope: TranscriptScope | undefined,
limits?: TranscriptReadLimits,
): Promise<string | null> {
if (!sessionFile) {
if (!transcriptScope) {
return null;
}
const texts: string[] = [];
const resolvedLimits = resolveTranscriptReadLimits(limits);
let collectedChars = 0;
await streamBoundedTranscriptJsonl({
sessionFile,
await streamBoundedTranscriptEvents({
transcriptScope,
limits: resolvedLimits,
onRecord: (record) => {
const text = extractAssistantTextFromSessionRecord(record);
@@ -2060,7 +1973,7 @@ async function waitForSubagentPartialTimeoutData(
async function buildTimeoutRecallResult(params: {
elapsedMs: number;
maxSummaryChars: number;
sessionFile?: string;
transcriptScope?: TranscriptScope;
rawReply?: string;
searchDebug?: ActiveMemorySearchDebug;
subagentPromise?: Promise<RecallSubagentResult>;
@@ -2072,7 +1985,7 @@ async function buildTimeoutRecallResult(params: {
const rawReply =
params.rawReply ??
subagentPartialData.rawReply ??
(await readPartialAssistantText(params.sessionFile));
(await readPartialAssistantText(params.transcriptScope));
const summary = truncateSummary(
normalizeActiveSummary(rawReply ?? "") ?? "",
params.maxSummaryChars,
@@ -2080,7 +1993,9 @@ async function buildTimeoutRecallResult(params: {
const searchDebug =
params.searchDebug ??
subagentPartialData.searchDebug ??
(params.sessionFile ? await readActiveMemorySearchDebug(params.sessionFile) : undefined);
(params.transcriptScope
? await readActiveMemorySearchDebug(params.transcriptScope)
: undefined);
if (summary.length === 0) {
return {
status: "timeout",
@@ -2480,7 +2395,7 @@ async function runRecallSubagent(params: {
currentModelId?: string;
modelRef?: { provider: string; model: string };
abortSignal?: AbortSignal;
onSessionFile?: (sessionFile: string) => void;
onTranscriptScope?: (transcriptScope: TranscriptScope) => void;
}): Promise<RecallSubagentResult> {
const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId);
const agentDir = resolveAgentDir(params.api.config, params.agentId);
@@ -2510,28 +2425,11 @@ async function runRecallSubagent(params: {
const subagentSessionKey = parentSessionKey
? `${parentSessionKey}:${subagentSuffix}`
: `agent:${params.agentId}:${subagentSuffix}`;
const transientWorkspace = params.config.persistTranscripts
? undefined
: await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-active-memory-",
});
const tempDir = transientWorkspace?.dir;
const persistedDir = params.config.persistTranscripts
? resolveSafeTranscriptDir(
resolvePersistentTranscriptBaseDir(params.api, params.agentId),
params.config.transcriptDir,
)
: undefined;
const sessionFile =
persistedDir !== undefined
? path.join(persistedDir, `${subagentSessionId}.jsonl`)
: path.join(requireTransientWorkspaceDir(tempDir), "session.jsonl");
params.onSessionFile?.(sessionFile);
if (persistedDir) {
await fs.mkdir(persistedDir, { recursive: true, mode: 0o700 });
await fs.chmod(persistedDir, 0o700).catch(() => undefined);
}
const transcriptScope = {
agentId: params.agentId,
sessionId: subagentSessionId,
};
params.onTranscriptScope?.(transcriptScope);
const prompt = buildRecallPrompt({
config: params.config,
query: params.query,
@@ -2555,7 +2453,6 @@ async function runRecallSubagent(params: {
agentId: params.agentId,
messageChannel,
messageProvider,
sessionFile,
workspaceDir,
agentDir,
config: embeddedConfig,
@@ -2595,18 +2492,22 @@ async function runRecallSubagent(params: {
.filter(Boolean)
.join("\n")
.trim();
const resultTranscriptScope = resolveRunTranscriptScope(transcriptScope, result);
params.onTranscriptScope?.(resultTranscriptScope);
const searchDebug =
(await readActiveMemorySearchDebug(sessionFile)) ??
(await readActiveMemorySearchDebug(resultTranscriptScope)) ??
readActiveMemorySearchDebugFromRunResult(result);
return {
rawReply: rawReply || "NONE",
transcriptPath: params.config.persistTranscripts ? sessionFile : undefined,
transcriptScope: params.config.persistTranscripts ? resultTranscriptScope : undefined,
searchDebug,
};
} catch (error) {
if (params.abortSignal?.aborted) {
const partialReply = await readPartialAssistantText(sessionFile);
const searchDebug = await readActiveMemorySearchDebug(sessionFile);
const partialReply = await readPartialAssistantText(transcriptScope);
const searchDebug = partialReply
? await readActiveMemorySearchDebug(transcriptScope)
: undefined;
attachPartialTimeoutData(error, partialReply, searchDebug);
}
if (
@@ -2626,8 +2527,6 @@ async function runRecallSubagent(params: {
return { rawReply: "NONE", resultStatus: "failed" };
}
throw error;
} finally {
await transientWorkspace?.cleanup();
}
}
@@ -2726,7 +2625,7 @@ async function maybeResolveActiveRecall(params: {
const controller = new AbortController();
const TIMEOUT_SENTINEL = Symbol("timeout");
let sessionFile: string | undefined;
const transcriptScopes: TranscriptScopeTracker = { scopes: [] };
const watchdogTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs;
const timeoutId = setTimeout(() => {
controller.abort(new Error(`active-memory timeout after ${watchdogTimeoutMs}ms`));
@@ -2744,17 +2643,18 @@ async function maybeResolveActiveRecall(params: {
});
let terminalMemorySearchWatch: TerminalMemorySearchWatch | undefined;
let subagentPromise: Promise<RecallSubagentResult> | undefined;
try {
const subagentPromise = runRecallSubagent({
subagentPromise = runRecallSubagent({
...params,
modelRef: resolvedModelRef,
abortSignal: controller.signal,
onSessionFile: (value) => {
sessionFile = value;
onTranscriptScope: (value) => {
rememberTranscriptScope(transcriptScopes, value);
},
});
terminalMemorySearchWatch = watchTerminalMemorySearchResult({
getSessionFile: () => sessionFile,
getTranscriptScope: () => transcriptScopes.current,
abortSignal: controller.signal,
});
// Silently catch late rejections after timeout so they don't become
@@ -2772,7 +2672,7 @@ async function maybeResolveActiveRecall(params: {
const result = await buildTimeoutRecallResult({
elapsedMs: Date.now() - startedAt,
maxSummaryChars: params.config.maxSummaryChars,
sessionFile,
transcriptScope: transcriptScopes.current,
subagentPromise,
});
if (params.config.logging) {
@@ -2820,13 +2720,20 @@ async function maybeResolveActiveRecall(params: {
return result;
}
const { rawReply, resultStatus, transcriptPath, searchDebug } = raceResult;
const {
rawReply,
resultStatus,
transcriptScope: persistedTranscriptScope,
searchDebug,
} = raceResult;
const summary = truncateSummary(
normalizeActiveSummary(rawReply) ?? "",
params.config.maxSummaryChars,
);
if (params.config.logging && transcriptPath) {
params.api.logger.info?.(`${logPrefix} transcript=${transcriptPath}`);
if (params.config.logging && persistedTranscriptScope) {
params.api.logger.info?.(
`${logPrefix} transcriptScope=${persistedTranscriptScope.agentId}/${persistedTranscriptScope.sessionId}`,
);
}
const result: ActiveRecallResult =
summary.length > 0
@@ -2881,7 +2788,7 @@ async function maybeResolveActiveRecall(params: {
const result = await buildTimeoutRecallResult({
elapsedMs: Date.now() - startedAt,
maxSummaryChars: params.config.maxSummaryChars,
sessionFile,
transcriptScope: transcriptScopes.current,
rawReply: partialTimeoutData.rawReply,
searchDebug: partialTimeoutData.searchDebug,
});
@@ -2922,6 +2829,18 @@ async function maybeResolveActiveRecall(params: {
} finally {
terminalMemorySearchWatch?.stop();
clearTimeout(timeoutId);
if (!params.config.persistTranscripts) {
for (const scope of transcriptScopes.scopes) {
deleteTransientRecallTranscript(scope);
}
subagentPromise
?.finally(() => {
for (const scope of transcriptScopes.scopes) {
deleteTransientRecallTranscript(scope);
}
})
.catch(() => undefined);
}
}
}
@@ -3101,11 +3020,18 @@ export default definePluginEntry({
});
return undefined;
}
const sessionEntry =
resolvedSessionKey && effectiveAgentId
? (api.runtime.agent.session.getSessionEntry({
agentId: effectiveAgentId,
sessionKey: resolvedSessionKey,
}) as ActiveMemorySessionEntry | undefined)
: undefined;
if (
!isAllowedChatType(config, {
...ctx,
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
mainKey: api.config.session?.mainKey,
sessionKey: resolvedSessionKey,
messageProvider: ctx.messageProvider,
sessionEntry,
})
) {
await persistPluginStatusLines({
@@ -3117,8 +3043,9 @@ export default definePluginEntry({
}
if (
!isAllowedChatId(config, {
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
sessionKey: resolvedSessionKey,
messageProvider: ctx.messageProvider,
sessionEntry,
})
) {
await persistPluginStatusLines({

View File

@@ -73,7 +73,6 @@
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"logging": { "type": "boolean" },
"persistTranscripts": { "type": "boolean" },
"transcriptDir": { "type": "string" },
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
"circuitBreakerMaxTimeouts": { "type": "integer", "minimum": 1, "maximum": 20 },
"circuitBreakerCooldownMs": { "type": "integer", "minimum": 5000, "maximum": 600000 },
@@ -171,11 +170,7 @@
},
"persistTranscripts": {
"label": "Persist Transcripts",
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
},
"transcriptDir": {
"label": "Transcript Directory",
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
"help": "Log the blocking memory sub-agent SQLite transcript scope for debugging."
},
"qmd.searchMode": {
"label": "QMD Search Mode",

View File

@@ -0,0 +1,6 @@
declare module "@aws/bedrock-token-generator" {
export function getTokenProvider(opts?: {
region?: string;
expiresInSeconds?: number;
}): () => Promise<string>;
}

View File

@@ -1,10 +1,11 @@
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm";
import type { Model, SimpleStreamOptions } from "openclaw/plugin-sdk/provider-ai";
import { streamAnthropic } from "openclaw/plugin-sdk/provider-ai";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
type MantleAnthropicStream = typeof stream;
type MantleAnthropicStream = typeof streamAnthropic;
type AnthropicStreamClient = Anthropic;
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
@@ -83,7 +84,7 @@ export function createMantleAnthropicStreamFn(deps?: {
return (model, context, options) => {
const apiKey = options?.apiKey ?? "";
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
const streamFn = deps?.stream ?? stream;
const streamFn = deps?.stream ?? streamAnthropic;
const client = createClient({
apiKey: null,
authToken: apiKey,

View File

@@ -1,8 +1,9 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
import { registerApiProvider } from "openclaw/plugin-sdk/llm";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { streamSimple } from "openclaw/plugin-sdk/provider-ai";
import {
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
normalizeProviderId,

View File

@@ -1,10 +1,10 @@
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import {
stream as streamDefault,
type Model,
type ProviderStreamOptions,
} from "openclaw/plugin-sdk/llm";
streamAnthropic as streamDefault,
} from "openclaw/plugin-sdk/provider-ai";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
@@ -195,7 +195,7 @@ export function createAnthropicVertexStreamFn(
opts.thinkingEnabled = false;
}
return deps.streamAnthropic(transportModel, context, opts);
return deps.streamAnthropic(transportModel, context, opts as ProviderStreamOptions);
};
}

View File

@@ -0,0 +1,6 @@
export { buildAnthropicCliBackend } from "./cli-backend.js";
export {
CLAUDE_CLI_BACKEND_ID,
isClaudeCliProvider,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";

View File

@@ -1,6 +1,6 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { streamSimple } from "openclaw/plugin-sdk/provider-ai";
import {
applyAnthropicPayloadPolicyToParams,
composeProviderStreamWrappers,

View File

@@ -73,8 +73,9 @@ function normalizeAzureSpeechProviderConfig(
rawConfig: Record<string, unknown>,
): AzureSpeechProviderConfig {
const raw = resolveAzureSpeechConfigRecord(rawConfig);
const region = trimToUndefined(raw?.region) ?? readAzureSpeechEnvRegion();
const endpoint = trimToUndefined(raw?.endpoint) ?? readAzureSpeechEnvEndpoint();
const region =
trimToUndefined(raw?.region) ?? (endpoint ? undefined : readAzureSpeechEnvRegion());
const baseUrl = normalizeAzureSpeechBaseUrl({
baseUrl: trimToUndefined(raw?.baseUrl),
endpoint,
@@ -99,8 +100,8 @@ function normalizeAzureSpeechProviderConfig(
function readAzureSpeechProviderConfig(config: SpeechProviderConfig): AzureSpeechProviderConfig {
const defaults = normalizeAzureSpeechProviderConfig({});
const region = trimToUndefined(config.region) ?? defaults.region;
const endpoint = trimToUndefined(config.endpoint) ?? defaults.endpoint;
const region = trimToUndefined(config.region) ?? (endpoint ? undefined : defaults.region);
const baseUrl = normalizeAzureSpeechBaseUrl({
baseUrl: trimToUndefined(config.baseUrl) ?? defaults.baseUrl,
endpoint,

View File

@@ -0,0 +1 @@
export * from "./src/browser-runtime.js";

View File

@@ -215,16 +215,14 @@ function wrapBrowserExternalJson(params: {
};
}
function formatTabsToolResult(tabs: unknown[]): AgentToolResult<unknown> {
function formatTabsToolResult(tabs: unknown[]): AgentToolResult {
const formattedTabs = tabs.map((tab) => formatAgentTab(tab));
const wrapped = wrapBrowserExternalJson({
kind: "tabs",
payload: { tabs: formattedTabs },
includeWarning: false,
});
const content: AgentToolResult<unknown>["content"] = [
{ type: "text", text: wrapped.wrappedText },
];
const content: AgentToolResult["content"] = [{ type: "text", text: wrapped.wrappedText }];
return {
content,
details: {
@@ -239,7 +237,7 @@ function formatConsoleToolResult(result: {
targetId?: string;
url?: string;
messages?: unknown[];
}): AgentToolResult<unknown> {
}): AgentToolResult {
const wrapped = wrapBrowserExternalJson({
kind: "console",
payload: result,
@@ -316,7 +314,7 @@ export async function executeTabsAction(params: {
profile?: string;
timeoutMs?: number;
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
}): Promise<AgentToolResult> {
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
if (proxyRequest) {
const result = await proxyRequest({
@@ -338,7 +336,7 @@ export async function executeSnapshotAction(params: {
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
}): Promise<AgentToolResult> {
const { input, baseUrl, profile, proxyRequest } = params;
const snapshotDefaults = browserToolActionDeps.getRuntimeConfig().browser?.snapshotDefaults;
const format: "ai" | "aria" | undefined =
@@ -525,7 +523,7 @@ export async function executeConsoleAction(params: {
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
}): Promise<AgentToolResult> {
const { input, baseUrl, profile, proxyRequest } = params;
const level = normalizeOptionalString(input.level);
const targetId = normalizeOptionalString(input.targetId);
@@ -555,7 +553,7 @@ export async function executeActAction(params: {
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
}): Promise<AgentToolResult> {
const { request, baseUrl, profile, proxyRequest } = params;
const effectiveRequest = withConfiguredActTimeout(request, profile);
try {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
type SchemaRecord = Record<string, { maximum?: number; properties?: SchemaRecord }>;

View File

@@ -1400,11 +1400,10 @@ describe("chrome.ts internal", () => {
.mockImplementation(() => {
throw new Error("decoration blew up");
});
// The real decoration throws via our writes fake by spying on
// fs.writeFileSync to throw for the marker file.
// The real decoration throws via preference writes; fake that path.
const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((p) => {
const s = String(p);
if (s.endsWith(".openclaw-profile-decorated") || s.endsWith("Preferences")) {
if (s.endsWith("Preferences")) {
throw new Error("write blew up");
}
});

View File

@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import {
@@ -6,10 +5,6 @@ import {
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".openclaw-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
const parsed = loadJsonFile(filePath);
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
@@ -166,12 +161,6 @@ export function decorateOpenClawProfile(
setDeep(prefs, ["savefile", "default_directory"], opts.downloadDir);
}
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
} catch {
// ignore
}
}
export function ensureProfileCleanExit(userDataDir: string) {

View File

@@ -186,11 +186,11 @@ describe("browser chrome profile decoration", () => {
expect(prefs.download).toBeUndefined();
expect(prefs.savefile).toBeUndefined();
const marker = await fsp.readFile(
path.join(userDataDir, ".openclaw-profile-decorated"),
"utf-8",
);
expect(marker.trim()).toMatch(/^\d+$/);
await expect(
fsp.access(path.join(userDataDir, ".openclaw-profile-decorated")),
).rejects.toMatchObject({
code: "ENOENT",
});
});
it("writes managed download prefs when a download dir is provided", async () => {

View File

@@ -21,10 +21,8 @@ describe("profile name validation", () => {
it("rejects empty or missing names", () => {
expect(isValidProfileName("")).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(null)).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(undefined)).toBe(false);
expect(isValidProfileName(null as unknown as string)).toBe(false);
expect(isValidProfileName(undefined as unknown as string)).toBe(false);
});
it("rejects names that are too long", () => {

View File

@@ -29,7 +29,7 @@ describe("persistBrowserProxyFiles", () => {
const savedPath = mapping.get(sourcePath);
expect(typeof savedPath).toBe("string");
expect(path.normalize(savedPath ?? "")).toContain(
`${path.sep}.openclaw${path.sep}media${path.sep}browser${path.sep}`,
`${path.sep}openclaw${path.sep}media${path.sep}browser${path.sep}`,
);
await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy");
});

View File

@@ -44,19 +44,7 @@ function createExistingSessionProfileState(params?: {
};
}
function readFirstReachabilityCall(
isReachable: ReturnType<typeof vi.fn>,
): [number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined] {
const [call] = isReachable.mock.calls as Array<
[number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined]
>;
if (!call) {
throw new Error("expected reachability probe call");
}
return call;
}
function createManagedProfileState(profileOverrides?: Record<string, unknown>) {
function createManagedProfileState(profileOverrides: Record<string, unknown> = {}) {
return {
resolved: {
enabled: true,
@@ -355,7 +343,12 @@ describe("basic browser routes", () => {
expect(response.statusCode).toBe(200);
expect(isTransportAvailable).toHaveBeenCalledTimes(1);
expect(isTransportAvailable).toHaveBeenCalledWith(5_000);
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
const [timeoutMs, reachabilityOptions] =
(
isReachable.mock.calls as unknown as Array<
[number, { ephemeral?: boolean; signal?: AbortSignal }]
>
)[0] ?? [];
expect(timeoutMs).toBeGreaterThan(0);
expect(timeoutMs).toBeLessThanOrEqual(7_000);
expect(reachabilityOptions?.ephemeral).toBe(true);
@@ -384,7 +377,12 @@ describe("basic browser routes", () => {
});
expect(response.statusCode).toBe(200);
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
const [timeoutMs, reachabilityOptions] =
(
isReachable.mock.calls as unknown as Array<
[number, { ephemeral?: boolean; signal?: AbortSignal }]
>
)[0] ?? [];
expect(timeoutMs).toBe(4_000);
expect(reachabilityOptions?.ephemeral).toBe(true);
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
@@ -409,9 +407,8 @@ describe("basic browser routes", () => {
});
expect(isReachable).toHaveBeenCalledTimes(1);
const [, reachabilityOptions] = readFirstReachabilityCall(isReachable);
expect(reachabilityOptions?.ephemeral).toBe(true);
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
expect(isReachable.mock.calls[0]?.[1]?.ephemeral).toBe(true);
expect(isReachable.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
});
it("skips the page-reachability probe when transport is unavailable", async () => {

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import "../test-support/browser-security.mock.js";
vi.hoisted(() => {
vi.resetModules();
});
import "./server-context.chrome-test-harness.js";
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import * as cdpHelpersModule from "./cdp.helpers.js";
@@ -43,14 +48,6 @@ function fetchCallUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(([url]) => String(url));
}
function fetchJsonCall(fetchJson: ReturnType<typeof vi.fn>, index: number): unknown[] {
const call = fetchJson.mock.calls[index];
if (!call) {
throw new Error(`expected fetchJson call ${index + 1}`);
}
return call;
}
function createOldTabCleanupFetchMock(
existingTabs: ReturnType<typeof makeManagedTabsWithNew>,
params?: { rejectNewTabClose?: boolean },
@@ -383,13 +380,13 @@ describe("browser server-context tab selection state", () => {
const opened = await openclaw.openTab("https://example.com");
expect(opened.targetId).toBe("NEW");
const jsonNewEndpoint = "http://127.0.0.1:18800/json/new?https%3A%2F%2Fexample.com";
expect(fetchJsonCall(fetchJson, 0)).toEqual([
expect(fetchJson.mock.calls[0]).toEqual([
jsonNewEndpoint,
CDP_JSON_NEW_TIMEOUT_MS,
{ method: "PUT" },
undefined,
]);
expect(fetchJsonCall(fetchJson, 1)).toEqual([
expect(fetchJson.mock.calls[1]).toEqual([
jsonNewEndpoint,
CDP_JSON_NEW_TIMEOUT_MS,
undefined,

View File

@@ -18,9 +18,7 @@ export async function runBrowserResizeWithOutput(params: {
return;
}
if (width > ACT_MAX_VIEWPORT_DIMENSION || height > ACT_MAX_VIEWPORT_DIMENSION) {
defaultRuntime.error(
danger(`width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`),
);
defaultRuntime.error(danger(`width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`));
defaultRuntime.exit(1);
return;
}

View File

@@ -1,22 +1,25 @@
import type { Command } from "commander";
import {
formatCliCommand,
formatHelpExamples,
addGatewayClientOptions,
formatDocsLink,
registerCommandGroups,
resolveCliArgvInvocation,
shouldEagerRegisterSubcommands,
theme,
type CommandGroupEntry,
type CommandGroupPlaceholder,
} from "openclaw/plugin-sdk/cli-runtime";
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import {
addGatewayClientOptions,
danger,
defaultRuntime,
formatCliCommand,
formatDocsLink,
formatHelpExamples,
theme,
} from "./core-api.js";
const browserCliRuntime = {
error: (...args: unknown[]) => console.error(...args),
exit: (code: number) => {
process.exit(code);
},
};
type BrowserCommandRegistrar = (args: {
browser: Command;
@@ -260,10 +263,10 @@ export function registerBrowserCli(program: Command, argv: string[] = process.ar
)
.action(() => {
browser.outputHelp();
defaultRuntime.error(
danger(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`),
browserCliRuntime.error(
theme.error(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`),
);
defaultRuntime.exit(1);
browserCliRuntime.exit(1);
});
addGatewayClientOptions(browser);

View File

@@ -32,6 +32,7 @@ vi.mock("./src/http-route.js", () => ({
}));
vi.mock("./src/documents.js", () => ({
resolveCanvasHttpPathToMaterializedLocalPath: mocks.resolveCanvasHttpPathToLocalPath,
resolveCanvasHttpPathToLocalPath: mocks.resolveCanvasHttpPathToLocalPath,
}));

View File

@@ -109,14 +109,19 @@ export default definePluginEntry({
await httpRouteHandler?.close();
},
});
let resolveCanvasHttpPathToLocalPathPromise:
| Promise<(typeof import("./src/documents.js"))["resolveCanvasHttpPathToLocalPath"]>
let resolveCanvasHttpPathToMaterializedLocalPathPromise:
| Promise<
(typeof import("./src/documents.js"))["resolveCanvasHttpPathToMaterializedLocalPath"]
>
| undefined;
api.registerHostedMediaResolver(async (mediaUrl) => {
resolveCanvasHttpPathToLocalPathPromise ??= import("./src/documents.js").then(
({ resolveCanvasHttpPathToLocalPath }) => resolveCanvasHttpPathToLocalPath,
resolveCanvasHttpPathToMaterializedLocalPathPromise ??= import("./src/documents.js").then(
({ resolveCanvasHttpPathToMaterializedLocalPath }) =>
resolveCanvasHttpPathToMaterializedLocalPath,
);
return (await resolveCanvasHttpPathToLocalPathPromise)(mediaUrl);
return await (
await resolveCanvasHttpPathToMaterializedLocalPathPromise
)(mediaUrl);
});
}
api.registerNodeInvokePolicy({

View File

@@ -102,7 +102,7 @@ export const canvasConfigSchema: CanvasPluginConfigSchema = {
},
"host.root": {
label: "Canvas Host Root Directory",
help: "Directory to serve. Defaults to the OpenClaw state canvas directory.",
help: "Optional directory to serve. Managed Canvas documents are stored in SQLite.",
advanced: true,
},
"host.port": {

View File

@@ -1,18 +1,22 @@
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCanvasDocumentEntryUrl,
createCanvasDocument,
readCanvasDocumentHttpBlob,
resolveCanvasDocumentAssets,
resolveCanvasDocumentDir,
resolveCanvasHttpPathToLocalPath,
resolveCanvasHttpPathToMaterializedLocalPath,
} from "./documents.js";
const tempDirs: string[] = [];
afterEach(async () => {
resetPluginBlobStoreForTests();
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }));
@@ -21,7 +25,7 @@ afterEach(async () => {
});
describe("canvas documents", () => {
it("builds entry urls for materialized path documents under managed storage", async () => {
it("builds entry urls for SQLite-backed managed documents", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
@@ -42,7 +46,17 @@ describe("canvas documents", () => {
expect(document.entryUrl).toContain("/__openclaw__/canvas/documents/");
expect(document.localEntrypoint).toBe("index.html");
expect(resolveCanvasDocumentDir(document.id, { stateDir })).toContain(stateDir);
expect(resolveCanvasDocumentDir(document.id, { stateDir })).toBe(
`sqlite:canvas/documents/${document.id}`,
);
await expect(
readCanvasDocumentHttpBlob(document.entryUrl, { stateDir }),
).resolves.toMatchObject({
documentId: document.id,
logicalPath: "index.html",
contentType: "text/html; charset=utf-8",
});
expect(resolveCanvasHttpPathToLocalPath(document.entryUrl, { stateDir })).toBeNull();
});
it("normalizes nested local entrypoint urls", () => {
@@ -74,12 +88,9 @@ describe("canvas documents", () => {
{ stateDir },
);
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
),
);
const indexHtml = (
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
)?.blob.toString("utf8");
expect(indexHtml).toContain("<div class='demo'>Front</div>");
expect(indexHtml).toContain("<style>.demo{color:red}</style>");
@@ -111,12 +122,9 @@ describe("canvas documents", () => {
expect(first.id).toBe("status-card");
expect(second.id).toBe("status-card");
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(second.id, { stateDir }), "index.html"),
"utf8",
),
);
const indexHtml = (
await readCanvasDocumentHttpBlob(second.entryUrl, { stateDir })
)?.blob.toString("utf8");
expect(indexHtml).toContain("second");
expect(indexHtml).not.toContain("first");
});
@@ -152,10 +160,7 @@ describe("canvas documents", () => {
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
localPath: `sqlite:canvas/documents/${document.id}/collection.media/audio.mp3`,
url: `/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
@@ -168,13 +173,15 @@ describe("canvas documents", () => {
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
localPath: `sqlite:canvas/documents/${document.id}/collection.media/audio.mp3`,
url: `http://127.0.0.1:19003/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
const audioBlob = await readCanvasDocumentHttpBlob(
`/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
{ stateDir },
);
expect(audioBlob?.blob.toString("utf8")).toBe("audio");
});
it("wraps local pdf documents in an index viewer page", async () => {
@@ -196,10 +203,9 @@ describe("canvas documents", () => {
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
const indexHtml = (
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
)?.blob.toString("utf8");
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="demo.pdf"');
});
@@ -220,10 +226,9 @@ describe("canvas documents", () => {
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
const indexHtml = (
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
)?.blob.toString("utf8");
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="https://example.com/demo.pdf"');
});
@@ -240,25 +245,83 @@ describe("canvas documents", () => {
).toBeNull();
});
it("rejects malformed encoded hosted canvas document paths", async () => {
it("materializes SQLite-backed canvas documents only when a local media path is needed", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const documentId = "cv_malformed";
const documentDir = resolveCanvasDocumentDir(documentId, { stateDir });
await mkdir(documentDir, { recursive: true });
await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
expect(
resolveCanvasHttpPathToLocalPath(
`/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`,
{ stateDir },
),
).toBeNull();
expect(
resolveCanvasHttpPathToLocalPath(
`/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`,
{ stateDir },
),
).toBe(path.join(documentDir, "%E0%A4%A.html"));
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>media</div>" },
},
{ stateDir },
);
const localPath = await resolveCanvasHttpPathToMaterializedLocalPath(document.entryUrl, {
stateDir,
});
expect(localPath).toMatch(/canvas-documents/);
expect(await readFile(localPath ?? "", "utf8")).toContain("<div>media</div>");
});
it("materializes nested SQLite-backed assets without basename collisions", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await mkdir(path.join(workspaceDir, "images"), { recursive: true });
await mkdir(path.join(workspaceDir, "thumbnails"), { recursive: true });
await writeFile(path.join(workspaceDir, "images/logo.png"), "full-size", "utf8");
await writeFile(path.join(workspaceDir, "thumbnails/logo.png"), "thumbnail", "utf8");
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>assets</div>" },
assets: [
{ logicalPath: "images/logo.png", sourcePath: "images/logo.png" },
{ logicalPath: "thumbnails/logo.png", sourcePath: "thumbnails/logo.png" },
],
},
{ stateDir, workspaceDir },
);
const imagePath = await resolveCanvasHttpPathToMaterializedLocalPath(
`/__openclaw__/canvas/documents/${document.id}/images/logo.png`,
{ stateDir },
);
const thumbnailPath = await resolveCanvasHttpPathToMaterializedLocalPath(
`/__openclaw__/canvas/documents/${document.id}/thumbnails/logo.png`,
{ stateDir },
);
expect(imagePath).not.toBe(thumbnailPath);
expect(await readFile(imagePath ?? "", "utf8")).toBe("full-size");
expect(await readFile(thumbnailPath ?? "", "utf8")).toBe("thumbnail");
});
it("keeps explicit canvas roots file-backed", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const canvasRootDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-root-"));
tempDirs.push(canvasRootDir);
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>file</div>" },
},
{ stateDir, canvasRootDir },
);
const documentDir = resolveCanvasDocumentDir(document.id, { stateDir, rootDir: canvasRootDir });
expect(documentDir).toContain(canvasRootDir);
expect(await readFile(path.join(documentDir, "index.html"), "utf8")).toContain(
"<div>file</div>",
);
expect(resolveCanvasHttpPathToLocalPath(document.entryUrl, { rootDir: canvasRootDir })).toBe(
path.join(documentDir, "index.html"),
);
});
});

View File

@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { createPluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { root as fsRoot, sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { CANVAS_HOST_PATH } from "./host/a2ui.js";
@@ -53,6 +54,41 @@ type CanvasDocumentResolvedAsset = {
};
const CANVAS_DOCUMENTS_DIR_NAME = "documents";
const CANVAS_DOCUMENTS_PLUGIN_ID = "canvas";
const CANVAS_DOCUMENTS_NAMESPACE = "documents";
const CANVAS_DOCUMENTS_MAX_ENTRIES = 20_000;
type CanvasDocumentBlobMetadata = {
documentId: string;
logicalPath: string;
role: "manifest" | "file";
contentType?: string;
};
type CanvasDocumentStorageRoot = {
write(logicalPath: string, value: string): Promise<void>;
copyIn(
logicalPath: string,
sourcePath: string,
options?: { contentType?: string },
): Promise<void>;
flush?(): Promise<void>;
};
type CanvasDocumentBlob = {
documentId: string;
logicalPath: string;
contentType?: string;
blob: Buffer;
};
function canvasDocumentBlobStore(stateDir?: string) {
return createPluginBlobStore<CanvasDocumentBlobMetadata>(CANVAS_DOCUMENTS_PLUGIN_ID, {
namespace: CANVAS_DOCUMENTS_NAMESPACE,
maxEntries: CANVAS_DOCUMENTS_MAX_ENTRIES,
...(stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } } : {}),
});
}
function isPdfPathLike(value: string): boolean {
return /\.pdf(?:[?#].*)?$/i.test(value.trim());
@@ -113,20 +149,25 @@ function normalizeCanvasDocumentId(value: string): string {
return normalized;
}
function resolveCanvasRootDir(rootDir?: string, stateDir = resolveStateDir()): string {
const resolved = rootDir?.trim() ? resolveUserPath(rootDir) : path.join(stateDir, "canvas");
return path.resolve(resolved);
function resolveCanvasRootDir(rootDir?: string): string {
if (!rootDir?.trim()) {
throw new Error("canvas rootDir required for file-backed document storage");
}
return path.resolve(resolveUserPath(rootDir));
}
function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir()): string {
return path.join(resolveCanvasRootDir(rootDir, stateDir), CANVAS_DOCUMENTS_DIR_NAME);
function resolveCanvasDocumentsDir(rootDir?: string): string {
return path.join(resolveCanvasRootDir(rootDir), CANVAS_DOCUMENTS_DIR_NAME);
}
export function resolveCanvasDocumentDir(
documentId: string,
options?: { rootDir?: string; stateDir?: string },
): string {
return path.join(resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir), documentId);
if (!options?.rootDir?.trim()) {
return `sqlite:canvas/documents/${normalizeCanvasDocumentId(documentId)}`;
}
return path.join(resolveCanvasDocumentsDir(options?.rootDir), documentId);
}
export function buildCanvasDocumentEntryUrl(documentId: string, entrypoint: string): string {
@@ -146,6 +187,9 @@ export function resolveCanvasHttpPathToLocalPath(
requestPath: string,
options?: { rootDir?: string; stateDir?: string },
): string | null {
if (!options?.rootDir?.trim()) {
return null;
}
const trimmed = requestPath.trim();
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
if (!trimmed.startsWith(prefix)) {
@@ -171,9 +215,7 @@ export function resolveCanvasHttpPathToLocalPath(
try {
const documentId = normalizeCanvasDocumentId(rawDocumentId);
const normalizedEntrypoint = normalizeLogicalPath(entrySegments.join("/"));
const documentsDir = path.resolve(
resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir),
);
const documentsDir = path.resolve(resolveCanvasDocumentsDir(options?.rootDir));
const candidatePath = path.resolve(
resolveCanvasDocumentDir(documentId, options),
normalizedEntrypoint,
@@ -189,17 +231,107 @@ export function resolveCanvasHttpPathToLocalPath(
}
}
type CanvasDocumentRoot = Awaited<ReturnType<typeof fsRoot>>;
async function createFilesystemCanvasRoot(rootDir: string): Promise<CanvasDocumentStorageRoot> {
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
await fs.mkdir(rootDir, { recursive: true });
const root = await fsRoot(rootDir);
return {
async write(logicalPath, value) {
await root.write(logicalPath, value);
},
async copyIn(logicalPath, sourcePath) {
await root.copyIn(logicalPath, sourcePath);
},
};
}
async function clearSqliteCanvasDocument(documentId: string, stateDir?: string): Promise<void> {
const store = canvasDocumentBlobStore(stateDir);
const prefix = `${documentId}/`;
const entries = await store.entries();
await Promise.all(
entries.filter((entry) => entry.key.startsWith(prefix)).map((entry) => store.delete(entry.key)),
);
}
function createSqliteCanvasRoot(documentId: string, stateDir?: string): CanvasDocumentStorageRoot {
const files = new Map<string, { blob: Buffer; contentType?: string }>();
return {
async write(logicalPath, value) {
files.set(normalizeLogicalPath(logicalPath), {
blob: Buffer.from(value, "utf8"),
contentType: contentTypeForLogicalPath(logicalPath),
});
},
async copyIn(logicalPath, sourcePath, options) {
const normalized = normalizeLogicalPath(logicalPath);
files.set(normalized, {
blob: await fs.readFile(sourcePath),
contentType: options?.contentType ?? contentTypeForLogicalPath(normalized),
});
},
async flush() {
await clearSqliteCanvasDocument(documentId, stateDir);
const store = canvasDocumentBlobStore(stateDir);
await Promise.all(
[...files.entries()].map(([logicalPath, file]) =>
store.register(
`${documentId}/${logicalPath}`,
{
documentId,
logicalPath,
role: logicalPath === "manifest.json" ? "manifest" : "file",
...(file.contentType ? { contentType: file.contentType } : {}),
},
file.blob,
),
),
);
},
};
}
function contentTypeForLogicalPath(logicalPath: string): string | undefined {
const lower = logicalPath.toLowerCase();
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
return "text/html; charset=utf-8";
}
if (lower.endsWith(".json")) {
return "application/json; charset=utf-8";
}
if (lower.endsWith(".pdf")) {
return "application/pdf";
}
if (lower.endsWith(".png")) {
return "image/png";
}
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg";
}
if (lower.endsWith(".gif")) {
return "image/gif";
}
if (lower.endsWith(".webp")) {
return "image/webp";
}
if (lower.endsWith(".mp3")) {
return "audio/mpeg";
}
if (lower.endsWith(".mp4")) {
return "video/mp4";
}
return undefined;
}
async function writeManifest(
root: CanvasDocumentRoot,
root: CanvasDocumentStorageRoot,
manifest: CanvasDocumentManifest,
): Promise<void> {
await root.writeJson("manifest.json", manifest, { space: 2 });
await root.write("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
}
async function copyAssets(
root: CanvasDocumentRoot,
root: CanvasDocumentStorageRoot,
assets: CanvasDocumentAsset[] | undefined,
workspaceDir: string,
): Promise<CanvasDocumentManifest["assets"]> {
@@ -211,7 +343,7 @@ async function copyAssets(
: path.isAbsolute(asset.sourcePath)
? path.resolve(asset.sourcePath)
: path.resolve(workspaceDir, asset.sourcePath);
await root.copyIn(logicalPath, sourcePath);
await root.copyIn(logicalPath, sourcePath, { contentType: asset.contentType });
copied.push({
logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
@@ -221,8 +353,8 @@ async function copyAssets(
}
async function materializeEntrypoint(
rootDir: string,
root: CanvasDocumentRoot,
documentId: string,
root: CanvasDocumentStorageRoot,
input: CanvasDocumentCreateInput,
workspaceDir: string,
): Promise<Pick<CanvasDocumentManifest, "entryUrl" | "localEntrypoint" | "externalUrl">> {
@@ -235,7 +367,7 @@ async function materializeEntrypoint(
await root.write(fileName, entrypoint.value);
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
};
}
if (entrypoint.type === "url") {
@@ -245,7 +377,7 @@ async function materializeEntrypoint(
return {
localEntrypoint: fileName,
externalUrl: entrypoint.value,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
};
}
return {
@@ -270,7 +402,7 @@ async function materializeEntrypoint(
await root.write("index.html", wrapper);
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
entryUrl: buildCanvasDocumentEntryUrl(documentId, "index.html"),
};
}
@@ -280,12 +412,12 @@ async function materializeEntrypoint(
await root.write("index.html", buildPdfWrapper(fileName));
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
entryUrl: buildCanvasDocumentEntryUrl(documentId, "index.html"),
};
}
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
};
}
@@ -295,15 +427,18 @@ export async function createCanvasDocument(
): Promise<CanvasDocumentManifest> {
const workspaceDir = options?.workspaceDir ?? process.cwd();
const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId();
const rootDir = resolveCanvasDocumentDir(id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
await fs.mkdir(rootDir, { recursive: true });
const root = await fsRoot(rootDir);
const fileBacked = Boolean(options?.canvasRootDir?.trim());
const rootDir = fileBacked
? resolveCanvasDocumentDir(id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
})
: "";
const root = fileBacked
? await createFilesystemCanvasRoot(rootDir)
: createSqliteCanvasRoot(id, options?.stateDir);
const assets = await copyAssets(root, input.assets, workspaceDir);
const entry = await materializeEntrypoint(rootDir, root, input, workspaceDir);
const entry = await materializeEntrypoint(id, root, input, workspaceDir);
const manifest: CanvasDocumentManifest = {
id,
kind: input.kind,
@@ -319,6 +454,7 @@ export async function createCanvasDocument(
assets,
};
await writeManifest(root, manifest);
await root.flush?.();
return manifest;
}
@@ -327,16 +463,106 @@ export function resolveCanvasDocumentAssets(
options?: { baseUrl?: string; stateDir?: string; canvasRootDir?: string },
): CanvasDocumentResolvedAsset[] {
const baseUrl = options?.baseUrl?.trim().replace(/\/+$/, "");
const documentDir = resolveCanvasDocumentDir(manifest.id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
const fileBacked = Boolean(options?.canvasRootDir?.trim());
const documentDir = fileBacked
? resolveCanvasDocumentDir(manifest.id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
})
: `sqlite:canvas/documents/${manifest.id}`;
return manifest.assets.map((asset) => ({
logicalPath: asset.logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
localPath: path.join(documentDir, asset.logicalPath),
localPath: fileBacked
? path.join(documentDir, asset.logicalPath)
: `${documentDir}/${asset.logicalPath}`,
url: baseUrl
? `${baseUrl}${buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath)}`
: buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath),
}));
}
function parseCanvasDocumentRequestPath(requestPath: string): {
documentId: string;
logicalPath: string;
} | null {
const trimmed = requestPath.trim();
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
const relative = pathWithoutQuery.startsWith(prefix)
? pathWithoutQuery.slice(prefix.length)
: pathWithoutQuery.startsWith(`/${CANVAS_DOCUMENTS_DIR_NAME}/`)
? pathWithoutQuery.slice(`/${CANVAS_DOCUMENTS_DIR_NAME}/`.length)
: null;
if (relative == null) {
return null;
}
const segments = relative
.split("/")
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
})
.filter(Boolean);
if (segments.length < 2) {
return null;
}
try {
return {
documentId: normalizeCanvasDocumentId(segments[0] ?? ""),
logicalPath: normalizeLogicalPath(segments.slice(1).join("/")),
};
} catch {
return null;
}
}
export async function readCanvasDocumentHttpBlob(
requestPath: string,
options?: { stateDir?: string },
): Promise<CanvasDocumentBlob | null> {
const parsed = parseCanvasDocumentRequestPath(requestPath);
if (!parsed) {
return null;
}
const entry = await canvasDocumentBlobStore(options?.stateDir).lookup(
`${parsed.documentId}/${parsed.logicalPath}`,
);
if (!entry) {
return null;
}
return {
documentId: parsed.documentId,
logicalPath: parsed.logicalPath,
...(entry.metadata.contentType ? { contentType: entry.metadata.contentType } : {}),
blob: entry.blob,
};
}
export async function resolveCanvasHttpPathToMaterializedLocalPath(
requestPath: string,
options?: { stateDir?: string; rootDir?: string },
): Promise<string | null> {
const filePath = resolveCanvasHttpPathToLocalPath(requestPath, options);
if (filePath) {
return filePath;
}
const entry = await readCanvasDocumentHttpBlob(requestPath, options);
if (!entry) {
return null;
}
const materializationDir = path.join(
resolvePreferredOpenClawTmpDir(),
"canvas-documents",
entry.documentId,
);
await fs.mkdir(materializationDir, { recursive: true, mode: 0o700 });
const normalizedLogicalPath = normalizeLogicalPath(entry.logicalPath);
const filePathOut = path.join(materializationDir, normalizedLogicalPath);
await fs.mkdir(path.dirname(filePathOut), { recursive: true, mode: 0o700 });
await fs.writeFile(filePathOut, entry.blob);
return filePathOut;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
import { beforeAll, describe, expect, it } from "vitest";
@@ -11,7 +12,7 @@ describe("canvas host state dir defaults", () => {
({ createCanvasHostHandler } = await import("./server.js"));
});
it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => {
it("uses a temp materialization root by default", async () => {
await withStateDirEnv("openclaw-canvas-state-", async ({ stateDir }) => {
const handler = await createCanvasHostHandler({
runtime: defaultRuntime,
@@ -19,10 +20,13 @@ describe("canvas host state dir defaults", () => {
});
try {
const expectedRoot = await fs.realpath(path.join(stateDir, "canvas"));
const tempRoot = await fs.realpath(
path.join(resolvePreferredOpenClawTmpDir(), "canvas-host"),
);
const actualRoot = await fs.realpath(handler.rootDir);
expect(actualRoot).toBe(expectedRoot);
const indexPath = path.join(expectedRoot, "index.html");
expect(actualRoot).toBe(tempRoot);
expect(actualRoot.startsWith(await fs.realpath(stateDir))).toBe(false);
const indexPath = path.join(tempRoot, "index.html");
const indexContents = await fs.readFile(indexPath, "utf8");
expect(indexContents).toContain("OpenClaw Canvas");
} finally {

View File

@@ -12,13 +12,14 @@ import {
import chokidar from "chokidar";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import {
lowercasePreservingWhitespace,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { type WebSocket, WebSocketServer } from "ws";
import { readCanvasDocumentHttpBlob } from "../documents.js";
import {
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
@@ -214,7 +215,7 @@ async function prepareCanvasRoot(rootDir: string) {
}
function resolveDefaultCanvasRoot(): string {
const candidates = [path.join(resolveStateDir(), "canvas")];
const candidates = [path.join(resolvePreferredOpenClawTmpDir(), "canvas-host")];
const existing = candidates.find((dir) => {
try {
return fsSync.statSync(dir).isDirectory();
@@ -374,6 +375,14 @@ export async function createCanvasHostHandler(
return true;
}
const documentBlob = await readCanvasDocumentHttpBlob(`${CANVAS_HOST_PATH}${urlPath}`);
if (documentBlob) {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", documentBlob.contentType ?? "application/octet-stream");
res.end(req.method === "HEAD" ? undefined : documentBlob.blob);
return true;
}
const opened = await resolveFileWithinRoot(rootReal, urlPath);
if (!opened) {
if (urlPath === "/" || urlPath.endsWith("/")) {

View File

@@ -20,6 +20,7 @@ type OAuthCredentials = {
refresh: string;
access: string;
expires: number;
email?: string;
[key: string]: unknown;
};

View File

@@ -39,7 +39,6 @@
"blurb": "self-hosted chat via first-class ClickClack bot tokens.",
"systemImage": "bubble.left.and.bubble.right",
"markdownCapable": true,
"preferSessionLookupForAnnounceTarget": true,
"order": 85,
"commands": {
"nativeCommandsAutoEnabled": false,

View File

@@ -119,9 +119,7 @@ export async function handleClickClackInbound(params: {
}
const senderName = message.author?.display_name || message.author_id;
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
}),
agentId: route.agentId,
sessionKey: route.sessionKey,
});
const body = runtime.channel.reply.formatAgentEnvelope({
@@ -132,9 +130,6 @@ export async function handleClickClackInbound(params: {
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
body: message.body,
});
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
});
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
@@ -169,7 +164,6 @@ export async function handleClickClackInbound(params: {
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:

View File

@@ -71,9 +71,12 @@ export function createCodexAppServerAgentHarness(options?: {
});
},
reset: async (params) => {
if (params.sessionFile) {
if (params.sessionId || params.sessionKey) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
await clearCodexAppServerBinding({
sessionKey: params.sessionKey,
sessionId: params.sessionId,
});
}
},
dispose: async () => {

View File

@@ -114,7 +114,7 @@
},
"marketplaceName": {
"type": "string",
"enum": ["openai-curated"]
"enum": ["openai-curated", "openai-bundled", "openai-primary-runtime"]
},
"pluginName": {
"type": "string"

View File

@@ -14,7 +14,10 @@ import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { isJsonObject } from "./protocol.js";
import type { CodexAppServerThreadBinding } from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import {
readCodexMirroredSessionHistoryMessages,
type CodexMirroredSessionHistoryScope,
} from "./session-history.js";
import {
areCodexDynamicToolFingerprintsCompatible,
buildContextEngineBinding,
@@ -70,12 +73,12 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
};
export async function readMirroredSessionHistoryMessages(
sessionFile: string,
transcriptScope: CodexMirroredSessionHistoryScope,
): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
const messages = await readCodexMirroredSessionHistoryMessages(transcriptScope);
if (!messages) {
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
sessionFile,
transcriptScope,
});
}
return messages;

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -914,14 +915,20 @@ describe("bridgeCodexAppServerStartOptions", () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:aws",
credential: {
type: "aws-sdk",
provider: "openai",
} as never,
});
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
profiles: {
"openai:aws": {
type: "aws-sdk",
provider: "openai",
} as never,
},
},
},
]);
await expect(
applyCodexAppServerAuthProfile({
@@ -1407,11 +1414,10 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("refreshes inherited main Codex OAuth without cloning it into the child store", async () => {
it("refreshes inherited main Codex OAuth through the owner store", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
@@ -1446,7 +1452,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-refresh-token");
await expectPathMissing(childAuthPath);
const mainProfile = expectOAuthProfile(
loadAuthProfileStoreForSecretsRuntime().profiles["openai:work"],
);
@@ -1462,7 +1467,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
@@ -1484,24 +1488,19 @@ describe("bridgeCodexAppServerStartOptions", () => {
email: "main-codex@example.test",
},
});
await fs.mkdir(childAgentDir, { recursive: true });
await fs.writeFile(
childAuthPath,
JSON.stringify({
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
expires: Date.now() - 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
},
}),
);
upsertAuthProfile({
agentDir: childAgentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
expires: Date.now() - 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
@@ -1524,8 +1523,8 @@ describe("bridgeCodexAppServerStartOptions", () => {
const childProfile = expectOAuthProfile(
loadAuthProfileStoreForSecretsRuntime(childAgentDir).profiles["openai:work"],
);
expect(childProfile?.access).toBe("child-stale-access-token");
expect(childProfile?.refresh).toBe("child-stale-refresh-token");
expect(childProfile?.access).toBe("main-refreshed-access-token");
expect(childProfile?.refresh).toBe("main-refreshed-refresh-token");
} finally {
await fs.rm(root, { recursive: true, force: true });
}

View File

@@ -17,6 +17,7 @@ import {
type AuthProfileStore,
type OAuthCredential,
} from "openclaw/plugin-sdk/agent-runtime";
import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type {
@@ -595,7 +596,49 @@ async function resolveOAuthCredentialForCodexAppServer(
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
const resolvedCredential = resolved?.apiKey
? { ...candidate, access: resolved.apiKey }
: candidate;
if (params.forceRefresh) {
await mirrorOwnerRefreshToLocalOAuthClone({
agentDir: params.agentDir,
ownerAgentDir,
profileId,
credential: resolvedCredential,
config: params.config,
});
}
return resolvedCredential;
}
async function mirrorOwnerRefreshToLocalOAuthClone(params: {
agentDir: string;
ownerAgentDir?: string;
profileId: string;
credential: OAuthCredential;
config?: AuthProfileOrderConfig;
}): Promise<void> {
if (params.ownerAgentDir === params.agentDir) {
return;
}
// Only rewrite an existing local clone. Missing child rows should keep
// inheriting the owner profile instead of materializing a new copy.
await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
saveOptions: {
filterExternalAuthProfiles: false,
forceLocalProfileIds: [params.profileId],
syncExternalCli: false,
},
updater: (store) => {
const local = store.profiles[params.profileId];
if (local?.type !== "oauth" || !isCodexAppServerAuthProvider(local.provider, params.config)) {
return false;
}
store.profiles[params.profileId] = { ...params.credential };
return true;
},
});
}
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {

View File

@@ -6,6 +6,10 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
@@ -37,12 +41,15 @@ function runCodexAppServerAttempt(
);
}
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
function testSessionId(suffix: string = AUTH_PROFILE_RUNTIME_CONTRACT.sessionId): string {
return suffix;
}
function createParams(sessionId: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
sessionFile,
sessionKey: `agent:main:${sessionId}`,
sessionId,
workspaceDir,
runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId,
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
@@ -158,18 +165,22 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
});
afterEach(async () => {
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
resetCodexAppServerClientFactoryForTest();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("passes the exact OpenAI Codex auth profile into app-server startup", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
const sessionId = testSessionId();
const params = createParams(sessionId, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
params.agentDir = tmpDir;
@@ -189,15 +200,18 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
dynamicToolsFingerprint: "[]",
});
const sessionId = testSessionId("auth-profile-resume");
await writeCodexAppServerBinding(
{ sessionKey: `agent:main:${sessionId}`, sessionId },
{
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
dynamicToolsFingerprint: "[]",
},
);
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
const params = createParams(sessionFile, tmpDir);
const params = createParams(sessionId, tmpDir);
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -214,14 +228,17 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai:stale",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, tmpDir);
const sessionId = testSessionId("auth-profile-abort");
await writeCodexAppServerBinding(
{ sessionKey: `agent:main:${sessionId}`, sessionId },
{
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai:stale",
dynamicToolsFingerprint: "[]",
},
);
const params = createParams(sessionId, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);
@@ -236,7 +253,10 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
await harness.completeTurn();
await run;
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.authProfileId).toBe(AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId);
await expect(
readCodexAppServerBinding({ sessionKey: params.sessionKey, sessionId }),
).resolves.toMatchObject({
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
});
});

View File

@@ -10,10 +10,16 @@ import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
import type { CodexServerNotification } from "./protocol.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
let tempDir: string;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
const testSessionKey = "agent:main:session-1";
const testSessionId = "session-1";
type MaybeCompactOptions = NonNullable<Parameters<typeof maybeCompactCodexAppServerSessionImpl>[1]>;
@@ -37,44 +43,47 @@ function maybeCompactCodexAppServerSession(
}
async function writeTestBinding(
options: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
options: Partial<CodexAppServerThreadBinding> = {},
): Promise<string> {
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
...options,
});
return sessionFile;
await writeCodexAppServerBinding(
{
sessionKey: testSessionKey,
sessionId: testSessionId,
},
{
threadId: "thread-1",
cwd: tempDir,
...options,
},
);
return testSessionId;
}
function startCompaction(sessionFile: string, options: { currentTokenCount?: number } = {}) {
function startCompaction(sessionId: string, options: { currentTokenCount?: number } = {}) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
sessionId,
sessionKey: testSessionKey,
workspaceDir: tempDir,
trigger: "manual",
...options,
});
}
function startSandboxedCompaction(sessionFile: string) {
function startSandboxedCompaction(sessionId: string) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
sessionId,
sessionKey: testSessionKey,
workspaceDir: tempDir,
trigger: "manual",
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
}
function startNodeExecCompaction(sessionFile: string) {
function startNodeExecCompaction(path: string) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path,
workspaceDir: tempDir,
trigger: "manual",
config: { tools: { exec: { host: "node", node: "worker-1" } } },
@@ -135,7 +144,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
@@ -164,7 +173,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
currentTokenCount: 789,
}),
@@ -260,10 +269,8 @@ describe("maybeCompactCodexAppServerSession", () => {
});
it("reports missing thread bindings as failed native compaction", async () => {
const sessionFile = path.join(tempDir, "missing-binding.jsonl");
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 123 }),
await startCompaction("missing-binding", { currentTokenCount: 123 }),
);
expect(result.ok).toBe(false);
@@ -336,7 +343,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
@@ -372,7 +379,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:sara:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
@@ -414,7 +421,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
@@ -463,7 +470,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:lossless:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "manual",
contextEngine,
@@ -487,7 +494,15 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(fake.request).not.toHaveBeenCalled();
expect(contextEngine.compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:lossless:session-1",
compactionTarget: "threshold",
force: true,
}),
);
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
@@ -517,7 +532,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:lossless-child:session-1",
sessionFile,
path: sessionFile,
workspaceDir: tempDir,
trigger: "manual",
contextEngine,
@@ -545,7 +560,15 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(fake.request).not.toHaveBeenCalled();
expect(contextEngine.compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:lossless-child:session-1",
compactionTarget: "threshold",
force: true,
}),
);
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
@@ -564,17 +587,18 @@ describe("maybeCompactCodexAppServerSession", () => {
const fake = createFakeCodexClient();
const factory = vi.fn(async () => fake.client);
setCodexAppServerClientFactoryForTest(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai:binding",
});
await writeCodexAppServerBinding(
{ sessionKey: testSessionKey, sessionId: testSessionId },
{
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai:binding",
},
);
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
sessionId: testSessionId,
sessionKey: testSessionKey,
workspaceDir: tempDir,
trigger: "manual",
authProfileId: "openai:runtime",
@@ -588,7 +612,7 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(factory).not.toHaveBeenCalled();
});
it("forwards compaction to native Codex even when a context engine owns compaction", async () => {
it("forwards compaction to native Codex when the context engine does not own compaction", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
@@ -608,6 +632,61 @@ describe("maybeCompactCodexAppServerSession", () => {
rewrittenEntries: 0,
}),
);
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: false },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
currentTokenCount: 123,
trigger: "manual",
}),
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
});
expect(compact).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-1",
});
});
it("delegates to an owning context engine without requiring a Codex binding", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 123,
},
}));
const maintain = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}),
);
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
@@ -620,39 +699,94 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
contextTokenBudget: 777,
currentTokenCount: 123,
trigger: "manual",
}),
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
});
expect(compact).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-1",
});
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
expect(result.result?.firstKeptEntryId).toBe("entry-1");
expect(result.result?.tokensBefore).toBe(123);
const details = compactDetails(result);
expect(details.engine).toBe("lossless-claw");
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(
await readCodexAppServerBinding({
sessionKey: "agent:main:session-1",
sessionId: "session-1",
}),
).toBeUndefined();
expect(compact).toHaveBeenCalledTimes(1);
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
transcriptScope: expect.objectContaining({ agentId: "main", sessionId: "session-1" }),
tokenBudget: 777,
currentTokenCount: 123,
compactionTarget: "threshold",
customInstructions: undefined,
force: true,
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
abortSignal: expect.any(AbortSignal),
}),
);
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
| {
sessionId?: string;
sessionKey?: string;
runtimeContext?: { workspaceDir?: string; provider?: string };
}
| undefined;
expect(maintainParams?.sessionId).toBe("session-1");
expect(maintainParams?.sessionKey).toBe("agent:main:session-1");
expect(maintainParams?.runtimeContext?.workspaceDir).toBe(tempDir);
expect(maintainParams?.runtimeContext?.provider).toBe("codex");
expect(info).toHaveBeenCalledWith(
"starting context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
tokenBudget: 777,
currentTokenCount: 123,
trigger: "manual",
compactionTarget: "threshold",
force: true,
}),
);
expect(info).toHaveBeenCalledWith(
"completed context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
ok: true,
compacted: true,
codexThreadBindingInvalidated: true,
}),
);
});
it("requires a Codex binding instead of delegating to an owning context engine", async () => {
it("honors explicit force for budget-triggered owning context-engine compaction", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 123,
tokensBefore: 900,
tokensAfter: 100,
},
}));
const contextEngine: ContextEngine = {
@@ -662,21 +796,291 @@ describe("maybeCompactCodexAppServerSession", () => {
compact,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
contextTokenBudget: 777,
currentTokenCount: 900,
trigger: "budget",
force: true,
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
compactionTarget: "budget",
force: true,
}),
);
expect(info).toHaveBeenCalledWith(
"starting context-engine-owned Codex app-server compaction",
expect.objectContaining({
trigger: "budget",
compactionTarget: "budget",
force: true,
}),
);
});
it("adopts successor session id after owning context-engine compaction", async () => {
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
sessionId: "session-1-compacted",
},
}));
const maintain = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}),
);
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.sessionId).toBe("session-1-compacted");
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
| {
sessionId?: string;
}
| undefined;
expect(maintainParams?.sessionId).toBe("session-1-compacted");
});
it("returns context-engine compaction success when maintenance fails", async () => {
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
},
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain: vi.fn(async () => {
throw new Error("maintenance boom");
}),
};
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
const details = compactDetails(result);
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(compact).toHaveBeenCalledTimes(1);
});
it("does not require a Codex binding when the owning context engine compacts", async () => {
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 8,
},
}));
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
path: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
trigger: "manual",
});
expect(result).toMatchObject({
ok: false,
compacted: false,
failure: { reason: "missing_thread_binding" },
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.compacted).toBe(true);
expect(compactResult.result?.summary).toBe("engine summary");
expect(compact).toHaveBeenCalledTimes(1);
expect(maintain).toHaveBeenCalledTimes(1);
});
it("does not run context-engine maintenance when owning compaction does not compact", async () => {
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact: vi.fn(async () => ({
ok: true,
compacted: false,
reason: "below threshold",
})),
maintain,
};
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
});
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.compacted).toBe(false);
expect(compactResult.reason).toBe("below threshold");
expect(maintain).not.toHaveBeenCalled();
});
describe("owning context-engine compaction safety timeout", () => {
afterEach(() => {
vi.useRealTimers();
});
it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => {
const sessionFile = await writeTestBinding();
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
vi.useFakeTimers();
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
// 1 s host-resolved compaction timeout.
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
});
await vi.advanceTimersByTimeAsync(1_000);
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain("timed out");
expect(compact).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("threads a composed caller abort signal into the owning context-engine compact()", async () => {
const sessionFile = await writeTestBinding();
const controller = new AbortController();
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
ok: true,
compacted: false,
reason: "below threshold",
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
abortSignal: controller.signal,
});
expect(compact).toHaveBeenCalledTimes(1);
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
});
it("aborts a hung owning context-engine compact() when the caller signal fires", async () => {
const sessionFile = await writeTestBinding();
const controller = new AbortController();
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
path: sessionFile,
workspaceDir: tempDir,
contextEngine,
abortSignal: controller.signal,
});
controller.abort(new Error("run aborted"));
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain("run aborted");
expect(compact).toHaveBeenCalledTimes(1);
});
expect(compact).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,9 @@
import {
compactContextEngineWithSafetyTimeout,
embeddedAgentLog,
formatErrorMessage,
resolveCompactionTimeoutMs,
runHarnessContextEngineMaintenance,
type CompactEmbeddedAgentSessionParams,
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -10,7 +14,11 @@ import {
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
type CodexAppServerBindingIdentity,
} from "./session-binding.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
@@ -19,12 +27,145 @@ export async function maybeCompactCodexAppServerSession(
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point starts native Codex compaction for the bound thread and
// returns immediately; Codex applies the compaction inside its app-server.
if (params.contextEngine?.info.ownsCompaction === true) {
return compactOwningContextEngine(params, params.contextEngine);
}
return compactCodexNativeThread(params, options);
}
async function compactOwningContextEngine(
params: CompactEmbeddedAgentSessionParams,
contextEngine: NonNullable<CompactEmbeddedAgentSessionParams["contextEngine"]>,
): Promise<EmbeddedAgentCompactResult> {
const compactionTarget = params.trigger === "manual" ? "threshold" : "budget";
const force = params.force === true || params.trigger === "manual";
embeddedAgentLog.info("starting context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
trigger: params.trigger,
compactionTarget,
force,
});
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
try {
result = await compactContextEngineWithSafetyTimeout(
contextEngine,
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
transcriptScope: buildContextEngineTranscriptScope(params),
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
compactionTarget,
customInstructions: params.customInstructions,
force,
runtimeContext: params.contextEngineRuntimeContext,
},
resolveCompactionTimeoutMs(params.config),
params.abortSignal,
);
} catch (error) {
embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
return {
ok: false,
compacted: false,
reason: `context engine compaction failed: ${formatErrorMessage(error)}`,
};
}
if (result.ok && result.compacted) {
const compactedSessionId = result.result?.sessionId ?? params.sessionId;
try {
await runHarnessContextEngineMaintenance({
contextEngine,
sessionId: compactedSessionId,
sessionKey: params.sessionKey,
transcriptScope: buildContextEngineTranscriptScope({
...params,
sessionId: compactedSessionId,
}),
reason: "compaction",
runtimeContext: params.contextEngineRuntimeContext,
config: params.config,
});
} catch (error) {
embeddedAgentLog.warn("context engine compaction maintenance failed", {
sessionId: compactedSessionId,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
}
await clearCodexAppServerBinding({
sessionKey: params.sessionKey,
sessionId: params.sessionId,
});
}
embeddedAgentLog.info("completed context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
codexThreadBindingInvalidated: result.ok && result.compacted,
});
return {
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
result: result.result
? {
...result.result,
summary: result.result.summary ?? "",
firstKeptEntryId: result.result.firstKeptEntryId ?? "",
details: mergeContextEngineCompactionDetails(result.result.details, {
engine: contextEngine.info.id,
codexThreadBindingInvalidated: result.ok && result.compacted,
}),
}
: result.ok && result.compacted
? {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: { engine: contextEngine.info.id, codexThreadBindingInvalidated: true },
}
: undefined,
};
}
function mergeContextEngineCompactionDetails(
details: unknown,
extra: Record<string, unknown>,
): unknown {
if (details && typeof details === "object" && !Array.isArray(details)) {
return {
...(details as Record<string, unknown>),
...extra,
};
}
return extra;
}
function buildContextEngineTranscriptScope(
params: Pick<CompactEmbeddedAgentSessionParams, "agentId" | "path" | "sessionId">,
): { agentId: string; path?: string; sessionId: string } {
return {
agentId: params.agentId ?? "main",
...(params.path ? { path: params.path } : {}),
sessionId: params.sessionId,
};
}
function warnIfIgnoringOpenClawCompactionOverrides(
params: CompactEmbeddedAgentSessionParams,
): void {
@@ -161,7 +302,11 @@ async function compactCodexNativeThread(
return { ok: false, compacted: false, reason: nativeExecutionBlock };
}
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
const bindingIdentity: CodexAppServerBindingIdentity = {
sessionKey: params.sessionKey,
sessionId: params.sessionId,
};
const binding = await readCodexAppServerBinding(bindingIdentity, { config: params.config });
if (!binding?.threadId) {
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",

View File

@@ -653,6 +653,59 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([]);
});
it("accepts native plugin identities from every first-party OpenAI marketplace", () => {
// OpenAI ships first-party Codex plugins across three marketplaces: the local
// openai-bundled marketplace shipped with Codex.app (chrome, browser, computer-use,
// latex-tectonic), the remote openai-curated marketplace, and the
// openai-primary-runtime marketplace owned by the Codex primary runtime
// (documents, spreadsheets, presentations). All three should resolve.
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
plugins: {
chrome: {
marketplaceName: "openai-bundled",
pluginName: "chrome",
},
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
documents: {
marketplaceName: "openai-primary-runtime",
pluginName: "documents",
},
},
},
});
expect(config.codexPlugins?.enabled).toBe(true);
const policy = resolveCodexPluginsPolicy(config);
expect(policy.pluginPolicies).toEqual([
{
configKey: "chrome",
marketplaceName: "openai-bundled",
pluginName: "chrome",
enabled: true,
allowDestructiveActions: true,
},
{
configKey: "documents",
marketplaceName: "openai-primary-runtime",
pluginName: "documents",
enabled: true,
allowDestructiveActions: true,
},
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
},
]);
});
it("treats configured and environment commands as explicit overrides", () => {
expectFields(
resolveRuntimeForTest({

View File

@@ -60,7 +60,30 @@ type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean;
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
// OpenAI ships first-party Codex plugins across three marketplaces:
// - openai-curated: remote curated marketplace, fetched via `codex plugin marketplace add`
// - openai-bundled: local marketplace that ships with Codex.app and the Codex CLI
// (browser, chrome, computer-use, latex-tectonic)
// - openai-primary-runtime: marketplace owned by the Codex primary runtime
// (documents, spreadsheets, presentations)
// All three are owned by OpenAI. Allow activating plugins from any of them.
export const CODEX_PLUGINS_MARKETPLACE_NAMES = [
"openai-curated",
"openai-bundled",
"openai-primary-runtime",
] as const;
export type CodexPluginsMarketplaceName = (typeof CODEX_PLUGINS_MARKETPLACE_NAMES)[number];
// Back-compat constant for callers that still reference the curated marketplace by name.
export const CODEX_PLUGINS_MARKETPLACE_NAME: CodexPluginsMarketplaceName = "openai-curated";
export function isCodexPluginsMarketplaceName(
name: string | undefined,
): name is CodexPluginsMarketplaceName {
return (
name !== undefined && (CODEX_PLUGINS_MARKETPLACE_NAMES as readonly string[]).includes(name)
);
}
export type CodexComputerUseConfig = {
enabled?: boolean;
@@ -103,7 +126,7 @@ export type CodexAppServerExperimentalConfig = {
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
marketplaceName: CodexPluginsMarketplaceName;
pluginName: string;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
@@ -130,7 +153,7 @@ export type CodexAppServerStartOptions = {
export type CodexAppServerRuntimeOptions = {
start: CodexAppServerStartOptions;
codeModeOnly: boolean;
codeModeOnly?: boolean;
requestTimeoutMs: number;
turnCompletionIdleTimeoutMs: number;
postToolRawAssistantCompletionIdleTimeoutMs?: number;
@@ -255,7 +278,7 @@ const codexAppServerExperimentalSchema = z
const codexPluginEntryConfigSchema = z
.object({
enabled: z.boolean().optional(),
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
marketplaceName: z.enum(CODEX_PLUGINS_MARKETPLACE_NAMES).optional(),
pluginName: z.string().trim().min(1).optional(),
allow_destructive_actions: z.boolean().optional(),
})
@@ -365,13 +388,13 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
const allowDestructiveActions = config?.allow_destructive_actions ?? true;
const pluginPolicies = Object.entries(config?.plugins ?? {})
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
if (!isCodexPluginsMarketplaceName(entry.marketplaceName) || !entry.pluginName) {
return [];
}
return [
{
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
marketplaceName: entry.marketplaceName,
pluginName: entry.pluginName,
enabled: enabled && entry.enabled !== false,
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,

View File

@@ -18,13 +18,10 @@ type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNoti
async function createParams(): Promise<EmbeddedRunAttemptParams> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-"));
tempDirs.add(tempDir);
const sessionFile = path.join(tempDir, "session.jsonl");
SessionManager.open(sessionFile);
return {
prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt,
sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId,
sessionKey: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionKey,
sessionFile,
workspaceDir: tempDir,
runId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.runId,
provider: "codex",

View File

@@ -36,7 +36,7 @@ function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
} as unknown as AnyAgentTool;
}
function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult<unknown> {
function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult {
return {
content: [{ type: "text", text: "Generated media reply." }],
details: {
@@ -48,7 +48,7 @@ function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult<
};
}
function textToolResult(text: string, details: unknown = {}): AgentToolResult<unknown> {
function textToolResult(text: string, details: unknown = {}): AgentToolResult {
return {
content: [{ type: "text", text }],
details,
@@ -57,7 +57,7 @@ function textToolResult(text: string, details: unknown = {}): AgentToolResult<un
function createBridgeWithToolResult(
toolName: string,
toolResult: AgentToolResult<unknown>,
toolResult: AgentToolResult,
hookContext?: Parameters<typeof createCodexDynamicToolBridge>[0]["hookContext"],
) {
return createCodexDynamicToolBridge({
@@ -131,7 +131,7 @@ function expectContextFields(context: unknown, fields: Record<string, unknown>)
}
}
function expectToolResult(value: unknown, expected: AgentToolResult<unknown>) {
function expectToolResult(value: unknown, expected: AgentToolResult) {
const result = requireRecord(value, "tool result");
expect(result.content).toEqual(expected.content);
expect(result.details).toEqual(expected.details);
@@ -610,7 +610,7 @@ describe("createCodexDynamicToolBridge", () => {
audioAsVoice: true,
},
},
} satisfies AgentToolResult<unknown>;
} satisfies AgentToolResult;
const tool = createTool({
execute: vi.fn(async () => toolResult),
});
@@ -640,7 +640,7 @@ describe("createCodexDynamicToolBridge", () => {
const toolResult = {
content: [{ type: "text", text: "Sent." }],
details: { messageId: "message-1" },
} satisfies AgentToolResult<unknown>;
} satisfies AgentToolResult;
const tool = createTool({
name: "message",
execute: vi.fn(async () => toolResult),
@@ -679,7 +679,7 @@ describe("createCodexDynamicToolBridge", () => {
const toolResult = {
content: [{ type: "text", text: "Sent." }],
details: { messageId: "message-1" },
} satisfies AgentToolResult<unknown>;
} satisfies AgentToolResult;
const tool = createTool({
name: "message",
execute: vi.fn(async () => toolResult),
@@ -808,14 +808,12 @@ describe("createCodexDynamicToolBridge", () => {
it("applies agent tool result middleware from the active plugin registry", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(
async (event: { result: AgentToolResult<unknown>; toolName: string }) => ({
result: {
...event.result,
content: [{ type: "text" as const, text: `${event.toolName} compacted` }],
},
}),
);
const handler = vi.fn(async (event: { result: AgentToolResult; toolName: string }) => ({
result: {
...event.result,
content: [{ type: "text" as const, text: `${event.toolName} compacted` }],
},
}));
registry.agentToolResultMiddlewares.push({
pluginId: "tokenjuice",
pluginName: "Tokenjuice",
@@ -1000,7 +998,7 @@ describe("createCodexDynamicToolBridge", () => {
it("uses raw tool provenance for media trust after middleware rewrites details", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async (event: { result: AgentToolResult<unknown> }) => ({
const handler = vi.fn(async (event: { result: AgentToolResult }) => ({
result: {
...event.result,
content: [{ type: "text" as const, text: "Generated media reply." }],
@@ -1047,7 +1045,7 @@ describe("createCodexDynamicToolBridge", () => {
const factory = async (codex: {
on: (
event: "tool_result",
handler: (event: any) => Promise<{ result: AgentToolResult<unknown> }>,
handler: (event: any) => Promise<{ result: AgentToolResult }>,
) => void;
}) => {
codex.on("tool_result", async (event) => ({
@@ -1084,7 +1082,7 @@ describe("createCodexDynamicToolBridge", () => {
});
it("keeps config out of Codex tool-result contexts", async () => {
const config = { session: { store: "/tmp/openclaw-session-store.json" } };
const config = { session: {} };
const registry = createEmptyPluginRegistry();
const middlewareContexts: Record<string, unknown>[] = [];
const legacyContexts: Record<string, unknown>[] = [];
@@ -1098,7 +1096,7 @@ describe("createCodexDynamicToolBridge", () => {
handler: (
event: unknown,
ctx: Record<string, unknown>,
) => Promise<{ result: AgentToolResult<unknown> } | void>,
) => Promise<{ result: AgentToolResult } | void>,
) => void;
}) => {
codex.on("tool_result", async (eventValue, ctx) => {
@@ -1369,7 +1367,7 @@ describe("createCodexDynamicToolBridge", () => {
);
const registry = createEmptyPluginRegistry();
const handler = vi.fn(
async (event: { args: Record<string, unknown>; result: AgentToolResult<unknown> }) => {
async (event: { args: Record<string, unknown>; result: AgentToolResult }) => {
events.push("middleware");
expect(event.args).toEqual({ command: "status" });
return {
@@ -1466,10 +1464,10 @@ describe("createCodexDynamicToolBridge", () => {
it("passes per-call abort signals into dynamic tool execution", async () => {
let capturedSignal: AbortSignal | undefined;
let resolveTool: ((result: AgentToolResult<unknown>) => void) | undefined;
let resolveTool: ((result: AgentToolResult) => void) | undefined;
const execute = vi.fn(
async (_callId: string, _args: Record<string, unknown>, signal: AbortSignal) =>
await new Promise<AgentToolResult<unknown>>((resolve) => {
await new Promise<AgentToolResult>((resolve) => {
capturedSignal = signal;
resolveTool = resolve;
}),

View File

@@ -21,7 +21,7 @@ import {
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/provider-ai";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
asOptionalRecord as readRecord,
@@ -435,8 +435,8 @@ function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortS
function collectToolTelemetry(params: {
toolName: string;
args: Record<string, unknown>;
result: AgentToolResult<unknown> | undefined;
mediaTrustResult?: AgentToolResult<unknown>;
result: AgentToolResult | undefined;
mediaTrustResult?: AgentToolResult;
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
}): void {
@@ -543,7 +543,7 @@ function readPositiveInteger(value: unknown): number | undefined {
return Math.floor(value);
}
function isToolResultError(result: AgentToolResult<unknown>): boolean {
function isToolResultError(result: AgentToolResult): boolean {
const details = result.details;
if (!isRecord(details)) {
return false;
@@ -572,7 +572,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
);
}
function isToolResultYield(result: AgentToolResult<unknown>): boolean {
function isToolResultYield(result: AgentToolResult): boolean {
const details = result.details;
if (!isRecord(details) || typeof details.status !== "string") {
return false;
@@ -580,13 +580,13 @@ function isToolResultYield(result: AgentToolResult<unknown>): boolean {
return details.status.trim().toLowerCase() === "yielded";
}
function isAsyncStartedToolResult(result: AgentToolResult<unknown>): boolean {
function isAsyncStartedToolResult(result: AgentToolResult): boolean {
const details = result.details;
return isRecord(details) && details.async === true && details.status === "started";
}
function inferToolResultDiagnosticTerminalType(
result: AgentToolResult<unknown>,
result: AgentToolResult,
isError: boolean,
): CodexDynamicToolDiagnosticTerminalType {
const details = result.details;

View File

@@ -4,9 +4,9 @@ import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import {
embeddedAgentLog,
replaceSqliteSessionTranscriptEvents,
resetAgentEventsForTest,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
@@ -61,12 +61,23 @@ function assistantMessage(text: string, timestamp: number) {
async function createParams(): Promise<EmbeddedRunAttemptParams> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-"));
tempDirs.add(tempDir);
const sessionFile = path.join(tempDir, "session.jsonl");
SessionManager.open(sessionFile).appendMessage(assistantMessage("history", Date.now()));
const sessionId = "session-1";
replaceSqliteSessionTranscriptEvents({
agentId: "main",
sessionId,
events: [
{ type: "session", version: 1, id: sessionId },
{
type: "message",
id: "history",
parentId: null,
message: assistantMessage("history", Date.now()),
},
],
});
return {
prompt: "hello",
sessionId: "session-1",
sessionFile,
sessionId,
workspaceDir: tempDir,
runId: "run-1",
provider: "openai",
@@ -143,6 +154,19 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
function mockCallArg(
mock: { mock: { calls: unknown[][] } },
callIndex: number,
argIndex: number,
label: string,
) {
const call = mock.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected ${label} call`);
}
return call[argIndex];
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
@@ -161,18 +185,6 @@ function expectUsageFields(
expect(record.total ?? record.totalTokens).toBe(expected.total);
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex + 1}`);
}
return call[argIndex];
}
function findAgentEvent(
mock: unknown,
params: { stream: string; phase?: string; itemId?: string; name?: string },
@@ -705,8 +717,7 @@ describe("CodexAppServerEventProjector", () => {
},
}),
);
const toolProgressText = (mockCallArg(onToolResult, 0, 0, "onToolResult") as { text?: string })
.text;
const toolProgressText = onToolResult.mock.calls[0]?.[0]?.text;
expect(toolProgressText).toBe("🛠️ `run tests (workspace)`");
await projector.handleNotification(
@@ -1155,7 +1166,6 @@ describe("CodexAppServerEventProjector", () => {
{
prompt: "hello",
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
runId: "run-1",
provider: "openai",
@@ -1546,7 +1556,9 @@ describe("CodexAppServerEventProjector", () => {
const onAgentEvent = vi.fn();
const onToolResult = vi.fn();
const trajectoryRecorder = {
enabled: true as const,
filePath: "trajectory.jsonl",
runtimeScope: "sqlite:test:trajectory:session-1",
recordEvent: vi.fn(),
flush: vi.fn(async () => undefined),
};
@@ -1630,7 +1642,9 @@ describe("CodexAppServerEventProjector", () => {
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
const onAgentEvent = vi.fn();
const trajectoryRecorder = {
enabled: true as const,
filePath: "trajectory.jsonl",
runtimeScope: "sqlite:test:trajectory:session-1",
recordEvent: vi.fn(),
flush: vi.fn(async () => undefined),
};
@@ -1738,7 +1752,9 @@ describe("CodexAppServerEventProjector", () => {
it("does not duplicate native tool starts when the snapshot completes a started item", async () => {
const onAgentEvent = vi.fn();
const trajectoryRecorder = {
enabled: true as const,
filePath: "trajectory.jsonl",
runtimeScope: "sqlite:test:trajectory:session-1",
recordEvent: vi.fn(),
flush: vi.fn(async () => undefined),
};
@@ -3138,7 +3154,6 @@ describe("CodexAppServerEventProjector", () => {
it("fires before_compaction and after_compaction hooks for codex compaction items", async () => {
const { projector, beforeCompaction, afterCompaction } = await createProjectorWithHooks();
const openSpy = vi.spyOn(SessionManager, "open");
await projector.handleNotification(
forCurrentTurn("item/started", {
@@ -3150,35 +3165,26 @@ describe("CodexAppServerEventProjector", () => {
item: { type: "contextCompaction", id: "compact-1" },
}),
);
expect(openSpy).not.toHaveBeenCalled();
const beforePayload = requireRecord(
mockCallArg(beforeCompaction, 0, 0, "beforeCompaction"),
"before payload",
expect(beforeCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: 1,
messages: [expect.objectContaining({ role: "assistant" })],
}),
expect.objectContaining({
runId: "run-1",
sessionId: "session-1",
}),
);
expect(beforePayload.messageCount).toBe(1);
expect(String(beforePayload.sessionFile)).toContain("session.jsonl");
const beforeMessages = requireArray(beforePayload.messages, "before messages");
expect(requireRecord(beforeMessages[0], "before message").role).toBe("assistant");
const beforeContext = requireRecord(
mockCallArg(beforeCompaction, 0, 1, "beforeCompaction"),
"before context",
expect(afterCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: 1,
compactedCount: -1,
}),
expect.objectContaining({
runId: "run-1",
sessionId: "session-1",
}),
);
expect(beforeContext.runId).toBe("run-1");
expect(beforeContext.sessionId).toBe("session-1");
const afterPayload = requireRecord(
mockCallArg(afterCompaction, 0, 0, "afterCompaction"),
"after payload",
);
expect(afterPayload.messageCount).toBe(1);
expect(afterPayload.compactedCount).toBe(-1);
expect(String(afterPayload.sessionFile)).toContain("session.jsonl");
const afterContext = requireRecord(
mockCallArg(afterCompaction, 0, 1, "afterCompaction"),
"after context",
);
expect(afterContext.runId).toBe("run-1");
expect(afterContext.sessionId).toBe("session-1");
});
it("projects codex hook started and completed notifications into agent events", async () => {

View File

@@ -7,6 +7,7 @@ import {
formatToolProgressOutput,
inferToolMetaFromArgs,
normalizeUsage,
resolveSessionAgentIds,
runAgentHarnessAfterCompactionHook,
runAgentHarnessAfterToolCallHook,
runAgentHarnessBeforeCompactionHook,
@@ -21,7 +22,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import { generatedImageAssetFromBase64 } from "openclaw/plugin-sdk/image-generation";
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/provider-ai";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
@@ -538,7 +539,6 @@ export class CodexAppServerEventProjector {
if (item?.type === "contextCompaction" && itemId) {
this.activeCompactionItemIds.add(itemId);
await runAgentHarnessBeforeCompactionHook({
sessionFile: this.params.sessionFile,
messages: await this.readMirroredSessionMessages(),
ctx: {
runId: this.params.runId,
@@ -597,7 +597,6 @@ export class CodexAppServerEventProjector {
this.activeCompactionItemIds.delete(itemId);
this.completedCompactionCount += 1;
await runAgentHarnessAfterCompactionHook({
sessionFile: this.params.sessionFile,
messages: await this.readMirroredSessionMessages(),
compactedCount: -1,
ctx: {
@@ -831,20 +830,16 @@ export class CodexAppServerEventProjector {
if (readString(item, "role") !== "assistant") {
return;
}
if (readString(item, "phase") === "commentary") {
return;
}
const text = extractRawAssistantText(item);
if (!text) {
return;
}
const itemId = readString(item, "id") ?? `raw-assistant-${this.assistantItemOrder.length + 1}`;
const phase = readString(item, "phase");
if (phase) {
this.assistantPhaseByItem.set(itemId, phase);
}
this.rememberAssistantItem(itemId);
this.assistantTextByItem.set(itemId, text);
if (phase === "commentary") {
this.emitCommentaryProgress({ itemId, text });
}
}
private recordNativeGeneratedMedia(item: CodexThreadItem | undefined): void {
@@ -1539,7 +1534,18 @@ export class CodexAppServerEventProjector {
}
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
const { sessionAgentId } = resolveSessionAgentIds({
agentId: this.params.agentId,
config: this.params.config,
sessionKey: this.params.sessionKey,
});
return (
(await readCodexMirroredSessionHistoryMessages({
agentId: sessionAgentId,
path: this.params.path,
sessionId: this.params.sessionId,
})) ?? []
);
}
private createAssistantMessage(text: string): AssistantMessage {

View File

@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 20_000,
progressSummary: "Codex native subagent started.",
});
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls.at(0)?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 40_000,
progressSummary: "Codex native subagent spawned.",
});
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls.at(0)?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({

View File

@@ -26,13 +26,10 @@ type MirrorTaggedMessage = { __openclaw?: { mirrorIdentity?: string } };
async function createParams(): Promise<EmbeddedRunAttemptParams> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-"));
tempDirs.add(tempDir);
const sessionFile = path.join(tempDir, "session.jsonl");
SessionManager.open(sessionFile);
return {
prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt,
sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId,
sessionKey: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionKey,
sessionFile,
workspaceDir: tempDir,
runId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.runId,
provider: "codex",

View File

@@ -22,6 +22,59 @@ describe("Codex plugin activation", () => {
expect((params as Record<string, unknown> | undefined)?.[key]).toBe(expected);
}
it("activates plugins from every first-party OpenAI marketplace", async () => {
// chrome ships in openai-bundled (with Codex.app), documents ships in
// openai-primary-runtime (Codex primary runtime). Both should activate the
// same way openai-curated plugins do.
for (const { plugin, marketplace } of [
{ plugin: "chrome", marketplace: "openai-bundled" as const },
{ plugin: "documents", marketplace: "openai-primary-runtime" as const },
]) {
const calls: string[] = [];
const result = await ensureCodexPluginActivation({
identity: identity(plugin, marketplace),
request: async (method) => {
calls.push(method);
if (method === "plugin/list") {
return pluginListFor(marketplace, [
pluginSummary(plugin, { installed: true, enabled: true }),
]);
}
throw new Error(`unexpected request ${method}`);
},
});
expectActivationResult(result, {
ok: true,
reason: "already_active",
installAttempted: false,
});
expect(result.marketplace?.name).toBe(marketplace);
expect(calls).toEqual(["plugin/list"]);
}
});
it("rejects activation requests for marketplaces outside the openai allowlist", async () => {
const result = await ensureCodexPluginActivation({
identity: {
configKey: "rogue",
marketplaceName: "third-party" as never,
pluginName: "rogue",
enabled: true,
allowDestructiveActions: false,
},
request: async () => {
throw new Error("plugin/list should not be reached when marketplace is rejected");
},
});
expectActivationResult(result, {
ok: false,
reason: "marketplace_missing",
installAttempted: false,
});
});
it("skips plugin/install when the migrated plugin is already active", async () => {
const calls: string[] = [];
const result = await ensureCodexPluginActivation({
@@ -295,10 +348,13 @@ describe("Codex plugin activation", () => {
});
});
function identity(pluginName: string): ResolvedCodexPluginPolicy {
function identity(
pluginName: string,
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"] = CODEX_PLUGINS_MARKETPLACE_NAME,
): ResolvedCodexPluginPolicy {
return {
configKey: pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
marketplaceName,
pluginName,
enabled: true,
allowDestructiveActions: false,
@@ -320,6 +376,24 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
};
}
function pluginListFor(
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"],
plugins: v2.PluginSummary[],
): v2.PluginListResponse {
return {
marketplaces: [
{
name: marketplaceName,
path: `/marketplaces/${marketplaceName}`,
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,

View File

@@ -1,7 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAMES,
isCodexPluginsMarketplaceName,
type ResolvedCodexPluginPolicy,
} from "./config.js";
import {
findOpenAiCuratedPluginSummary,
pluginReadParams,
@@ -48,27 +52,32 @@ export type CodexPluginRuntimeRefreshResult = {
export async function ensureCodexPluginActivation(
params: EnsureCodexPluginActivationParams,
): Promise<CodexPluginActivationResult> {
if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) {
if (!isCodexPluginsMarketplaceName(params.identity.marketplaceName)) {
return activationFailure(params.identity, "marketplace_missing", {
message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.",
message:
"Only " + CODEX_PLUGINS_MARKETPLACE_NAMES.join(" or ") + " plugins can be activated.",
});
}
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
const resolved = findOpenAiCuratedPluginSummary(
listed,
params.identity.pluginName,
params.identity.marketplaceName,
);
if (!resolved) {
const hasCuratedMarketplace = listed.marketplaces.some(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
const hasMarketplace = listed.marketplaces.some(
(marketplace) => marketplace.name === params.identity.marketplaceName,
);
if (!hasCuratedMarketplace) {
if (!hasMarketplace) {
return activationFailure(params.identity, "marketplace_missing", {
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
message: `Codex marketplace ${params.identity.marketplaceName} was not found.`,
});
}
return activationFailure(params.identity, "plugin_missing", {
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
message: `${params.identity.pluginName} was not found in ${params.identity.marketplaceName}.`,
});
}

View File

@@ -6,7 +6,10 @@ import type {
} from "./app-inventory-cache.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
CODEX_PLUGINS_MARKETPLACE_NAMES,
isCodexPluginsMarketplaceName,
resolveCodexPluginsPolicy,
type CodexPluginsMarketplaceName,
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
@@ -15,7 +18,7 @@ import type { v2 } from "./protocol.js";
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
export type CodexPluginMarketplaceRef = {
name: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
name: CodexPluginsMarketplaceName;
path?: string;
remoteMarketplaceName?: string;
};
@@ -57,7 +60,6 @@ export type CodexPluginInventoryRecord = {
export type CodexPluginInventory = {
policy: ResolvedCodexPluginsPolicy;
marketplace?: CodexPluginMarketplaceRef;
records: CodexPluginInventoryRecord[];
diagnostics: CodexPluginInventoryDiagnostic[];
appInventory?: CodexAppInventoryCacheRead;
@@ -95,25 +97,14 @@ export async function readCodexPluginInventory(
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return {
policy,
records: [],
diagnostics: policy.pluginPolicies
.filter((pluginPolicy) => pluginPolicy.enabled)
.map((pluginPolicy) => ({
code: "marketplace_missing",
plugin: pluginPolicy,
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
})),
...(appInventory ? { appInventory } : {}),
};
// Index the supported marketplaces (curated + bundled) by name so each plugin
// policy is matched to the marketplace its config actually points at.
const marketplaceByName = new Map<CodexPluginsMarketplaceName, v2.PluginMarketplaceEntry>();
for (const marketplace of listed.marketplaces) {
if (isCodexPluginsMarketplaceName(marketplace.name)) {
marketplaceByName.set(marketplace.name, marketplace);
}
}
const marketplace = marketplaceRef(marketplaceEntry);
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
const records: CodexPluginInventoryRecord[] = [];
if (appInventory?.state === "missing") {
@@ -132,12 +123,22 @@ export async function readCodexPluginInventory(
if (!pluginPolicy.enabled) {
continue;
}
const marketplaceEntry = marketplaceByName.get(pluginPolicy.marketplaceName);
if (!marketplaceEntry) {
diagnostics.push({
code: "marketplace_missing",
plugin: pluginPolicy,
message: `Codex marketplace ${pluginPolicy.marketplaceName} was not found.`,
});
continue;
}
const marketplace = marketplaceRef(marketplaceEntry);
const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName);
if (!summary) {
diagnostics.push({
code: "plugin_missing",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
message: `${pluginPolicy.pluginName} was not found in ${pluginPolicy.marketplaceName}.`,
});
continue;
}
@@ -187,7 +188,6 @@ export async function readCodexPluginInventory(
const inventory = {
policy,
marketplace,
records,
diagnostics,
...(appInventory ? { appInventory } : {}),
@@ -198,15 +198,32 @@ export async function readCodexPluginInventory(
export function findOpenAiCuratedPluginSummary(
listed: v2.PluginListResponse,
pluginName: string,
marketplaceName?: CodexPluginsMarketplaceName,
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return undefined;
if (marketplaceName) {
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === marketplaceName,
);
if (!marketplaceEntry) {
return undefined;
}
const summary = findPluginSummary(marketplaceEntry, pluginName);
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
}
const summary = findPluginSummary(marketplaceEntry, pluginName);
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
// No marketplace hint: search every supported marketplace and return the first hit.
for (const allowedName of CODEX_PLUGINS_MARKETPLACE_NAMES) {
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === allowedName,
);
if (!marketplaceEntry) {
continue;
}
const summary = findPluginSummary(marketplaceEntry, pluginName);
if (summary) {
return { marketplace: marketplaceRef(marketplaceEntry), summary };
}
}
return undefined;
}
export function pluginReadParams(
@@ -349,8 +366,12 @@ function pluginNameFromPluginId(pluginId: string, marketplaceName: string): stri
}
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
// marketplace.name is validated at every call site via isCodexPluginsMarketplaceName.
const name = isCodexPluginsMarketplaceName(marketplace.name)
? marketplace.name
: CODEX_PLUGINS_MARKETPLACE_NAME;
return {
name: CODEX_PLUGINS_MARKETPLACE_NAME,
name,
...(marketplace.path ? { path: marketplace.path } : {}),
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
};

View File

@@ -9,6 +9,10 @@ import {
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
@@ -89,12 +93,15 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
]);
}
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
export function createParams(
transcriptPath: string,
workspaceDir: string,
): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
path: transcriptPath,
workspaceDir,
runId: "run-1",
provider: "codex",
@@ -115,6 +122,18 @@ export function createParams(sessionFile: string, workspaceDir: string): Embedde
} as EmbeddedRunAttemptParams;
}
export function codexAppServerTestBindingIdentity(
params: Pick<EmbeddedRunAttemptParams, "sessionKey" | "sessionId"> = {
sessionKey: "agent:main:session-1",
sessionId: "session-1",
},
) {
return {
sessionKey: params.sessionKey,
sessionId: params.sessionId,
};
}
export function createCodexRuntimePlanFixture(): NonNullable<
EmbeddedRunAttemptParams["runtimePlan"]
> {
@@ -470,6 +489,7 @@ export function setupRunAttemptTestHooks(): void {
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
tempDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-codex-run-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
});
afterEach(async () => {
@@ -487,6 +507,8 @@ export function setupRunAttemptTestHooks(): void {
resetGlobalHookRunner();
clearInternalHooks();
defaultCodexAppInventoryCache.clear();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllEnvs();

View File

@@ -5,6 +5,7 @@ import {
resetAgentEventsForTest,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { closeOpenClawStateDatabaseForTest } from "openclaw/plugin-sdk/sqlite-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -12,6 +13,7 @@ import { runCodexAppServerAttempt } from "./run-attempt.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
let previousStateDir: string | undefined;
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
@@ -89,12 +91,20 @@ describe("Codex app-server main thread cleanup", () => {
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = tempDir;
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
resetAgentEventsForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(tempDir, { recursive: true, force: true });
});

View File

@@ -1,14 +1,21 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import {
embeddedAgentLog,
openTranscriptSessionManagerForSession,
type AgentMessage,
type HarnessContextEngine as ContextEngine,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import {
replaceSqliteSessionTranscriptEvents,
upsertSessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -42,12 +49,11 @@ function runCodexAppServerAttempt(
);
}
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
function createParams(sessionId: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
sessionId,
sessionKey: `agent:main:${sessionId}`,
workspaceDir,
runId: "run-1",
provider: "codex",
@@ -107,6 +113,41 @@ function toolResultMessage(payload: unknown, timestamp: number): AgentMessage {
} as unknown as AgentMessage;
}
function seedSessionTranscript(sessionId: string, messages: AgentMessage[]): void {
replaceSqliteSessionTranscriptEvents({
agentId: "main",
sessionId,
events: [
{
type: "session",
id: "session-1",
timestamp: new Date(1).toISOString(),
cwd: tempDir || "/tmp/openclaw-codex-test",
},
...messages.map((message, index) => ({
type: "message",
id: `entry-${index + 1}`,
parentId: index === 0 ? null : `entry-${index}`,
timestamp: new Date(message.timestamp ?? Date.now()).toISOString(),
message,
})),
],
});
}
function openTestTranscriptSession(params: {
sessionId: string;
sessionFile?: string;
workspaceDir: string;
}) {
return openTranscriptSessionManagerForSession({
agentId: "main",
path: params.sessionFile,
sessionId: params.sessionId,
cwd: params.workspaceDir,
});
}
function threadStartResult(threadId = "thread-1") {
return {
thread: {
@@ -255,7 +296,7 @@ function optionalString(value: unknown): string {
}
function requireFirstCallArg(mock: unknown, label: string): unknown {
const call = (mock as MockCallReader).mock.calls[0];
const call = (mock as MockCallReader).mock.calls.at(0);
if (!call) {
throw new Error(`expected ${label} to be called`);
}
@@ -306,24 +347,25 @@ function getRequestInputTextAt(
describe("runCodexAppServerAttempt context-engine lifecycle", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-context-engine-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
});
afterEach(async () => {
resetCodexAppServerClientFactoryForTest();
vi.restoreAllMocks();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("bootstraps and assembles non-legacy context before the Codex turn starts", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("existing context", Date.now()) as never,
);
const openSpy = vi.spyOn(SessionManager, "open");
seedSessionTranscript(sessionId, [assistantMessage("existing context", Date.now())]);
const contextEngine = createContextEngine();
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 321;
params.config = { memory: { citations: "on" } } as EmbeddedRunAttemptParams["config"];
@@ -338,15 +380,15 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
NonNullable<ContextEngine["bootstrap"]>
>[0];
expect(bootstrapParams.sessionId).toBe("session-1");
expect(bootstrapParams.sessionId).toBe(sessionId);
expect(bootstrapParams.sessionKey).toBe("agent:main:session-1");
expect(bootstrapParams.sessionFile).toBe(sessionFile);
expect(bootstrapParams.transcriptScope).toEqual({ agentId: "main", sessionId });
expect(contextEngine.assemble).toHaveBeenCalledTimes(1);
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.sessionId).toBe("session-1");
expect(assembleParams.sessionId).toBe(sessionId);
expect(assembleParams.sessionKey).toBe("agent:main:session-1");
expect(assembleParams.tokenBudget).toBe(321);
expect(assembleParams.citationsMode).toBe("on");
@@ -361,46 +403,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
await harness.completeTurn();
await run;
expect(openSpy).not.toHaveBeenCalled();
});
it("keeps context-engine history bound to the run session when sandbox key differs", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("canonical main context", Date.now()) as never,
);
const contextEngine = createContextEngine();
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.sessionKey = "agent:main:main";
params.sandboxSessionKey = "agent:main:telegram:default:direct:12345";
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
if (!contextEngine.bootstrap) {
throw new Error("expected bootstrap hook");
}
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
NonNullable<ContextEngine["bootstrap"]>
>[0];
expect(bootstrapParams.sessionKey).toBe("agent:main:main");
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.sessionKey).toBe("agent:main:main");
await harness.completeTurn();
await run;
});
it("uses the runtime token budget for large Codex context-engine projections", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const longContext = `large LCM context start ${"x".repeat(30_000)} LARGE_CONTEXT_END`;
const contextEngine = createContextEngine({
@@ -411,7 +419,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
})),
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 80_000;
@@ -428,7 +436,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const longContext = `configured reserve context start ${"x".repeat(30_000)} CONFIG_END`;
const contextEngine = createContextEngine({
@@ -439,7 +447,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
})),
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 80_000;
params.config = {
@@ -460,11 +468,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
it("projects thread-bootstrap context only once for a matching context-engine epoch", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("bootstrap-only context", Date.now()) as never,
);
seedSessionTranscript(sessionId, [assistantMessage("bootstrap-only context", Date.now())]);
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ messages, prompt }) => ({
messages: [...messages, userMessage(prompt ?? "", 10)],
@@ -474,7 +480,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
})),
});
const firstHarness = createStartedThreadHarness();
const firstParams = createParams(sessionFile, workspaceDir);
const firstParams = createParams(sessionId, workspaceDir);
firstParams.contextEngine = contextEngine;
const firstRun = runCodexAppServerAttempt(firstParams);
@@ -484,7 +490,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await firstHarness.completeTurn();
await firstRun;
const savedBinding = await readCodexAppServerBinding(sessionFile);
const savedBinding = await readCodexAppServerBinding({
sessionKey: `agent:main:${sessionId}`,
sessionId,
});
expect(savedBinding?.contextEngine?.projection).toEqual({
schemaVersion: 1,
mode: "thread_bootstrap",
@@ -712,40 +721,44 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("does not inject mirrored history when a stale thread-bootstrap binding has no active context engine", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage("previous stale-bootstrap request", Date.now()) as never,
);
sessionManager.appendMessage(
assistantMessage("previous stale-bootstrap answer", Date.now() + 1) as never,
);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-stale-bootstrap",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-stale",
},
seedSessionTranscript(sessionId, [
userMessage("previous stale-bootstrap request", Date.now()),
assistantMessage("previous stale-bootstrap answer", Date.now() + 1),
]);
upsertSessionEntry({
agentId: "main",
sessionKey: `agent:main:${sessionId}`,
entry: {
sessionId,
updatedAt: Date.now(),
totalTokens: 12_000,
totalTokensFresh: true,
},
});
await fs.writeFile(
path.join(path.dirname(sessionFile), "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionFile,
totalTokens: 12_000,
await writeCodexAppServerBinding(
{
sessionKey: `agent:main:${sessionId}`,
sessionId,
},
{
threadId: "thread-stale-bootstrap",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-stale",
},
},
}),
},
);
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
await fs.mkdir(rolloutDir, { recursive: true });
@@ -771,13 +784,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.agentDir = agentDir;
params.config = {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
rotateAfterCompaction: true,
maxActiveTranscriptBytes: "1mb",
},
},
@@ -804,24 +817,30 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
it("starts a fresh Codex thread and reprojects when context-engine epoch changes", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
await writeCodexAppServerBinding(
{
sessionKey: `agent:main:${sessionId}`,
sessionId,
},
{
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-old",
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-old",
},
},
},
});
);
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("new epoch context", 10), userMessage(prompt ?? "", 11)],
@@ -836,7 +855,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
@@ -863,7 +882,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
await run;
const savedBinding = await readCodexAppServerBinding(sessionFile);
const savedBinding = await readCodexAppServerBinding({
sessionKey: `agent:main:${sessionId}`,
sessionId,
});
expect(savedBinding?.threadId).toBe("thread-new");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-new");
expect(info).toHaveBeenCalledWith(
@@ -952,121 +974,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("reprojects thread-bootstrap context for native-disabled transient Codex threads", async () => {
const restoreSandboxBackend = registerSandboxBackend(
"codex-context-test-sandbox",
async () => ({
id: "codex-context-test-sandbox",
runtimeId: "codex-context-test-runtime",
runtimeLabel: "Codex Context Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
try {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [
assistantMessage("native-disabled context", 10),
userMessage(prompt ?? "", 11),
],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-transient");
}
if (method === "thread/resume") {
throw new Error("native-disabled turns should not resume the previous Codex thread");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-context-test-sandbox",
scope: "session",
workspaceAccess: "rw",
prune: { idleHours: 0, maxAgeDays: 0 },
},
},
},
} as EmbeddedRunAttemptParams["config"];
let runError: unknown;
const run = runCodexAppServerAttempt(params).catch((error: unknown) => {
runError = error;
throw error;
});
await vi.waitFor(
() => {
if (runError) {
throw runError;
}
expect(harness.requests.map((request) => request.method)).toContain("turn/start");
},
{ interval: 1 },
);
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(harness, "native-disabled context");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-transient",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "transient answer" }],
},
},
});
await run;
} finally {
restoreSandboxBackend();
}
});
it("starts a fresh Codex thread when thread-bootstrap projection falls back to per-turn projection", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -1135,28 +1042,38 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const successorSessionId = "session-1-compacted";
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("pre-compaction context", Date.now()) as never,
);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
projection: {
seedSessionTranscript(sessionId, [assistantMessage("pre-compaction context", Date.now())]);
await writeCodexAppServerBinding(
{
sessionKey: `agent:main:${sessionId}`,
sessionId,
},
{
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-before",
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-before",
},
},
},
});
);
let epoch = "epoch-before";
const compact = vi.fn(async () => {
epoch = "epoch-after";
seedSessionTranscript(successorSessionId, [
assistantMessage("successor compacted context", Date.now()),
]);
return {
ok: true,
compacted: true,
@@ -1164,7 +1081,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 10,
sessionId: "session-1-compacted",
sessionId: successorSessionId,
},
};
});
@@ -1197,7 +1114,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 400_000;
@@ -1230,7 +1147,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const retryInputText = getRequestInputTextAt(harness, -1);
expect(retryInputText).toBe("hello");
expect(retryInputText).not.toContain("successor compacted context");
const savedBinding = await readCodexAppServerBinding(sessionFile);
const savedBinding = await readCodexAppServerBinding({
sessionKey: `agent:main:${sessionId}`,
sessionId,
});
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before");
@@ -1387,9 +1307,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("pre-compaction context", Date.now()) as never,
);
seedSessionTranscript(sessionFile, [assistantMessage("pre-compaction context", Date.now())]);
const hugePayload = {
rows: Array.from({ length: 10 }, (_, index) => ({
id: index,
@@ -1429,49 +1347,16 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(result.assistantTexts).toContain("final answer");
});
it("fails first-turn Codex context overflow instead of falling back to OpenClaw compaction", async () => {
it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => {
const sessionId = "session-1";
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
ok: true,
compacted: true,
result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 },
}));
const assemble = vi.fn<ContextEngine["assemble"]>().mockResolvedValue({
messages: [assistantMessage("large projected context", 10)],
estimatedTokens: 100_000,
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-before" },
});
const contextEngine = createContextEngine({ assemble, compact });
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw new Error("Codex ran out of room in the model's context window");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 16_000;
await expect(runCodexAppServerAttempt(params)).rejects.toThrow(
"Codex ran out of room in the model's context window",
);
expect(compact).not.toHaveBeenCalled();
expect(assemble).toHaveBeenCalledTimes(1);
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
});
it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("pre-compaction context", Date.now()) as never,
);
openTranscriptSessionManagerForSession({
agentId: "main",
path: sessionFile,
sessionId,
cwd: workspaceDir,
}).appendMessage(assistantMessage("pre-compaction context", Date.now()) as never);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
@@ -1548,14 +1433,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("older context", Date.now()) as never,
);
seedSessionTranscript(sessionId, [assistantMessage("older context", Date.now())]);
const contextEngine = createContextEngine();
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.currentInboundContext = {
text: [
@@ -1579,7 +1462,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("calls afterTurn with the mirrored transcript and runs turn maintenance", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const afterTurn = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
@@ -1587,7 +1470,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
const contextEngine = createContextEngine({ afterTurn, maintain, bootstrap: undefined });
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 111;
@@ -1600,7 +1483,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnCall.sessionId).toBe("session-1");
expect(afterTurnCall.sessionId).toBe(sessionId);
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
expect(afterTurnCall.prePromptMessageCount).toBe(0);
expect(afterTurnCall.tokenBudget).toBe(111);
@@ -1609,53 +1492,8 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(maintain).toHaveBeenCalledTimes(1);
});
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("existing context", Date.now()) as never,
);
const afterTurn = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
);
const bootstrap = vi.fn(
async ({ sessionFile: file }: Parameters<NonNullable<ContextEngine["bootstrap"]>>[0]) => {
SessionManager.open(file).appendMessage(
assistantMessage("bootstrap context", Date.now() + 1) as never,
);
return { bootstrapped: true };
},
);
const contextEngine = createContextEngine({
bootstrap,
afterTurn,
maintain: undefined,
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.messages.map((message) => message.role)).toEqual([
"assistant",
"assistant",
]);
const afterTurnParams = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnParams.prePromptMessageCount).toBe(2);
expectRequestInputTextContains(harness, "bootstrap context");
});
it("logs assemble failures as a formatted message instead of the raw error object", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const rawError = new Error("Authorization: Bearer sk-abcdefghijklmnopqrstuv");
const contextEngine = createContextEngine({
@@ -1666,7 +1504,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
@@ -1684,7 +1522,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
});
it("falls back to ingestBatch and skips turn maintenance on prompt failure", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = "session-1";
const workspaceDir = path.join(tempDir, "workspace");
const ingestBatch = vi.fn(async () => ({ ingestedCount: 2 }));
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
@@ -1695,7 +1533,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
bootstrap: undefined,
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
const params = createParams(sessionId, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);

View File

@@ -1,10 +1,10 @@
import path from "node:path";
import {
abortAgentHarnessRun,
openTranscriptSessionManagerForSession,
onAgentEvent,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import {
onInternalDiagnosticEvent,
waitForDiagnosticEventsDrained,
@@ -37,6 +37,15 @@ function flushDiagnosticEvents() {
return waitForDiagnosticEventsDrained();
}
function openTestTranscriptSession(params: { sessionFile: string; workspaceDir: string }) {
return openTranscriptSessionManagerForSession({
agentId: "main",
path: params.sessionFile,
sessionId: "session-1",
cwd: params.workspaceDir,
});
}
setupRunAttemptTestHooks();
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
@@ -56,7 +65,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
const harness = createStartedThreadHarness();
@@ -204,7 +213,8 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
const diagnosticEvents: DiagnosticEventPayload[] = [];
const diagnosticContentByType = new Map<string, DiagnosticEventPrivateData>();
let diagnosticTypesAtLlmOutput: string[] = [];
const llmOutput = vi.fn(() => {
const llmOutput = vi.fn(async () => {
await waitForDiagnosticEventsDrained();
diagnosticTypesAtLlmOutput = diagnosticEvents.map((event) => event.type);
});
initializeGlobalHookRunner(
@@ -296,7 +306,10 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
).toContain("hello back");
expect(completedEvent?.requestPayloadBytes).toBeGreaterThan(0);
expect(llmOutput).toHaveBeenCalledTimes(1);
expect(diagnosticTypesAtLlmOutput).toContain("model.call.completed");
await vi.waitFor(
() => expect(diagnosticTypesAtLlmOutput).toContain("model.call.completed"),
fastWait,
);
expect(diagnosticTypesAtLlmOutput).not.toContain("model.call.error");
} finally {
stopDiagnostics();
@@ -489,7 +502,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
openTestTranscriptSession({ sessionFile, workspaceDir }).appendMessage(
assistantMessage("existing context", Date.now()),
);
createStartedThreadHarness(async (method) => {

View File

@@ -7,6 +7,7 @@ import {
import { describe, expect, it, vi } from "vitest";
import * as approvalBridge from "./approval-bridge.js";
import {
codexAppServerTestBindingIdentity,
createParams,
createResumeHarness,
createStartedThreadHarness,
@@ -462,9 +463,10 @@ describe("runCodexAppServerAttempt native hook relay", () => {
await firstHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await firstRun;
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
firstGeneration,
);
expect(
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
?.nativeHookRelayGeneration,
).toBe(firstGeneration);
const secondHarness = createResumeHarness();
const secondParams = createParams(sessionFile, workspaceDir);
@@ -491,7 +493,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
it("accepts a stale first hook generation when resuming a pre-generation binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
@@ -545,16 +547,17 @@ describe("runCodexAppServerAttempt native hook relay", () => {
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
currentGeneration,
);
expect(
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
?.nativeHookRelayGeneration,
).toBe(currentGeneration);
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
});
it("rotates native hook relay generations when an existing binding starts a fresh thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
@@ -594,16 +597,17 @@ describe("runCodexAppServerAttempt native hook relay", () => {
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
currentGeneration,
);
expect(
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
?.nativeHookRelayGeneration,
).toBe(currentGeneration);
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
});
it("rotates native hook relay generations when resume fails over to a fresh thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
@@ -647,9 +651,10 @@ describe("runCodexAppServerAttempt native hook relay", () => {
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
currentGeneration,
);
expect(
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
?.nativeHookRelayGeneration,
).toBe(currentGeneration);
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
});

View File

@@ -3,11 +3,13 @@ import path from "node:path";
import {
abortAgentHarnessRun,
embeddedAgentLog,
listTrajectoryRuntimeEvents,
loadSqliteSessionTranscriptEvents,
openTranscriptSessionManagerForSession,
onAgentEvent,
type AgentEventPayload,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import {
onInternalDiagnosticEvent,
waitForDiagnosticEventsDrained,
@@ -34,7 +36,6 @@ import {
} from "./attempt-context.js";
import * as authBridge from "./auth-bridge.js";
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
import { CodexAppServerRpcError } from "./client.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
@@ -73,7 +74,6 @@ import {
} from "./sandbox-exec-server.js";
import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import * as sharedClientModule from "./shared-client.js";
import { createCodexTestModel } from "./test-support.js";
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
@@ -117,12 +117,38 @@ function expectResumeRequest(
}
}
function openTestTranscriptSession(params: { sessionFile: string; workspaceDir: string }) {
return openTranscriptSessionManagerForSession({
agentId: "main",
path: params.sessionFile,
sessionId: "session-1",
cwd: params.workspaceDir,
});
}
function appServerTestBindingIdentity(sessionId = "session-1") {
return {
sessionKey: `agent:main:${sessionId}`,
sessionId,
};
}
function readTranscriptRawForTest(sessionFile: string, sessionId = "session-1"): string {
return loadSqliteSessionTranscriptEvents({
agentId: "main",
path: sessionFile,
sessionId,
})
.map((entry) => JSON.stringify(entry.event))
.join("\n");
}
async function writeExistingBinding(
sessionFile: string,
_sessionFile: string,
workspaceDir: string,
overrides: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
) {
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
@@ -939,14 +965,14 @@ describe("runCodexAppServerAttempt", () => {
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
});
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
});
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
threadId: "old-thread",
cwd: workspaceDir,
dynamicToolsFingerprint: JSON.stringify([]),
@@ -974,50 +1000,10 @@ describe("runCodexAppServerAttempt", () => {
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
});
it("uses task cwd for Codex app-server requests while keeping bootstrap workspace separate", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const taskCwd = path.join(tempDir, "task-repo");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(taskCwd, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "workspace bootstrap", "utf8");
await fs.writeFile(path.join(taskCwd, "task-marker.txt"), "task marker", "utf8");
const appServer = createThreadLifecycleAppServerOptions();
const params = createParams(sessionFile, workspaceDir);
const requests: Array<{ method: string; params: unknown }> = [];
await startOrResumeThread({
client: {
getServerVersion: () => "0.132.0",
request: async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === "thread/start") {
return threadStartResult();
}
return {};
},
} as never,
params,
cwd: taskCwd,
dynamicTools: [],
appServer,
developerInstructions: "workspace bootstrap",
});
const threadStart = requests.find((request) => request.method === "thread/start");
expect((threadStart?.params as { cwd?: string } | undefined)?.cwd).toBe(taskCwd);
const turnStart = buildTurnStartParams(params, {
threadId: "thread-1",
cwd: taskCwd,
appServer,
});
expect(turnStart.cwd).toBe(taskCwd);
});
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
threadId: "old-thread",
cwd: workspaceDir,
dynamicToolsFingerprint: JSON.stringify([]),
@@ -1042,7 +1028,9 @@ describe("runCodexAppServerAttempt", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(binding.threadId).toBe("new-thread");
expect(binding.mcpServersFingerprint).toBeUndefined();
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
expect(
(await readCodexAppServerBinding(appServerTestBindingIdentity()))?.mcpServersFingerprint,
).toBeUndefined();
});
it("scopes Codex developer reply instructions to message-tool-only delivery", () => {
@@ -1085,8 +1073,8 @@ describe("runCodexAppServerAttempt", () => {
text: "Unscoped structured command guidance.",
},
{
text: "OpenClaw main command guidance.",
surfaces: ["openclaw_main"],
text: "PI main command guidance.",
surfaces: ["pi_main"],
},
],
handler: async () => ({ text: "ok" }),
@@ -1099,7 +1087,7 @@ describe("runCodexAppServerAttempt", () => {
expect(instructions).toContain("Codex app-server command guidance.");
expect(instructions).not.toContain("Legacy global command guidance.");
expect(instructions).not.toContain("Unscoped structured command guidance.");
expect(instructions).not.toContain("OpenClaw main command guidance.");
expect(instructions).not.toContain("PI main command guidance.");
});
it("passes OpenClaw skills as turn collaboration developer instructions", async () => {
@@ -1147,15 +1135,10 @@ describe("runCodexAppServerAttempt", () => {
expect(inputText).toBe("hello");
const [llmInputPayload] = mockCall(llmInput, "llm_input") as [{ prompt?: string }, unknown];
expect(llmInputPayload.prompt).toBe(inputText);
const trajectoryEvents = (
await fs.readFile(path.join(tempDir, "trajectory", "session-1.jsonl"), "utf8")
)
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as { data?: { prompt?: string; systemPrompt?: string }; type?: string },
);
const trajectoryEvents = listTrajectoryRuntimeEvents({
agentId: "main",
sessionId: "session-1",
});
const compiledContext = trajectoryEvents.find((event) => event.type === "context.compiled");
expect(compiledContext?.data?.prompt).toBe(inputText);
expect(compiledContext?.data?.systemPrompt).toContain("## OpenClaw Skills");
@@ -1210,7 +1193,7 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await vi.waitFor(async () => {
const raw = await fs.readFile(sessionFile, "utf8");
const raw = readTranscriptRawForTest(sessionFile);
expect(raw).toContain('"role":"user"');
expect(raw).toContain('"content":"external channel prompt"');
expect(raw).toContain('"idempotencyKey":"codex-app-server:thread-1:turn-1:prompt"');
@@ -1225,13 +1208,13 @@ describe("runCodexAppServerAttempt", () => {
);
});
const rawBeforeCompletion = await fs.readFile(sessionFile, "utf8");
const rawBeforeCompletion = readTranscriptRawForTest(sessionFile);
expect(rawBeforeCompletion).not.toContain('"role":"assistant"');
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const rawAfterCompletion = await fs.readFile(sessionFile, "utf8");
const rawAfterCompletion = readTranscriptRawForTest(sessionFile);
expect(rawAfterCompletion.match(/"role":"user"/gu)).toHaveLength(1);
expect(onUserMessagePersisted).toHaveBeenCalledTimes(1);
});
@@ -1687,7 +1670,7 @@ describe("runCodexAppServerAttempt", () => {
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
sessionManager.appendMessage(assistantMessage("previous turn", Date.now()));
const harness = createStartedThreadHarness();
@@ -1711,7 +1694,9 @@ describe("runCodexAppServerAttempt", () => {
const wrappedPluginSystemContext = (text: string) =>
`---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`;
expect(threadStartParams?.developerInstructions).toContain(
`${wrappedPluginSystemContext("pre system")}\n\ncustom codex system\n\n${wrappedPluginSystemContext("post system")}`,
`${wrappedPluginSystemContext("pre system")}\n\ncustom codex system\n\n${wrappedPluginSystemContext(
"post system",
)}`,
);
const turnStart = harness.requests.find((request) => request.method === "turn/start");
const turnStartParams = turnStart?.params as
@@ -1725,7 +1710,7 @@ describe("runCodexAppServerAttempt", () => {
it("does not inject mirrored history when starting Codex without a native thread binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
const harness = createStartedThreadHarness();
@@ -1754,17 +1739,19 @@ describe("runCodexAppServerAttempt", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
if (!Number.isFinite(bindingUpdatedAt)) {
throw new Error("expected valid Codex binding timestamp");
}
const sessionManager = SessionManager.open(sessionFile);
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(bindingUpdatedAt), fastWait);
const messageStartedAt = Date.now();
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
sessionManager.appendMessage(
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
userMessage("we were discussing the Sonnet leak screenshots", messageStartedAt),
);
sessionManager.appendMessage(
assistantMessage("David Ondrej was mentioned in that prior thread", bindingUpdatedAt + 2_000),
assistantMessage("David Ondrej was mentioned in that prior thread", messageStartedAt + 1),
);
const harness = createResumeHarness();
const params = createParams(sessionFile, workspaceDir);
@@ -1793,24 +1780,21 @@ describe("runCodexAppServerAttempt", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
const oldBindingUpdatedAt = Date.now() - 60_000;
const bindingPath = `${sessionFile}.codex-app-server.json`;
const bindingPayload = JSON.parse(await fs.readFile(bindingPath, "utf8")) as Record<
string,
unknown
>;
bindingPayload.updatedAt = new Date(oldBindingUpdatedAt).toISOString();
await fs.writeFile(bindingPath, `${JSON.stringify(bindingPayload, null, 2)}\n`);
const sessionManager = SessionManager.open(sessionFile);
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
if (!Number.isFinite(bindingUpdatedAt)) {
throw new Error("expected valid Codex binding timestamp");
}
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(bindingUpdatedAt), fastWait);
const messageStartedAt = Date.now();
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
sessionManager.appendMessage(
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
userMessage("we were discussing the Sonnet leak screenshots", messageStartedAt),
);
sessionManager.appendMessage(
assistantMessage(
"David Ondrej was mentioned in that prior thread",
oldBindingUpdatedAt + 2_000,
),
assistantMessage("David Ondrej was mentioned in that prior thread", messageStartedAt),
);
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(messageStartedAt), fastWait);
const firstHarness = createResumeHarness();
const firstParams = createParams(sessionFile, workspaceDir);
@@ -2639,7 +2623,7 @@ describe("runCodexAppServerAttempt", () => {
"turn/start",
"thread/unsubscribe",
]);
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
expect(binding?.threadId).toBe("thread-existing");
});
@@ -2666,7 +2650,7 @@ describe("runCodexAppServerAttempt", () => {
"turn/start",
"thread/unsubscribe",
]);
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
expect(binding?.threadId).toBe("thread-existing");
});
@@ -2735,18 +2719,17 @@ describe("runCodexAppServerAttempt", () => {
});
it("does not drop turn completion notifications emitted while turn/start is in flight", async () => {
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
return turnStartResult("turn-1", "completed");
}
return {};
},
);
let harness: ReturnType<typeof createAppServerHarness>;
harness = createAppServerHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
return turnStartResult("turn-1", "completed");
}
return {};
});
const result = await runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
@@ -2756,31 +2739,30 @@ describe("runCodexAppServerAttempt", () => {
});
it("does not fail when a buffered terminal notification is followed by client close", async () => {
let harness: ReturnType<typeof createAppServerHarness>;
let resolveBufferedTerminal!: () => void;
const bufferedTerminal = new Promise<void>((resolve) => {
resolveBufferedTerminal = resolve;
});
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "tool-1", type: "commandExecution" },
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
resolveBufferedTerminal();
return turnStartResult("turn-1", "inProgress");
}
return {};
},
);
harness = createAppServerHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "tool-1", type: "commandExecution" },
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
resolveBufferedTerminal();
return turnStartResult("turn-1", "inProgress");
}
return {};
});
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
@@ -2797,25 +2779,24 @@ describe("runCodexAppServerAttempt", () => {
});
it("does not time out when turn progress arrives before turn/start returns", async () => {
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.notify({
method: "turn/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "inProgress" },
},
});
return turnStartResult("turn-1", "inProgress");
}
return {};
},
);
let harness: ReturnType<typeof createAppServerHarness>;
harness = createAppServerHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
await harness.notify({
method: "turn/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "inProgress" },
},
});
return turnStartResult("turn-1", "inProgress");
}
return {};
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
@@ -3801,7 +3782,7 @@ describe("runCodexAppServerAttempt", () => {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
rotateAfterCompaction: true,
maxActiveTranscriptBytes: "1mb",
},
},
@@ -3817,7 +3798,7 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).toContain("thread/start");
expect(requests.map((entry) => entry.method)).not.toContain("thread/resume");
const savedBinding = await readCodexAppServerBinding(sessionFile);
const savedBinding = await readCodexAppServerBinding(appServerTestBindingIdentity());
expect(savedBinding?.threadId).toBe("thread-1");
});
@@ -3914,7 +3895,7 @@ describe("runCodexAppServerAttempt", () => {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
rotateAfterCompaction: true,
maxActiveTranscriptBytes: "1mb",
},
},
@@ -3934,7 +3915,7 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).toContain("thread/start");
expect(requests.map((entry) => entry.method)).not.toContain("thread/resume");
expect(seenAuthProfileIds).toEqual(["openai:work"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
const savedBinding = await readCodexAppServerBinding(appServerTestBindingIdentity());
expect(savedBinding?.authProfileId).toBe("openai:work");
expect(savedBinding?.threadId).toBe("thread-1");
});
@@ -4044,134 +4025,6 @@ describe("runCodexAppServerAttempt", () => {
]);
});
it("does not retire the shared Codex client when a spawned helper run fails with a logical thread/start error", async () => {
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
clearSpy.mockClear();
let failedClient: unknown;
setCodexAppServerClientFactoryForTest(async () => {
const c = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
throw new CodexAppServerRpcError(
{ message: "401 authentication_error: Invalid bearer token" },
"thread/start",
);
}
return {};
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
};
failedClient = c;
return c as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.spawnedBy = "agent:main:session-parent";
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("Invalid bearer token");
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
expect(calledWithFailedClient).toBe(false);
clearSpy.mockRestore();
});
it("retires the shared Codex client when a spawned helper run times out during thread/start", async () => {
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
clearSpy.mockClear();
let failedClient: unknown;
setCodexAppServerClientFactoryForTest(async () => {
const c = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
return await new Promise<never>(() => undefined);
}
return {};
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
};
failedClient = c;
return c as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.spawnedBy = "agent:main:session-parent";
params.timeoutMs = 1;
await expect(runCodexAppServerAttempt(params, { startupTimeoutFloorMs: 1 })).rejects.toThrow(
"codex app-server startup timed out",
);
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
expect(calledWithFailedClient).toBe(true);
clearSpy.mockRestore();
});
it("retires the shared Codex client when a spawned helper hits a thread/start write failure", async () => {
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
clearSpy.mockClear();
let failedClient: unknown;
setCodexAppServerClientFactoryForTest(async () => {
const c = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
throw new Error("write EPIPE");
}
return {};
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
};
failedClient = c;
return c as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.spawnedBy = "agent:main:session-parent";
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("write EPIPE");
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
expect(calledWithFailedClient).toBe(true);
clearSpy.mockRestore();
});
it("retires the shared Codex client when a top-level run fails with a logical thread/start error", async () => {
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
clearSpy.mockClear();
let failedClient: unknown;
setCodexAppServerClientFactoryForTest(async () => {
const c = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
throw new CodexAppServerRpcError(
{ message: "401 authentication_error: Invalid bearer token" },
"thread/start",
);
}
return {};
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
};
failedClient = c;
return c as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("Invalid bearer token");
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
expect(calledWithFailedClient).toBe(true);
clearSpy.mockRestore();
});
it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

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