Compare commits

..

38 Commits

Author SHA1 Message Date
Alex Knight
5fa840c26f fix: surface message-tool-only diagnostics 2026-05-22 15:14:46 +10:00
吴杨帆
e399a92e6c fix(anthropic): preserve unsafe integer tool inputs (#83063)
* fix(anthropic): preserve unsafe integer tool inputs

Fixes #47229

* docs: add Anthropic unsafe integer changelog

* fix: narrow Anthropic partial JSON type

---------

Co-authored-by: Alex Knight <aknight@atlassian.com>
2026-05-22 13:48:38 +10:00
WhatsSkiLL
36e76ef424 fix(codex): block progress-only completions [AI-assisted] (#85110)
Summary:
- The PR adds shared required-completion classification for ACP/subagent finalization, marks missing, progress-only, and delivery-exhausted completions as blocked, and adds regression tests plus a changelog entry.
- Reproducibility: yes. source-reproducible. Current main finalizes the implicated ACP and subagent success pa ... he linked issue supplies production-shaped evidence; this read-only pass did not run a live provider repro.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(codex): preserve final completions after progress
- PR branch already contained follow-up commit before automerge: fix(codex): accept progress-prefixed final completions
- PR branch already contained follow-up commit before automerge: fix(codex): accept separator-delimited completions
- PR branch already contained follow-up commit before automerge: fix(codex): keep follow-up planning blocked
- PR branch already contained follow-up commit before automerge: fix(codex): block progress-only completions [AI-assisted]

Validation:
- ClawSweeper review passed for head 21a1159165.
- Required merge gates passed before the squash merge.

Prepared head SHA: 21a1159165
Review: https://github.com/openclaw/openclaw/pull/85110#issuecomment-4513104331

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-22 03:43:28 +00:00
NVIDIAN
ddd3d69b86 fix(codex): unsubscribe app-server thread after runs (#84969)
Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-05-22 04:39:35 +01:00
Bob
ae4806ed9a feat(plugins): add embedding provider contract (#84947)
Summary:
- Merged feat(plugins): add embedding provider contract after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: chore(plugins): refresh embedding provider sdk baseline
- PR branch already contained follow-up commit before automerge: docs(plugins): document embedding provider contract
- PR branch already contained follow-up commit before automerge: fix(plugins): restore embedding providers after snapshot loads
- PR branch already contained follow-up commit before automerge: fix(plugins): resolve embedding providers from manifests
- PR branch already contained follow-up commit before automerge: fix(plugin-sdk): keep embedding provider registry mutators internal
- PR branch already contained follow-up commit before automerge: chore(plugin-sdk): refresh embedding provider API baseline

Validation:
- ClawSweeper review passed for head 41ebd66ab4.
- Required merge gates passed before the squash merge.

Prepared head SHA: 41ebd66ab4
Review: https://github.com/openclaw/openclaw/pull/84947#issuecomment-4514762026

Co-authored-by: Bob <dutifulbob@gmail.com>
Co-authored-by: Mariano Belinky <mbelinky@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: osolmaz
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
2026-05-22 03:36:51 +00:00
WhatsSkiLL
0a4de3de57 [AI-assisted] fix(reply): wait for block replies before tools (#83722)
Summary:
- The branch adds an abort-aware dispatcher-idle wait after successful same-channel and direct ACP block replies, plus regression tests and a changelog entry.
- Reproducibility: yes. Current main source shows the same-channel block callback queues dispatcher delivery w ... spatcher idle, and the PR body supplies before/after diagnostic output for the tool-start ordering failure.

Automerge notes:
- PR branch already contained follow-up commit before automerge: [AI-assisted] fix(reply): wait for block replies before tools

Validation:
- ClawSweeper review passed for head 32576209a2.
- Required merge gates passed before the squash merge.

Prepared head SHA: 32576209a2
Review: https://github.com/openclaw/openclaw/pull/83722#issuecomment-4480639845

Co-authored-by: JARVIS-Glasses <284122573+JARVIS-Glasses@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-22 03:32:45 +00:00
Kaspre
eb7f3b7b50 fix(agent): support explicit CLI session keys (#85121)
Summary:
- The PR adds `openclaw agent --session-key`, normalizes explicit session keys through Gateway and embedded agent execution, and updates docs, tests, and changelog.
- Reproducibility: yes. Current main's `openclaw agent` registration and gateway CLI option type lack `--sessi ... Gateway agent protocol already accepts `sessionKey`; this is source-reproducible without executing the CLI.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agent): support explicit CLI session keys

Validation:
- ClawSweeper review passed for head 2c76dd339f.
- Required merge gates passed before the squash merge.

Prepared head SHA: 2c76dd339f
Review: https://github.com/openclaw/openclaw/pull/85121#issuecomment-4513508932

Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-22 03:08:25 +00:00
Vincent Koc
a4c81c6f35 fix(codex): recover final text after prompt timeout (#84993) 2026-05-22 11:02:47 +08:00
Josh Avant
b8e9ab9385 fix(codex): surface native compaction failures (#85160)
* fix(codex): surface native compaction failures

* docs: add changelog for codex compaction fix

* test: align compaction failure fixtures
2026-05-21 19:41:54 -07:00
Dallin Romney
c8a35c4645 fix: coalesce repeated idle TUI abort notices (#85167) 2026-05-21 18:57:56 -07:00
Josh Avant
577e64db63 fix: require configured subagent allowlist targets (#85154)
* fix subagent allowlists to configured agents

* add changelog for subagent allowlist fix
2026-05-21 18:53:30 -07:00
Vincent Koc
60d200f797 fix(codex): make post-tool raw assistant timeout configurable (#84974)
* fix(codex): make post-tool raw assistant timeout configurable

* docs(codex): align post-tool assistant timeout docs

* docs(changelog): move codex timeout note to unreleased

---------

Co-authored-by: 0x505badc0de <32790662+rozmiarD@users.noreply.github.com>
2026-05-22 09:39:38 +08:00
clawsweeper[bot]
7f4bd454fe fix(agents): preserve accepted spawn terminal success (#85135)
Summary:
- The branch adds accepted `sessions_spawn` tracking through embedded Pi subscribe, runner, fallback, replay, lifecycle, tests, deadcode allowlist, and changelog surfaces.
- Reproducibility: yes. at source level. Current main documents accepted `sessions_spawn` results but the pre- ...  and classifier paths do not carry that accepted child-run fact into incomplete-turn or fallback decisions.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(qa-lab): allow codex fixtures in deadcode
- PR branch already contained follow-up commit before automerge: fix(agents): preserve accepted spawn terminal success

Validation:
- ClawSweeper review passed for head 0f6d92b8cd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0f6d92b8cd
Review: https://github.com/openclaw/openclaw/pull/85135#issuecomment-4513861326

Co-authored-by: samzong <samzong.lu@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-22 01:16:41 +00:00
Josh Avant
221f5349b5 fix: redact denied exec failure params (#85140)
* fix exec failure log redaction

* docs: add exec redaction changelog

* test: satisfy redaction lint
2026-05-21 18:10:50 -07:00
Gio Della-Libera
ee9813f478 fix(cli): keep nodes json stdout clean (#84423)
Co-authored-by: Gio Della-Libera <giodl@microsoft.com>
2026-05-21 18:05:11 -07:00
Josh Avant
cbe68ba1a1 Fix inherited XDG env for exec subprocesses (#85139)
* fix exec xdg env inheritance

* chore changelog xdg env fix
2026-05-21 18:01:38 -07:00
Dallin Romney
d391434f4e perf: skip dts for local launcher builds (#85142) 2026-05-21 17:58:48 -07:00
Agustin Rivera
4faeb378ee fix(changelog): record provider setup trust fix (#81069) 2026-05-21 17:48:24 -07:00
Josh Avant
1f9ebb9dda Fix Matrix configured two-person room routing (#85137)
* Fix Matrix configured room DM routing

* Add Matrix room routing changelog
2026-05-21 17:40:17 -07:00
Michael Appel
0aabaebba1 fix: address issue (#81069) 2026-05-21 17:39:48 -07:00
Kevin Lin
6fe3088bc6 docs: refactor plugin bundle docs 2026-05-21 17:34:42 -07:00
Kevin Lin
7f499643b2 enhance(slack): deliver native plugin approvals (#85062)
* fix(slack): deliver native plugin approvals

* fix(slack): deliver plugin approvals with native UI

* docs: defer slack plugin approval docs
2026-05-21 17:31:06 -07:00
Kevin Lin
777a113973 fix(codex): await computer use elicitation bridge (#85117)
* fix(codex): bridge computer use elicitations

* fix(codex): preserve computer use approval boundary

* fix(codex): await app-server elicitation bridge
2026-05-21 17:17:46 -07:00
Gio Della-Libera
bc9e601491 fix: allow provider timeout overlays (#83990)
* fix: allow provider timeout overlays

* test: fix provider overlay fixture types
2026-05-21 17:10:32 -07:00
Firas Alswihry
0df9f297b6 fix(gateway): mirror source message sends into transcript (#84837)
Co-authored-by: Firas Alswihry <itzfiras@gmail.com>
2026-05-22 01:08:00 +01:00
Vincent Koc
f015c3ff52 test(qa-lab): tag live-only runtime sentinels 2026-05-22 07:42:09 +08:00
Vincent Koc
15a0156a8c fix(update): reject openclaw source package targets 2026-05-22 07:35:57 +08:00
Vincent Koc
fad1c8a071 test(qa-lab): add long-context watchdog scenario 2026-05-22 07:16:35 +08:00
Peter Steinberger
e2c92be90b chore(release): bump version to 2026.5.21 2026-05-22 00:09:45 +01:00
Josh Avant
ba06376c79 fix: harden codex sandbox execution
Harden the Codex app-server native execution bridge for OpenClaw sandboxed runs. The change keeps core sandbox policy in OpenClaw while exposing the process, filesystem, and HTTP relay behavior Codex needs inside a scoped exec server.

The large exec-server/test files were split into focused modules before landing, and the PR was rebased onto current main with focused tests, Testbox changed checks, CI, and Codex autoreview green.

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-05-21 23:47:32 +01:00
Andy Ye
c2004fe662 fix(agents): surface blocked subagent completions (#80886)
Summary:
- The PR adds shared blocked-liveness normalization, applies it to agent.wait, gateway dedupe, subagent registry, and announcement paths, and adds regression tests plus a changelog entry.
- Reproducibility: yes. from source inspection: current main accepts blocked lifecycle/wait metadata as ok thr ...  gateway wait and registry completion paths. I did not run a live provider overflow in this read-only pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): normalize blocked wait completions
- PR branch already contained follow-up commit before automerge: fix(agents): surface blocked subagent completions

Validation:
- ClawSweeper review passed for head 224785c8a6.
- Required merge gates passed before the squash merge.

Prepared head SHA: 224785c8a6
Review: https://github.com/openclaw/openclaw/pull/80886#issuecomment-4427552621

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-21 22:34:21 +00:00
Peter Steinberger
373e3fc719 fix: harden autoreview target handling 2026-05-21 23:26:49 +01:00
Peter Steinberger
08f66133ef ci(release): link durable release evidence 2026-05-21 23:24:35 +01:00
Dallin Romney
dca9cecaee perf(plugins): thread install records through plugin load options (#85026)
Adds installRecords to PluginLoadOptions and PluginRuntimeLoadContext so
callers that already hold a PluginMetadataSnapshot can pass the snapshot's
in-memory records instead of forcing each downstream loader to re-read
installs.json. resolvePluginRuntimeLoadContext extracts the records from
the snapshot via extractPluginInstallRecordsFromInstalledPluginIndex,
buildPluginRuntimeLoadOptionsFromValues forwards them, and the setup +
runtime provider load paths in providers.runtime.ts pass them through
from params.pluginMetadataSnapshot. resolvePluginLoadCacheContext uses
the threaded records (falling back to the sync read) and
loader-provenance now uses params.installRecords ?? sync-read instead of
always reading and overlaying.
2026-05-21 15:24:31 -07:00
Peter Steinberger
d4c6bdfeae docs: credit per-agent lean changelog entry 2026-05-21 23:20:02 +01:00
Andy Ye
6b5eba1f43 fix(cli): preserve numeric config set record keys (#83769)
Merged via squash.

Prepared head SHA: cb55b4a40d
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-22 01:09:15 +03:00
xiaotian
1b77145687 fix(agents): tolerate in-process session writes during prompt release (#84250)
Merged via squash.

Prepared head SHA: 33f88febc3
Co-authored-by: tianxiaochannel-oss88 <272340815+tianxiaochannel-oss88@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-05-21 18:06:12 -04:00
Josh Avant
7cda26aa6c Handle Codex turns missing completion (#85107)
* fix(codex): handle missing turn completion

* docs: add changelog for Codex completion fix
2026-05-21 15:02:17 -07:00
451 changed files with 18907 additions and 2378 deletions

View File

@@ -23,7 +23,7 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Default to Codex review with no fallback. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. Use `--fallback-reviewer auto|claude|pi|opencode|droid|copilot` only when a second-model fallback is explicitly wanted and authenticated. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
@@ -52,11 +52,11 @@ git fetch origin
codex review --base origin/main
```
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
error, run plain `codex review --base <ref>` and report that the helper prompt
injection was skipped.
Do not pass any prompt with `--base`, `--commit`, or `--uncommitted`. Codex CLI
review targets and custom review prompts are mutually exclusive: target modes
generate their own review prompt internally. Use plain target review for native
Codex closeout, or use custom prompt review (`codex review -`) only when you
intentionally want a generated diff prompt instead of native target review.
If an open PR exists, use its actual base:
@@ -117,13 +117,13 @@ The helper:
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is `none`
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
- with `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, disables auto local `pnpm run check` and routes Codex through generated prompt review (`codex review -`) so the no-local-heavy-tests policy is included; native Codex target review cannot accept extra prompt text
- non-Codex reviewers receive the generated diff prompt and maintainer validation policy text when maintainer validation is active
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0

View File

@@ -11,9 +11,9 @@ Options:
--base REF Base ref for branch review. Default: PR base or origin/main.
--commit REF Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex with configured fallback on error.
Review engine. Default: Codex.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero. Default: auto.
Fallback when Codex is unavailable or exits nonzero. Default: none.
--codex-bin PATH Codex binary. Default: codex.
--claude-bin PATH Claude binary. Default: claude.
--pi-bin PATH Pi binary. Default: pi.
@@ -44,7 +44,7 @@ mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-none}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
@@ -58,7 +58,6 @@ parallel_tests=
parallel_tests_auto=false
dry_run=false
codex_review_prompt=
codex_review_stdin_prompt=false
codex_review_prompt_file=false
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
@@ -249,13 +248,8 @@ OpenClaw maintainer autoreview validation policy:
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
EOF
)
if [[ "$review_kind" == local ]]; then
review_cmd+=(-)
codex_review_stdin_prompt=true
else
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
printf 'autoreview target: %s\n' "$review_kind"
@@ -278,8 +272,8 @@ if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
if [[ "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer validation active; using generated prompt review because Codex target review cannot accept extra prompt text\n'
fi
else
printf 'review: %s prompt review\n' "$reviewer"
@@ -337,11 +331,8 @@ run_review() {
rm -f "$prompt_file"
prompt_file=
return "$status"
elif [[ "$codex_review_stdin_prompt" == true ]]; then
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
else
"${review_cmd[@]}" 2>&1 | tee "$review_output"
fi
"${review_cmd[@]}" 2>&1 | tee "$review_output"
}
diff_for_review() {

View File

@@ -1062,9 +1062,17 @@ jobs:
exit 0
fi
evidence_package_spec="$PACKAGE_SPEC"
if [[ -z "${evidence_package_spec// }" ]]; then
tag_ref="${TARGET_REF#refs/tags/}"
if [[ "$tag_ref" =~ ^v([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?)$ ]]; then
evidence_package_spec="openclaw@${BASH_REMATCH[1]}"
fi
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
if [[ "$evidence_package_spec" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
@@ -1078,7 +1086,7 @@ jobs:
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg package_spec "$evidence_package_spec" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
@@ -1100,6 +1108,15 @@ jobs:
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
{
echo "### Private release evidence dispatch failed"
echo
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
echo
echo "\`\`\`bash"
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Write release validation manifest

View File

@@ -812,6 +812,7 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,

View File

@@ -9,10 +9,13 @@ Docs: https://docs.openclaw.ai
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
- QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.
- QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.
- QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.
- QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.
- QA-Lab: include an opt-in `update.run` package self-upgrade sentinel for destructive latest-package recovery checks.
- QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.
@@ -21,9 +24,18 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
- Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.
- Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
- WebChat: keep internal message-tool replies visible after history reload while keeping the internal-ui tool result compact. (#84268, #84773) Thanks @100yenadmin and @jason-allen-oneal.
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Gateway/chat: surface message-tool-only room-event failures in chat diagnostics and session transcripts so suppressed source replies stay debuggable. Thanks @amknight.
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
@@ -33,9 +45,11 @@ Docs: https://docs.openclaw.ai
- QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.
- Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.
- Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI.
- Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.
- Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.
- Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.
@@ -44,14 +58,22 @@ Docs: https://docs.openclaw.ai
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.
- Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from `agents_list` and rejected by `sessions_spawn`. Fixes #84811. (#85154) Thanks @joshavant.
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
- Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.
- Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.
@@ -64,6 +86,7 @@ Docs: https://docs.openclaw.ai
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
## 2026.5.20
@@ -74,7 +97,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: include bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` profile context in realtime voice session instructions by default, with `voice.realtime.bootstrapContextFiles: []` available to disable it. (#84499) Thanks @fuller-stack-dev.
- Dependencies: bump the bundled Codex harness to `@openai/codex` `0.132.0` and refresh the app-server model-list docs for the new catalog.
- CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally.
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally. (#84073) Thanks @dutifulbob.
- Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.
- Providers/OpenRouter: honor provider-level `params.provider` routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.
@@ -129,6 +152,7 @@ Docs: https://docs.openclaw.ai
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.
- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.
@@ -217,6 +241,7 @@ Docs: https://docs.openclaw.ai
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
- CLI/config: preserve numeric-looking record keys such as Discord guild IDs when creating missing config containers with `config set`. (#83769) Thanks @TurboTheTurtle.
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026052000
versionName = "2026.5.20"
versionCode = 2026052100
versionName = "2026.5.21"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
## 2026.5.21 - 2026-05-21
Maintenance update for the current OpenClaw release.
## 2026.5.20 - 2026-05-20
Maintenance update for the current OpenClaw release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.5.20
OPENCLAW_MARKETING_VERSION = 2026.5.20
OPENCLAW_IOS_VERSION = 2026.5.21
OPENCLAW_MARKETING_VERSION = 2026.5.21
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.5.20"
"version": "2026.5.21"
}

View File

@@ -192,8 +192,6 @@ enum HostEnvSecurityPolicy {
"VIRTUAL_ENV",
"VISUAL",
"WGETRC",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
"YARN_RC_FILENAME"
]
@@ -437,8 +435,13 @@ enum HostEnvSecurityPolicy {
"VISUAL",
"WGETRC",
"WINDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
"XDG_DATA_DIRS",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME",
"YARN_RC_FILENAME",
"ZDOTDIR"
]

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.5.20</string>
<string>2026.5.21</string>
<key>CFBundleVersion</key>
<string>2026052000</string>
<string>2026052100</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,4 +1,4 @@
352c794e55d080e2f3649e90cc982c3944e4d289c6e04bfe628ec7066624e444 config-baseline.json
4f9946cf7d5afea985c8b9be185c7d8b420e038d915ce91be7476dd6541e0f08 config-baseline.json
35d17c60d2858a9cbc875807cdfc7f2fc8ba4745aa4e140a3cdf7ecf38b8a034 config-baseline.core.json
6ca65e5c46c4e219c371ec660b1766f0a23092daff6bdeb64fc0574f001c7f81 config-baseline.channel.json
d455f53b424976f99990330503692728121cbbeff04014fb50b5eed23aae59d4 config-baseline.plugin.json
11839c7a1b858c66075156f0e203aa8367cd8321047684679a18e18b7c8fe1f7 config-baseline.channel.json
a0a88df97080adf50c2c2bccd2ca076ad43e81b24dd25f3c3cace41f09a7c8f0 config-baseline.plugin.json

View File

@@ -742,6 +742,11 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
"description": "Show or set exec defaults",
"usage_hint": "host=<auto|sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>"
},
{
"command": "/approve",
"description": "Approve or deny pending approval requests",
"usage_hint": "<id> <decision>"
},
{
"command": "/model",
"description": "Show or set the model",
@@ -1283,13 +1288,15 @@ compact, redacted `Slack interaction: ...` system event. If the handler returns
fields are included in that compact event so the agent can reference
plugin-owned storage without seeing the complete form payload.
## Exec approvals in Slack
## Native approvals in Slack
Slack can act as a native approval client with interactive buttons and interactions, instead of falling back to the Web UI or terminal.
- Exec approvals use `channels.slack.execApprovals.*` for native DM/channel routing.
- Plugin approvals can still resolve through the same Slack-native button surface when the request already lands in Slack and the approval id kind is `plugin:`.
- Approver authorization is still enforced: only users identified as approvers can approve or deny requests through Slack.
- Exec and plugin approvals can render as Slack-native Block Kit prompts.
- `channels.slack.execApprovals.*` remains the native approval client enablement and DM/channel routing config.
- Exec approval DMs use `channels.slack.execApprovals.approvers` or `commands.ownerAllowFrom`.
- Plugin approval DMs use Slack plugin approvers from `channels.slack.allowFrom`, named-account `allowFrom`, or the account default route.
- Approver authorization is still enforced: exec-only approvers cannot approve plugin requests unless they are also plugin approvers.
This uses the same shared approval button surface as other channels. When `interactivity` is enabled in your Slack app settings, approval prompts render as Block Kit buttons directly in the conversation.
When those buttons are present, they are the primary approval UX; OpenClaw
@@ -1336,8 +1343,8 @@ opt into origin-chat delivery:
Shared `approvals.exec` forwarding is separate. Use it only when exec approval prompts must also
route to other chats or explicit out-of-band targets. Shared `approvals.plugin` forwarding is also
separate; Slack-native buttons can still resolve plugin approvals when those requests already land
in Slack.
separate; Slack native delivery suppresses that fallback only when Slack can handle the plugin
approval request natively.
Same-chat `/approve` also works in Slack channels and DMs that already support commands. See [Exec approvals](/tools/exec-approvals) for the full approval forwarding model.

View File

@@ -13,6 +13,7 @@ Use `--agent <id>` to target a configured agent directly.
Pass at least one session selector:
- `--to <dest>`
- `--session-key <key>`
- `--session-id <id>`
- `--agent <id>`
@@ -24,6 +25,7 @@ Related:
- `-m, --message <text>`: required message body
- `-t, --to <dest>`: recipient used to derive the session key
- `--session-key <key>`: explicit session key to use for routing
- `--session-id <id>`: explicit session id
- `--agent <id>`: agent id; overrides routing bindings
- `--model <id>`: model override for this run (`provider/model` or model id)
@@ -44,6 +46,8 @@ Related:
openclaw agent --to +15555550123 --message "status update" --deliver
openclaw agent --agent ops --message "Summarize logs"
openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs"
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
@@ -57,6 +61,7 @@ openclaw agent --agent ops --message "Run locally" --local
- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive.
- Gateway-backed runs leave Gateway-owned MCP loopback resources under the running Gateway process; older clients may still send the historical cleanup flag, but the Gateway accepts it as a compatibility no-op.
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent:<agent-id>:<session-key>`, and `--agent` must match the key's agent id when both are provided. Bare non-sentinel keys are scoped to `--agent` when supplied, or to the configured default agent otherwise; for example, `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. Literal `global` and `unknown` remain unscoped only when no `--agent` is supplied; in that case, embedded fallback and store ownership use the configured default agent.
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs.
- If the Gateway accepts an agent run but the CLI times out waiting for the final reply, embedded fallback uses a fresh explicit `gateway-fallback-*` session/run id and reports `meta.fallbackReason: "gateway_timeout"` plus the fallback session fields. This avoids racing the Gateway-owned transcript lock or silently replacing the original routed conversation session.

View File

@@ -23,7 +23,6 @@ openclaw update wizard
openclaw update --channel beta
openclaw update --channel dev
openclaw update --tag beta
openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --yes
@@ -35,7 +34,7 @@ openclaw --update
- `--no-restart`: skip restarting the Gateway service after a successful update. Package-manager updates that do restart the Gateway verify the restarted service reports the expected updated version before the command succeeds.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
- `--tag <dist-tag|version|spec>`: override the package target for this update only. Use `--channel dev`, not `--tag main`, for the moving GitHub `main` checkout.
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
- `--json`: print machine-readable `UpdateRunResult` JSON, including
`postUpdate.plugins.warnings` when corrupt or unloadable managed plugins need

View File

@@ -21,11 +21,12 @@ Treat them differently from normal config:
## Currently documented flags
| Surface | Key | Use it when | More |
| ------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
| Surface | Key | Use it when | More |
| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) |
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
## Local model lean mode

View File

@@ -1093,7 +1093,7 @@ for provider examples and precedence.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
- `subagents.allowAgents`: allowlist of agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any configured target; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed.
- `subagents.allowAgents`: allowlist of configured agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any configured target; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed. Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up, or add a minimal `agents.list[]` entry if that target should remain spawnable while inheriting defaults.
- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed.
- `subagents.requireAgentId`: when true, block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection; default: false).

View File

@@ -433,7 +433,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
```
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
- `allowAgents`: default allowlist of target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only).
- `allowAgents`: default allowlist of configured target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only). Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up.
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.

View File

@@ -98,12 +98,13 @@ If you deploy the OpenClaw Gateway itself as a Docker container, it orchestrates
- **Config requires host paths**: The `openclaw.json` `workspace` configuration MUST contain the **Host's absolute path** (e.g. `/home/user/.openclaw/workspaces`), not the internal Gateway container path. When OpenClaw asks the Docker daemon to spawn a sandbox, the daemon evaluates paths relative to the Host OS namespace, not the Gateway namespace.
- **FS bridge parity (identical volume map)**: The OpenClaw Gateway native process also writes heartbeat and bridge files to the `workspace` directory. Because the Gateway evaluates the exact same string (the host path) from within its own containerized environment, the Gateway deployment MUST include an identical volume map linking the host namespace natively (`-v /home/user/.openclaw:/home/user/.openclaw`).
- **Codex code mode**: When an OpenClaw sandbox is active, OpenClaw constrains Codex app-server turns to Codex `workspace-write` sandboxing even if the Codex plugin default is `danger-full-access`. The Codex turn network flag follows the OpenClaw sandbox egress setting, so Docker `network: "none"` stays offline and `network: "bridge"` or a custom Docker network allows outbound access. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes.
- **Codex code mode**: When an OpenClaw sandbox is active, OpenClaw disables Codex app-server native Code Mode, user MCP servers, and app-backed plugin execution for that turn because those native surfaces run from the Gateway-host app-server process instead of the OpenClaw sandbox backend. Shell access is exposed through OpenClaw sandbox-backed tools such as `sandbox_exec` and `sandbox_process` when the normal exec/process tools are available. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes.
On Ubuntu/AppArmor hosts, Codex `workspace-write` can fail before shell startup
when the service user is not allowed to create unprivileged user namespaces.
When Docker sandbox egress is disabled (`network: "none"`, the default),
Codex also needs an unprivileged network namespace. Common symptoms are
when you intentionally run native Codex `workspace-write` without active
OpenClaw sandboxing and the service user is not allowed to create unprivileged
user namespaces. When Docker sandbox egress is disabled (`network: "none"`, the
default), Codex also needs an unprivileged network namespace. Common symptoms are
`bwrap: setting up uid map: Permission denied` and
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. Run
`openclaw doctor`; if it reports a Codex bwrap namespace probe failure, prefer

View File

@@ -60,8 +60,8 @@ openclaw update --tag 2026.4.1-beta.1
# Install from the beta dist-tag (one-off, does not persist)
openclaw update --tag beta
# Install from GitHub main branch (npm tarball)
openclaw update --tag main
# Switch to the moving GitHub main checkout
openclaw update --channel dev
# Install a specific npm package spec
openclaw update --tag openclaw@2026.4.1-beta.1
@@ -72,6 +72,9 @@ Notes:
- `--tag` applies to **package (npm) installs only**. Git installs ignore it.
- The tag is not persisted. Your next `openclaw update` uses your configured
channel as usual.
- OpenClaw does not support npm GitHub source installs for `openclaw/openclaw`.
Use `--channel dev` or `--install-method git --version main` for the moving
`main` checkout.
- Downgrade protection: if the target version is older than your current version,
OpenClaw prompts for confirmation (skip with `--yes`).
- `--channel beta` is different from `--tag beta`: the channel flow can fall back

View File

@@ -131,10 +131,10 @@ openclaw onboard --install-daemon
Or skip the link and use `pnpm openclaw ...` from inside the repo. See [Setup](/start/setup) for full development workflows.
### Install from GitHub main
### Install from the GitHub main checkout
```bash
npm install -g github:openclaw/openclaw#main
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --version main
```
### Containers and package managers

View File

@@ -119,9 +119,9 @@ The script exits with code `2` for invalid method selection or invalid `--instal
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git
```
</Tab>
<Tab title="GitHub main via npm">
<Tab title="GitHub main checkout">
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --version main
```
</Tab>
<Tab title="Dry run">
@@ -154,19 +154,19 @@ The script exits with code `2` for invalid method selection or invalid `--instal
<Accordion title="Environment variables reference">
| Variable | Description |
| ------------------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=latest\|next\|main\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
| `OPENCLAW_VERBOSE=1` | Debug mode |
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
| Variable | Description |
| ------------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=latest\|next\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
| `OPENCLAW_VERBOSE=1` | Debug mode |
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
</Accordion>
</AccordionGroup>
@@ -315,9 +315,9 @@ by default, plus git-checkout installs under the same prefix flow.
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
```
</Tab>
<Tab title="GitHub main via npm">
<Tab title="GitHub main checkout">
```powershell
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -Tag main
```
</Tab>
<Tab title="Custom git directory">

View File

@@ -21,7 +21,6 @@ To switch channels or target a specific version:
```bash
openclaw update --channel beta
openclaw update --channel dev
openclaw update --tag main
openclaw update --dry-run # preview without applying
```
@@ -35,6 +34,10 @@ installer has its own `--verbose` flag, but that flag is not part of
the beta tag is missing or older than the latest stable release. Use `--tag beta`
if you want the raw npm beta dist-tag for a one-off package update.
Use `--channel dev` for the moving GitHub `main` checkout. Package updates do
not support npm GitHub source installs for `openclaw/openclaw`; target a
published dist-tag, exact version, or built tarball instead.
For managed plugins, beta-channel fallback is a warning: the core update can
still succeed while a plugin uses its recorded default/latest release because no
plugin beta is available.

View File

@@ -15,7 +15,8 @@ sidebarTitle: "Adding capabilities"
load pipeline, runtime helpers), see [Plugin internals](/plugins/architecture).
</Info>
Use this when OpenClaw needs a new shared domain such as image generation, video generation, or some future vendor-backed feature area.
Use this when OpenClaw needs a new shared domain such as embeddings, image
generation, video generation, or some future vendor-backed feature area.
The rule:
@@ -113,6 +114,19 @@ The config key is intentionally separate from vision-analysis routing:
Keep those separate so fallback and policy remain explicit.
## Embedding providers
Use `embeddingProviders` for reusable vector embedding providers. This contract
is intentionally broader than memory: tools, search, retrieval, importers, or
future feature plugins can consume embeddings without depending on the memory
engine.
For memory-engine-specific adapters, keep using `memoryEmbeddingProviders`.
Those adapters own memory indexing details such as query/document split,
runtime metadata, and local memory engine setup. Do not make a generic
embedding provider depend on memory-owned modules unless the provider is only
usable by memory.
## Review checklist
Before shipping a new capability, verify:

View File

@@ -37,6 +37,7 @@ Capabilities are the public **native plugin** model inside OpenClaw. Every nativ
| ---------------------- | ------------------------------------------------ | ------------------------------------ |
| Text inference | `api.registerProvider(...)` | `openai`, `anthropic` |
| CLI inference backend | `api.registerCliBackend(...)` | `openai`, `anthropic` |
| Embeddings | `api.registerEmbeddingProvider(...)` | Provider-owned vector plugins |
| Speech | `api.registerSpeechProvider(...)` | `elevenlabs`, `microsoft` |
| Realtime transcription | `api.registerRealtimeTranscriptionProvider(...)` | `openai` |
| Realtime voice | `api.registerRealtimeVoiceProvider(...)` | `openai` |

View File

@@ -1,34 +1,50 @@
---
summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins"
summary: "Install Codex, Claude, and Cursor-compatible bundles as OpenClaw plugins"
read_when:
- You want to install a Codex, Claude, or Cursor-compatible bundle
- You need to understand how OpenClaw maps bundle content into native features
- You are debugging bundle detection or missing capabilities
- You need to know which bundle features OpenClaw executes
- You are debugging bundle detection, MCP tools, LSP defaults, or missing capabilities
title: "Plugin bundles"
doc-schema-version: 1
---
OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**,
and **Cursor**. These are called **bundles** — content and metadata packs that
OpenClaw maps into native features like skills, hooks, and MCP tools.
Plugin bundles let OpenClaw reuse compatible Codex, Claude, and Cursor plugin
layouts without loading them as native OpenClaw runtime modules. Use this page
when you have an existing bundle and need to install it, verify how OpenClaw
classified it, and understand which parts become OpenClaw skills, hooks, MCP
tools, settings, or diagnostics.
<Info>
Bundles are **not** the same as native OpenClaw plugins. Native plugins run
in-process and can register any capability. Bundles are content packs with
selective feature mapping and a narrower trust boundary.
Bundles are not native OpenClaw plugins. Native plugins run in process and can
register OpenClaw capabilities directly. Bundles are content and metadata
packs that OpenClaw maps selectively into supported surfaces.
</Info>
## Why bundles exist
## Choose the right plugin format
Many useful plugins are published in Codex, Claude, or Cursor format. Instead
of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw
detects these formats and maps their supported content into the native feature
set. This means you can install a Claude command pack or a Codex skill bundle
and use it immediately.
Use a bundle when you already have a Codex, Claude, or Cursor-compatible
package and want OpenClaw to map its supported content into skills, hook packs,
MCP tools, settings, or LSP defaults without rewriting it as a native plugin.
Build a native OpenClaw plugin when the integration must register a channel,
provider, service, HTTP route, Gateway method, plugin-owned CLI command, or
another runtime capability.
## Install a bundle
| Need | Use |
| --------------------------------------------------------------------------------------- | ------------- |
| Reuse skills, command markdown, MCP config, or LSP defaults from a compatible ecosystem | Bundle |
| Execute arbitrary plugin runtime code in OpenClaw | Native plugin |
| Publish a full OpenClaw capability | Native plugin |
| Port an existing Claude or Cursor command pack | Bundle |
See [Building plugins](/plugins/building-plugins) for native plugin authoring
and [Plugins](/tools/plugin) for the main install workflow.
## Install and verify a bundle
<Steps>
<Step title="Install from a directory, archive, or marketplace">
<Step title="Install the bundle">
Install from a local directory, archive, or supported marketplace source:
```bash
# Local directory
openclaw plugins install ./my-bundle
@@ -43,268 +59,281 @@ and use it immediately.
</Step>
<Step title="Verify detection">
<Step title="Check detection">
```bash
openclaw plugins list
openclaw plugins inspect <id>
```
Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`.
A compatible bundle appears with `Format: bundle` and a `codex`, `claude`,
or `cursor` subtype.
</Step>
<Step title="Restart and use">
<Step title="Restart the Gateway">
```bash
openclaw gateway restart
```
Mapped features (skills, hooks, MCP tools, LSP defaults) are available in the next session.
Installing or updating plugin code requires restarting the Gateway.
</Step>
</Steps>
## What OpenClaw maps from bundles
Not every bundle feature runs in OpenClaw today. Here is what works and what
is detected but not yet wired.
Not every bundle feature runs in OpenClaw today. OpenClaw maps supported content
into native surfaces and reports detect-only content in plugin diagnostics.
### Supported now
| Feature | How it maps | Applies to |
| ------------- | ------------------------------------------------------------------------------------------- | -------------- |
| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats |
| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor |
| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex |
| MCP tools | Bundle MCP config merged into embedded Pi settings; supported stdio and HTTP servers loaded | All formats |
| LSP servers | Claude `.lsp.json` and manifest-declared `lspServers` merged into embedded Pi LSP defaults | Claude |
| Settings | Claude `settings.json` imported as embedded Pi defaults | Claude |
| Feature | How it maps | Applies to |
| ------------- | -------------------------------------------------------------------------------------------- | --------------- |
| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats |
| Commands | `commands/` and `.cursor/commands/` are treated as skill roots | Claude, Cursor |
| Hook packs | OpenClaw-style `HOOK.md` and `handler.ts` or `handler.js` layouts | Primarily Codex |
| MCP tools | Bundle MCP config merges into embedded Pi settings; supported stdio and HTTP servers load | All formats |
| LSP servers | Claude `.lsp.json` and manifest-declared `lspServers` merge into embedded Pi LSP defaults | Claude |
| Settings | Claude `settings.json` imports as embedded Pi defaults after shell override keys are removed | Claude |
#### Skill content
### Skill content
- bundle skill roots load as normal OpenClaw skill roots
- Claude `commands` roots are treated as additional skill roots
- Cursor `.cursor/commands` roots are treated as additional skill roots
Bundle skill roots load as normal OpenClaw skill roots. Claude `commands/` and
Cursor `.cursor/commands/` load through the same path.
This means Claude markdown command files work through the normal OpenClaw skill
loader. Cursor command markdown works through the same path.
### Hook packs
#### Hook packs
Bundle hook roots run **only** when they use the normal OpenClaw hook-pack layout:
`HOOK.md` with `handler.ts` or `handler.js`. Today this is primarily the
Codex-compatible case.
- bundle hook roots work **only** when they use the normal OpenClaw hook-pack
layout. Today this is primarily the Codex-compatible case:
- `HOOK.md`
- `handler.ts` or `handler.js`
### MCP tools
#### MCP for Pi
Enabled bundles can contribute MCP server config to embedded Pi as `mcpServers`.
Supported stdio and HTTP servers can expose tools during embedded Pi turns. The
`coding` and `messaging` tool profiles include bundle MCP tools by default; use
`tools.deny: ["bundle-mcp"]` to opt out for an agent or Gateway.
- enabled bundles can contribute MCP server config
- OpenClaw merges bundle MCP config into the effective embedded Pi settings as
`mcpServers`
- OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by
launching stdio servers or connecting to HTTP servers
- the `coding` and `messaging` tool profiles include bundle MCP tools by
default; use `tools.deny: ["bundle-mcp"]` to opt out for an agent or gateway
- project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed
- bundle MCP tool catalogs are sorted deterministically before registration, so
upstream `listTools()` order changes do not thrash prompt-cache tool blocks
### Embedded Pi settings
##### Transports
Claude `settings.json` imports as default embedded Pi settings when the bundle is
enabled. OpenClaw removes shell override keys before applying them.
MCP servers can use stdio or HTTP transport:
### Embedded Pi LSP
**Stdio** launches a child process:
Claude `.lsp.json` and manifest-declared `lspServers` merge into embedded Pi LSP
defaults. Supported stdio-backed LSP servers can run.
### Detected but not executed
OpenClaw reports these in diagnostics but does not run them:
- Claude `agents`, `hooks/hooks.json`, `outputStyles`
- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules`
- Codex app or inline metadata
## Bundle formats and detection
OpenClaw checks native plugin markers before bundle markers. A directory with
`openclaw.plugin.json` or a valid `package.json` `openclaw.extensions` entry is
treated as a native plugin, even if it also contains bundle files. This prevents
dual-format packages from being partially loaded through the bundle path.
After native detection, OpenClaw recognizes these bundle layouts:
<AccordionGroup>
<Accordion title="Codex bundles">
Marker: `.codex-plugin/plugin.json`
Supported mapped content: `skills/`, `hooks/`, `.mcp.json`, and `.app.json`
capability reporting.
Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style
hook-pack directories.
</Accordion>
<Accordion title="Claude bundles">
Detection modes:
- **Manifest-based:** `.claude-plugin/plugin.json`
- **Manifestless:** default Claude layout with `skills/`, `commands/`,
`agents/`, `hooks/hooks.json`, `.mcp.json`, `.lsp.json`, or
`settings.json`
Supported mapped content: `skills/`, `commands/`, `settings.json`,
`.mcp.json`, `.lsp.json`, manifest-declared `mcpServers`, and
manifest-declared `lspServers`.
Detect-only content: `agents`, `hooks/hooks.json`, and `outputStyles`.
</Accordion>
<Accordion title="Cursor bundles">
Marker: `.cursor-plugin/plugin.json`
Supported mapped content: `skills/`, `.cursor/commands/`, and `.mcp.json`.
Detect-only content: `.cursor/agents`, `.cursor/hooks.json`, and
`.cursor/rules`.
</Accordion>
</AccordionGroup>
Claude manifest component paths are additive. Declaring custom paths extends
the default paths that exist in the bundle instead of replacing them.
## MCP config reference
Bundle MCP tools use the synthetic plugin key `bundle-mcp` for profile filtering.
To opt out for an agent or Gateway, deny that key:
```json5
{
tools: {
deny: ["bundle-mcp"],
},
}
```
Project-local embedded Pi settings still apply after bundle defaults, so
workspace settings can override bundle MCP entries when needed.
### MCP config shape
Bundle MCP files can use either `mcpServers`, `servers`, or a top-level server
map. Stdio servers launch a child process:
```json
{
"mcp": {
"servers": {
"my-server": {
"command": "node",
"args": ["server.js"],
"env": { "PORT": "3000" }
}
"mcpServers": {
"my-server": {
"command": "node",
"args": ["server.js"],
"env": { "PORT": "3000" }
}
}
}
```
**HTTP** connects to a running MCP server over `sse` by default, or `streamable-http` when requested:
HTTP servers connect over `sse` by default, or `streamable-http` when requested:
```json
{
"mcp": {
"servers": {
"my-server": {
"url": "http://localhost:3100/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer ${MY_SECRET_TOKEN}"
},
"connectionTimeoutMs": 30000
}
"mcpServers": {
"my-server": {
"url": "http://localhost:3100/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer local-dev-token"
},
"connectionTimeoutMs": 30000
}
}
}
```
- `transport` may be set to `"streamable-http"` or `"sse"`; when omitted, OpenClaw uses `sse`
- `type: "http"` is a CLI-native downstream shape; use `transport: "streamable-http"` in OpenClaw config. `openclaw mcp set` and `openclaw doctor --fix` normalize the common alias.
- only `http:` and `https:` URL schemes are allowed
- `headers` values support `${ENV_VAR}` interpolation
- a server entry with both `command` and `url` is rejected
- URL credentials (userinfo and query params) are redacted from tool
descriptions and logs
Rules:
- `transport` may be `"sse"` or `"streamable-http"`. When omitted, OpenClaw
uses `sse`.
- `type: "http"` is a CLI-native downstream alias. Prefer
`transport: "streamable-http"` in bundle config; `openclaw mcp set` and
`openclaw doctor --fix` normalize the alias.
- Only `http:` and `https:` URLs are supported.
- `headers` must be a JSON object with string-compatible values.
- A server entry with `command` is treated as stdio. A server entry with `url`
and no command is treated as HTTP.
- URL credentials, including userinfo and query params, are redacted from tool
descriptions and logs.
- `connectionTimeoutMs` overrides the default 30-second connection timeout for
both stdio and HTTP transports
stdio and HTTP transports.
##### Tool naming
For stdio startup safety, unsupported environment-variable entries are ignored
with diagnostics instead of being passed through blindly.
OpenClaw registers bundle MCP tools with provider-safe names in the form
`serverName__toolName`. For example, a server keyed `"vigil-harbor"` exposing a
`memory_search` tool registers as `vigil-harbor__memory_search`.
### MCP paths and tool names
- characters outside `A-Za-z0-9_-` are replaced with `-`
- fragments that would start with a non-letter get a letter prefix, so numeric
server keys such as `12306` become provider-safe tool prefixes
- server prefixes are capped at 30 characters
- full tool names are capped at 64 characters
- empty server names fall back to `mcp`
- colliding sanitized names are disambiguated with numeric suffixes
- final exposed tool order is deterministic by safe name to keep repeated Pi
turns cache-stable
- profile filtering treats all tools from one bundle MCP server as plugin-owned
by `bundle-mcp`, so profile allowlists and deny lists can include either
individual exposed tool names or the `bundle-mcp` plugin key
File-backed MCP config is resolved relative to the bundle file that declared
it. Explicit relative `command`, `args`, `cwd`, and `workingDirectory` values
are expanded against that file's directory. Claude bundle config can also use
`${CLAUDE_PLUGIN_ROOT}` to refer to the bundle root.
#### Embedded Pi settings
OpenClaw registers bundle MCP tools with provider-safe names:
- Claude `settings.json` is imported as default embedded Pi settings when the
bundle is enabled
- OpenClaw sanitizes shell override keys before applying them
```text
serverName__toolName
```
Naming rules:
- Characters outside `A-Za-z0-9_-` become `-`.
- Server prefixes must start with a letter; numeric server keys get an `mcp-`
prefix.
- Empty server names fall back to `mcp`.
- Server prefixes are capped at 30 characters.
- Full tool names are capped at 64 characters.
- Colliding sanitized names get numeric suffixes.
- Exposed tools are sorted deterministically by safe name so repeated Pi turns
keep stable tool blocks.
- Profile allowlists and denylists can name either individual exposed tools or
the `bundle-mcp` plugin key.
## Embedded Pi settings and LSP defaults
Enabled Claude bundles can contribute `settings.json` defaults to the embedded
Pi runtime. OpenClaw applies those settings before project-local settings, then
sanitizes shell override keys so bundle or workspace settings cannot change
shell execution behavior.
Sanitized keys:
- `shellPath`
- `shellCommandPrefix`
#### Embedded Pi LSP
- enabled Claude bundles can contribute LSP server config
- OpenClaw loads `.lsp.json` plus any manifest-declared `lspServers` paths
- bundle LSP config is merged into the effective embedded Pi LSP defaults
- only supported stdio-backed LSP servers are runnable today; unsupported
transports still show up in `openclaw plugins inspect <id>`
### Detected but not executed
These are recognized and shown in diagnostics, but OpenClaw does not run them:
- Claude `agents`, `hooks.json` automation, `outputStyles`
- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules`
- Codex inline/app metadata beyond capability reporting
## Bundle formats
<AccordionGroup>
<Accordion title="Codex bundles">
Markers: `.codex-plugin/plugin.json`
Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json`
Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style
hook-pack directories (`HOOK.md` + `handler.ts`).
</Accordion>
<Accordion title="Claude bundles">
Two detection modes:
- **Manifest-based:** `.claude-plugin/plugin.json`
- **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `.lsp.json`, `settings.json`)
Claude-specific behavior:
- `commands/` is treated as skill content
- `settings.json` is imported into embedded Pi settings (shell override keys are sanitized)
- `.mcp.json` exposes supported stdio tools to embedded Pi
- `.lsp.json` plus manifest-declared `lspServers` paths load into embedded Pi LSP defaults
- `hooks/hooks.json` is detected but not executed
- Custom component paths in the manifest are additive (they extend defaults, not replace them)
</Accordion>
<Accordion title="Cursor bundles">
Markers: `.cursor-plugin/plugin.json`
Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json`
- `.cursor/commands/` is treated as skill content
- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only
</Accordion>
</AccordionGroup>
## Detection precedence
OpenClaw checks for native plugin format first:
1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` — treated as **native plugin**
2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) — treated as **bundle**
If a directory contains both, OpenClaw uses the native path. This prevents
dual-format packages from being partially installed as bundles.
Enabled Claude bundles can also contribute LSP server config through `.lsp.json`
or manifest-declared `lspServers`. OpenClaw merges those entries into embedded
Pi LSP defaults. Supported stdio-backed LSP servers can run; unsupported server
entries still appear in `openclaw plugins inspect <id>` diagnostics.
## Runtime dependencies and cleanup
- Third-party compatible bundles do not get startup `npm install` repair. They
should be installed through `openclaw plugins install` and ship everything
they need in the installed plugin directory.
- OpenClaw-owned bundled plugins are either shipped lightweight in core or
downloadable through the plugin installer. Gateway startup never runs a
package manager for them.
- `openclaw doctor --fix` removes legacy staged dependency directories and can
recover downloadable plugins that are missing from the local plugin index when
config references them.
Third-party compatible bundles do not get startup `npm install` repair. Install
them with `openclaw plugins install`, and ship every runtime file they need
inside the installed plugin directory.
## Security
OpenClaw-owned bundled plugins are either shipped lightweight in core or
downloadable through the plugin installer. Gateway startup does not run a
package manager for them. `openclaw doctor --fix` can remove legacy staged
dependency directories and recover downloadable plugins that config references
but the local plugin index is missing.
Bundles have a narrower trust boundary than native plugins:
## Security boundary
- OpenClaw does **not** load arbitrary bundle runtime modules in-process
- Skills and hook-pack paths must stay inside the plugin root (boundary-checked)
- Settings files are read with the same boundary checks
- Supported stdio MCP servers may be launched as subprocesses
Bundles have a narrower runtime boundary than native plugins:
This makes bundles safer by default, but you should still treat third-party
bundles as trusted content for the features they do expose.
- OpenClaw does not load arbitrary bundle runtime modules in process.
- Skill roots, hook-pack paths, settings files, MCP files, and LSP files are
read with plugin-root boundary checks.
- OpenClaw-style hook packs must stay inside the plugin root.
- Supported stdio MCP servers can still launch subprocesses.
Treat third-party bundles as trusted content for the mapped features they
expose, especially MCP servers and hook packs.
## Troubleshooting
<AccordionGroup>
<Accordion title="Bundle is detected but capabilities do not run">
Run `openclaw plugins inspect <id>`. If a capability is listed but marked as
not wired, that is a product limit — not a broken install.
</Accordion>
<Accordion title="Claude command files do not appear">
Make sure the bundle is enabled and the markdown files are inside a detected
`commands/` or `skills/` root.
</Accordion>
<Accordion title="Claude settings do not apply">
Only embedded Pi settings from `settings.json` are supported. OpenClaw does
not treat bundle settings as raw config patches.
</Accordion>
<Accordion title="Claude hooks do not execute">
`hooks/hooks.json` is detect-only. If you need runnable hooks, use the
OpenClaw hook-pack layout or ship a native plugin.
</Accordion>
</AccordionGroup>
| Symptom | Check | Fix |
| -------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Capability is listed but does not run | Run `openclaw plugins inspect <id>` and check whether it is marked as not wired | This is a current product limit, not a broken install |
| Claude command files do not appear as skills | Check that markdown files are inside `commands/` or a declared command path | Move the files under a detected `commands/` or `skills/` root, enable the bundle, and restart |
| Claude `settings.json` does not apply | Check that the bundle is enabled and inspect diagnostics | Only embedded Pi settings are imported; shell override keys are removed |
| Claude hooks do not execute | Check whether the bundle only has `hooks/hooks.json` | Use an OpenClaw hook-pack layout or ship a native plugin |
## Related
- [Install and Configure Plugins](/tools/plugin)
- [Building Plugins](/plugins/building-plugins) create a native plugin
- [Plugin Manifest](/plugins/manifest) — native manifest schema
- [Plugins](/tools/plugin) - install, configure, and troubleshoot plugins
- [Manage plugins](/plugins/manage-plugins) - common plugin CLI examples
- [Plugin inventory](/plugins/plugin-inventory) - generated bundled and external plugin list
- [Plugin manifest](/plugins/manifest) - native plugin manifest schema
- [Building plugins](/plugins/building-plugins) - create a native plugin

View File

@@ -85,23 +85,25 @@ For an already-running app-server, use WebSocket transport:
Supported `appServer` fields:
| Field | Default | Meaning |
| ----------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | unset | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Defaults to the assistant completion idle timeout when unset. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.
@@ -147,15 +149,16 @@ values are allowed. Individual policy fields override `mode`. The older
but new configs should use `auto_review`.
When an OpenClaw sandbox is active, the local Codex app-server process still
runs on the Gateway host. OpenClaw therefore keeps Codex's own filesystem
sandbox for native code-mode turns. `danger-full-access` turns are narrowed to
Codex `workspace-write`, and `workspace-write` turn `networkAccess` is derived
from the OpenClaw sandbox egress setting: Docker `network: "none"` stays
offline, while `network: "bridge"` or a custom Docker network permits outbound
access.
runs on the Gateway host. OpenClaw therefore disables Codex native Code Mode,
user MCP servers, and app-backed plugin execution for that turn instead of
treating Codex host-side sandboxing as equivalent to the OpenClaw sandbox
backend. Shell access is exposed through OpenClaw sandbox-backed dynamic tools
such as `sandbox_exec` and `sandbox_process` when the normal exec/process tools
are available.
On Ubuntu/AppArmor hosts, Codex bwrap can fail under `workspace-write` before
the shell command starts. If you see
the shell command starts when you intentionally run native Codex
`workspace-write` without active OpenClaw sandboxing. If you see
`bwrap: setting up uid map: Permission denied` or
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, run
`openclaw doctor` and fix the reported host namespace policy for the OpenClaw
@@ -164,6 +167,43 @@ a scoped AppArmor profile for the service process; the
`kernel.apparmor_restrict_unprivileged_userns=0` fallback is host-wide and has
security tradeoffs.
## Sandboxed native execution
The stable default is fail-closed: active OpenClaw sandboxing disables native
Codex execution surfaces that would otherwise run from the Codex app-server
host. Use `appServer.experimental.sandboxExecServer: true` only when you want to
try Codex's remote environment support with OpenClaw's sandbox backend. This
preview path requires Codex app-server 0.132.0 or newer.
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
experimental: {
sandboxExecServer: true,
},
},
},
},
},
},
}
```
When the flag is on and the current OpenClaw session is sandboxed, OpenClaw
starts a local loopback exec-server backed by the active sandbox, registers it
with Codex app-server, and starts the Codex thread and turn with that
OpenClaw-owned environment. If the app-server cannot register the environment,
the run fails closed instead of silently falling back to host execution.
This preview path is local-only. A remote WebSocket app-server cannot reach the
loopback exec-server unless it is running on the same host, so OpenClaw rejects
that combination.
## Auth and environment isolation
Auth is selected in this order:
@@ -291,9 +331,12 @@ turn-scoped tool-result handoff. Completed `agentMessage` items and pre-tool raw
assistant `rawResponseItem/completed` items arm the assistant-output release: if
Codex then goes quiet without `turn/completed`, OpenClaw best-effort interrupts
the native turn and releases the session lane. Post-tool raw assistant progress
keeps waiting for `turn/completed` or the terminal watchdog. Timeout diagnostics
include the last app-server notification method and, for raw assistant response
items, the item type, role, id, and a bounded assistant text preview.
keeps waiting for `turn/completed` while a completion-idle guard stays armed; the
guard uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when
configured and falls back to the assistant completion idle timeout otherwise.
Timeout diagnostics include the last app-server notification method and, for raw
assistant response items, the item type, role, id, and a bounded assistant text
preview.
## Model discovery

View File

@@ -21,11 +21,12 @@ Do not configure `openai-codex/gpt-*` model refs. Put OpenAI agent auth order
under `auth.order.openai`; older `openai-codex:*` profiles and
`auth.order.openai-codex` entries remain supported for existing installs.
OpenClaw starts Codex app-server threads with Codex native code mode enabled
while leaving code-mode-only off by default. That keeps Codex native workspace
and code capabilities available while OpenClaw dynamic tools continue through
the app-server `item/tool/call` bridge. Restricted tool policies still disable
native code mode entirely.
When no OpenClaw sandbox is active, OpenClaw starts Codex app-server threads
with Codex native code mode enabled while leaving code-mode-only off by default.
That keeps Codex native workspace and code capabilities available while
OpenClaw dynamic tools continue through the app-server `item/tool/call` bridge.
Active OpenClaw sandboxing and restricted tool policies disable native code mode
entirely unless you opt into the experimental sandbox exec-server path.
For the broader model/provider/runtime split, start with
[Agent runtimes](/concepts/agent-runtimes). The short version is:
@@ -346,12 +347,11 @@ Local stdio app-server sessions default to the trusted local operator posture:
`approvalPolicy: "never"`, `approvalsReviewer: "user"`, and
`sandbox: "danger-full-access"`. If local Codex requirements disallow that
implicit YOLO posture, OpenClaw selects allowed guardian permissions instead.
When an OpenClaw sandbox is active for the session, OpenClaw narrows Codex
`danger-full-access` to Codex `workspace-write` so native Codex code-mode turns
stay inside the sandboxed workspace. The Codex turn network flag follows the
OpenClaw sandbox egress policy: Docker `network: "none"` stays offline, while
`network: "bridge"` or a custom Docker network allows outbound access.
Explicit Codex `workspace-write` turns use the same egress-derived network flag.
When an OpenClaw sandbox is active for the session, OpenClaw disables Codex
native Code Mode, user MCP servers, and app-backed plugin execution for that
turn instead of relying on Codex host-side sandboxing. Shell access is exposed
through OpenClaw sandbox-backed dynamic tools such as `sandbox_exec` and
`sandbox_process` when the normal exec/process tools are available.
Use guardian mode when you want Codex native auto-review before sandbox escapes
or extra permissions:
@@ -511,23 +511,25 @@ Supported top-level Codex plugin fields:
Supported `appServer` fields:
| Field | Default | Meaning |
| ----------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | unset | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Defaults to the assistant completion idle timeout when unset. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 30 second
@@ -556,9 +558,12 @@ Completed `agentMessage` items and pre-tool raw assistant
`rawResponseItem/completed` items arm the assistant-output release: if Codex then
goes quiet without `turn/completed`, OpenClaw best-effort interrupts the native
turn and releases the session lane. Post-tool raw assistant progress keeps
waiting for `turn/completed` or the terminal watchdog. Timeout diagnostics
include the last app-server notification method and, for raw assistant response
items, the item type, role, id, and a bounded assistant text preview.
waiting for `turn/completed` while a completion-idle guard stays armed; the guard
uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when configured and
falls back to the assistant completion idle timeout otherwise. Timeout
diagnostics include the last app-server notification method and, for raw
assistant response items, the item type, role, id, and a bounded assistant text
preview.
Environment overrides remain available for local testing:

View File

@@ -145,47 +145,47 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
## Top-level field reference
| Field | Required | Type | What it means |
| ------------------------------------ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.<id>`. |
| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. |
| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. |
| `enabledByDefaultOnPlatforms` | No | `string[]` | Marks a bundled plugin as enabled by default only on the listed Node.js platforms, for example `["darwin"]`. Explicit config still wins. |
| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. |
| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. |
| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `providerCatalogEntry` | No | `string` | Lightweight provider-catalog module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. |
| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. |
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
| `contracts` | No | `object` | Static capability ownership snapshot for external auth hooks, speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `mediaUnderstandingProviderMetadata` | No | `Record<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
| Field | Required | Type | What it means |
| ------------------------------------ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.<id>`. |
| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. |
| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. |
| `enabledByDefaultOnPlatforms` | No | `string[]` | Marks a bundled plugin as enabled by default only on the listed Node.js platforms, for example `["darwin"]`. Explicit config still wins. |
| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. |
| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. |
| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `providerCatalogEntry` | No | `string` | Lightweight provider-catalog module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. |
| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. |
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
| `contracts` | No | `object` | Static capability ownership snapshot for external auth hooks, embeddings, speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `mediaUnderstandingProviderMetadata` | No | `Record<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
## Generation provider metadata reference
@@ -619,6 +619,7 @@ read without importing the plugin runtime.
"contracts": {
"agentToolResultMiddleware": ["pi", "codex"],
"externalAuthProviders": ["acme-ai"],
"embeddingProviders": ["openai-compatible"],
"speechProviders": ["openai"],
"realtimeTranscriptionProviders": ["openai"],
"realtimeVoiceProviders": ["openai"],
@@ -642,6 +643,7 @@ Each list is optional:
| `embeddedExtensionFactories` | `string[]` | Codex app-server extension factory ids, currently `codex-app-server`. |
| `agentToolResultMiddleware` | `string[]` | Runtime ids a bundled plugin may register tool-result middleware for. |
| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. |
| `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use outside memory. |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
@@ -677,6 +679,12 @@ built-in adapters such as `local`. Standalone CLI paths use this manifest
contract to load only the owning plugin before the full Gateway runtime has
registered providers.
General embedding providers should declare `contracts.embeddingProviders` for
each adapter registered with `api.registerEmbeddingProvider(...)`. Use the
general contract when vectors are meant to be consumed by multiple features,
tools, or plugins. Keep `contracts.memoryEmbeddingProviders` for adapters whose
shape and lifecycle are specific to OpenClaw memory indexing.
`contracts.gatewayMethodDispatch` currently accepts
`"authenticated-request"`. It is an API hygiene gate for native plugin HTTP
routes that intentionally dispatch Gateway control-plane methods in-process, not

View File

@@ -140,38 +140,38 @@ commands.
## Official external packages
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm; ClawHub | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm; ClawHub | providers: amazon-bedrock-mantle |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm; ClawHub | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm; ClawHub | providers: amazon-bedrock-mantle |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
## Source checkout only

View File

@@ -94,7 +94,7 @@ pnpm plugins:inventory:gen
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |

View File

@@ -1,5 +1,5 @@
---
summary: "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution."
summary: "Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution."
read_when:
- You are installing, configuring, or auditing the openshell plugin
title: "Openshell plugin"
@@ -7,7 +7,7 @@ title: "Openshell plugin"
# Openshell plugin
Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.
Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution.
## Distribution

View File

@@ -95,6 +95,7 @@ methods:
| `api.registerAgentHarness(...)` | Experimental low-level agent executor |
| `api.registerCliBackend(...)` | Local CLI inference backend |
| `api.registerChannel(...)` | Messaging channel |
| `api.registerEmbeddingProvider(...)` | Reusable vector embedding provider |
| `api.registerSpeechProvider(...)` | Text-to-speech / STT synthesis |
| `api.registerRealtimeTranscriptionProvider(...)` | Streaming realtime transcription |
| `api.registerRealtimeVoiceProvider(...)` | Duplex realtime voice sessions |
@@ -105,6 +106,12 @@ methods:
| `api.registerWebFetchProvider(...)` | Web fetch / scrape provider |
| `api.registerWebSearchProvider(...)` | Web search |
Embedding providers registered with `api.registerEmbeddingProvider(...)` must
also be listed in `contracts.embeddingProviders` in the plugin manifest. This
is the generic embedding surface for reusable vector generation. Memory-only
adapters still use `api.registerMemoryEmbeddingProvider(...)` and
`contracts.memoryEmbeddingProviders`.
### Tools and commands
Use [`defineToolPlugin`](/plugins/tool-plugins) for simple tool-only plugins

View File

@@ -511,9 +511,9 @@ API key auth, and dynamic model resolution.
<Step title="Add extra capabilities (optional)">
### Step 5: Add extra capabilities
A provider plugin can register speech, realtime transcription, realtime
voice, media understanding, image generation, video generation, web fetch,
and web search alongside text inference. OpenClaw classifies this as a
A provider plugin can register embeddings, speech, realtime transcription,
realtime voice, media understanding, image generation, video generation,
web fetch, and web search alongside text inference. OpenClaw classifies this as a
**hybrid-capability** plugin - the recommended pattern for company plugins
(one plugin per vendor). See
[Internals: Capability Ownership](/plugins/architecture#capability-ownership-model).
@@ -655,6 +655,38 @@ API key auth, and dynamic model resolution.
});
```
</Tab>
<Tab title="Embeddings">
```typescript
api.registerEmbeddingProvider({
id: "acme-ai",
defaultModel: "acme-embed",
transport: "remote",
authProviderId: "acme-ai",
create: async ({ model }) => ({
provider: {
id: "acme-ai",
model,
dimensions: 1536,
embed: async (input) => {
const text = typeof input === "string" ? input : input.text;
return fetchAcmeEmbedding(text);
},
embedBatch: async (inputs) =>
Promise.all(
inputs.map((input) =>
fetchAcmeEmbedding(typeof input === "string" ? input : input.text),
),
),
},
}),
});
```
Declare the same id in `contracts.embeddingProviders`. This is the
general embedding contract for reusable vector generation. Use
`registerMemoryEmbeddingProvider(...)` only for memory-engine-specific
adapters.
</Tab>
<Tab title="Image and video generation">
Video capabilities use a **mode-aware** shape: `generate`,
`imageToVideo`, and `videoToVideo`. Flat aggregate fields like

View File

@@ -176,6 +176,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/provider-web-search-config-contract` | Narrow web-search config/credential helpers for providers that do not need plugin-enable wiring |
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `createWebSearchProviderContractFields`, `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/embedding-providers` | General embedding provider types and read helpers, including `EmbeddingProviderAdapter`, `getEmbeddingProvider(...)`, and `listEmbeddingProviders(...)`; plugins register providers through `api.registerEmbeddingProvider(...)` so manifest ownership is enforced |
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |

View File

@@ -15,7 +15,7 @@ programmatic delivery.
<Steps>
<Step title="Run a simple agent turn">
```bash
openclaw agent --message "What is the weather today?"
openclaw agent --agent main --message "What is the weather today?"
```
This sends the message through the Gateway and prints the reply.
@@ -32,6 +32,9 @@ programmatic delivery.
# Reuse an existing session
openclaw agent --session-id abc123 --message "Continue the task"
# Target an exact session key
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
```
</Step>
@@ -55,6 +58,7 @@ programmatic delivery.
| ----------------------------- | ----------------------------------------------------------- |
| `--message \<text\>` | Message to send (required) |
| `--to \<dest\>` | Derive session key from a target (phone, chat id) |
| `--session-key \<key\>` | Use an explicit session key |
| `--agent \<id\>` | Target a configured agent (uses its `main` session) |
| `--session-id \<id\>` | Reuse an existing session by id |
| `--local` | Force local embedded runtime (skip Gateway) |
@@ -75,6 +79,14 @@ programmatic delivery.
- If the Gateway is unreachable, the CLI **falls back** to the local embedded run.
- Session selection: `--to` derives the session key (group/channel targets
preserve isolation; direct chats collapse to `main`).
- `--session-key` selects an explicit key. Agent-prefixed keys must use
`agent:<agent-id>:<session-key>`, and `--agent` must match that agent id when
both are supplied. Bare non-sentinel keys are scoped to `--agent` when
supplied; for example, `--agent ops --session-key incident-42` routes to
`agent:ops:incident-42`. Without `--agent`, bare non-sentinel keys are scoped
to the configured default agent. Literal `global` and `unknown` remain
unscoped only when no `--agent` is supplied; in that case, embedded fallback
and store ownership use the configured default agent.
- Thinking and verbose flags persist into the session store.
- Output: plain text by default, or `--json` for structured payload + metadata.
- With `--json --deliver`, the JSON includes delivery status for sent,
@@ -90,6 +102,12 @@ openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
# Turn with thinking level
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
# Exact session key
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
# Legacy key scoped to an agent
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
# Deliver to a different channel than the session
openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin"
```

View File

@@ -190,7 +190,7 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
Optional human-readable label.
</ParamField>
<ParamField path="agentId" type="string">
Spawn under another agent id when allowed by `subagents.allowAgents`.
Spawn under another configured agent id when allowed by `subagents.allowAgents`.
</ParamField>
<ParamField path="runtime" type='"subagent" | "acp"' default="subagent">
`acp` is only for external ACP harnesses (`claude`, `droid`, `gemini`, `opencode`, or explicitly requested Codex ACP/acpx) and for `agents.list[]` entries whose `runtime.type` is `acp`.
@@ -340,10 +340,10 @@ See [Configuration reference](/gateway/configuration-reference) and
### Allowlist
<ParamField path="agents.list[].subagents.allowAgents" type="string[]">
List of agent ids that can be targeted via explicit `agentId` (`["*"]` allows any configured target). Default: only the requester agent. If you set a list and still want the requester to spawn itself with `agentId`, include the requester id in the list.
List of configured agent ids that can be targeted via explicit `agentId` (`["*"]` allows any configured target). Default: only the requester agent. If you set a list and still want the requester to spawn itself with `agentId`, include the requester id in the list.
</ParamField>
<ParamField path="agents.defaults.subagents.allowAgents" type="string[]">
Default target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`.
Default configured target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`.
</ParamField>
<ParamField path="agents.defaults.subagents.requireAgentId" type="boolean" default="false">
Block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection). Per-agent override: `agents.list[].subagents.requireAgentId`.
@@ -362,6 +362,13 @@ Use `agents_list` to see which agent ids are currently allowed for
model and embedded runtime metadata so callers can distinguish PI, Codex
app-server, and other configured native runtimes.
`allowAgents` entries must point at configured agent ids in `agents.list[]`.
`["*"]` means any configured target agent plus the requester. If an agent config
is deleted but its id remains in `allowAgents`, `sessions_spawn` rejects that id
and `agents_list` omits it. Run `openclaw doctor --fix` to clean stale
allowlist entries, or add a minimal `agents.list[]` entry when the target should
remain spawnable while inheriting defaults.
### Auto-archive
- Sub-agent sessions are automatically archived after `agents.defaults.subagents.archiveAfterMinutes` (default `60`).

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw ACP runtime backend",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20",
"openclawVersion": "2026.5.21",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/admin-http-rpc",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw admin HTTP RPC endpoint",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"repository": {
"type": "git",
@@ -25,10 +25,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20",
"openclawVersion": "2026.5.21",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Amazon Bedrock provider plugin",
"repository": {
"type": "git",
@@ -27,10 +27,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20",
"openclawVersion": "2026.5.21",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Anthropic Vertex provider plugin",
"repository": {
"type": "git",
@@ -25,10 +25,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20",
"openclawVersion": "2026.5.21",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Azure Speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bonjour",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Brave plugin",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20"
"openclawVersion": "2026.5.21"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Cerebras provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.20"
"openclaw": ">=2026.5.21"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.5.20",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",

View File

@@ -114,6 +114,7 @@ export default definePluginEntry({
);
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
config: api.runtime.config?.current?.() as OpenClawConfig | undefined,
pluginConfig: resolveCurrentPluginConfig(),
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),

View File

@@ -206,6 +206,11 @@ describe("codex media understanding provider", () => {
serviceName: "OpenClaw",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
@@ -363,6 +368,11 @@ describe("codex media understanding provider", () => {
serviceName: "OpenClaw",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,

View File

@@ -31,6 +31,7 @@ import {
type JsonObject,
type JsonValue,
} from "./src/app-server/protocol.js";
import { buildCodexRuntimeThreadConfig } from "./src/app-server/thread-lifecycle.js";
const DEFAULT_CODEX_IMAGE_MODEL =
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
@@ -158,6 +159,8 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
sandbox: "read-only",
serviceName: "OpenClaw",
developerInstructions: params.developerInstructions,
config: buildCodexRuntimeThreadConfig(undefined, { nativeCodeModeEnabled: false }),
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
persistExtendedHistory: false,

View File

@@ -175,6 +175,10 @@
"minimum": 1,
"default": 60000
},
"postToolRawAssistantCompletionIdleTimeoutMs": {
"type": "number",
"minimum": 1
},
"approvalPolicy": {
"type": "string",
"enum": ["never", "on-request", "on-failure", "untrusted"]
@@ -190,6 +194,16 @@
"serviceTier": { "type": ["string", "null"] },
"defaultWorkspaceDir": {
"type": "string"
},
"experimental": {
"type": "object",
"additionalProperties": false,
"properties": {
"sandboxExecServer": {
"type": "boolean",
"default": false
}
}
}
}
}
@@ -345,6 +359,11 @@
"help": "Maximum quiet time after Codex accepts a turn or after a turn-scoped app-server request before OpenClaw interrupts the turn while waiting for turn/completed.",
"advanced": true
},
"appServer.postToolRawAssistantCompletionIdleTimeoutMs": {
"label": "Post-Tool Raw Assistant Completion Idle Timeout",
"help": "Completion-idle guard after a tool handoff when Codex emits raw assistant completion or progress without turn/completed. Defaults to the assistant completion idle timeout when unset.",
"advanced": true
},
"appServer.approvalPolicy": {
"label": "Approval Policy",
"help": "Codex native approval policy sent to thread start, resume, and turns.",
@@ -369,6 +388,16 @@
"label": "Default Workspace",
"help": "Workspace used by /codex bind when --cwd is omitted.",
"advanced": true
},
"appServer.experimental": {
"label": "Experimental",
"help": "Experimental Codex app-server integrations.",
"advanced": true
},
"appServer.experimental.sandboxExecServer": {
"label": "Sandbox Exec Server",
"help": "Route native Codex execution through an OpenClaw sandbox-backed exec-server when OpenClaw sandboxing is active.",
"advanced": true
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.5.20",
"version": "2026.5.21",
"description": "OpenClaw Codex harness and model provider plugin",
"repository": {
"type": "git",
@@ -27,10 +27,10 @@
"minHostVersion": ">=2026.5.1-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.20"
"pluginApi": ">=2026.5.21"
},
"build": {
"openclawVersion": "2026.5.20"
"openclawVersion": "2026.5.21"
},
"release": {
"publishToClawHub": true,

View File

@@ -106,6 +106,7 @@ export class CodexAppServerClient {
private initialized = false;
private closed = false;
private closeError: Error | undefined;
private serverVersion: string | undefined;
private stderrTail = "";
private pendingParse:
| {
@@ -178,11 +179,15 @@ export class CodexAppServerClient {
experimentalApi: true,
},
} satisfies CodexInitializeParams);
assertSupportedCodexAppServerVersion(response);
this.serverVersion = assertSupportedCodexAppServerVersion(response);
this.notify("initialized");
this.initialized = true;
}
getServerVersion(): string | undefined {
return this.serverVersion;
}
request<M extends CodexAppServerRequestMethod>(
method: M,
params: CodexAppServerRequestParams<M>,
@@ -568,18 +573,19 @@ function timeoutServerRequestResponse(
};
}
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): void {
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): string {
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
if (!detectedVersion) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but OpenClaw could not determine the running Codex version. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
if (compareVersions(detectedVersion, MIN_CODEX_APP_SERVER_VERSION) < 0) {
if (compareCodexAppServerVersions(detectedVersion, MIN_CODEX_APP_SERVER_VERSION) < 0) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${detectedVersion}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
return detectedVersion;
}
export function readCodexVersionFromUserAgent(userAgent: string | undefined): string | undefined {
@@ -592,7 +598,7 @@ export function readCodexVersionFromUserAgent(userAgent: string | undefined): st
return match?.[1];
}
function compareVersions(left: string, right: string): number {
export function compareCodexAppServerVersions(left: string, right: string): number {
const leftVersion = parseVersionForComparison(left);
const rightVersion = parseVersionForComparison(right);
const leftParts = leftVersion.parts;

View File

@@ -56,6 +56,16 @@ function startCompaction(sessionFile: string, options: { currentTokenCount?: num
});
}
function startSandboxedCompaction(sessionFile: string) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
}
type CompactResult = NonNullable<Awaited<ReturnType<typeof maybeCompactCodexAppServerSession>>>;
function requireCompactResult(result: CompactResult | undefined): CompactResult {
@@ -100,19 +110,113 @@ describe("maybeCompactCodexAppServerSession", () => {
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 27_170,
},
},
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensBefore).toBe(123);
expect(result.result?.tokensAfter).toBe(27_170);
const details = compactDetails(result);
expect(details.backend).toBe("codex-app-server");
expect(details.threadId).toBe("thread-1");
expect(details.signal).toBe("thread/compacted");
expect(details.turnId).toBe("turn-1");
expect(details.tokenUsageSource).toBe("thread/tokenUsage/updated");
});
it("accepts native context-compaction item completion as success", async () => {
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(await startSandboxedCompaction(sessionFile));
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain(
"Codex-native native compaction is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(fake.request).not.toHaveBeenCalled();
});
it("uses native token usage that arrives before compaction completion", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 18_004,
},
},
},
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBe(18_004);
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
});
it("accepts native current token usage with a total alias", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last: {
total: 16_384,
},
},
},
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBe(16_384);
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
});
it("accepts native context-compaction item completion with unknown token count as success", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
@@ -133,11 +237,44 @@ describe("maybeCompactCodexAppServerSession", () => {
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBeUndefined();
const details = compactDetails(result);
expect(details.signal).toBe("item/completed");
expect(details.itemId).toBe("compact-1");
});
it("does not treat zero native token usage as an authoritative post-compaction count", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 0,
},
},
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBeUndefined();
expect(compactDetails(result).tokenUsageSource).toBeUndefined();
});
it("reuses the bound auth profile for native compaction", async () => {
const fake = createFakeCodexClient();
let seenAuthProfileId: string | undefined;
@@ -160,6 +297,39 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(seenAuthProfileId).toBe("openai-codex:work");
});
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 }),
);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("no codex app-server thread binding");
expect(result.failure?.reason).toBe("missing_thread_binding");
expect(result.result).toBeUndefined();
});
it("clears stale thread bindings and reports failed native compaction", async () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(new Error("thread not found: thread-1"));
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 456 }),
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("thread not found: thread-1");
expect(result.failure?.reason).toBe("stale_thread_binding");
expect(result.result).toBeUndefined();
});
it("warns when stale OpenClaw compaction overrides are ignored", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const fake = createFakeCodexClient();
@@ -516,6 +686,58 @@ describe("maybeCompactCodexAppServerSession", () => {
);
});
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: 900,
tokensAfter: 100,
},
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
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 transcript handles after owning context-engine compaction", async () => {
const sessionFile = await writeTestBinding();
const successorFile = path.join(tempDir, "session.compacted.jsonl");

View File

@@ -16,11 +16,13 @@ import {
import type { CodexAppServerClient, CodexServerNotificationHandler } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import { resolveCodexNativeSandboxBlock } from "./sandbox-guard.js";
import { clearCodexAppServerBinding, readCodexAppServerBinding } from "./session-binding.js";
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
turnId?: string;
itemId?: string;
tokensAfter?: number;
};
type CodexNativeCompactionWaiter = {
promise: Promise<CodexNativeCompactionCompletion>;
@@ -29,6 +31,7 @@ type CodexNativeCompactionWaiter = {
};
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
const CODEX_COMPACTION_TOKEN_USAGE_GRACE_MS = 250;
const warnedIgnoredCompactionOverrides = new Set<string>();
export async function maybeCompactCodexAppServerSession(
@@ -69,6 +72,8 @@ async function compactOwningContextEngine(
params: CompactEmbeddedPiSessionParams,
contextEngine: NonNullable<CompactEmbeddedPiSessionParams["contextEngine"]>,
): Promise<EmbeddedPiCompactResult> {
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,
@@ -76,8 +81,8 @@ async function compactOwningContextEngine(
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
trigger: params.trigger,
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
force: params.trigger === "manual",
compactionTarget,
force,
});
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
try {
@@ -94,9 +99,9 @@ async function compactOwningContextEngine(
sessionFile: params.sessionFile,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
compactionTarget,
customInstructions: params.customInstructions,
force: params.trigger === "manual",
force,
runtimeContext: params.contextEngineRuntimeContext,
},
resolveCompactionTimeoutMs(params.config),
@@ -136,9 +141,9 @@ async function compactOwningContextEngine(
error: formatErrorMessage(error),
});
}
await clearCodexAppServerBinding(params.sessionFile);
await clearCodexAppServerBinding(params.sessionFile, { config: params.config });
if (compactedSessionFile !== params.sessionFile) {
await clearCodexAppServerBinding(compactedSessionFile);
await clearCodexAppServerBinding(compactedSessionFile, { config: params.config });
}
}
@@ -322,10 +327,22 @@ async function compactCodexNativeThread(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: params.config,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
sessionId: params.sessionId,
surface: "native compaction",
});
if (sandboxBlock) {
return { ok: false, compacted: false, reason: sandboxBlock };
}
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
if (!binding?.threadId) {
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",
recovery: "missing_thread_binding",
});
}
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
@@ -357,6 +374,14 @@ async function compactCodexNativeThread(
completion = await waiter.promise;
} catch (error) {
waiter.cancel();
if (isCodexThreadNotFoundError(error)) {
await clearCodexAppServerBinding(params.sessionFile, { config: params.config });
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
reason: formatCompactionError(error),
recovery: "stale_thread_binding",
});
}
return {
ok: false,
compacted: false,
@@ -369,7 +394,22 @@ async function compactCodexNativeThread(
signal: completion.signal,
turnId: completion.turnId,
itemId: completion.itemId,
tokensAfter: completion.tokensAfter,
});
const resultDetails: JsonObject = {
backend: "codex-app-server",
threadId: binding.threadId,
signal: completion.signal,
};
if (completion.turnId) {
resultDetails.turnId = completion.turnId;
}
if (completion.itemId) {
resultDetails.itemId = completion.itemId;
}
if (completion.tokensAfter !== undefined) {
resultDetails.tokenUsageSource = "thread/tokenUsage/updated";
}
return {
ok: true,
compacted: true,
@@ -377,17 +417,42 @@ async function compactCodexNativeThread(
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: {
backend: "codex-app-server",
threadId: binding.threadId,
signal: completion.signal,
turnId: completion.turnId,
itemId: completion.itemId,
},
...(completion.tokensAfter !== undefined ? { tokensAfter: completion.tokensAfter } : {}),
details: resultDetails,
},
};
}
function failedCodexThreadBindingCompactionResult(
params: CompactEmbeddedPiSessionParams,
recovery: {
reason: string;
recovery: "missing_thread_binding" | "stale_thread_binding";
threadId?: string;
},
): EmbeddedPiCompactResult {
embeddedAgentLog.warn("codex app-server compaction could not use thread binding", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
threadId: recovery.threadId,
reason: recovery.reason,
recovery: recovery.recovery,
});
return {
ok: false,
compacted: false,
reason: recovery.reason,
failure: {
reason: recovery.recovery,
rawError: recovery.reason,
},
};
}
function isCodexThreadNotFoundError(error: unknown): boolean {
return formatCompactionError(error).toLowerCase().includes("thread not found");
}
function createCodexNativeCompactionWaiter(
client: CodexAppServerClient,
threadId: string,
@@ -395,6 +460,7 @@ function createCodexNativeCompactionWaiter(
let settled = false;
let removeHandler: () => void = () => {};
let timeout: ReturnType<typeof setTimeout> | undefined;
let tokenUsageGraceTimeout: ReturnType<typeof setTimeout> | undefined;
let failWaiter: (error: Error) => void = () => {};
const promise = new Promise<CodexNativeCompactionCompletion>((resolve, reject) => {
@@ -403,6 +469,9 @@ function createCodexNativeCompactionWaiter(
if (timeout) {
clearTimeout(timeout);
}
if (tokenUsageGraceTimeout) {
clearTimeout(tokenUsageGraceTimeout);
}
};
const complete = (completion: CodexNativeCompactionCompletion): void => {
if (settled) {
@@ -420,11 +489,49 @@ function createCodexNativeCompactionWaiter(
cleanup();
reject(error);
};
let latestTokensAfter: number | undefined;
const completionWithLatestTokenUsage = (
completion: CodexNativeCompactionCompletion,
): CodexNativeCompactionCompletion =>
latestTokensAfter === undefined
? completion
: { ...completion, tokensAfter: latestTokensAfter };
const completeAfterTokenUsageGrace = (completion: CodexNativeCompactionCompletion): void => {
if (settled || tokenUsageGraceTimeout) {
return;
}
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
tokenUsageGraceTimeout = setTimeout(
() => complete(completionWithLatestTokenUsage(observedCompletion ?? completion)),
CODEX_COMPACTION_TOKEN_USAGE_GRACE_MS,
);
tokenUsageGraceTimeout.unref?.();
};
failWaiter = fail;
let observedCompletion: CodexNativeCompactionCompletion | undefined;
const handler: CodexServerNotificationHandler = (notification) => {
const tokensAfter = readNativeCompactionTokenUsage(notification, threadId);
if (tokensAfter !== undefined) {
latestTokensAfter = tokensAfter;
if (observedCompletion) {
complete(completionWithLatestTokenUsage(observedCompletion));
return;
}
}
const completion = readNativeCompactionCompletion(notification, threadId);
if (completion) {
complete(completion);
observedCompletion = completionWithLatestTokenUsage({
...observedCompletion,
...completion,
});
if (latestTokensAfter !== undefined) {
complete(observedCompletion);
return;
}
completeAfterTokenUsageGrace(observedCompletion);
}
};
removeHandler = client.addNotificationHandler(handler);
@@ -454,6 +561,49 @@ function createCodexNativeCompactionWaiter(
};
}
function readNativeCompactionTokenUsage(
notification: CodexServerNotification,
threadId: string,
): number | undefined {
const params = notification.params;
if (!isJsonObject(params) || readString(params, "threadId", "thread_id") !== threadId) {
return undefined;
}
if (notification.method !== "thread/tokenUsage/updated") {
return undefined;
}
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
const currentUsage = readCodexCurrentTokenUsage(tokenUsage) ?? readCodexCurrentTokenUsage(params);
return readCodexTotalTokens(currentUsage);
}
function readCodexCurrentTokenUsage(value: JsonObject | undefined): JsonObject | undefined {
if (!value) {
return undefined;
}
for (const key of [
"last",
"current",
"lastCall",
"lastCallUsage",
"lastTokenUsage",
"last_token_usage",
]) {
const usage = value[key];
if (isJsonObject(usage)) {
return usage;
}
}
return undefined;
}
function readCodexTotalTokens(value: JsonObject | undefined): number | undefined {
const totalTokens = value?.total_tokens ?? value?.totalTokens ?? value?.total;
return typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0
? Math.floor(totalTokens)
: undefined;
}
function readNativeCompactionCompletion(
notification: CodexServerNotification,
threadId: string,

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import {
CODEX_APP_SERVER_CONFIG_KEYS,
CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS,
CODEX_COMPUTER_USE_CONFIG_KEYS,
CODEX_PLUGIN_ENTRY_CONFIG_KEYS,
CODEX_PLUGINS_CONFIG_KEYS,
@@ -69,6 +70,7 @@ describe("Codex app-server config", () => {
serviceTier: "flex",
codeModeOnly: true,
turnCompletionIdleTimeoutMs: 120_000,
postToolRawAssistantCompletionIdleTimeoutMs: 180_000,
},
},
env: {
@@ -84,6 +86,7 @@ describe("Codex app-server config", () => {
serviceTier: "flex",
codeModeOnly: true,
turnCompletionIdleTimeoutMs: 120_000,
postToolRawAssistantCompletionIdleTimeoutMs: 180_000,
});
expectFields(runtime.start, "runtime start", {
transport: "websocket",
@@ -166,6 +169,17 @@ describe("Codex app-server config", () => {
).toStrictEqual({});
});
it("rejects unknown app-server fields", () => {
expect(
readCodexPluginConfig({
appServer: {
postToolRawAssistantCompletionIdleTimeoutMs: 180_000,
unknownTimeoutMs: 1,
},
}),
).toStrictEqual({});
});
it("requires a websocket url when websocket transport is configured", () => {
expect(() =>
resolveRuntimeForTest({
@@ -464,6 +478,18 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
});
it("parses app-server experimental flags", () => {
expect(
readCodexPluginConfig({
appServer: {
experimental: {
sandboxExecServer: true,
},
},
}).appServer?.experimental,
).toEqual({ sandboxExecServer: true });
});
it("rejects the retired dynamic tool profile key", () => {
expect(
readCodexPluginConfig({
@@ -833,6 +859,17 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
expectUiHintLabel(manifest, `appServer.${key}`);
}
const appServerExperimentalProperties = (
manifest.configSchema.properties.appServer.properties.experimental as {
properties: Record<string, unknown>;
}
).properties;
expect(Object.keys(appServerExperimentalProperties).toSorted()).toEqual([
...CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS,
]);
for (const key of CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS) {
expectUiHintLabel(manifest, `appServer.experimental.${key}`);
}
const computerUseManifestKeys = Object.keys(
manifest.configSchema.properties.computerUse.properties,
).toSorted();

View File

@@ -72,6 +72,10 @@ export type CodexPluginsConfig = {
plugins?: Record<string, CodexPluginEntryConfig>;
};
export type CodexAppServerExperimentalConfig = {
sandboxExecServer?: boolean;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
@@ -104,6 +108,7 @@ export type CodexAppServerRuntimeOptions = {
codeModeOnly: boolean;
requestTimeoutMs: number;
turnCompletionIdleTimeoutMs: number;
postToolRawAssistantCompletionIdleTimeoutMs?: number;
approvalPolicy: CodexAppServerEffectiveApprovalPolicy;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
@@ -131,11 +136,13 @@ export type CodexPluginConfig = {
codeModeOnly?: boolean;
requestTimeoutMs?: number;
turnCompletionIdleTimeoutMs?: number;
postToolRawAssistantCompletionIdleTimeoutMs?: number;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier | null;
defaultWorkspaceDir?: string;
experimental?: CodexAppServerExperimentalConfig;
};
};
@@ -151,13 +158,17 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"codeModeOnly",
"requestTimeoutMs",
"turnCompletionIdleTimeoutMs",
"postToolRawAssistantCompletionIdleTimeoutMs",
"approvalPolicy",
"sandbox",
"approvalsReviewer",
"serviceTier",
"defaultWorkspaceDir",
"experimental",
] as const;
export const CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS = ["sandboxExecServer"] as const;
export const CODEX_COMPUTER_USE_CONFIG_KEYS = [
"enabled",
"autoInstall",
@@ -203,6 +214,11 @@ const codexAppServerServiceTierSchema = z
z.string().trim().min(1).nullable().optional(),
)
.optional();
const codexAppServerExperimentalSchema = z
.object({
sandboxExecServer: z.boolean().optional(),
})
.strict();
const codexPluginEntryConfigSchema = z
.object({
@@ -259,11 +275,13 @@ const codexPluginConfigSchema = z
codeModeOnly: z.boolean().optional(),
requestTimeoutMs: z.number().positive().optional(),
turnCompletionIdleTimeoutMs: z.number().positive().optional(),
postToolRawAssistantCompletionIdleTimeoutMs: z.number().positive().optional(),
approvalPolicy: codexAppServerApprovalPolicySchema.optional(),
sandbox: codexAppServerSandboxSchema.optional(),
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: codexAppServerServiceTierSchema,
defaultWorkspaceDir: z.string().optional(),
experimental: codexAppServerExperimentalSchema.optional(),
})
.strict()
.optional(),
@@ -283,6 +301,10 @@ export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
return { ...config, ...(plugins.data ? { codexPlugins: plugins.data } : {}) };
}
export function isCodexSandboxExecServerEnabled(pluginConfig?: unknown): boolean {
return readCodexPluginConfig(pluginConfig).appServer?.experimental?.sandboxExecServer === true;
}
export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodexPluginsPolicy {
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
const configured = config !== undefined;
@@ -377,6 +399,14 @@ export function resolveCodexAppServerRuntimeOptions(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy:
resolveApprovalPolicy(config.approvalPolicy) ??
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??

View File

@@ -758,11 +758,62 @@ describe("createCodexDynamicToolBridge", () => {
success: false,
contentItems: [{ type: "inputText", text: "failed output" }],
});
expect(result.sideEffectEvidence).toBe(true);
const event = requireRecord(callArg(handler, 0, 0, "middleware event"), "middleware event");
expect(event.isError).toBe(true);
expectContextFields(callArg(handler, 0, 1, "middleware context"), { runtime: "codex" });
});
it("marks executed dynamic tool results as side-effect evidence", async () => {
const bridge = createBridgeWithToolResult("exec", textToolResult("done"));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "exec",
arguments: { command: "pwd" },
});
expect(result).toEqual(expectInputText("done"));
expect(result.sideEffectEvidence).toBe(true);
});
it("does not mark pre-execution argument failures as side-effect evidence", async () => {
const execute = vi.fn(async () => textToolResult("should not run"));
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "exec",
execute,
...({
prepareArguments: () => {
throw new Error("invalid arguments");
},
} as { prepareArguments: () => never }),
}),
],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "exec",
arguments: {},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "invalid arguments" }],
});
expect(result.sideEffectEvidence).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
});
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> }) => ({
@@ -1082,6 +1133,7 @@ describe("createCodexDynamicToolBridge", () => {
success: false,
contentItems: [{ type: "inputText", text: "blocked by policy" }],
});
expect(result.sideEffectEvidence).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
await vi.waitFor(() => {

View File

@@ -125,8 +125,10 @@ export function createCodexDynamicToolBridge(params: {
const args = jsonObjectToRecord(call.arguments);
const startedAt = Date.now();
const signal = composeAbortSignals(params.signal, options?.signal);
let didStartExecution = false;
try {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
didStartExecution = true;
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
const rawIsError = isToolResultError(rawResult);
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
@@ -167,12 +169,16 @@ export function createCodexDynamicToolBridge(params: {
result,
startedAt,
});
return withDiagnosticTerminalType(
{
contentItems: convertToolContents(result.content, toolResultMaxChars),
success: !resultIsError,
},
inferToolResultDiagnosticTerminalType(result, resultIsError),
const terminalType = inferToolResultDiagnosticTerminalType(result, resultIsError);
return withSideEffectEvidence(
withDiagnosticTerminalType(
{
contentItems: convertToolContents(result.content, toolResultMaxChars),
success: !resultIsError,
},
terminalType,
),
terminalType !== "blocked",
);
} catch (error) {
collectToolTelemetry({
@@ -194,17 +200,20 @@ export function createCodexDynamicToolBridge(params: {
error: error instanceof Error ? error.message : String(error),
startedAt,
});
return withDiagnosticTerminalType(
{
contentItems: [
{
type: "inputText",
text: error instanceof Error ? error.message : String(error),
},
],
success: false,
},
"error",
return withSideEffectEvidence(
withDiagnosticTerminalType(
{
contentItems: [
{
type: "inputText",
text: error instanceof Error ? error.message : String(error),
},
],
success: false,
},
"error",
),
didStartExecution,
);
}
},
@@ -230,7 +239,6 @@ function createCodexDynamicToolSpec(params: {
deferLoading: true,
};
}
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
@@ -463,6 +471,21 @@ function withDiagnosticTerminalType<T extends CodexDynamicToolCallResponse>(
return response;
}
function withSideEffectEvidence<T extends CodexDynamicToolCallResponse>(
response: T,
sideEffectEvidence: boolean,
): T {
if (!sideEffectEvidence) {
return response;
}
Object.defineProperty(response, "sideEffectEvidence", {
configurable: true,
enumerable: false,
value: true,
});
return response;
}
function normalizeToolResultMaxChars(maxChars: number): number {
return typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0
? Math.floor(maxChars)

View File

@@ -439,7 +439,14 @@ describe("Codex app-server elicitation bridge", () => {
);
});
it("does not bridge Computer Use elicitations without an approval form schema", async () => {
it("normalizes missing Computer Use schemas to the empty object schema", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-computer-use-schema", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-computer-use-schema",
decision: "allow-once",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildComputerUseApprovalElicitation({
requestedSchema: "not-a-schema",
@@ -451,8 +458,11 @@ describe("Codex app-server elicitation bridge", () => {
computerUseMcpServerName: "computer-use",
});
expect(result).toBeUndefined();
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
});
it("does not bridge Computer Use elicitations outside form mode", async () => {

View File

@@ -44,6 +44,7 @@ const MCP_TOOL_APPROVAL_SOURCE_KEY = "source";
const MCP_TOOL_APPROVAL_CONNECTOR_SOURCE = "connector";
const CODEX_APPS_SERVER_NAME = "codex_apps";
const COMPUTER_USE_APPROVAL_TITLE = "Computer Use approval";
const EMPTY_OBJECT_SCHEMA: JsonObject = { type: "object", properties: {} };
const PLUGIN_APP_ID_META_KEYS = ["app_id", "appId", "codex_app_id", "codexAppId"];
const PLUGIN_CONNECTOR_ID_META_KEYS = ["connector_id", "connectorId"];
const PLUGIN_NAME_META_KEYS = ["plugin_name", "pluginName", "codex_plugin_name", "codexPluginName"];
@@ -363,13 +364,14 @@ function readComputerUseApprovalElicitation(
!serverName ||
!expectedServerName ||
serverName !== expectedServerName ||
readString(requestParams, "mode") !== "form" ||
!isJsonObject(requestParams?.requestedSchema)
readString(requestParams, "mode") !== "form"
) {
return undefined;
}
const requestedSchema = requestParams.requestedSchema;
const requestedSchema = isJsonObject(requestParams?.requestedSchema)
? requestParams.requestedSchema
: EMPTY_OBJECT_SCHEMA;
if (
readString(requestedSchema, "type") !== "object" ||
!isJsonObject(requestedSchema.properties)

View File

@@ -1991,6 +1991,95 @@ describe("CodexAppServerEventProjector", () => {
expect(payload.text).toContain("```txt\nopened\n```");
});
it("keeps side-effect evidence for dynamic tools that error after execution", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-process-kill",
tool: "process",
arguments: { action: "kill", sessionId: "session-1" },
});
projector.recordDynamicToolResult({
callId: "call-process-kill",
tool: "process",
success: false,
terminalType: "error",
sideEffectEvidence: true,
contentItems: [{ type: "inputText", text: "process exited" }],
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
it("does not keep side-effect evidence for pre-execution dynamic tool errors", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-unknown-message",
tool: "message",
arguments: { action: "send", text: "hello" },
});
projector.recordDynamicToolResult({
callId: "call-unknown-message",
tool: "message",
success: false,
terminalType: "error",
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: message" }],
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("does not mark blocked dynamic tools as side-effecting", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-bash-blocked",
tool: "bash",
arguments: { command: "touch blocked.txt" },
});
projector.recordDynamicToolResult({
callId: "call-bash-blocked",
tool: "bash",
success: false,
terminalType: "blocked",
sideEffectEvidence: true,
contentItems: [{ type: "inputText", text: "blocked" }],
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("treats completed native MCP tool calls as side-effect evidence", async () => {
const projector = await createProjector();
await projector.handleNotification({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "mcp-1",
type: "mcpToolCall",
server: "github",
tool: "create_issue",
status: "completed",
arguments: { title: "check replay safety" },
},
},
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
it("suppresses transcript progress for message-like tools", async () => {
const onAgentEvent = vi.fn();
const onToolResult = vi.fn();
@@ -2021,7 +2110,7 @@ describe("CodexAppServerEventProjector", () => {
expect(onToolResult).not.toHaveBeenCalled();
});
it("suppresses transcript progress for activity-log bash commands", async () => {
it("does not parse shell command text to suppress transcript progress", async () => {
const onAgentEvent = vi.fn();
const onToolResult = vi.fn();
const projector = await createProjector({
@@ -2047,12 +2136,11 @@ describe("CodexAppServerEventProjector", () => {
contentItems: [{ type: "inputText", text: "Logged: [web_search] Grilled salmon research" }],
});
const toolEvents = onAgentEvent.mock.calls.filter(([event]) => {
const record = requireRecord(event, "agent event");
return record.stream === "tool";
});
expect(toolEvents).toHaveLength(0);
expect(onToolResult).not.toHaveBeenCalled();
expect(onAgentEvent).not.toHaveBeenCalled();
const toolProgressText = onToolResult.mock.calls
.map(([payload]) => (payload as { text?: string }).text ?? "")
.join("\n");
expect(toolProgressText).toContain("log_activity.sh");
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.messagesSnapshot.some((message) => message.role === "toolResult")).toBe(true);

View File

@@ -23,6 +23,10 @@ import {
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import { CodexNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
readCodexNotificationThreadId,
readCodexNotificationTurnId,
} from "./notification-correlation.js";
import { readCodexTurn } from "./protocol-validators.js";
import {
isJsonObject,
@@ -104,13 +108,9 @@ const TRANSCRIPT_PROGRESS_SUPPRESSED_TOOL_NAMES = new Set([
"typing",
]);
export function shouldEmitTranscriptToolProgress(toolName: unknown, args?: unknown): boolean {
export function shouldEmitTranscriptToolProgress(toolName: unknown, _args?: unknown): boolean {
const normalized = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
return Boolean(
normalized &&
!TRANSCRIPT_PROGRESS_SUPPRESSED_TOOL_NAMES.has(normalized) &&
!isActivityLogCommandProgress(normalized, args),
);
return Boolean(normalized && !TRANSCRIPT_PROGRESS_SUPPRESSED_TOOL_NAMES.has(normalized));
}
type ToolTranscriptCallInput = {
@@ -148,6 +148,8 @@ export class CodexAppServerEventProjector {
>();
private readonly toolResultOutputTextByItem = new Map<string, string>();
private readonly toolMetas = new Map<string, { toolName: string; meta?: string }>();
private readonly sideEffectingToolItemIds = new Set<string>();
private readonly sideEffectingDynamicToolCallIds = new Set<string>();
private readonly toolTranscriptMessages: AgentMessage[] = [];
private readonly toolTranscriptCallIds = new Set<string>();
private readonly toolTranscriptResultIds = new Set<string>();
@@ -322,6 +324,12 @@ export class CodexAppServerEventProjector {
promptError,
turnCompleted: Boolean(this.completedTurn),
});
const toolMetas = [...this.toolMetas.values()];
const hadPotentialSideEffects =
toolTelemetry.didSendViaMessagingTool ||
(toolTelemetry.successfulCronAdds ?? 0) > 0 ||
this.sideEffectingToolItemIds.size > 0 ||
this.sideEffectingDynamicToolCallIds.size > 0;
return {
aborted: this.aborted || turnInterrupted,
externalAbort: false,
@@ -337,7 +345,7 @@ export class CodexAppServerEventProjector {
bootstrapPromptWarningSignature: this.params.bootstrapPromptWarningSignature,
messagesSnapshot,
assistantTexts,
toolMetas: [...this.toolMetas.values()],
toolMetas,
lastAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
@@ -352,8 +360,8 @@ export class CodexAppServerEventProjector {
cloudCodeAssistFormatError: false,
attemptUsage: this.tokenUsage,
replayMetadata: {
hadPotentialSideEffects: toolTelemetry.didSendViaMessagingTool,
replaySafe: !toolTelemetry.didSendViaMessagingTool,
hadPotentialSideEffects,
replaySafe: !hadPotentialSideEffects,
},
itemLifecycle: {
startedCount: this.activeItemIds.size + this.completedItemIds.size,
@@ -369,10 +377,11 @@ export class CodexAppServerEventProjector {
}
recordDynamicToolCall(params: { callId: string; tool: string; arguments?: JsonValue }): void {
const args = sanitizeCodexToolArguments(params.arguments);
this.recordToolTranscriptCall({
id: params.callId,
name: params.tool,
arguments: sanitizeCodexToolArguments(params.arguments),
arguments: args,
});
}
@@ -380,6 +389,8 @@ export class CodexAppServerEventProjector {
callId: string;
tool: string;
success: boolean;
terminalType?: "blocked" | "completed" | "error";
sideEffectEvidence?: boolean;
contentItems: CodexDynamicToolCallOutputContentItem[];
}): void {
this.recordToolTranscriptResult({
@@ -388,6 +399,10 @@ export class CodexAppServerEventProjector {
text: collectDynamicToolContentText(params.contentItems),
isError: !params.success,
});
const terminalType = params.terminalType ?? (params.success ? "completed" : "error");
if (terminalType !== "blocked" && params.sideEffectEvidence === true) {
this.sideEffectingDynamicToolCallIds.add(params.callId);
}
}
markTimedOut(): void {
@@ -501,6 +516,7 @@ export class CodexAppServerEventProjector {
},
});
}
this.recordToolMeta(item);
this.emitStandardItemEvent({ phase: "start", item });
this.emitNormalizedToolItemEvent({ phase: "start", item });
this.recordNativeToolTranscriptCall(item);
@@ -1214,6 +1230,11 @@ export class CodexAppServerEventProjector {
toolName,
...(meta ? { meta } : {}),
});
if (isSideEffectingNativeToolItem(item)) {
this.sideEffectingToolItemIds.add(item.id);
} else {
this.sideEffectingToolItemIds.delete(item.id);
}
}
private recordNativeToolTranscriptCall(item: CodexThreadItem | undefined): void {
@@ -1502,7 +1523,7 @@ export class CodexAppServerEventProjector {
}
private isNotificationForTurn(params: JsonObject): boolean {
const threadId = readString(params, "threadId");
const threadId = readCodexNotificationThreadId(params);
const turnId = readNotificationTurnId(params);
return threadId === this.threadId && turnId === this.turnId;
}
@@ -1519,12 +1540,7 @@ function isHookNotificationMethod(method: string): method is "hook/started" | "h
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readString(record, "turnId") ?? readNestedTurnId(record);
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
return readCodexNotificationTurnId(record);
}
function readString(record: JsonObject, key: string): string | undefined {
@@ -1745,6 +1761,13 @@ function itemName(item: CodexThreadItem): string | undefined {
return undefined;
}
function isSideEffectingNativeToolItem(item: CodexThreadItem): boolean {
return (
itemStatus(item) !== "blocked" &&
(isMutatingNativeToolItem(item) || item.type === "mcpToolCall")
);
}
function shouldSynthesizeToolProgressForItem(item: CodexThreadItem): boolean {
switch (item.type) {
case "commandExecution":
@@ -1814,7 +1837,7 @@ function itemToolArgs(item: CodexThreadItem): Record<string, unknown> | undefine
if (item.type === "webSearch" && typeof item.query === "string") {
return sanitizeCodexAgentEventRecord({ query: item.query });
}
if (item.type === "mcpToolCall") {
if (item.type === "dynamicToolCall" || item.type === "mcpToolCall") {
return sanitizeCodexToolArguments(item.arguments);
}
return undefined;
@@ -1950,31 +1973,6 @@ function normalizeToolTranscriptArguments(value: unknown): Record<string, unknow
return value as Record<string, unknown>;
}
function isActivityLogCommandProgress(toolName: string, args: unknown): boolean {
if (toolName !== "bash" && toolName !== "exec" && toolName !== "shell") {
return false;
}
const command = readToolCommandText(args);
return Boolean(command && command.includes("log_activity.sh"));
}
function readToolCommandText(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
for (const key of ["command", "cmd", "shellCommand", "script"]) {
const text = record[key];
if (typeof text === "string" && text) {
return text;
}
}
return undefined;
}
function collectDynamicToolContentText(contentItems: CodexThreadItem["contentItems"]): string {
if (!Array.isArray(contentItems)) {
return "";

View File

@@ -0,0 +1,91 @@
import {
isJsonObject,
type CodexServerNotification,
type JsonObject,
type JsonValue,
} from "./protocol.js";
export type CodexNotificationCorrelation = {
method: string;
paramsKeys?: string[];
activeThreadId: string;
activeTurnId?: string;
threadId?: string;
turnId?: string;
nestedTurnThreadId?: string;
nestedTurnId?: string;
turnStatus?: string;
turnItemCount?: number;
matchesActiveThread: boolean;
matchesActiveTurn?: boolean;
};
export function isCodexNotificationForTurn(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
return (
readCodexNotificationThreadId(value) === threadId &&
readCodexNotificationTurnId(value) === turnId
);
}
export function readCodexNotificationThreadId(record: JsonObject): string | undefined {
return readNestedTurnThreadId(record) ?? readString(record, "threadId");
}
export function readCodexNotificationTurnId(record: JsonObject): string | undefined {
return readNestedTurnId(record) ?? readString(record, "turnId");
}
export function describeCodexNotificationCorrelation(
notification: CodexServerNotification,
active: { threadId: string; turnId?: string },
): CodexNotificationCorrelation {
const params = isJsonObject(notification.params) ? notification.params : undefined;
const turn = params && isJsonObject(params.turn) ? params.turn : undefined;
const threadId = params ? readString(params, "threadId") : undefined;
const turnId = params ? readString(params, "turnId") : undefined;
const nestedTurnThreadId = turn ? readString(turn, "threadId") : undefined;
const nestedTurnId = turn ? readString(turn, "id") : undefined;
const resolvedThreadId = params ? readCodexNotificationThreadId(params) : undefined;
const resolvedTurnId = params ? readCodexNotificationTurnId(params) : undefined;
const matchesActiveThread = resolvedThreadId === active.threadId;
const matchesActiveTurn = active.turnId
? matchesActiveThread && resolvedTurnId === active.turnId
: undefined;
const items = turn?.items;
return {
method: notification.method,
...(params ? { paramsKeys: Object.keys(params).toSorted() } : {}),
activeThreadId: active.threadId,
...(active.turnId ? { activeTurnId: active.turnId } : {}),
...(threadId ? { threadId } : {}),
...(turnId ? { turnId } : {}),
...(nestedTurnThreadId ? { nestedTurnThreadId } : {}),
...(nestedTurnId ? { nestedTurnId } : {}),
...(turn ? { turnStatus: readString(turn, "status") } : {}),
...(Array.isArray(items) ? { turnItemCount: items.length } : {}),
matchesActiveThread,
...(matchesActiveTurn === undefined ? {} : { matchesActiveTurn }),
};
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
function readNestedTurnThreadId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "threadId") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

View File

@@ -70,6 +70,11 @@ export type CodexDynamicToolSpec = JsonObject & {
inputSchema: JsonValue;
};
export type CodexTurnEnvironmentParams = JsonObject & {
environmentId: string;
cwd: string;
};
export type CodexThreadStartParams = JsonObject & {
input?: CodexUserInput[];
cwd?: string;
@@ -77,11 +82,12 @@ export type CodexThreadStartParams = JsonObject & {
modelProvider?: string | null;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: CodexSandboxPolicy;
sandbox?: string;
serviceTier?: CodexServiceTier | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
developerInstructions?: string;
experimentalRawEvents?: boolean;
environments?: CodexTurnEnvironmentParams[] | null;
persistExtendedHistory?: boolean;
};
@@ -89,6 +95,13 @@ export type CodexThreadResumeParams = JsonObject & {
threadId: string;
model?: string;
modelProvider?: string | null;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
persistExtendedHistory?: boolean;
};
export type CodexThreadStartResponse = {
@@ -137,6 +150,7 @@ export type CodexTurnStartParams = JsonObject & {
sandboxPolicy?: CodexSandboxPolicy;
serviceTier?: CodexServiceTier | null;
effort?: string | null;
environments?: CodexTurnEnvironmentParams[] | null;
collaborationMode?: {
mode: string;
settings: JsonObject & {
@@ -256,6 +270,7 @@ export type CodexDynamicToolCallParams = {
export type CodexDynamicToolCallResponse = {
contentItems: CodexDynamicToolCallOutputContentItem[];
diagnosticTerminalType?: CodexDynamicToolDiagnosticTerminalType;
sideEffectEvidence?: boolean;
success: boolean;
};
@@ -475,6 +490,7 @@ export declare namespace v2 {
}
type CodexAppServerRequestParamsOverride = {
"environment/add": { environmentId: string; execServerUrl: string };
"thread/fork": CodexThreadForkParams;
"thread/inject_items": CodexThreadInjectItemsParams;
"thread/start": CodexThreadStartParams;
@@ -488,6 +504,7 @@ type CodexAppServerRequestResultMap = {
"account/read": CodexGetAccountResponse;
"app/list": CodexAppsListResponse;
"config/mcpServer/reload": JsonValue;
"environment/add": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;
"hooks/list": CodexHooksListResponse;

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./shared-client.js", () => sharedClientMocks);
const { requestCodexAppServerJson } = await import("./request.js");
describe("requestCodexAppServerJson sandbox guard", () => {
beforeEach(() => {
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
});
it("fails closed before raw app-server bypass methods in sandboxed sessions", async () => {
await expect(
requestCodexAppServerJson({
method: "command/exec",
requestParams: { command: ["sh", "-lc", "id"] },
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).rejects.toThrow(
"Codex-native app-server method `command/exec` is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("allows metadata methods in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ ok: true }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
await expect(
requestCodexAppServerJson({
method: "thread/list",
requestParams: { limit: 10 },
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
});
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = {
cwd: "/workspace",
environments: [{ environmentId: "openclaw-sandbox-abc123", cwd: "/workspace" }],
};
await expect(
requestCodexAppServerJson({
method: "thread/start",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ thread: { id: "thread-1" }, model: "gpt-5.5" });
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
});
});

View File

@@ -6,6 +6,7 @@ import type {
CodexAppServerRequestResult,
JsonValue,
} from "./protocol.js";
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
import {
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
@@ -20,6 +21,8 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<CodexAppServerRequestResult<M>>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
@@ -30,6 +33,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<T>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
@@ -40,8 +45,20 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<T> {
const sandboxBlock = resolveCodexAppServerDirectSandboxBypassBlock({
method: params.method,
requestParams: params.requestParams,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
});
if (sandboxBlock) {
throw new Error(sandboxBlock);
}
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
(async () => {

View File

@@ -0,0 +1,197 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
resetAgentEventsForTest,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt } from "./run-attempt.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4-codex",
model: createCodexTestModel("codex"),
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
authProfileStore: { version: 1, profiles: {} },
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function threadStartResult(threadId = "thread-1") {
return {
thread: {
id: threadId,
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
serviceTier: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function turnStartResult(turnId = "turn-1") {
return {
turn: {
id: turnId,
status: "inProgress",
items: [],
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
};
}
describe("Codex app-server main thread cleanup", () => {
beforeEach(async () => {
resetAgentEventsForTest();
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
});
afterEach(async () => {
resetAgentEventsForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("unsubscribes the main Codex thread after a completed turn", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
return {};
});
const clientFactory: CodexAppServerClientFactory = async () => {
return {
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
};
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
clientFactory,
});
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/start"), {
interval: 1,
timeout: 5_000,
});
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
const result = await run;
expect(result.aborted).toBe(false);
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(requests.map((entry) => entry.method)).toEqual([
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
});
it("unsubscribes the main Codex thread when turn start fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const requests: Array<{ method: string; params: unknown }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
throw new Error("turn start exploded");
}
return {};
});
const clientFactory: CodexAppServerClientFactory = async () => {
return {
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
} as never;
};
await expect(
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
clientFactory,
}),
).rejects.toThrow("turn start exploded");
expect(requests.map((entry) => entry.method)).toEqual([
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
});
});

View File

@@ -8,6 +8,7 @@ import {
embeddedAgentLog,
type HarnessContextEngine as ContextEngine,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -677,6 +678,121 @@ 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");

File diff suppressed because it is too large Load Diff

View File

@@ -36,9 +36,6 @@ import {
resolveBootstrapContextForRun,
setActiveEmbeddedRun,
supportsModelTools,
hasSandboxBindContainerPathAliases,
hasSandboxBindReadonlyHostShadows,
resolveWritableSandboxBindHostRoots,
runAgentCleanupStep,
type AgentMessage,
type EmbeddedRunAttemptParams,
@@ -55,6 +52,7 @@ import {
onInternalDiagnosticEvent,
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
@@ -79,6 +77,7 @@ import {
import { ensureCodexComputerUse } from "./computer-use.js";
import {
isCodexAppServerApprovalPolicyAllowedByRequirements,
isCodexSandboxExecServerEnabled,
readCodexPluginConfig,
resolveCodexComputerUseConfig,
resolveCodexPluginsPolicy,
@@ -114,6 +113,10 @@ import {
buildCodexNativeHookRelayConfig,
CODEX_NATIVE_HOOK_RELAY_EVENTS,
} from "./native-hook-relay.js";
import {
describeCodexNotificationCorrelation,
isCodexNotificationForTurn,
} from "./notification-correlation.js";
import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
import {
buildCodexPluginThreadConfig,
@@ -126,9 +129,10 @@ import {
readCodexDynamicToolCallParams,
} from "./protocol-validators.js";
import {
type CodexSandboxPolicy,
type CodexTurnEnvironmentParams,
type CodexUserInput,
isJsonObject,
type CodexSandboxPolicy,
type CodexServerNotification,
type CodexDynamicToolSpec,
type CodexDynamicToolCallParams,
@@ -144,6 +148,11 @@ import {
resolveCodexUsageLimitResetAtMs,
shouldRefreshCodexRateLimitsForUsageLimitMessage,
} from "./rate-limits.js";
import {
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
@@ -188,6 +197,7 @@ const CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000;
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
const CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS = 100;
const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
const CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS = 5_000;
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
const CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS = 10_000;
@@ -197,6 +207,14 @@ const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
const CODEX_NATIVE_HOOK_RELAY_RENEW_INTERVAL_MS = 60_000;
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
const LOG_FIELD_MAX_LENGTH = 160;
const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"exec",
"process",
"read",
"write",
"edit",
"apply_patch",
] as const;
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
"identity.md",
@@ -268,6 +286,10 @@ function collectTerminalAssistantText(result: EmbeddedRunAttemptResult): string
return result.assistantTexts.join("\n\n").trim();
}
function hasCodexAppServerPotentialSideEffectEvidence(result: EmbeddedRunAttemptResult): boolean {
return result.replayMetadata.hadPotentialSideEffects;
}
type CodexSteeringQueueOptions = {
debounceMs?: number;
};
@@ -465,39 +487,6 @@ function toCodexTextInput(text: string): CodexUserInput {
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
function resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer: CodexAppServerRuntimeOptions,
sandbox: OpenClawSandboxContext,
cwd: string,
): CodexSandboxPolicy | undefined {
if (!sandbox?.enabled || appServer.sandbox === "read-only") {
return undefined;
}
const networkAccess = codexNetworkAccessForOpenClawSandbox(sandbox);
const writableRoots = new Set([cwd]);
if (sandbox.backendId === "docker") {
for (const root of resolveWritableSandboxBindHostRoots(sandbox.docker.binds)) {
writableRoots.add(root);
}
}
// Codex app-server still runs on the Gateway host, so keep Codex's
// filesystem sandbox while mirroring the OpenClaw sandbox egress policy.
return {
type: "workspaceWrite",
writableRoots: [...writableRoots],
networkAccess,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
};
}
function codexNetworkAccessForOpenClawSandbox(sandbox: OpenClawSandboxContext): boolean {
if (!sandbox?.enabled || sandbox.backendId !== "docker") {
return true;
}
return sandbox.docker.network.trim().toLowerCase() !== "none";
}
function resolveCodexAppServerForOpenClawToolPolicy(params: {
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
@@ -795,6 +784,7 @@ export async function runCodexAppServerAttempt(
};
turnCompletionIdleTimeoutMs?: number;
turnAssistantCompletionIdleTimeoutMs?: number;
postToolRawAssistantCompletionIdleTimeoutMs?: number;
turnTerminalIdleTimeoutMs?: number;
clientFactory?: CodexAppServerClientFactory;
} = {},
@@ -820,11 +810,6 @@ export async function runCodexAppServerAttempt(
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
const codexSandboxPolicy = resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
configuredAppServer,
sandbox,
effectiveWorkspace,
);
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
appServer: configuredAppServer,
pluginConfig,
@@ -921,7 +906,10 @@ export async function runCodexAppServerAttempt(
disableTools: params.disableTools,
toolsAllow: params.toolsAllow,
});
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox);
const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig);
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox, {
sandboxExecServerEnabled,
});
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
@@ -1112,7 +1100,9 @@ export async function runCodexAppServerAttempt(
};
if (activeContextEngine) {
try {
await applyActiveContextEngineProjection(startupBinding);
await applyActiveContextEngineProjection(
!nativeToolSurfaceEnabled ? undefined : startupBinding,
);
} catch (assembleErr) {
embeddedAgentLog.warn("context engine assemble failed; using Codex baseline prompt", {
error: formatErrorMessage(assembleErr),
@@ -1123,6 +1113,7 @@ export async function runCodexAppServerAttempt(
startupBinding,
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
historyMessages,
forceProject: !nativeToolSurfaceEnabled,
})
) {
const projection = projectContextEngineAssemblyForCodex({
@@ -1168,6 +1159,16 @@ export async function runCodexAppServerAttempt(
let trajectoryEndRecorded = false;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
let startupClientForCleanup: CodexAppServerClient | undefined;
let sandboxExecEnvironmentAcquired = false;
const releaseSandboxExecEnvironment = async () => {
if (sandboxExecEnvironmentAcquired) {
sandboxExecEnvironmentAcquired = false;
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
};
let codexEnvironmentSelection: CodexTurnEnvironmentParams[] | undefined;
let codexExecutionCwd = effectiveWorkspace;
let codexSandboxPolicy: CodexSandboxPolicy | undefined;
let restartContextEngineCodexThread:
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
| undefined;
@@ -1259,9 +1260,14 @@ export async function runCodexAppServerAttempt(
approvalPolicy: withMcpElicitationsApprovalPolicy(appServer.approvalPolicy),
}
: appServer;
({ client, thread } = await withCodexStartupTimeout({
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
const startupResult = await withCodexStartupTimeout({
timeoutMs: startupTimeoutMs,
signal: runAbortController.signal,
onTimeout: async () => {
runAbortController.abort("codex_startup_timeout");
await releaseStartupResourcesOnTimeout?.();
},
operation: async () => {
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
@@ -1279,12 +1285,66 @@ export async function runCodexAppServerAttempt(
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
let startupSandboxEnvironmentAcquired = false;
const releaseStartupSandboxEnvironment = async () => {
if (startupSandboxEnvironmentAcquired) {
startupSandboxEnvironmentAcquired = false;
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
};
releaseStartupResourcesOnTimeout = releaseStartupSandboxEnvironment;
try {
startupSandboxEnvironment = shouldRequireCodexSandboxExecServerEnvironment({
sandbox,
nativeToolSurfaceEnabled,
sandboxExecServerEnabled,
})
? await ensureCodexSandboxExecServerEnvironment({
client: startupClient,
sandbox: sandbox ?? null,
appServerStartOptions: appServer.start,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
})
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (runAbortController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (
sandbox?.enabled &&
nativeToolSurfaceEnabled &&
sandboxExecServerEnabled &&
!startupSandboxEnvironment
) {
throw new Error(
"Codex app-server did not register an OpenClaw sandbox exec-server environment.",
);
}
} catch (error) {
await releaseStartupSandboxEnvironment();
throw error;
}
const startupEnvironmentSelection = resolveCodexSandboxEnvironmentSelection(
startupSandboxEnvironment,
nativeToolSurfaceEnabled,
);
const startupExecutionCwd = resolveCodexAppServerExecutionCwd({
effectiveWorkspace,
environment: startupSandboxEnvironment,
nativeToolSurfaceEnabled,
});
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(sandbox)
: undefined;
const buildThreadLifecycleParams = () =>
({
client: startupClient,
params: buildActiveRunAttemptParams(),
agentId: sessionAgentId,
cwd: effectiveWorkspace,
cwd: startupExecutionCwd,
dynamicTools: toolBridge.specs,
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
@@ -1295,6 +1355,7 @@ export async function runCodexAppServerAttempt(
userMcpServersEnabled: nativeToolSurfaceEnabled,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection,
pluginThreadConfig: pluginThreadConfigRequired
? {
@@ -1315,9 +1376,31 @@ export async function runCodexAppServerAttempt(
}
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
restartContextEngineCodexThread = () => startOrResumeThread(buildThreadLifecycleParams());
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
return { client: startupClient, thread: startupThread };
try {
restartContextEngineCodexThread = () =>
startOrResumeThread(buildThreadLifecycleParams());
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
if (runAbortController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
startupSandboxEnvironmentAcquired = false;
return {
client: startupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
};
} catch (error) {
await releaseStartupSandboxEnvironment();
throw error;
} finally {
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
releaseStartupResourcesOnTimeout = undefined;
}
}
};
for (
let attempt = 1;
@@ -1365,7 +1448,13 @@ export async function runCodexAppServerAttempt(
}
throw new Error("codex app-server startup retry loop exited unexpectedly");
},
}));
});
client = startupResult.client;
thread = startupResult.thread;
sandboxExecEnvironmentAcquired = Boolean(startupResult.sandboxEnvironment);
codexEnvironmentSelection = startupResult.environmentSelection;
codexExecutionCwd = startupResult.executionCwd;
codexSandboxPolicy = startupResult.sandboxPolicy;
startupClientForCleanup = undefined;
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
@@ -1373,6 +1462,7 @@ export async function runCodexAppServerAttempt(
});
} catch (error) {
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
clearSharedCodexAppServerClientIfCurrent(startupClientForCleanup);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
@@ -1417,6 +1507,12 @@ export async function runCodexAppServerAttempt(
const turnAssistantCompletionIdleTimeoutMs = resolveCodexTurnAssistantCompletionIdleTimeoutMs(
options.turnAssistantCompletionIdleTimeoutMs,
);
const postToolRawAssistantCompletionIdleTimeoutMs =
resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(
options.postToolRawAssistantCompletionIdleTimeoutMs ??
appServer.postToolRawAssistantCompletionIdleTimeoutMs,
turnAssistantCompletionIdleTimeoutMs,
);
const turnTerminalIdleTimeoutMs = resolveCodexTurnTerminalIdleTimeoutMs(
options.turnTerminalIdleTimeoutMs,
);
@@ -1918,7 +2014,7 @@ export async function runCodexAppServerAttempt(
} else if (isCurrentTurnNotification && assistantCompletionCanRelease) {
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
} else if (postToolRawAssistantCompletionNeedsTerminalGuard) {
armTurnCompletionIdleWatch({ timeoutMs: turnAssistantCompletionIdleTimeoutMs });
armTurnCompletionIdleWatch({ timeoutMs: postToolRawAssistantCompletionIdleTimeoutMs });
} else if (unblockedAssistantCompletionRelease) {
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
} else if (shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem) {
@@ -1996,6 +2092,17 @@ export async function runCodexAppServerAttempt(
}
};
const enqueueNotification = (notification: CodexServerNotification): Promise<void> => {
const correlation = describeCodexNotificationCorrelation(notification, {
threadId: thread.threadId,
...(turnId ? { turnId } : {}),
});
embeddedAgentLog.debug("codex app-server raw notification received", correlation);
if (notification.method === "turn/completed" && correlation.matchesActiveTurn === false) {
embeddedAgentLog.warn(
"codex app-server turn/completed did not match active turn",
correlation,
);
}
if (!projector || !turnId) {
userInputBridge?.handleNotification(notification);
pendingNotifications.push(notification);
@@ -2040,7 +2147,7 @@ export async function runCodexAppServerAttempt(
armCompletionWatchOnResponse = true;
markCurrentTurnRequestProgress();
}
return handleCodexAppServerElicitationRequest({
return await handleCodexAppServerElicitationRequest({
requestParams: request.params,
paramsForRun: params,
threadId: thread.threadId,
@@ -2177,6 +2284,9 @@ export async function runCodexAppServerAttempt(
callId: call.callId,
tool: call.tool,
success: protocolResponse.success,
terminalType:
response.diagnosticTerminalType ?? (protocolResponse.success ? "completed" : "error"),
sideEffectEvidence: response.sideEffectEvidence === true,
contentItems: protocolResponse.contentItems,
});
if (shouldEmitDynamicToolProgress) {
@@ -2364,10 +2474,11 @@ export async function runCodexAppServerAttempt(
"turn/start",
buildTurnStartParams(params, {
threadId: thread.threadId,
cwd: effectiveWorkspace,
cwd: codexExecutionCwd,
appServer: pluginAppServer,
promptText: codexTurnPromptText,
sandboxPolicy: codexSandboxPolicy,
environmentSelection: codexEnvironmentSelection,
heartbeatCollaborationInstructions:
workspaceBootstrapContext.heartbeatCollaborationInstructions,
}),
@@ -2401,24 +2512,29 @@ export async function runCodexAppServerAttempt(
error: formatErrorMessage(turnStartError),
},
);
const preRetrySessionFile = activeSessionFile;
const compactedForRetry = await forceContextEngineCompactionForCodexOverflow(turnStartError);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
try {
turn = await startCodexTurn();
} catch (retryError) {
turnStartError = retryError;
const preRetrySessionFile = activeSessionFile;
const compactedForRetry =
await forceContextEngineCompactionForCodexOverflow(turnStartError);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
try {
turn = await startCodexTurn();
} catch (retryError) {
turnStartError = retryError;
}
} catch (retrySetupError) {
turnStartError = retrySetupError;
}
}
if (turn === undefined) {
@@ -2474,9 +2590,16 @@ export async function runCodexAppServerAttempt(
},
ctx: hookContext,
});
if (!timedOut) {
await unsubscribeCodexThreadBestEffort(client, {
threadId: thread.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
}
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
@@ -2662,6 +2785,23 @@ export async function runCodexAppServerAttempt(
}
const finalPromptErrorSource =
timedOut || clientClosedPromptError ? "prompt" : result.promptErrorSource;
const completionIdleTimeoutHadPotentialSideEffects =
hasCodexAppServerPotentialSideEffectEvidence(result);
const promptTimeoutOutcome =
turnCompletionIdleTimedOut &&
(result.itemLifecycle.completedCount > 0 || completionIdleTimeoutHadPotentialSideEffects)
? {
message: completionIdleTimeoutHadPotentialSideEffects
? CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_SIDE_EFFECT_USER_MESSAGE
: CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_USER_MESSAGE,
...(completionIdleTimeoutHadPotentialSideEffects
? {
replayInvalid: true,
livenessState: "abandoned" as const,
}
: {}),
}
: undefined;
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: params,
result,
@@ -2769,6 +2909,7 @@ export async function runCodexAppServerAttempt(
aborted: finalAborted,
promptError: finalPromptError,
promptErrorSource: finalPromptErrorSource,
...(promptTimeoutOutcome ? { promptTimeoutOutcome } : {}),
systemPromptReport,
};
} finally {
@@ -2800,6 +2941,12 @@ export async function runCodexAppServerAttempt(
if (!timedOut && !runAbortController.signal.aborted) {
await steeringQueue?.flushPending();
}
if (!timedOut) {
await unsubscribeCodexThreadBestEffort(client, {
threadId: thread.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
}
userInputBridge?.cancelPending();
clearTurnAttemptIdleTimer();
clearTurnCompletionIdleTimer();
@@ -2809,6 +2956,7 @@ export async function runCodexAppServerAttempt(
requestCleanup();
closeCleanup?.();
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
runAbortController.signal.removeEventListener("abort", abortListener);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
steeringQueue?.cancel();
@@ -2904,7 +3052,7 @@ async function handleDynamicToolCallWithTimeout(params: {
const abortFromRun = () => {
const message = "OpenClaw dynamic tool call aborted.";
controller.abort(params.signal.reason ?? new Error(message));
resolveAbort?.(failedDynamicToolResponse(message));
resolveAbort?.(failedDynamicToolResponse(message, { sideEffectEvidence: true }));
};
const abortPromise = new Promise<CodexDynamicToolCallResponse>((resolve) => {
resolveAbort = resolve;
@@ -2920,7 +3068,9 @@ async function handleDynamicToolCallWithTimeout(params: {
...timeoutDetails.meta,
consoleMessage: timeoutDetails.consoleMessage,
});
resolve(failedDynamicToolResponse(timeoutDetails.responseMessage));
resolve(
failedDynamicToolResponse(timeoutDetails.responseMessage, { sideEffectEvidence: true }),
);
}, timeoutMs);
timeout.unref?.();
});
@@ -2936,7 +3086,9 @@ async function handleDynamicToolCallWithTimeout(params: {
timeoutPromise,
]);
} catch (error) {
return failedDynamicToolResponse(error instanceof Error ? error.message : String(error));
return failedDynamicToolResponse(error instanceof Error ? error.message : String(error), {
sideEffectEvidence: true,
});
} finally {
if (timeout) {
clearTimeout(timeout);
@@ -2949,7 +3101,10 @@ async function handleDynamicToolCallWithTimeout(params: {
}
}
function failedDynamicToolResponse(message: string): CodexDynamicToolCallResponse {
function failedDynamicToolResponse(
message: string,
options?: { sideEffectEvidence?: boolean },
): CodexDynamicToolCallResponse {
const response: CodexDynamicToolCallResponse = {
contentItems: [{ type: "inputText", text: message }],
success: false,
@@ -2959,6 +3114,13 @@ function failedDynamicToolResponse(message: string): CodexDynamicToolCallRespons
enumerable: false,
value: "error",
});
if (options?.sideEffectEvidence === true) {
Object.defineProperty(response, "sideEffectEvidence", {
configurable: true,
enumerable: false,
value: true,
});
}
return response;
}
@@ -3253,6 +3415,27 @@ function interruptCodexTurnBestEffort(
}
}
async function unsubscribeCodexThreadBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
timeoutMs: number;
},
): Promise<void> {
try {
await client.request(
"thread/unsubscribe",
{ threadId: params.threadId },
{ timeoutMs: params.timeoutMs },
);
} catch (error) {
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
threadId: params.threadId,
error,
});
}
}
function retireCodexAppServerClientAfterTimedOutTurn(
client: CodexAppServerClient,
params: {
@@ -3439,32 +3622,96 @@ function includeForcedMessageToolAllow(
function shouldEnableCodexAppServerNativeToolSurface(
params: EmbeddedRunAttemptParams,
sandbox?: OpenClawSandboxContext,
options: { sandboxExecServerEnabled?: boolean } = {},
): boolean {
const toolsAllow = includeForcedMessageToolAllow(params.toolsAllow, params);
if (toolsAllow === undefined) {
return canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox);
return canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox, options);
}
// Codex native code mode exposes its shell/file surface as one app-server
// capability, so narrow OpenClaw allowlists must fail closed rather than
// widening `message` or `web_search` into shell access.
return (
hasWildcardCodexToolsAllow(toolsAllow) &&
canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox)
canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox, options)
);
}
function canCodexAppServerNativeToolSurfaceHonorSandbox(
sandbox: OpenClawSandboxContext | undefined,
options: { sandboxExecServerEnabled?: boolean } = {},
): boolean {
if (!sandbox?.enabled || sandbox.backendId !== "docker") {
if (!sandbox?.enabled) {
return true;
}
return (
!hasSandboxBindContainerPathAliases(sandbox.docker.binds) &&
!hasSandboxBindReadonlyHostShadows(sandbox.docker.binds)
if (
options.sandboxExecServerEnabled === true &&
sandbox.backend &&
canSandboxToolPolicyExposeCodexNativeToolSurface(sandbox)
) {
return true;
}
// Codex app-server native shell, filesystem, and user MCP execution are owned
// by the app-server process. Without the explicit exec-server integration,
// active OpenClaw sandboxing must disable the native surface and route shell
// access through sandbox-backed dynamic tools instead.
return false;
}
function canSandboxToolPolicyExposeCodexNativeToolSurface(sandbox: {
tools: Parameters<typeof isToolAllowed>[0];
}): boolean {
return CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS.every((toolName) =>
isToolAllowed(sandbox.tools, toolName),
);
}
function shouldRequireCodexSandboxExecServerEnvironment(params: {
sandbox?: OpenClawSandboxContext;
nativeToolSurfaceEnabled: boolean;
sandboxExecServerEnabled: boolean;
}): boolean {
return Boolean(
params.sandbox?.enabled && params.nativeToolSurfaceEnabled && params.sandboxExecServerEnabled,
);
}
function resolveCodexSandboxEnvironmentSelection(
environment: CodexSandboxExecEnvironment | undefined,
nativeToolSurfaceEnabled: boolean,
): CodexTurnEnvironmentParams[] | undefined {
return environment && nativeToolSurfaceEnabled ? [environment] : undefined;
}
function resolveCodexAppServerExecutionCwd(params: {
effectiveWorkspace: string;
environment?: CodexSandboxExecEnvironment;
nativeToolSurfaceEnabled: boolean;
}): string {
return params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveWorkspace;
}
function resolveCodexExternalSandboxPolicyForOpenClawSandbox(
sandbox: OpenClawSandboxContext | undefined,
): CodexSandboxPolicy {
return {
type: "externalSandbox",
networkAccess: codexNetworkAccessForOpenClawSandbox(sandbox) ? "enabled" : "restricted",
};
}
function codexNetworkAccessForOpenClawSandbox(
sandbox: OpenClawSandboxContext | undefined,
): boolean {
if (sandbox?.backendId !== "docker") {
return true;
}
const network = sandbox?.docker?.network?.trim().toLowerCase();
return Boolean(network && network !== "none");
}
function disableCodexPluginThreadConfig(pluginConfig?: unknown): CodexPluginConfig {
const config = readCodexPluginConfig(pluginConfig);
return {
@@ -3498,7 +3745,7 @@ function addSandboxShellDynamicToolsIfAvailable(
...execTool,
name: "sandbox_exec",
description:
"Run a shell command through OpenClaw's configured sandbox backend for this session. Use only when the command must execute in the OpenClaw sandbox backend, such as an SSH-backed sandbox or Docker container-path bind layout that Codex's native shell cannot represent. Use Codex's native shell for normal local workspace commands.",
"Run a shell command through OpenClaw's configured sandbox backend for this session. Use when OpenClaw sandboxing is active or when a command must execute in the sandbox backend, such as an SSH-backed sandbox or Docker container-path bind layout. Use Codex's native shell only when no OpenClaw sandbox is active and native Code Mode is available.",
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(toolCallId, args, signal, onUpdate);
return {
@@ -3520,14 +3767,14 @@ function addSandboxShellDynamicToolsIfAvailable(
...processTool,
name: "sandbox_process",
description:
"Manage sandbox_exec sessions that were started through OpenClaw's configured sandbox backend for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for sandbox_exec follow-up; use Codex's native shell session handling for normal native shell commands.",
"Manage sandbox_exec sessions that were started through OpenClaw's configured sandbox backend for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for sandbox_exec follow-up; use Codex's native shell session handling only when no OpenClaw sandbox is active and native Code Mode is available.",
};
return [...filteredTools, sandboxExecTool, sandboxProcessTool];
}
function shouldExposeSandboxExecDynamicTool(input: DynamicToolBuildParams): boolean {
const backendId = input.sandbox?.enabled ? input.sandbox.backendId.trim().toLowerCase() : "";
return Boolean(backendId && (backendId !== "docker" || input.nativeToolSurfaceEnabled === false));
return Boolean(backendId && input.nativeToolSurfaceEnabled === false);
}
function isSandboxShellDynamicToolExcluded(config: CodexPluginConfig): boolean {
@@ -3582,10 +3829,14 @@ function shouldProjectMirroredHistoryForCodexStart(params: {
startupBinding: CodexAppServerThreadBinding | undefined;
dynamicToolsFingerprint: string;
historyMessages: AgentMessage[];
forceProject?: boolean;
}): boolean {
if (!params.historyMessages.some((message) => message.role === "user")) {
return false;
}
if (params.forceProject) {
return true;
}
if (!params.startupBinding?.threadId) {
return true;
}
@@ -3657,6 +3908,7 @@ function resolveContextEngineBootstrapProjectionDecision(params: {
async function withCodexStartupTimeout<T>(params: {
timeoutMs: number;
signal: AbortSignal;
onTimeout?: () => void | Promise<void>;
operation: () => Promise<T>;
}): Promise<T> {
if (params.signal.aborted) {
@@ -3664,6 +3916,8 @@ async function withCodexStartupTimeout<T>(params: {
}
let timeout: NodeJS.Timeout | undefined;
let abortCleanup: (() => void) | undefined;
let timeoutError: Error | undefined;
let timeoutCleanup: Promise<void> | undefined;
try {
return await Promise.race([
params.operation(),
@@ -3676,13 +3930,26 @@ async function withCodexStartupTimeout<T>(params: {
reject(error);
};
timeout = setTimeout(() => {
rejectOnce(new Error("codex app-server startup timed out"));
timeoutError = new Error("codex app-server startup timed out");
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
);
void timeoutCleanup.finally(() => {
rejectOnce(timeoutError!);
});
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });
abortCleanup = () => params.signal.removeEventListener("abort", abortListener);
}),
]);
} catch (error) {
if (timeoutError) {
await timeoutCleanup;
throw timeoutError;
}
throw error;
} finally {
if (timeout) {
clearTimeout(timeout);
@@ -3721,6 +3988,19 @@ function resolveCodexTurnAssistantCompletionIdleTimeoutMs(value: number | undefi
return Math.max(1, Math.floor(value));
}
function resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(
value: number | undefined,
fallbackMs: number,
): number {
if (value === undefined) {
return fallbackMs;
}
if (!Number.isFinite(value)) {
return fallbackMs;
}
return Math.max(1, Math.floor(value));
}
function resolveCodexTurnTerminalIdleTimeoutMs(value: number | undefined): number {
if (value === undefined) {
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
@@ -4135,10 +4415,7 @@ function isTurnNotification(
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
return readString(value, "threadId") === threadId && readNotificationTurnId(value) === turnId;
return isCodexNotificationForTurn(value, threadId, turnId);
}
function isCurrentThreadTurnRequestParams(
@@ -4187,21 +4464,16 @@ function isTerminalTurnStatus(status: string | undefined): boolean {
return status === "completed" || status === "interrupted" || status === "failed";
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readString(record, "turnId") ?? readNestedTurnId(record);
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
const CODEX_TURN_ABORT_MARKER_START = "<turn_aborted>";
const CODEX_TURN_ABORT_MARKER_END = "</turn_aborted>";
const CODEX_INTERRUPTED_USER_GUIDANCE =
"The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed.";
const CODEX_INTERRUPTED_DEVELOPER_GUIDANCE =
"The previous turn was interrupted on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed.";
const CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_USER_MESSAGE =
"Codex stopped before confirming the turn was complete. The response may be incomplete; retry if needed.";
const CODEX_APP_SERVER_MISSING_TERMINAL_EVENT_SIDE_EFFECT_USER_MESSAGE =
"Codex stopped before confirming the turn was complete. Some work may already have been performed; verify the current state before retrying.";
function isCodexTurnAbortMarkerNotification(
notification: CodexServerNotification,
@@ -4866,9 +5138,9 @@ export const testing = {
resolveDynamicToolCallTimeoutMs,
resolveCodexDynamicToolsLoading,
rotateOversizedCodexAppServerStartupBinding,
resolveCodexAppServerSandboxPolicyForOpenClawSandbox,
resolveCodexAppServerForOpenClawToolPolicy,
resolveOpenClawCodingToolsSessionKeys,
shouldProjectMirroredHistoryForCodexStart,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
buildCodexPluginThreadConfigEligibilityLogData,

View File

@@ -0,0 +1,527 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
codexFsSandboxContext,
createClient,
createSandboxContext,
execServerUrlFromClient,
globPath,
openSocket,
rpc,
specialPath,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server filesystem", () => {
it("routes file writes through the sandbox fs bridge", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/writeFile", {
path: "/workspace/note.txt",
dataBase64: Buffer.from("hello").toString("base64"),
});
await rpc(socket, "fs/writeFile", {
path: "/workspace/empty.txt",
dataBase64: "",
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/note.txt",
data: Buffer.from("hello"),
mkdir: false,
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/empty.txt",
data: Buffer.alloc(0),
mkdir: false,
});
socket.close();
});
it("preserves missing-parent failures for file writes", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
stat: async ({ filePath }) =>
filePath === "/workspace" ? { type: "directory", size: 1, mtimeMs: 1 } : null,
writeFile,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/missing/note.txt",
dataBase64: Buffer.from("hello").toString("base64"),
}),
).rejects.toThrow("parent directory not found");
expect(writeFile).not.toHaveBeenCalled();
socket.close();
});
it("enforces Codex fs sandbox policy before mutating through the fs bridge", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/read-only.txt",
dataBase64: Buffer.from("blocked").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [{ path: specialPath("root"), access: "read" }],
}),
}),
).rejects.toThrow("Codex fs sandbox denied write access");
await rpc(socket, "fs/writeFile", {
path: "/workspace/allowed.txt",
dataBase64: Buffer.from("allowed").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
],
}),
});
expect(writeFile).toHaveBeenCalledTimes(1);
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/allowed.txt",
data: Buffer.from("allowed"),
mkdir: false,
});
socket.close();
});
it("honors Codex fs sandbox protected metadata carveouts", async () => {
const remove = vi.fn(async () => undefined);
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ remove, writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const workspacePolicy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: specialPath("project_roots", ".git"), access: "read" },
],
});
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/.git/config",
dataBase64: Buffer.from("blocked").toString("base64"),
sandbox: workspacePolicy,
}),
).rejects.toThrow("Codex fs sandbox denied write access");
await expect(
rpc(socket, "fs/remove", {
path: "/workspace",
recursive: true,
force: true,
sandbox: workspacePolicy,
}),
).rejects.toThrow("because /workspace/.git is not writable");
expect(writeFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
socket.close();
});
it("enforces Codex fs sandbox glob deny entries", async () => {
const remove = vi.fn(async () => undefined);
const readFile = vi.fn(async () => Buffer.from("ok"));
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ readFile, remove, writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const policy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("private/*.txt"), access: "deny" },
],
});
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/private/secret.txt",
sandbox: policy,
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/key.pem",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.pem"), access: "deny" },
],
}),
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/KEY.PEM",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.[Pp][Ee][Mm]"), access: "deny" },
],
}),
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await rpc(socket, "fs/writeFile", {
path: "/workspace/private/nested/allowed.txt",
dataBase64: Buffer.from("ok").toString("base64"),
sandbox: policy,
});
await expect(
rpc(socket, "fs/remove", {
path: "/workspace/private",
recursive: true,
force: true,
sandbox: policy,
}),
).rejects.toThrow("because /workspace/private/*.txt is not writable");
expect(readFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledTimes(1);
socket.close();
});
it("ignores non-granting Codex fs sandbox special entries", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/writeFile", {
path: "/workspace/allowed.txt",
dataBase64: Buffer.from("ok").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("minimal"), access: "read" },
{ path: specialPath("unknown"), access: "read" },
{ path: specialPath("current_working_directory"), access: "write" },
],
}),
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/allowed.txt",
data: Buffer.from("ok"),
mkdir: false,
});
socket.close();
});
it("fails closed for unsupported Codex fs sandbox glob classes", async () => {
const readFile = vi.fn(async () => Buffer.from("ok"));
const sandbox = createSandboxContext({ readFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/key.pem",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.[Pp"), access: "deny" },
],
}),
}),
).rejects.toThrow("fs sandbox glob character class must be closed");
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("fails closed for recursive removes below protected glob prefixes", async () => {
const remove = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ remove });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const policy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.pem"), access: "deny" },
],
});
await expect(
rpc(socket, "fs/remove", {
path: "/workspace/src",
recursive: true,
force: true,
sandbox: policy,
}),
).rejects.toThrow("because /workspace/**/*.pem is not writable");
expect(remove).not.toHaveBeenCalled();
socket.close();
});
it("routes recursive copies through the sandbox filesystem bridge", async () => {
const mkdirp = vi.fn(async () => undefined);
const readFile = vi.fn(async ({ filePath }: { filePath: string }) =>
Buffer.from(`data:${filePath}`),
);
const writeFile = vi.fn(async () => undefined);
const runShellCommand = vi.fn(async (_params?: { args?: string[] }) => ({
stdout: Buffer.from("f\tfile.txt\nd\tsubdir\n"),
stderr: Buffer.alloc(0),
code: 0,
}));
runShellCommand.mockImplementation(async (params?: { args?: string[] }) => ({
stdout: Buffer.from(
params?.args?.[0] === "/workspace/source-dir/subdir"
? "f\tnested.txt\n"
: "f\tfile.txt\nd\tsubdir\n",
),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({
mkdirp,
readFile,
runShellCommand,
stat: async ({ filePath }) => ({
type: filePath.endsWith("source-dir") || filePath.endsWith("subdir") ? "directory" : "file",
size: 1,
mtimeMs: 1,
}),
writeFile,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/copy", {
sourcePath: "/workspace/source-dir",
destinationPath: "/workspace/destination-dir",
recursive: true,
});
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/destination-dir" });
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/destination-dir/subdir" });
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/destination-dir/file.txt",
data: Buffer.from("data:/workspace/source-dir/file.txt"),
mkdir: true,
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/destination-dir/subdir/nested.txt",
data: Buffer.from("data:/workspace/source-dir/subdir/nested.txt"),
mkdir: true,
});
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({ args: ["/workspace/source-dir"] }),
);
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({ args: ["/workspace/source-dir/subdir"] }),
);
socket.close();
});
it("rejects recursive directory copies into their own subtree", async () => {
const mkdirp = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
mkdirp,
stat: async () => ({
type: "directory",
size: 1,
mtimeMs: 1,
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/copy", {
sourcePath: "/workspace/source-dir",
destinationPath: "/workspace/source-dir/backup",
recursive: true,
}),
).rejects.toThrow("Cannot recursively copy a directory into itself");
expect(mkdirp).not.toHaveBeenCalled();
socket.close();
});
it("reports missing metadata as an exec-server not found error", async () => {
const sandbox = createSandboxContext({ stat: async () => null });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "fs/getMetadata", { path: "/workspace/missing" })).rejects.toThrow(
"file not found",
);
socket.close();
});
it("rejects oversized file reads before buffering through the fs bridge", async () => {
const readFile = vi.fn(async () => Buffer.from("too-large"));
const sandbox = createSandboxContext({
readFile,
stat: async () => ({
type: "file",
size: 512 * 1024 * 1024 + 1,
mtimeMs: 1,
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "fs/readFile", { path: "/workspace/huge.bin" })).rejects.toThrow(
"file is too large to read through Codex sandbox exec-server",
);
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("does not create parent directories for non-recursive directory creation", async () => {
const mkdirp = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
mkdirp,
stat: async ({ filePath }) =>
filePath === "/workspace/existing" ? { type: "directory", size: 1, mtimeMs: 1 } : null,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/createDirectory", {
path: "/workspace/missing/child",
recursive: false,
}),
).rejects.toThrow("parent directory not found");
expect(mkdirp).not.toHaveBeenCalled();
await rpc(socket, "fs/createDirectory", {
path: "/workspace/existing/child",
recursive: false,
});
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/existing/child" });
socket.close();
});
it("surfaces sandbox bridge denials as exec-server errors", async () => {
const sandbox = createSandboxContext({
writeFile: async () => {
throw new Error("sandbox denied write outside workspace");
},
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/outside/note.txt",
dataBase64: Buffer.from("no").toString("base64"),
}),
).rejects.toThrow("sandbox denied write outside workspace");
socket.close();
});
});

View File

@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
collectNotifications,
createClient,
createSandboxContext,
execServerUrlFromClient,
openSocket,
rpc,
shellQuote,
waitForHttpBodyDeltas,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server HTTP", () => {
it("routes HTTP requests through the sandbox backend", async () => {
const runShellCommand = vi.fn(async () => ({
stdout: Buffer.from(
JSON.stringify({
status: 201,
headers: [{ name: "content-type", value: "text/plain" }],
bodyBase64: Buffer.from("sandbox-http").toString("base64"),
}),
),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({ runShellCommand });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-1",
method: "POST",
url: "https://example.test/mcp",
headers: [{ name: "authorization", value: "Bearer test" }],
bodyBase64: Buffer.from("body").toString("base64"),
}),
).resolves.toEqual({
status: 201,
headers: [{ name: "content-type", value: "text/plain" }],
bodyBase64: Buffer.from("sandbox-http").toString("base64"),
});
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({
allowFailure: true,
stdin: expect.stringContaining("https://example.test/mcp"),
}),
);
socket.close();
});
it("streams HTTP response body deltas from the sandbox backend", async () => {
const headerLine = JSON.stringify({
type: "headers",
status: 202,
headers: [{ name: "content-type", value: "text/event-stream" }],
});
const bodyLine = JSON.stringify({
type: "bodyDelta",
seq: 1,
deltaBase64: Buffer.from("event: ok\n\n").toString("base64"),
done: false,
});
const doneLine = JSON.stringify({
type: "bodyDelta",
seq: 2,
deltaBase64: "",
done: true,
});
const buildExecSpec = vi.fn(async () => ({
argv: [
"/bin/sh",
"-lc",
[headerLine, bodyLine, doneLine]
.map((line) => `printf '%s\\n' ${shellQuote(line)}`)
.join("; "),
],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const runShellCommand = vi.fn(async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({ buildExecSpec, runShellCommand });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-stream",
method: "GET",
url: "https://example.test/sse",
streamResponse: true,
}),
).resolves.toEqual({
status: 202,
headers: [{ name: "content-type", value: "text/event-stream" }],
bodyBase64: "",
});
const deltas = await waitForHttpBodyDeltas(notifications, 2);
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining("python3"),
usePty: false,
workdir: "/workspace",
}),
);
expect(runShellCommand).not.toHaveBeenCalled();
expect(deltas).toEqual([
expect.objectContaining({
requestId: "http-stream",
seq: 1,
deltaBase64: Buffer.from("event: ok\n\n").toString("base64"),
done: false,
}),
expect.objectContaining({
requestId: "http-stream",
seq: 2,
deltaBase64: "",
done: true,
}),
]);
socket.close();
});
it("terminates streaming HTTP subprocesses when the exec-server socket closes", async () => {
const finalizeExec = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
process.execPath,
"-e",
[
"process.on('SIGTERM', () => process.exit(143));",
`console.log(${JSON.stringify(
JSON.stringify({
type: "headers",
status: 200,
headers: [],
}),
)});`,
"setInterval(() => {}, 1000);",
].join(""),
],
env: process.env,
finalizeToken: "stream-token",
stdinMode: "pipe-closed",
}),
finalizeExec,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-stream-close",
method: "GET",
url: "https://example.test/sse",
streamResponse: true,
}),
).resolves.toEqual({
status: 200,
headers: [],
bodyBase64: "",
});
socket.terminate();
await vi.waitFor(
() =>
expect(finalizeExec).toHaveBeenCalledWith(
expect.objectContaining({
status: "failed",
token: "stream-token",
}),
),
{ timeout: 5_000 },
);
});
});

View File

@@ -0,0 +1,236 @@
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { vi } from "vitest";
import WebSocket from "ws";
type RpcResponse = {
id: number;
result?: unknown;
error?: { message: string };
};
export function createSandboxContext(overrides: {
buildExecSpec?: NonNullable<SandboxContext["backend"]>["buildExecSpec"];
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
mkdirp?: NonNullable<SandboxContext["fsBridge"]>["mkdirp"];
readFile?: NonNullable<SandboxContext["fsBridge"]>["readFile"];
remove?: NonNullable<SandboxContext["fsBridge"]>["remove"];
runShellCommand?: NonNullable<SandboxContext["backend"]>["runShellCommand"];
stat?: NonNullable<SandboxContext["fsBridge"]>["stat"];
writeFile?: NonNullable<SandboxContext["fsBridge"]>["writeFile"];
}): SandboxContext {
return {
enabled: true,
backendId: "docker",
sessionKey: "agent:codex:test",
workspaceDir: "/host/workspace",
agentWorkspaceDir: "/host/workspace",
workspaceAccess: "rw",
runtimeId: "openclaw-test-runtime",
runtimeLabel: "openclaw-test-runtime",
containerName: "openclaw-test-runtime",
containerWorkdir: "/workspace",
docker: { binds: [], image: "test", workdir: "/workspace", env: {}, network: "none" },
tools: {},
browserAllowHostControl: false,
backend: {
id: "docker",
runtimeId: "openclaw-test-runtime",
runtimeLabel: "openclaw-test-runtime",
workdir: "/workspace",
buildExecSpec:
overrides.buildExecSpec ??
(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
stdinMode: "pipe-closed",
})),
finalizeExec: overrides.finalizeExec,
runShellCommand:
overrides.runShellCommand ??
(async () => ({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0 })),
},
fsBridge: {
resolvePath: ({
filePath,
}: Parameters<NonNullable<SandboxContext["fsBridge"]>["resolvePath"]>[0]) => ({
relativePath: filePath,
containerPath: filePath,
}),
readFile: overrides.readFile ?? (async () => Buffer.alloc(0)),
writeFile: overrides.writeFile ?? (async () => undefined),
mkdirp: overrides.mkdirp ?? (async () => undefined),
remove: overrides.remove ?? (async () => undefined),
rename: async () => undefined,
stat:
overrides.stat ??
(async ({ filePath }) => ({
type: /\.[^/]+$/u.test(filePath) ? "file" : "directory",
size: 1,
mtimeMs: 1,
})),
},
} as unknown as SandboxContext;
}
export function createClient(options: { serverVersion?: string } = {}) {
return {
getServerVersion: vi.fn(() => options.serverVersion ?? "0.132.0"),
request: vi.fn(async (_method: string, _params?: unknown) => ({})),
};
}
export function execServerUrlFromClient(
client: ReturnType<typeof createClient>,
callIndex = 0,
): string {
const params = client.request.mock.calls[callIndex]?.[1];
if (!params || typeof params !== "object" || !("execServerUrl" in params)) {
throw new Error(`missing execServerUrl for environment/add call ${callIndex}`);
}
const { execServerUrl } = params as { execServerUrl?: unknown };
if (typeof execServerUrl !== "string" || !execServerUrl) {
throw new Error(`invalid execServerUrl for environment/add call ${callIndex}`);
}
return execServerUrl;
}
export function codexFsSandboxContext(params: {
entries: Array<{ path: unknown; access: "read" | "write" | "none" | "deny" }>;
cwd?: string;
}): unknown {
return {
permissions: {
type: "managed",
file_system: {
type: "restricted",
entries: params.entries,
},
network: "restricted",
},
cwd: params.cwd ?? "/workspace",
windowsSandboxLevel: "disabled",
windowsSandboxPrivateDesktop: false,
useLegacyLandlock: false,
};
}
export function specialPath(kind: string, subpath?: string): unknown {
return {
type: "special",
value: {
kind,
...(subpath ? { subpath } : {}),
},
};
}
export function globPath(pattern: string): unknown {
return {
type: "glob_pattern",
pattern,
};
}
export function openSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
}
export function collectNotifications(
socket: WebSocket,
): Array<{ method: string; params?: unknown }> {
const notifications: Array<{ method: string; params?: unknown }> = [];
socket.on("message", (data) => {
const message = JSON.parse(Buffer.from(data as Buffer).toString("utf8")) as {
id?: number;
method?: string;
params?: unknown;
};
if (message.id === undefined && message.method) {
notifications.push({ method: message.method, params: message.params });
}
});
return notifications;
}
export async function readUntilClosed(
socket: WebSocket,
processId: string,
): Promise<{
chunks?: Array<{ stream: string; chunk: string }>;
exited?: boolean;
exitCode?: number;
closed?: boolean;
nextSeq?: number;
}> {
let afterSeq = 0;
const chunks: Array<{ stream: string; chunk: string }> = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
const read = (await rpc(socket, "process/read", {
processId,
afterSeq,
waitMs: 1000,
})) as {
chunks?: Array<{ seq?: number; stream: string; chunk: string }>;
exited?: boolean;
exitCode?: number;
closed?: boolean;
nextSeq?: number;
};
chunks.push(...(read.chunks ?? []));
afterSeq = Math.max(afterSeq, (read.nextSeq ?? 1) - 1);
if (read.closed) {
return { ...read, chunks };
}
}
throw new Error(`process ${processId} did not close`);
}
export function waitForSocketClose(socket: WebSocket): Promise<{ code: number }> {
return new Promise((resolve) => {
socket.once("close", (code) => resolve({ code }));
});
}
export async function waitForHttpBodyDeltas(
notifications: Array<{ method: string; params?: unknown }>,
count: number,
): Promise<unknown[]> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const deltas = notifications
.filter((notification) => notification.method === "http/request/bodyDelta")
.map((notification) => notification.params);
if (deltas.length >= count) {
return deltas;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`expected ${count} http body deltas`);
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function rpc(socket: WebSocket, method: string, params: unknown): Promise<unknown> {
const id = Math.floor(Math.random() * 1_000_000);
return new Promise((resolve, reject) => {
const onMessage = (data: WebSocket.RawData) => {
const response = JSON.parse(Buffer.from(data as Buffer).toString("utf8")) as RpcResponse;
if (response.id !== id) {
return;
}
socket.off("message", onMessage);
if (response.error) {
reject(new Error(response.error.message));
return;
}
resolve(response.result);
};
socket.on("message", onMessage);
socket.send(JSON.stringify({ id, method, params }));
});
}

View File

@@ -0,0 +1,460 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
collectNotifications,
createClient,
createSandboxContext,
execServerUrlFromClient,
openSocket,
readUntilClosed,
rpc,
waitForSocketClose,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server", () => {
it("reports unavailable app-server remote environment support without exposing an environment", async () => {
const sandbox = createSandboxContext({});
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
request: vi.fn(async () => {
throw new Error("unknown variant environment/add");
}),
};
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
}),
).resolves.toBeUndefined();
});
it("does not advertise a local exec-server URL to remote app-servers", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://codex.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("does not treat 127-prefixed DNS names as local app-server hosts", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://127.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("rejects Codex app-server versions before the sandbox exec-server environment contract", async () => {
const sandbox = createSandboxContext({});
const client = createClient({ serverVersion: "0.131.0" });
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
}),
).rejects.toThrow("Codex app-server 0.132.0 or newer is required");
expect(client.request).not.toHaveBeenCalled();
});
it("registers a sandbox-backed Codex environment and routes process execution through it", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "printf 'sandbox-process-ok\\n'"],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const requests: Array<{ method: string; params: unknown }> = [];
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
return {};
}),
};
const environment = await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const addRequest = requests[0];
expect(addRequest?.method).toBe("environment/add");
expect(environment).toEqual({
environmentId: expect.stringMatching(/^openclaw-sandbox-/),
cwd: "/workspace",
});
const execServerUrl =
typeof addRequest?.params === "object" &&
addRequest.params &&
"execServerUrl" in addRequest.params
? String(addRequest.params.execServerUrl)
: "";
expect(execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
const socket = await openSocket(execServerUrl);
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const start = (await rpc(socket, "process/start", {
processId: "proc-1",
argv: ["/bin/sh", "-lc", "printf ok"],
cwd: "/workspace",
env: { POLICY_SET: "env-wins", TEST_FLAG: "1" },
envPolicy: {
inherit: "none",
ignoreDefaultExcludes: true,
exclude: [],
set: { POLICY_SET: "policy", POLICY_ONLY: "1" },
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
})) as { processId?: string; nextSeq?: number };
expect(start).toEqual({ processId: "proc-1" });
const read = await readUntilClosed(socket, "proc-1");
expect(read.exited).toBe(true);
expect(read.exitCode).toBe(0);
expect(read.closed).toBe(true);
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"sandbox-process-ok\n",
);
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
command: "'/bin/sh' '-lc' 'printf ok'",
env: { POLICY_ONLY: "1", POLICY_SET: "env-wins", TEST_FLAG: "1" },
usePty: false,
workdir: "/workspace",
}),
);
expect(notifications.map((notification) => notification.method)).toEqual(
expect.arrayContaining(["process/output", "process/exited", "process/closed"]),
);
socket.close();
});
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/start", {
processId: "proc-arg0",
argv: ["/bin/sh", "-lc", "true"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: "codex-linux-sandbox",
}),
).rejects.toThrow("does not support arg0 overrides");
expect(buildExecSpec).not.toHaveBeenCalled();
socket.close();
});
it("accepts stdin writes for pipe-backed processes", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "echo:%s\\n" "$line"'],
env: process.env,
stdinMode: "pipe-open",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-stdin",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: true,
arg0: null,
});
await expect(
rpc(socket, "process/write", {
processId: "proc-stdin",
chunk: Buffer.from("hello\n").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-stdin");
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"echo:hello\n",
);
socket.close();
});
it("keeps tty process starts pipe-backed for sandbox backends", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "tty:%s\\n" "$line"'],
env: process.env,
stdinMode: "pipe-open" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-tty",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "/workspace",
env: {},
tty: true,
pipeStdin: false,
arg0: null,
});
await expect(
rpc(socket, "process/write", {
processId: "proc-tty",
chunk: Buffer.from("hello\n").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-tty");
expect(buildExecSpec).toHaveBeenCalledWith(expect.objectContaining({ usePty: false }));
expect(read.chunks?.[0]?.stream).toBe("pty");
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"tty:hello\n",
);
socket.close();
});
it("does not let Codex env policy inherit host secret variables", async () => {
vi.stubEnv("HOME", "/gateway-home");
vi.stubEnv("USER", "gateway-user");
vi.stubEnv("TMPDIR", "/gateway-tmp");
vi.stubEnv("OPENCLAW_TEST_SECRET_TOKEN", "host-secret");
vi.stubEnv("OPENCLAW_TEST_DATABASE_PASSWORD", "host-password");
vi.stubEnv("OPENCLAW_TEST_PRIVATE_KEY", "host-private-key");
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: {},
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-secret-env",
argv: ["/bin/sh", "-lc", "true"],
cwd: "/workspace",
env: {},
envPolicy: {
inherit: "all",
ignoreDefaultExcludes: true,
exclude: [],
set: {},
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
});
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
env: {},
}),
);
socket.close();
});
it("keeps process/read cursors at the last returned byte-limited chunk", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
process.execPath,
"-e",
"process.stdout.write('aaaa'); process.stderr.write('bbbb');",
],
env: process.env,
stdinMode: "pipe-closed",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-cursor",
argv: [process.execPath, "-e", "ignored"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
});
const complete = await readUntilClosed(socket, "proc-cursor");
expect(complete.chunks?.length ?? 0).toBeGreaterThanOrEqual(2);
const firstRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: 0,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }>; nextSeq?: number };
expect(firstRead.chunks).toHaveLength(1);
expect(firstRead.nextSeq).toBe((firstRead.chunks?.[0]?.seq ?? 0) + 1);
expect(firstRead.nextSeq ?? 0).toBeLessThan(complete.nextSeq ?? 0);
const secondRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: (firstRead.nextSeq ?? 1) - 1,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }> };
expect(secondRead.chunks?.length ?? 0).toBeGreaterThanOrEqual(1);
socket.close();
});
it("returns protocol statuses for unsupported process writes and unknown termination", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/write", {
processId: "missing",
chunk: Buffer.from("hello").toString("base64"),
}),
).resolves.toEqual({ status: "unknownProcess" });
await expect(
rpc(socket, "process/terminate", {
processId: "missing",
}),
).resolves.toEqual({ running: false });
socket.close();
});
it("rejects WebSocket clients that do not know the exec-server capability path", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const unauthorizedUrl = execServerUrlFromClient(client).replace(
/\/openclaw-[^/?#]+/u,
"/wrong",
);
const socket = await openSocket(unauthorizedUrl);
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1008 });
});
it("closes the exec-server when its sandbox environment is released", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const execServerUrl = execServerUrlFromClient(client);
await releaseCodexSandboxExecServerEnvironment(sandbox);
await expect(openSocket(execServerUrl)).rejects.toThrow();
});
it("keeps a shared exec-server open when another turn reacquires during release", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const firstExecServerUrl = execServerUrlFromClient(client);
const release = releaseCodexSandboxExecServerEnvironment(sandbox);
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
await release;
const secondExecServerUrl = execServerUrlFromClient(client, 1);
expect(secondExecServerUrl).toBe(firstExecServerUrl);
const socket = await openSocket(secondExecServerUrl);
await expect(rpc(socket, "initialize", { clientName: "test" })).resolves.toEqual({
sessionId: expect.any(String),
});
socket.close();
});
});

View File

@@ -0,0 +1,355 @@
import { createHash, randomUUID } from "node:crypto";
import { once } from "node:events";
import type { IncomingMessage } from "node:http";
import { isIP, type AddressInfo } from "node:net";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { compareCodexAppServerVersions, type CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type { JsonValue } from "./protocol.js";
import {
createDirectory,
copyPath,
getMetadata,
readDirectory,
readFile,
removePath,
writeFile,
} from "./sandbox-exec-server/filesystem.js";
import { httpRequest } from "./sandbox-exec-server/http.js";
import {
JsonRpcProtocolError,
parseRequest,
sendError,
sendResult,
} from "./sandbox-exec-server/json-rpc.js";
import {
readProcess,
startProcess,
terminateProcess,
writeProcess,
} from "./sandbox-exec-server/processes.js";
import type {
JsonRpcRequest,
ManagedProcess,
OpenClawExecServer,
} from "./sandbox-exec-server/types.js";
import { MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION } from "./version.js";
export type CodexSandboxExecEnvironment = {
environmentId: string;
cwd: string;
};
const SANDBOX_EXEC_SERVERS = new Map<string, Promise<OpenClawExecServer>>();
export async function closeCodexSandboxExecServersForTests(): Promise<void> {
const servers = await Promise.allSettled(SANDBOX_EXEC_SERVERS.values());
SANDBOX_EXEC_SERVERS.clear();
await Promise.all(
servers.map(async (entry) => {
if (entry.status === "fulfilled") {
entry.value.refCount = 0;
await closeOpenClawExecServer(entry.value);
}
}),
);
}
export async function ensureCodexSandboxExecServerEnvironment(params: {
client: CodexAppServerClient;
sandbox: SandboxContext | null;
appServerStartOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CodexSandboxExecEnvironment | undefined> {
if (!params.sandbox?.enabled || !params.sandbox.backend) {
return undefined;
}
if (!canExposeLocalExecServerToAppServer(params.appServerStartOptions)) {
throw new Error(
"OpenClaw Codex exec-server uses a local loopback URL and cannot be registered with a remote Codex app-server.",
);
}
assertCodexSandboxExecServerSupported(params.client);
const execServer = await acquireOpenClawExecServer(params.sandbox);
try {
await params.client.request(
"environment/add",
{
environmentId: execServer.environmentId,
execServerUrl: execServer.url,
},
{ timeoutMs: params.timeoutMs, signal: params.signal },
);
} catch (error) {
await releaseOpenClawExecServer(execServer);
if (isEnvironmentAddUnsupported(error)) {
embeddedAgentLog.warn("codex app-server does not support remote environments yet", {
environmentId: execServer.environmentId,
});
return undefined;
}
throw error;
}
return {
environmentId: execServer.environmentId,
cwd: params.sandbox.containerWorkdir,
};
}
export async function releaseCodexSandboxExecServerEnvironment(
sandbox: SandboxContext | null | undefined,
): Promise<void> {
if (!sandbox?.enabled) {
return;
}
const server = await SANDBOX_EXEC_SERVERS.get(sandbox.runtimeId)?.catch(() => undefined);
if (server) {
await releaseOpenClawExecServer(server);
}
}
function assertCodexSandboxExecServerSupported(client: CodexAppServerClient): void {
const detectedVersion = client.getServerVersion();
if (
!detectedVersion ||
compareCodexAppServerVersions(
detectedVersion,
MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION,
) < 0
) {
throw new Error(
`Codex app-server ${MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION} or newer is required for OpenClaw sandbox exec-server environments, but detected ${
detectedVersion ?? "an unknown version"
}. Disable appServer.experimental.sandboxExecServer or configure a newer Codex app-server binary.`,
);
}
}
function isEnvironmentAddUnsupported(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return (
error.message.includes("environment/add") &&
(error.message.includes("unknown variant") || error.message.includes("Method not found"))
);
}
function canExposeLocalExecServerToAppServer(
startOptions: CodexAppServerStartOptions | undefined,
): boolean {
if (!startOptions || startOptions.transport !== "websocket") {
return true;
}
if (typeof startOptions.url !== "string") {
return false;
}
try {
const host = new URL(startOptions.url).hostname.toLowerCase();
const ipHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
if (host === "localhost" || ipHost === "::1") {
return true;
}
return isIP(ipHost) === 4 && ipHost.split(".")[0] === "127";
} catch {
return false;
}
}
async function acquireOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const key = sandbox.runtimeId;
while (true) {
const existing = SANDBOX_EXEC_SERVERS.get(key);
const promise = existing ?? startAndRememberOpenClawExecServer(sandbox);
const server = await promise;
if (!server.closed && SANDBOX_EXEC_SERVERS.get(key) === promise) {
server.refCount += 1;
return server;
}
}
}
function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const created = startOpenClawExecServer(sandbox);
const key = sandbox.runtimeId;
SANDBOX_EXEC_SERVERS.set(key, created);
void created.catch(() => {
if (SANDBOX_EXEC_SERVERS.get(key) === created) {
SANDBOX_EXEC_SERVERS.delete(key);
}
});
return created;
}
async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("OpenClaw Codex exec-server did not bind to a TCP port.");
}
const environmentId = buildEnvironmentId(sandbox);
const authPath = `/openclaw-${randomUUID()}`;
const url = `ws://127.0.0.1:${(address as AddressInfo).port}${authPath}`;
const execServer: OpenClawExecServer = {
authPath,
closed: false,
environmentId,
refCount: 0,
url,
sandbox,
server,
};
server.on("connection", (socket, request) => {
if (!isAuthorizedExecServerRequest(execServer, request)) {
socket.close(1008, "unauthorized");
return;
}
handleConnection(execServer, socket);
});
embeddedAgentLog.info("codex sandbox exec-server started", {
environmentId,
runtimeId: sandbox.runtimeId,
backendId: sandbox.backendId,
});
return execServer;
}
async function releaseOpenClawExecServer(execServer: OpenClawExecServer): Promise<void> {
if (execServer.closed) {
return;
}
execServer.refCount = Math.max(0, execServer.refCount - 1);
if (execServer.refCount > 0) {
return;
}
const current = await SANDBOX_EXEC_SERVERS.get(execServer.sandbox.runtimeId)?.catch(
() => undefined,
);
if (execServer.refCount > 0 || execServer.closed) {
return;
}
if (current === execServer) {
SANDBOX_EXEC_SERVERS.delete(execServer.sandbox.runtimeId);
}
await closeOpenClawExecServer(execServer);
}
async function closeOpenClawExecServer(execServer: OpenClawExecServer): Promise<void> {
if (execServer.closed) {
return;
}
execServer.closed = true;
for (const client of execServer.server.clients) {
client.close(1001, "shutdown");
}
await new Promise<void>((resolve) => {
execServer.server.close(() => resolve());
});
}
function buildEnvironmentId(sandbox: SandboxContext): string {
const hash = createHash("sha256").update(sandbox.runtimeId).digest("hex").slice(0, 16);
return `openclaw-sandbox-${hash}`;
}
function isAuthorizedExecServerRequest(
execServer: OpenClawExecServer,
request: IncomingMessage,
): boolean {
const url = new URL(request.url ?? "", "ws://127.0.0.1");
return url.pathname === execServer.authPath;
}
function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): void {
const processes = new Map<string, ManagedProcess>();
socket.on("message", (data) => {
void handleMessage(execServer, processes, socket, data).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox exec-server message failed", { error });
});
});
socket.on("close", () => {
for (const process of processes.values()) {
process.abortController.abort();
}
});
}
async function handleMessage(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
data: RawData,
): Promise<void> {
const request = parseRequest(data);
if (!request.method) {
sendError(socket, request.id, -32600, "Invalid Request");
return;
}
const method = request.method;
if (request.id === undefined) {
if (method !== "initialized") {
sendError(socket, -1, -32600, `Unexpected notification: ${method}`);
}
return;
}
try {
const result = await dispatchRequest(execServer, processes, socket, { ...request, method });
sendResult(socket, request.id, result);
} catch (error) {
sendError(
socket,
request.id,
error instanceof JsonRpcProtocolError ? error.code : -32603,
error instanceof Error ? error.message : String(error),
);
}
}
async function dispatchRequest(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
request: Required<Pick<JsonRpcRequest, "method">> & Pick<JsonRpcRequest, "id" | "params">,
): Promise<JsonValue | undefined> {
switch (request.method) {
case "initialize":
return { sessionId: randomUUID() };
// These method names are the Codex exec-server remote-environment RPCs.
// The app-server process-control surface uses different names such as
// process/spawn, but those are not sent to registered exec-server URLs.
case "process/start":
return startProcess(execServer, processes, socket, request.params);
case "process/read":
return await readProcess(processes, request.params);
case "process/write":
return writeProcess(processes, request.params);
case "process/terminate":
return terminateProcess(processes, request.params);
case "fs/readFile":
return await readFile(execServer, request.params);
case "fs/writeFile":
await writeFile(execServer, request.params);
return {};
case "fs/createDirectory":
await createDirectory(execServer, request.params);
return {};
case "fs/getMetadata":
return await getMetadata(execServer, request.params);
case "fs/readDirectory":
return await readDirectory(execServer, request.params);
case "fs/remove":
await removePath(execServer, request.params);
return {};
case "fs/copy":
await copyPath(execServer, request.params);
return {};
case "http/request":
return await httpRequest(execServer, socket, request.params);
default:
throw new Error(`Unsupported OpenClaw sandbox exec-server method: ${request.method}`);
}
}

View File

@@ -0,0 +1,261 @@
import { posix as pathPosix } from "node:path";
import type { SandboxFsStat } from "openclaw/plugin-sdk/sandbox";
import type { JsonObject, JsonValue } from "../protocol.js";
import {
assertFsSandboxAccess,
assertNoReadOnlyDescendant,
assertResolvedFsSandboxAccess,
joinSandboxChildPath,
normalizeSandboxAbsolutePath,
pathContains,
resolveFsSandboxPolicy,
} from "./fs-policy.js";
import {
JSON_RPC_NOT_FOUND,
JsonRpcProtocolError,
requireBase64String,
requireObject,
requireString,
} from "./json-rpc.js";
import { requireBackend, requireFsBridge } from "./runtime.js";
import type { DirectoryEntry, OpenClawExecServer, ResolvedFsSandboxPolicy } from "./types.js";
const CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES = 512 * 1024 * 1024;
export async function readFile(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/readFile params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const stat = await fsBridge.stat({ filePath });
if (!stat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
if (stat.type === "file" && stat.size > CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES) {
throw new Error(
`file is too large to read through Codex sandbox exec-server: ${stat.size} bytes`,
);
}
const data = await fsBridge.readFile({
filePath,
});
return { dataBase64: data.toString("base64") };
}
export async function writeFile(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/writeFile params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "write" }]);
const fsBridge = requireFsBridge(execServer);
const parent = await fsBridge.stat({ filePath: pathPosix.dirname(filePath) });
if (parent?.type !== "directory") {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "parent directory not found");
}
await fsBridge.writeFile({
filePath,
data: Buffer.from(requireBase64String(record.dataBase64, "dataBase64"), "base64"),
mkdir: false,
});
}
export async function createDirectory(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/createDirectory params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "write" }]);
const fsBridge = requireFsBridge(execServer);
if (record.recursive === false) {
const parentPath = pathPosix.dirname(filePath);
const parent = await fsBridge.stat({ filePath: parentPath });
if (parent?.type !== "directory") {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "parent directory not found");
}
}
await fsBridge.mkdirp({
filePath,
});
}
export async function getMetadata(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/getMetadata params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const stat = await fsBridge.stat({
filePath,
});
if (!stat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
return metadataResponse(stat);
}
export async function readDirectory(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/readDirectory params");
const filePath = requireString(record.path, "path");
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
return {
entries: await listDirectoryEntries(execServer, filePath, fsSandboxPolicy),
};
}
async function listDirectoryEntries(
execServer: OpenClawExecServer,
filePath: string,
fsSandboxPolicy: ResolvedFsSandboxPolicy | undefined,
): Promise<DirectoryEntry[]> {
assertResolvedFsSandboxAccess(fsSandboxPolicy, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const backend = requireBackend(execServer);
const resolved = fsBridge.resolvePath({
filePath,
});
if (!resolved) {
throw new Error(`Cannot resolve sandbox path: ${filePath}`);
}
const result = await backend.runShellCommand({
script:
'find "$1" -mindepth 1 -maxdepth 1 -exec sh -c \'for path do name=${path##*/}; if [ -L "$path" ]; then kind=o; elif [ -d "$path" ]; then kind=d; elif [ -f "$path" ]; then kind=f; else kind=o; fi; printf "%s\\t%s\\n" "$kind" "$name"; done\' sh {} +',
args: [resolved.containerPath],
allowFailure: true,
});
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8").trim();
throw new Error(stderr || `sandbox directory listing failed with code ${result.code}`);
}
const lines = result.stdout.toString("utf8").split("\n").filter(Boolean);
return lines.map((line) => {
const [kind = "o", fileName = ""] = line.split("\t");
return {
fileName,
isDirectory: kind === "d",
isFile: kind === "f",
};
});
}
export async function removePath(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/remove params");
const filePath = requireString(record.path, "path");
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
assertResolvedFsSandboxAccess(fsSandboxPolicy, [{ path: filePath, access: "write" }]);
if (record.recursive !== false) {
assertNoReadOnlyDescendant(fsSandboxPolicy, filePath, "remove");
}
const fsBridge = requireFsBridge(execServer);
await fsBridge.remove({
filePath,
recursive: record.recursive !== false,
force: record.force !== false,
});
}
export async function copyPath(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/copy params");
const sourcePath = requireString(record.sourcePath ?? record.source, "sourcePath");
const destinationPath = requireString(
record.destinationPath ?? record.destination,
"destinationPath",
);
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
assertResolvedFsSandboxAccess(fsSandboxPolicy, [
{ path: sourcePath, access: "read" },
{ path: destinationPath, access: "write" },
]);
await copySandboxPath(execServer, {
sourcePath,
destinationPath,
recursive: record.recursive === true,
fsSandboxPolicy,
});
}
async function copySandboxPath(
execServer: OpenClawExecServer,
params: {
sourcePath: string;
destinationPath: string;
recursive: boolean;
fsSandboxPolicy: ResolvedFsSandboxPolicy | undefined;
},
): Promise<void> {
const fsBridge = execServer.sandbox.fsBridge;
if (!fsBridge) {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
assertResolvedFsSandboxAccess(params.fsSandboxPolicy, [
{ path: params.sourcePath, access: "read" },
{ path: params.destinationPath, access: "write" },
]);
const sourceStat = await fsBridge.stat({ filePath: params.sourcePath });
if (!sourceStat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
if (sourceStat?.type === "directory") {
if (!params.recursive) {
throw new Error(`Cannot copy directory without recursive=true: ${params.sourcePath}`);
}
if (
pathContains(
normalizeSandboxAbsolutePath(params.sourcePath, "copy source path"),
normalizeSandboxAbsolutePath(params.destinationPath, "copy destination path"),
)
) {
throw new Error("Cannot recursively copy a directory into itself.");
}
await fsBridge.mkdirp({ filePath: params.destinationPath });
for (const entry of await listDirectoryEntries(
execServer,
params.sourcePath,
params.fsSandboxPolicy,
)) {
if (!entry.isDirectory && !entry.isFile) {
throw new Error(`Cannot copy unsupported filesystem entry: ${entry.fileName}`);
}
await copySandboxPath(execServer, {
sourcePath: joinSandboxChildPath(params.sourcePath, entry.fileName),
destinationPath: joinSandboxChildPath(params.destinationPath, entry.fileName),
recursive: true,
fsSandboxPolicy: params.fsSandboxPolicy,
});
}
return;
}
const data = await fsBridge.readFile({ filePath: params.sourcePath });
await fsBridge.writeFile({
filePath: params.destinationPath,
data,
mkdir: true,
});
}
function metadataResponse(stat: SandboxFsStat | null): JsonObject {
return {
isDirectory: stat?.type === "directory",
isFile: stat?.type === "file",
isSymlink: false,
createdAtMs: 0,
modifiedAtMs: stat?.mtimeMs ?? 0,
};
}

View File

@@ -0,0 +1,346 @@
import { posix as pathPosix } from "node:path";
import type { JsonObject } from "../protocol.js";
import { requireObject, requireString } from "./json-rpc.js";
import type {
FsAccessMode,
OpenClawExecServer,
ResolvedFsSandboxEntry,
ResolvedFsSandboxPolicy,
} from "./types.js";
export function assertFsSandboxAccess(
execServer: OpenClawExecServer,
record: JsonObject,
requests: Array<{ path: string; access: "read" | "write" }>,
): void {
assertResolvedFsSandboxAccess(resolveFsSandboxPolicy(execServer, record), requests);
}
export function resolveFsSandboxPolicy(
execServer: OpenClawExecServer,
record: JsonObject,
): ResolvedFsSandboxPolicy | undefined {
if (record.sandbox === undefined || record.sandbox === null) {
return undefined;
}
const sandbox = requireObject(record.sandbox, "fs sandbox context");
const permissions = requireObject(sandbox.permissions, "fs sandbox permissions");
const permissionType = requireString(permissions.type, "fs sandbox permissions type");
if (permissionType === "disabled" || permissionType === "external") {
return { unrestricted: true, entries: [] };
}
if (permissionType !== "managed") {
throw new Error(`Unsupported Codex fs sandbox permission type: ${permissionType}`);
}
const fileSystem = requireObject(permissions.file_system, "fs sandbox file system permissions");
const fileSystemType = requireString(fileSystem.type, "fs sandbox file system permissions type");
if (fileSystemType === "unrestricted") {
return { unrestricted: true, entries: [] };
}
if (fileSystemType !== "restricted") {
throw new Error(`Unsupported Codex fs sandbox file system type: ${fileSystemType}`);
}
if (!Array.isArray(fileSystem.entries)) {
throw new Error("fs sandbox file system entries must be an array.");
}
const cwd = readFsSandboxCwd(execServer, sandbox);
return {
unrestricted: false,
entries: fileSystem.entries.flatMap((entry, index) => {
const resolved = resolveFsSandboxEntry(
requireObject(entry, `fs sandbox entry ${index}`),
cwd,
);
return resolved ? [resolved] : [];
}),
};
}
function readFsSandboxCwd(execServer: OpenClawExecServer, sandbox: JsonObject): string {
if (sandbox.cwd === undefined || sandbox.cwd === null) {
return normalizeSandboxAbsolutePath(execServer.sandbox.containerWorkdir, "sandbox cwd");
}
return normalizeSandboxAbsolutePath(requireString(sandbox.cwd, "sandbox cwd"), "sandbox cwd");
}
function resolveFsSandboxEntry(entry: JsonObject, cwd: string): ResolvedFsSandboxEntry | undefined {
const access = readFsAccessMode(entry.access);
const pathSpec = requireObject(entry.path, "fs sandbox entry path");
const pathType = requireString(pathSpec.type, "fs sandbox entry path type");
if (pathType === "path") {
return {
kind: "path",
path: normalizeSandboxAbsolutePath(
requireString(pathSpec.path, "fs sandbox path"),
"fs sandbox path",
),
access,
};
}
if (pathType === "special") {
if (isNonGrantingFsSpecialPath(requireObject(pathSpec.value, "fs sandbox special path"))) {
return undefined;
}
return {
kind: "path",
path: resolveFsSpecialPath(requireObject(pathSpec.value, "fs sandbox special path"), cwd),
access,
};
}
if (pathType === "glob_pattern") {
const pattern = requireString(pathSpec.pattern, "fs sandbox glob pattern");
const absolutePattern = normalizeSandboxGlobPattern(
pattern.startsWith("/") ? pattern : pathPosix.join(cwd, pattern),
);
return {
kind: "glob",
pattern: absolutePattern,
matcher: compileSandboxGlobPattern(absolutePattern),
literalPrefix: sandboxGlobLiteralPrefix(absolutePattern),
access,
};
}
throw new Error(`Unsupported Codex fs sandbox path type: ${pathType}`);
}
function isNonGrantingFsSpecialPath(value: JsonObject): boolean {
const kind = requireString(value.kind, "fs sandbox special path kind");
return kind === "minimal" || kind === "unknown";
}
function readFsAccessMode(value: unknown): FsAccessMode {
if (value === "read" || value === "write" || value === "none") {
return value;
}
if (value === "deny") {
return "none";
}
throw new Error("fs sandbox entry access must be read, write, none, or deny.");
}
function resolveFsSpecialPath(value: JsonObject, cwd: string): string {
const kind = requireString(value.kind, "fs sandbox special path kind");
if (kind === "root") {
return "/";
}
if (kind === "project_roots" || kind === "current_working_directory") {
const subpath =
value.subpath === undefined || value.subpath === null
? undefined
: requireString(value.subpath, "fs sandbox project roots subpath");
return normalizeSandboxAbsolutePath(
subpath ? pathPosix.join(cwd, subpath) : cwd,
"fs sandbox project roots path",
);
}
if (kind === "slash_tmp" || kind === "tmpdir") {
return "/tmp";
}
throw new Error(`Unsupported Codex fs sandbox special path: ${kind}`);
}
export function assertResolvedFsSandboxAccess(
policy: ResolvedFsSandboxPolicy | undefined,
requests: Array<{ path: string; access: "read" | "write" }>,
): void {
if (!policy?.unrestricted && policy) {
for (const request of requests) {
const access = resolveFsAccess(policy, request.path);
if (request.access === "read" && access === "none") {
throw new Error(`Codex fs sandbox denied read access to ${request.path}`);
}
if (request.access === "write" && access !== "write") {
throw new Error(`Codex fs sandbox denied write access to ${request.path}`);
}
}
}
}
function resolveFsAccess(policy: ResolvedFsSandboxPolicy, rawPath: string): FsAccessMode {
if (policy.unrestricted) {
return "write";
}
const target = normalizeSandboxAbsolutePath(rawPath, "fs path");
let selected: { specificity: number; rank: number; access: FsAccessMode } | undefined;
for (const entry of policy.entries) {
if (!fsSandboxEntryMatches(entry, target)) {
continue;
}
const candidate = {
specificity: fsSandboxEntrySpecificity(entry),
rank: fsAccessRank(entry.access),
access: entry.access,
};
if (
!selected ||
candidate.specificity > selected.specificity ||
(candidate.specificity === selected.specificity && candidate.rank > selected.rank)
) {
selected = candidate;
}
}
return selected?.access ?? "none";
}
export function assertNoReadOnlyDescendant(
policy: ResolvedFsSandboxPolicy | undefined,
rawPath: string,
operation: string,
): void {
if (!policy || policy.unrestricted) {
return;
}
const target = normalizeSandboxAbsolutePath(rawPath, "fs path");
const protectedDescendant = policy.entries.find((entry) => {
if (entry.access === "write" || !fsSandboxEntryCanAffectDescendant(entry, target)) {
return false;
}
if (entry.kind === "glob") {
return true;
}
const protectedPath = entry.path;
return protectedPath && resolveFsAccess(policy, protectedPath) !== "write";
});
if (protectedDescendant) {
const protectedPath =
protectedDescendant.kind === "path" ? protectedDescendant.path : protectedDescendant.pattern;
throw new Error(
`Codex fs sandbox denied recursive ${operation} of ${rawPath} because ${protectedPath} is not writable.`,
);
}
}
export function normalizeSandboxAbsolutePath(rawPath: string, label: string): string {
if (!rawPath || rawPath.includes("\0") || !rawPath.startsWith("/")) {
throw new Error(`${label} must be an absolute sandbox path.`);
}
const normalized = pathPosix.normalize(rawPath);
return normalized === "//" ? "/" : normalized;
}
export function pathContains(root: string, target: string): boolean {
return root === "/" || target === root || target.startsWith(`${root}/`);
}
function fsSandboxEntryMatches(entry: ResolvedFsSandboxEntry, target: string): boolean {
if (entry.kind === "path") {
return pathContains(entry.path, target);
}
return entry.matcher.test(target);
}
function fsSandboxEntryCanAffectDescendant(entry: ResolvedFsSandboxEntry, target: string): boolean {
if (entry.kind === "path") {
return pathContains(target, entry.path) && target !== entry.path;
}
return pathContains(target, entry.literalPrefix) || pathContains(entry.literalPrefix, target);
}
function fsSandboxEntrySpecificity(entry: ResolvedFsSandboxEntry): number {
return pathSpecificity(entry.kind === "path" ? entry.path : entry.literalPrefix);
}
function pathSpecificity(filePath: string): number {
return filePath === "/" ? 0 : filePath.split("/").filter(Boolean).length;
}
function fsAccessRank(access: FsAccessMode): number {
if (access === "none") {
return 2;
}
if (access === "write") {
return 1;
}
return 0;
}
function normalizeSandboxGlobPattern(pattern: string): string {
if (!pattern || pattern.includes("\0") || !pattern.startsWith("/")) {
throw new Error("fs sandbox glob pattern must be absolute.");
}
return pattern.replace(/\/{2,}/gu, "/");
}
function compileSandboxGlobPattern(pattern: string): RegExp {
let source = "^";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
const next = pattern[index + 1];
if (char === "*" && next === "*" && pattern[index + 2] === "/") {
source += "(?:.*/)?";
index += 2;
} else if (char === "*" && next === "*") {
source += ".*";
index += 1;
} else if (char === "*") {
source += "[^/]*";
} else if (char === "?") {
source += "[^/]";
} else if (char === "[") {
const compiledClass = compileSandboxGlobCharacterClass(pattern, index);
source += compiledClass.source;
index = compiledClass.endIndex;
} else {
source += char?.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&") ?? "";
}
}
source += "$";
return new RegExp(source, "u");
}
function compileSandboxGlobCharacterClass(
pattern: string,
startIndex: number,
): { source: string; endIndex: number } {
let index = startIndex + 1;
if (index >= pattern.length) {
throw new Error("fs sandbox glob character class must be closed.");
}
const negated = pattern[index] === "!" || pattern[index] === "^";
if (negated) {
index += 1;
}
let body = "";
for (; index < pattern.length; index += 1) {
const char = pattern[index];
if (char === "]" && body) {
return {
source: `[${negated ? "^" : ""}${body}]`,
endIndex: index,
};
}
if (!char || char === "/") {
throw new Error("fs sandbox glob character class cannot match path separators.");
}
body += escapeSandboxGlobCharacterClassChar(char, body.length === 0);
}
throw new Error("fs sandbox glob character class must be closed.");
}
function escapeSandboxGlobCharacterClassChar(char: string, first: boolean): string {
if (char === "\\" || char === "]") {
return `\\${char}`;
}
if (first && char === "^") {
return "\\^";
}
return char;
}
function sandboxGlobLiteralPrefix(pattern: string): string {
const wildcardIndex = pattern.search(/[*?[]/u);
const prefix = wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
const slash = prefix.lastIndexOf("/");
if (slash <= 0) {
return "/";
}
return normalizeSandboxAbsolutePath(prefix.slice(0, slash), "fs sandbox glob prefix");
}
export function joinSandboxChildPath(parent: string, child: string): string {
if (!child || child === "." || child === ".." || child.includes("/") || child.includes("\0")) {
throw new Error(`Invalid sandbox directory entry name: ${child}`);
}
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
}

View File

@@ -0,0 +1,312 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import { readHttpHeaders, requireNumber, requireObject, requireString } from "./json-rpc.js";
import { requireBackend } from "./runtime.js";
import type { HttpHeader, OpenClawExecServer } from "./types.js";
export async function httpRequest(
execServer: OpenClawExecServer,
socket: WebSocket,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "http/request params");
const requestId = requireString(record.requestId, "requestId");
const request = {
method: requireString(record.method, "method"),
url: requireString(record.url, "url"),
headers: readHttpHeaders(record.headers),
bodyBase64: typeof record.bodyBase64 === "string" ? record.bodyBase64 : undefined,
timeoutMs:
typeof record.timeoutMs === "number" && record.timeoutMs > 0
? Math.floor(record.timeoutMs)
: undefined,
streamResponse: record.streamResponse === true,
};
if (request.streamResponse) {
return await runStreamingSandboxHttpRequest(execServer, socket, requestId, request);
}
const result = await runSandboxHttpRequest(execServer, {
...request,
streamResponse: false,
});
return result;
}
type SandboxHttpRequest = {
method: string;
url: string;
headers: HttpHeader[];
bodyBase64?: string;
timeoutMs?: number;
streamResponse: boolean;
};
async function runSandboxHttpRequest(
execServer: OpenClawExecServer,
params: SandboxHttpRequest,
): Promise<JsonObject & { status: number; headers: HttpHeader[]; bodyBase64: string }> {
const backend = requireBackend(execServer);
const result = await backend.runShellCommand({
script: SANDBOX_HTTP_REQUEST_SCRIPT,
stdin: JSON.stringify(params),
allowFailure: true,
});
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8").trim();
throw new Error(stderr || `sandbox http/request failed with code ${result.code}`);
}
const parsed = JSON.parse(result.stdout.toString("utf8")) as {
status?: unknown;
headers?: unknown;
bodyBase64?: unknown;
};
if (typeof parsed.status !== "number" || !Array.isArray(parsed.headers)) {
throw new Error("sandbox http/request returned an invalid response envelope");
}
return {
status: parsed.status,
headers: readHttpHeaders(parsed.headers),
bodyBase64: typeof parsed.bodyBase64 === "string" ? parsed.bodyBase64 : "",
};
}
async function runStreamingSandboxHttpRequest(
execServer: OpenClawExecServer,
socket: WebSocket,
requestId: string,
params: SandboxHttpRequest,
): Promise<JsonObject> {
const backend = requireBackend(execServer);
const execSpec = await backend.buildExecSpec({
command: SANDBOX_HTTP_REQUEST_SCRIPT,
workdir: execServer.sandbox.containerWorkdir,
env: {},
usePty: false,
});
const [command, ...args] = execSpec.argv;
if (!command) {
throw new Error("OpenClaw sandbox HTTP exec spec did not provide a command.");
}
const child = spawn(command, args, {
env: execSpec.env,
stdio: ["pipe", "pipe", "pipe"],
});
const abortOnSocketClose = () => child.kill("SIGTERM");
socket.once("close", abortOnSocketClose);
child.once("close", () => {
socket.off("close", abortOnSocketClose);
});
child.stdin.end(JSON.stringify(params));
return await readStreamingSandboxHttpResponse({
child,
execSpec,
finalizeExec: backend.finalizeExec,
requestId,
socket,
});
}
function readStreamingSandboxHttpResponse(params: {
child: ChildProcessWithoutNullStreams;
execSpec: { finalizeToken?: unknown };
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
requestId: string;
socket: WebSocket;
}): Promise<JsonObject> {
return new Promise((resolve, reject) => {
let headerResolved = false;
let failed = false;
let lastBodySeq = 0;
let stdoutBuffer = "";
let stderr = "";
const finalize = async (status: "completed" | "failed", exitCode: number | null) => {
await params.finalizeExec?.({
status,
exitCode,
timedOut: false,
token: params.execSpec.finalizeToken,
});
};
const fail = (message: string, exitCode: number | null) => {
if (failed) {
return;
}
failed = true;
void finalize("failed", exitCode).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
});
if (headerResolved) {
sendHttpBodyDelta(params.socket, {
requestId: params.requestId,
seq: lastBodySeq + 1,
deltaBase64: "",
done: true,
error: message,
});
return;
}
reject(new Error(message));
};
params.child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString("utf8");
let newline = stdoutBuffer.indexOf("\n");
while (newline >= 0) {
const line = stdoutBuffer.slice(0, newline).trim();
stdoutBuffer = stdoutBuffer.slice(newline + 1);
if (line) {
try {
const message = requireObject(JSON.parse(line) as JsonValue, "http stream message");
const type = requireString(message.type, "http stream message type");
if (type === "headers") {
headerResolved = true;
resolve({
status: requireNumber(message.status, "http status"),
headers: readHttpHeaders(message.headers),
bodyBase64: "",
});
} else if (type === "bodyDelta") {
const seq = requireNumber(message.seq, "http body sequence");
lastBodySeq = Math.max(lastBodySeq, seq);
sendHttpBodyDelta(params.socket, {
requestId: params.requestId,
seq,
deltaBase64: typeof message.deltaBase64 === "string" ? message.deltaBase64 : "",
done: message.done === true,
error: typeof message.error === "string" ? message.error : null,
});
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error), null);
}
}
newline = stdoutBuffer.indexOf("\n");
}
});
params.child.stderr.on("data", (chunk: Buffer) => {
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-4096);
});
params.child.once("error", (error) => fail(error.message, null));
params.child.once("close", (code) => {
const exitCode = code ?? 1;
if (failed) {
return;
}
if (exitCode === 0) {
void finalize("completed", exitCode).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
});
if (!headerResolved) {
reject(new Error("sandbox http/request exited before returning headers"));
}
return;
}
fail(stderr.trim() || `sandbox http/request failed with code ${exitCode}`, exitCode);
});
});
}
const SANDBOX_HTTP_REQUEST_SCRIPT = String.raw`
tmp=$(mktemp "$TMPDIR/openclaw-http.XXXXXX.py" 2>/dev/null || mktemp "/tmp/openclaw-http.XXXXXX.py") || exit 1
trap 'rm -f "$tmp"' EXIT
cat > "$tmp" <<'PY'
import base64
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
def emit(payload):
print(json.dumps(payload, separators=(",", ":")), flush=True)
def response_headers(response):
return [{"name": name, "value": value} for name, value in response.headers.items()]
def handle_response(input_data, response):
headers = response_headers(response)
status = int(getattr(response, "status", getattr(response, "code", 0)))
if input_data.get("streamResponse"):
emit({"type": "headers", "status": status, "headers": headers})
seq = 1
while True:
chunk = response.read(65536)
if not chunk:
break
emit({
"type": "bodyDelta",
"seq": seq,
"deltaBase64": base64.b64encode(chunk).decode("ascii"),
"done": False,
})
seq += 1
emit({"type": "bodyDelta", "seq": seq, "deltaBase64": "", "done": True})
return
body = response.read()
emit({
"status": status,
"headers": headers,
"bodyBase64": base64.b64encode(body).decode("ascii"),
})
def main():
input_data = json.load(sys.stdin)
url = str(input_data.get("url", ""))
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("http/request only supports http and https URLs")
body_base64 = input_data.get("bodyBase64")
data = base64.b64decode(body_base64) if isinstance(body_base64, str) else None
request = urllib.request.Request(
url,
data=data,
method=str(input_data.get("method", "GET")),
)
for header in input_data.get("headers") or []:
request.add_header(str(header.get("name", "")), str(header.get("value", "")))
timeout_ms = input_data.get("timeoutMs")
timeout = None
if isinstance(timeout_ms, (int, float)) and timeout_ms > 0:
timeout = timeout_ms / 1000
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
handle_response(input_data, response)
except urllib.error.HTTPError as response:
handle_response(input_data, response)
if __name__ == "__main__":
main()
PY
python3 "$tmp"
`.trim();
function sendHttpBodyDelta(
socket: WebSocket,
params: {
requestId: string;
seq: number;
deltaBase64: string;
done: boolean;
error?: string | null;
},
): void {
if (socket.readyState !== 1) {
return;
}
socket.send(
JSON.stringify({
jsonrpc: "2.0",
method: "http/request/bodyDelta",
params: {
requestId: params.requestId,
seq: params.seq,
deltaBase64: params.deltaBase64,
done: params.done,
error: params.error ?? null,
},
}),
);
}

View File

@@ -0,0 +1,93 @@
import type { RawData, WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import type { HttpHeader, JsonRpcRequest } from "./types.js";
export const JSON_RPC_NOT_FOUND = -32004;
export class JsonRpcProtocolError extends Error {
constructor(
readonly code: number,
message: string,
) {
super(message);
}
}
export function parseRequest(data: RawData): JsonRpcRequest {
const buffer = Array.isArray(data)
? Buffer.concat(data)
: Buffer.isBuffer(data)
? data
: Buffer.from(data);
const text = buffer.toString("utf8");
const parsed = JSON.parse(text) as unknown;
return requireObject(parsed, "JSON-RPC request") as JsonRpcRequest;
}
export function requireObject(value: unknown, label: string): JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value as JsonObject;
}
export function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value) {
throw new Error(`${label} must be a non-empty string.`);
}
return value;
}
export function requireBase64String(value: unknown, label: string): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string.`);
}
return value;
}
export function requireNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} must be a finite number.`);
}
return value;
}
export function requireStringArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
throw new Error(`${label} must be a string array.`);
}
if (value.length === 0) {
throw new Error(`${label} must not be empty.`);
}
return value;
}
export function readHttpHeaders(value: unknown): HttpHeader[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry, index) => {
const record = requireObject(entry as JsonValue, `header ${index}`);
return {
name: requireString(record.name, "header name"),
value: requireString(record.value, "header value"),
};
});
}
export function sendResult(
socket: WebSocket,
id: string | number,
result: JsonValue | undefined,
): void {
socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
}
export function sendError(
socket: WebSocket,
id: string | number | undefined,
code: number,
message: string,
): void {
socket.send(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }));
}

View File

@@ -0,0 +1,411 @@
import { spawn } from "node:child_process";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import { requireObject, requireString, requireStringArray } from "./json-rpc.js";
import type { ManagedProcess, OpenClawExecServer, ProcessChunk } from "./types.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const RETAINED_PROCESS_OUTPUT_BYTES = 1024 * 1024;
const CLOSED_PROCESS_EVICTION_MS = 60_000;
export async function startProcess(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "process/start params");
const processId = requireString(record.processId, "processId");
if (processes.has(processId)) {
throw new Error(`process already exists: ${processId}`);
}
const argv = requireStringArray(record.argv, "argv");
const cwd = requireString(record.cwd, "cwd");
rejectUnsupportedArg0(record.arg0);
const env = readProcessEnv(record);
const tty = record.tty === true;
const pipeStdin = record.pipeStdin === true;
const managed: ManagedProcess = {
processId,
chunks: [],
retainedOutputBytes: 0,
nextSeq: 1,
exited: false,
exitCode: null,
closed: false,
failure: null,
tty,
pipeStdin,
abortController: new AbortController(),
child: null,
finalized: false,
waiters: [],
emitNotification: (method, notificationParams) => {
if (socket.readyState === 1) {
socket.send(JSON.stringify({ jsonrpc: "2.0", method, params: notificationParams }));
}
},
evictProcess: () => {
if (managed.evictionTimer) {
return;
}
managed.evictionTimer = setTimeout(() => {
if (processes.get(processId) === managed && managed.closed) {
processes.delete(processId);
}
}, CLOSED_PROCESS_EVICTION_MS);
managed.evictionTimer.unref?.();
},
};
processes.set(processId, managed);
try {
await runProcess(execServer, managed, { argv, cwd, env });
} catch (error) {
processes.delete(processId);
managed.failure = error instanceof Error ? error.message : String(error);
managed.exitCode = null;
managed.exited = true;
managed.closed = true;
notifyProcessWaiters(managed);
throw error;
}
return { processId };
}
async function runProcess(
execServer: OpenClawExecServer,
managed: ManagedProcess,
params: { argv: string[]; cwd: string; env: Record<string, string> },
): Promise<void> {
const backend = execServer.sandbox.backend;
if (!backend) {
throw new Error("OpenClaw sandbox backend is unavailable.");
}
throwIfProcessStartCancelled(managed);
const execSpec = await backend.buildExecSpec({
command: shellCommandFromArgv(params.argv),
workdir: params.cwd,
env: params.env,
// This bridge currently owns only pipe-backed child processes. Asking the
// backend for a PTY can produce commands such as `docker exec -t`, which
// require this process itself to own a real TTY.
usePty: false,
});
managed.finalizeToken = execSpec.finalizeToken;
managed.finalizeExec = backend.finalizeExec;
if (managed.abortController.signal.aborted) {
managed.failure = "process start cancelled";
await finalizeProcess(managed);
throw new Error("process start cancelled");
}
const [command, ...args] = execSpec.argv;
if (!command) {
throw new Error("OpenClaw sandbox exec spec did not provide a command.");
}
const child = spawn(command, args, {
env: execSpec.env,
stdio: ["pipe", "pipe", "pipe"],
});
managed.child = child;
const abortListener = () => child.kill("SIGTERM");
managed.abortController.signal.addEventListener("abort", abortListener, { once: true });
child.stdout.on("data", (chunk: Buffer) =>
appendProcessChunk(managed, managed.tty ? "pty" : "stdout", chunk),
);
child.stderr.on("data", (chunk: Buffer) => appendProcessChunk(managed, "stderr", chunk));
child.once("error", (error) => {
managed.failure = error.message;
emitProcessClosed(managed, null);
});
child.once("close", (code) => {
managed.abortController.signal.removeEventListener("abort", abortListener);
emitProcessClosed(managed, code ?? 1);
});
if (!managed.tty && !managed.pipeStdin) {
child.stdin.end();
}
}
function throwIfProcessStartCancelled(managed: ManagedProcess): void {
if (managed.abortController.signal.aborted) {
throw new Error("process start cancelled");
}
}
function appendProcessChunk(
managed: ManagedProcess,
stream: ProcessChunk["stream"],
data: Buffer,
): void {
if (data.length === 0) {
return;
}
const chunk = {
seq: managed.nextSeq,
stream,
chunk: data.toString("base64"),
};
managed.chunks.push(chunk);
managed.retainedOutputBytes += data.length;
while (managed.retainedOutputBytes > RETAINED_PROCESS_OUTPUT_BYTES && managed.chunks.length > 1) {
const removed = managed.chunks.shift();
if (!removed) {
break;
}
managed.retainedOutputBytes -= Buffer.from(removed.chunk, "base64").byteLength;
}
managed.nextSeq += 1;
managed.emitNotification("process/output", {
processId: managed.processId,
seq: chunk.seq,
stream: chunk.stream,
chunk: chunk.chunk,
});
notifyProcessWaiters(managed);
}
function emitProcessClosed(managed: ManagedProcess, exitCode: number | null): void {
if (!managed.exited) {
const exitSeq = managed.nextSeq;
managed.nextSeq += 1;
managed.exitCode = exitCode;
managed.exited = true;
if (exitCode !== null) {
managed.emitNotification("process/exited", {
processId: managed.processId,
seq: exitSeq,
exitCode,
});
}
}
if (!managed.closed) {
const closeSeq = managed.nextSeq;
managed.nextSeq += 1;
managed.closed = true;
managed.emitNotification("process/closed", {
processId: managed.processId,
seq: closeSeq,
});
}
void finalizeProcess(managed).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
managed.failure ??= message;
embeddedAgentLog.warn("codex sandbox exec-server finalize failed", {
processId: managed.processId,
error: message,
});
});
managed.evictProcess();
notifyProcessWaiters(managed);
}
async function finalizeProcess(managed: ManagedProcess): Promise<void> {
if (managed.finalized) {
return;
}
managed.finalized = true;
managed.child?.stdin.destroy();
await managed.finalizeExec?.({
status: managed.failure ? "failed" : "completed",
exitCode: managed.exitCode,
timedOut: false,
token: managed.finalizeToken,
});
}
function limitProcessChunks(chunks: ProcessChunk[], maxBytes: number | undefined): ProcessChunk[] {
if (!maxBytes) {
return chunks;
}
const retained: ProcessChunk[] = [];
let retainedBytes = 0;
for (const chunk of chunks) {
const byteLength = Buffer.from(chunk.chunk, "base64").byteLength;
if (retained.length > 0 && retainedBytes + byteLength > maxBytes) {
break;
}
retained.push(chunk);
retainedBytes += byteLength;
if (retainedBytes >= maxBytes) {
break;
}
}
return retained;
}
export async function readProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "process/read params");
const processId = requireString(record.processId, "processId");
const managed = requireProcess(processes, processId);
const afterSeq = typeof record.afterSeq === "number" ? record.afterSeq : 0;
const waitMs = typeof record.waitMs === "number" && record.waitMs > 0 ? record.waitMs : 0;
if (!managed.exited && !hasChunksAtOrAfter(managed, afterSeq) && waitMs > 0) {
await waitForProcessUpdate(managed, waitMs);
}
const chunks = limitProcessChunks(
managed.chunks.filter((chunk) => chunk.seq > afterSeq),
typeof record.maxBytes === "number" && record.maxBytes > 0 ? record.maxBytes : undefined,
);
const lastChunk = chunks.at(-1);
return {
chunks,
nextSeq: lastChunk ? lastChunk.seq + 1 : managed.nextSeq,
exited: managed.exited,
exitCode: managed.exitCode,
closed: managed.closed,
failure: managed.failure,
};
}
export function writeProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "process/write params");
const processId = requireString(record.processId, "processId");
const managed = processes.get(processId);
if (!managed) {
return { status: "unknownProcess" };
}
const chunk = Buffer.from(requireString(record.chunk, "chunk"), "base64");
if ((!managed.tty && !managed.pipeStdin) || managed.closed || !managed.child?.stdin.writable) {
return { status: "stdinClosed" };
}
managed.child.stdin.write(chunk);
return { status: "accepted" };
}
export function terminateProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "process/terminate params");
const processId = requireString(record.processId, "processId");
const managed = processes.get(processId);
if (!managed) {
return { running: false };
}
const running = !managed.exited;
managed.abortController.abort();
managed.child?.kill("SIGTERM");
if (running && !managed.child) {
emitProcessClosed(managed, null);
}
return { running };
}
function waitForProcessUpdate(managed: ManagedProcess, waitMs: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, Math.min(waitMs, 30_000));
function done() {
clearTimeout(timer);
managed.waiters = managed.waiters.filter((waiter) => waiter !== done);
resolve();
}
managed.waiters.push(done);
});
}
function notifyProcessWaiters(managed: ManagedProcess): void {
const waiters = managed.waiters;
managed.waiters = [];
for (const waiter of waiters) {
waiter();
}
}
function hasChunksAtOrAfter(managed: ManagedProcess, afterSeq: number): boolean {
return managed.chunks.some((chunk) => chunk.seq > afterSeq);
}
function shellCommandFromArgv(argv: string[]): string {
return argv.map(shellEscape).join(" ");
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function requireProcess(processes: Map<string, ManagedProcess>, processId: string): ManagedProcess {
const managed = processes.get(processId);
if (!managed) {
throw new Error(`unknown process: ${processId}`);
}
return managed;
}
function rejectUnsupportedArg0(value: unknown): void {
if (value === undefined || value === null) {
return;
}
if (typeof value === "string") {
throw new Error("Codex sandbox exec-server does not support arg0 overrides.");
}
throw new Error("arg0 must be a string or null.");
}
function readEnv(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const env: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value)) {
if (typeof rawValue === "string" && ENV_KEY_RE.test(key)) {
env[key] = rawValue;
}
}
return env;
}
function readProcessEnv(record: JsonObject): Record<string, string> {
const policyEnv = buildEnvFromPolicy(record.envPolicy);
return {
...policyEnv,
...readEnv(record.env),
};
}
function buildEnvFromPolicy(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const policy = value as Record<string, unknown>;
const inheritedEnv = readEnv(policy.set);
const includeOnly = readStringList(policy.includeOnly);
if (includeOnly.length > 0) {
filterEnvKeys(inheritedEnv, includeOnly, true);
}
return inheritedEnv;
}
function filterEnvKeys(
env: Record<string, string>,
patterns: string[],
keepMatches: boolean,
): void {
if (patterns.length === 0) {
return;
}
const regexes = patterns.map((pattern) => wildcardPatternToRegex(pattern));
for (const key of Object.keys(env)) {
const matches = regexes.some((regex) => regex.test(key));
if (matches !== keepMatches) {
delete env[key];
}
}
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
return new RegExp(`^${escaped.replaceAll("*", ".*").replaceAll("?", ".")}$`, "iu");
}
function readStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}

View File

@@ -0,0 +1,22 @@
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { OpenClawExecServer } from "./types.js";
export function requireBackend(
execServer: OpenClawExecServer,
): NonNullable<SandboxContext["backend"]> {
const backend = execServer.sandbox.backend;
if (!backend) {
throw new Error("OpenClaw sandbox backend is unavailable.");
}
return backend;
}
export function requireFsBridge(
execServer: OpenClawExecServer,
): NonNullable<SandboxContext["fsBridge"]> {
const fsBridge = execServer.sandbox.fsBridge;
if (!fsBridge) {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
return fsBridge;
}

View File

@@ -0,0 +1,80 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { WebSocketServer } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
export type JsonRpcRequest = {
id?: string | number;
method?: string;
params?: JsonValue;
};
export type ProcessChunk = {
seq: number;
stream: "stdout" | "stderr" | "pty";
chunk: string;
};
export type DirectoryEntry = {
fileName: string;
isDirectory: boolean;
isFile: boolean;
};
export type FsAccessMode = "read" | "write" | "none";
export type ResolvedFsSandboxEntry =
| {
kind: "path";
path: string;
access: FsAccessMode;
}
| {
kind: "glob";
pattern: string;
matcher: RegExp;
literalPrefix: string;
access: FsAccessMode;
};
export type ResolvedFsSandboxPolicy = {
unrestricted: boolean;
entries: ResolvedFsSandboxEntry[];
};
export type HttpHeader = {
name: string;
value: string;
};
export type ManagedProcess = {
processId: string;
chunks: ProcessChunk[];
retainedOutputBytes: number;
nextSeq: number;
exited: boolean;
exitCode: number | null;
closed: boolean;
failure: string | null;
tty: boolean;
pipeStdin: boolean;
abortController: AbortController;
child: ChildProcessWithoutNullStreams | null;
finalizeToken?: unknown;
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
finalized: boolean;
evictionTimer?: ReturnType<typeof setTimeout>;
waiters: Array<() => void>;
emitNotification: (method: string, params: JsonObject) => void;
evictProcess: () => void;
};
export type OpenClawExecServer = {
environmentId: string;
authPath: string;
refCount: number;
closed: boolean;
url: string;
sandbox: SandboxContext;
server: WebSocketServer;
};

View File

@@ -0,0 +1,153 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
type DirectMethodPolicy =
| "allowed-control-plane"
| "blocked-native-bypass"
| "requires-openclaw-environment";
const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["account/rateLimits/read", "allowed-control-plane"],
["account/read", "allowed-control-plane"],
["app/list", "allowed-control-plane"],
["config/mcpServer/reload", "allowed-control-plane"],
["environment/add", "allowed-control-plane"],
["experimentalFeature/enablement/set", "allowed-control-plane"],
["feedback/upload", "allowed-control-plane"],
["hooks/list", "allowed-control-plane"],
["initialize", "allowed-control-plane"],
["marketplace/add", "allowed-control-plane"],
["mcpServerStatus/list", "allowed-control-plane"],
["model/list", "allowed-control-plane"],
["plugin/install", "allowed-control-plane"],
["plugin/list", "allowed-control-plane"],
["plugin/read", "allowed-control-plane"],
["skills/list", "allowed-control-plane"],
["thread/archive", "allowed-control-plane"],
["thread/inject_items", "allowed-control-plane"],
["thread/list", "allowed-control-plane"],
["thread/metadata/update", "allowed-control-plane"],
["thread/name/update", "allowed-control-plane"],
["thread/read", "allowed-control-plane"],
["thread/rollback", "allowed-control-plane"],
["thread/start", "requires-openclaw-environment"],
["thread/unarchive", "allowed-control-plane"],
["thread/unsubscribe", "allowed-control-plane"],
["turn/interrupt", "allowed-control-plane"],
["turn/steer", "allowed-control-plane"],
["command/exec", "blocked-native-bypass"],
["command/resize", "blocked-native-bypass"],
["command/terminate", "blocked-native-bypass"],
["command/write", "blocked-native-bypass"],
["fuzzyFileSearch", "blocked-native-bypass"],
["mcpServer/resource/read", "blocked-native-bypass"],
["mcpServer/tool/call", "blocked-native-bypass"],
["process/kill", "blocked-native-bypass"],
["process/resizePty", "blocked-native-bypass"],
["process/spawn", "blocked-native-bypass"],
["process/writeStdin", "blocked-native-bypass"],
["review/start", "blocked-native-bypass"],
["thread/compact/start", "blocked-native-bypass"],
["thread/fork", "blocked-native-bypass"],
["thread/resume", "blocked-native-bypass"],
["thread/shellCommand", "blocked-native-bypass"],
["turn/start", "blocked-native-bypass"],
]);
const BLOCKED_DIRECT_METHOD_PREFIXES = ["command/", "fs/", "windowsSandbox/"] as const;
export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
method: string;
requestParams?: unknown;
config?: OpenClawConfig;
sessionKey?: string;
sessionId?: string;
}): string | undefined {
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
if (!sessionKey) {
return undefined;
}
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey,
});
if (!runtime.sandboxed) {
return undefined;
}
const policy = resolveDirectMethodPolicy(params.method);
if (policy === "allowed-control-plane") {
return undefined;
}
if (
policy === "requires-openclaw-environment" &&
hasOpenClawSandboxEnvironmentSelection(params.requestParams)
) {
return undefined;
}
return formatCodexNativeSandboxBlock({
surface: `app-server method \`${params.method}\``,
});
}
export function resolveCodexNativeSandboxBlock(params: {
config?: OpenClawConfig;
sessionKey?: string;
sessionId?: string;
surface: string;
}): string | undefined {
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
if (!sessionKey) {
return undefined;
}
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey,
});
if (!runtime.sandboxed) {
return undefined;
}
return formatCodexNativeSandboxBlock({ surface: params.surface });
}
function resolveDirectMethodPolicy(method: string): DirectMethodPolicy {
const exact = DIRECT_METHOD_POLICIES.get(method);
if (exact) {
return exact;
}
if (BLOCKED_DIRECT_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
return "blocked-native-bypass";
}
return "blocked-native-bypass";
}
function hasOpenClawSandboxEnvironmentSelection(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const environments = (value as { environments?: unknown }).environments;
return (
Array.isArray(environments) &&
environments.length > 0 &&
environments.every((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
const environment = entry as { environmentId?: unknown; cwd?: unknown };
return (
typeof environment.environmentId === "string" &&
environment.environmentId.startsWith("openclaw-sandbox-") &&
typeof environment.cwd === "string" &&
environment.cwd.trim().length > 0
);
})
);
}
function formatCodexNativeSandboxBlock(params: { surface: string }): string {
return [
`Codex-native ${params.surface} is unavailable because OpenClaw sandboxing is active for this session.`,
"This mode cannot route execution through the OpenClaw sandbox backend.",
"Use a normal Codex harness turn, or run an intentionally unsandboxed session.",
].join(" ");
}

View File

@@ -46,6 +46,7 @@ export type CodexAppServerThreadBinding = {
pluginAppsInputFingerprint?: string;
pluginAppPolicyContext?: PluginAppPolicyContext;
contextEngine?: CodexAppServerContextEngineBinding;
environmentSelectionFingerprint?: string;
createdAt: string;
updatedAt: string;
};
@@ -123,6 +124,10 @@ export async function readCodexAppServerBinding(
: undefined,
pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext),
contextEngine: readContextEngineBinding(parsed.contextEngine),
environmentSelectionFingerprint:
typeof parsed.environmentSelectionFingerprint === "string"
? parsed.environmentSelectionFingerprint
: undefined,
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
@@ -165,6 +170,7 @@ export async function writeCodexAppServerBinding(
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: binding.contextEngine,
environmentSelectionFingerprint: binding.environmentSelectionFingerprint,
createdAt: binding.createdAt ?? now,
updatedAt: now,
};
@@ -274,7 +280,10 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un
};
}
export async function clearCodexAppServerBinding(sessionFile: string): Promise<void> {
export async function clearCodexAppServerBinding(
sessionFile: string,
_lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
): Promise<void> {
try {
await fs.unlink(resolveCodexAppServerBindingPath(sessionFile));
} catch (error) {

View File

@@ -10,7 +10,7 @@ import {
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexServerNotification, RpcRequest } from "./protocol.js";
import type { CodexServerNotification, JsonObject, RpcRequest } from "./protocol.js";
const readCodexAppServerBindingMock = vi.fn();
const isCodexAppServerNativeAuthProfileMock = vi.fn();
@@ -277,6 +277,16 @@ function turnCompleted(threadId: string, turnId: string, text: string): CodexSer
};
}
function turnCompletedWithNestedThread(
threadId: string,
turnId: string,
text: string,
): CodexServerNotification {
const notification = turnCompleted(threadId, turnId, text);
const turn = (notification.params as JsonObject).turn;
return { method: notification.method, params: { threadId: "parent-thread", turn } };
}
function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQuestion>[0]> = {}) {
return {
cfg: {} as never,
@@ -458,6 +468,48 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
});
it("returns side-thread completions scoped by nested turn thread id", async () => {
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() =>
client.emit(turnCompletedWithNestedThread("side-thread", "turn-1", "Nested answer.")),
);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(sideParams());
expect(result).toEqual({ text: "Nested answer." });
});
it("rejects /btw before forking when the current OpenClaw session is sandboxed", async () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { agents: { defaults: { sandbox: { mode: "all" } } } } as never,
sessionKey: "sandboxed-session",
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("installs native hook relay config for opted-in side threads", async () => {
const client = createFakeClient();
let relayIdDuringFork: string | undefined;

View File

@@ -36,12 +36,16 @@ import {
buildCodexNativeHookRelayDisabledConfig,
CODEX_NATIVE_HOOK_RELAY_EVENTS,
} from "./native-hook-relay.js";
import {
readCodexNotificationThreadId,
readCodexNotificationTurnId,
} from "./notification-correlation.js";
import { mergeCodexThreadConfigs } from "./plugin-thread-config.js";
import {
assertCodexThreadForkResponse,
assertCodexTurnStartResponse,
readCodexDynamicToolCallParams,
readCodexTurnCompletedNotification,
readCodexTurn,
} from "./protocol-validators.js";
import {
isJsonObject,
@@ -55,6 +59,7 @@ import {
} from "./protocol.js";
import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { resolveCodexNativeSandboxBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { getSharedCodexAppServerClient } from "./shared-client.js";
import {
@@ -121,6 +126,15 @@ export async function runCodexAppServerSideQuestion(
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
);
}
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: params.cfg,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
surface: "/btw side-question mode",
});
if (sandboxBlock) {
throw new Error(sandboxBlock);
}
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
@@ -913,8 +927,7 @@ class CodexSideQuestionCollector {
}
private completeFromTurn(params: JsonObject): void {
const notification = readCodexTurnCompletedNotification(params);
const turn = notification?.turn;
const turn = readCodexTurn(params.turn);
if (!turn || turn.id !== this.turnId) {
return;
}
@@ -963,16 +976,13 @@ function collectAssistantText(turn: CodexTurn): string {
}
function isNotificationForTurn(params: JsonObject, threadId: string, turnId: string): boolean {
return readString(params, "threadId") === threadId && readNotificationTurnId(params) === turnId;
return (
readCodexNotificationThreadId(params) === threadId && readNotificationTurnId(params) === turnId
);
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readString(record, "turnId") ?? readNestedTurnId(record);
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
return readCodexNotificationTurnId(record);
}
function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined {

View File

@@ -29,6 +29,7 @@ import {
type CodexSandboxPolicy,
type CodexThreadResumeParams,
type CodexThreadStartParams,
type CodexTurnEnvironmentParams,
type CodexTurnStartParams,
type JsonObject,
type CodexUserInput,
@@ -96,6 +97,7 @@ export async function startOrResumeThread(params: {
userMcpServersEnabled?: boolean;
mcpServersFingerprint?: string;
mcpServersFingerprintEvaluated?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
}): Promise<CodexAppServerThreadLifecycleBinding> {
@@ -111,6 +113,9 @@ export async function startOrResumeThread(params: {
agentId: params.agentId ?? params.params.agentId,
});
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
@@ -160,6 +165,19 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (
binding?.threadId &&
binding.environmentSelectionFingerprint !== environmentSelectionFingerprint
) {
embeddedAgentLog.debug(
"codex app-server environment selection changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
@@ -296,6 +314,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
@@ -329,6 +348,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
lifecycle: { action: "resumed" },
};
} catch (error) {
@@ -363,6 +383,7 @@ export async function startOrResumeThread(params: {
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
),
);
@@ -392,6 +413,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
@@ -427,6 +449,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
updatedAt: createdAt,
lifecycle: {
@@ -563,6 +586,7 @@ export function buildThreadStartParams(
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
nativeCodeModeOnlyEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
},
): CodexThreadStartParams {
const modelProvider = resolveCodexAppServerModelProvider({
@@ -585,7 +609,7 @@ export function buildThreadStartParams(
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
}),
...(options.nativeCodeModeEnabled === false ? { environments: [] } : {}),
...resolveCodexThreadEnvironmentSelection(options),
developerInstructions:
options.developerInstructions ??
buildDeveloperInstructions(params, { dynamicTools: options.dynamicTools }),
@@ -691,6 +715,7 @@ export function buildTurnStartParams(
appServer: CodexAppServerRuntimeOptions;
promptText?: string;
sandboxPolicy?: CodexSandboxPolicy;
environmentSelection?: CodexTurnEnvironmentParams[];
heartbeatCollaborationInstructions?: string;
},
): CodexTurnStartParams {
@@ -705,12 +730,26 @@ export function buildTurnStartParams(
model: params.modelId,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
effort: resolveReasoningEffort(params.thinkLevel, params.modelId),
...(options.environmentSelection ? { environments: options.environmentSelection } : {}),
collaborationMode: buildTurnCollaborationMode(params, {
heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions,
}),
};
}
function resolveCodexThreadEnvironmentSelection(options: {
nativeCodeModeEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
}): Pick<CodexThreadStartParams, "environments"> {
if (options.nativeCodeModeEnabled === false) {
return { environments: [] };
}
if (options.environmentSelection) {
return { environments: options.environmentSelection };
}
return {};
}
type CodexTurnCollaborationMode = NonNullable<CodexTurnStartParams["collaborationMode"]>;
export function buildTurnCollaborationMode(
@@ -787,6 +826,12 @@ function fingerprintUserMcpServersConfigPatch(
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
}
function fingerprintEnvironmentSelection(
environments: CodexTurnEnvironmentParams[] | undefined,
): string | undefined {
return environments ? JSON.stringify(environments.map(stabilizeJsonValue)) : undefined;
}
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
if (!isJsonObject(tool)) {
return stabilizeJsonValue(tool);

View File

@@ -1,4 +1,5 @@
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.132.0";

View File

@@ -11,6 +11,7 @@ import { isCodexFastServiceTier, type CodexComputerUseConfig } from "./app-serve
import { listAllCodexAppServerModels } from "./app-server/models.js";
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js";
import { resolveCodexNativeSandboxBlock } from "./app-server/sandbox-guard.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
@@ -210,6 +211,16 @@ const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE = 100;
const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES = 100;
const CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS = 128;
const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
const CODEX_NATIVE_EXECUTION_SUBCOMMANDS = new Set([
"bind",
"resume",
"steer",
"model",
"fast",
"permissions",
"compact",
"review",
]);
const lastCodexDiagnosticsUploadByThread = new Map<string, number>();
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
@@ -233,6 +244,10 @@ export async function handleCodexSubcommand(
if (normalized === "help") {
return { text: buildHelp() };
}
const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest);
if (sandboxBlock) {
return { text: sandboxBlock };
}
if (normalized === "plugins") {
if (!deps.codexPluginsManagementIo) {
return {
@@ -401,6 +416,62 @@ export async function handleCodexSubcommand(
return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` };
}
function resolveCodexNativeCommandSandboxBlock(
ctx: PluginCommandContext,
subcommand: string,
args: readonly string[],
): string | undefined {
if (!CODEX_NATIVE_EXECUTION_SUBCOMMANDS.has(subcommand)) {
return undefined;
}
if (returnsBeforeNativeCodexExecution(subcommand, args)) {
return undefined;
}
return resolveCodexNativeSandboxBlock({
config: ctx.config,
sessionKey: ctx.sessionKey,
sessionId: ctx.sessionId,
surface: `/${["codex", subcommand].join(" ")}`,
});
}
function returnsBeforeNativeCodexExecution(subcommand: string, args: readonly string[]): boolean {
switch (subcommand) {
case "bind":
return parseBindArgs([...args]).help === true;
case "resume":
return returnsBeforeNativeCodexResume(args);
case "steer":
return args.join(" ").trim() === "";
case "model":
return args.length === 0 || args.length > 1;
case "fast":
return args.length === 0 || args.length > 1 || parseCodexFastModeArg(args[0]) === undefined;
case "permissions":
return (
args.length === 0 || args.length > 1 || parseCodexPermissionsModeArg(args[0]) === undefined
);
case "compact":
case "review":
case "stop":
return args.length > 0;
default:
return false;
}
}
function returnsBeforeNativeCodexResume(args: readonly string[]): boolean {
const parsed = parseResumeArgs([...args]);
const normalizedThreadId = parsed.threadId?.trim();
if (parsed.help) {
return true;
}
if (parsed.host) {
return !normalizedThreadId || parsed.bindHere !== true;
}
return !normalizedThreadId || args.length !== 1;
}
async function handleComputerUseCommand(
deps: CodexCommandDeps,
pluginConfig: unknown,

View File

@@ -24,6 +24,8 @@ export type CodexControlRequestOptions = {
config?: AuthProfileOrderConfig;
authProfileId?: string;
agentDir?: string;
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
};
@@ -68,6 +70,8 @@ export async function codexControlRequest(
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
config: options.config,
sessionKey: options.sessionKey,
sessionId: options.sessionId,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
isolated: options.isolated,

View File

@@ -51,6 +51,18 @@ function createContext(
};
}
function createSandboxedContext(
args: string,
sessionFile?: string,
overrides: Partial<PluginCommandContext> = {},
): PluginCommandContext {
return createContext(args, sessionFile, {
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
...overrides,
} as Partial<PluginCommandContext>);
}
function createDeps(overrides: Partial<CodexCommandDeps> = {}): Partial<CodexCommandDeps> {
return {
codexControlRequest: vi.fn(),
@@ -331,6 +343,147 @@ describe("codex command", () => {
expect(writeCodexAppServerBinding).not.toHaveBeenCalled();
});
it.each([
"bind",
"resume thread-123",
"steer keep going",
"steer keep going --help",
"model --help",
"model gpt-5.5",
"fast on",
"permissions yolo",
"compact",
"review",
])("blocks /codex %s in sandboxed sessions before native Codex execution", async (args) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const codexControlRequest = vi.fn();
const startCodexConversationThread = vi.fn();
const steerCodexConversationTurn = vi.fn();
const setCodexConversationModel = vi.fn();
const setCodexConversationFastMode = vi.fn();
const setCodexConversationPermissions = vi.fn();
const stopCodexConversationTurn = vi.fn();
const result = await handleCodexCommand(createSandboxedContext(args, sessionFile), {
deps: createDeps({
codexControlRequest,
startCodexConversationThread,
steerCodexConversationTurn,
setCodexConversationModel,
setCodexConversationFastMode,
setCodexConversationPermissions,
stopCodexConversationTurn,
}),
});
expect(result.text).toContain(
"Codex-native /codex " +
args.split(/\s+/u)[0] +
" is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(codexControlRequest).not.toHaveBeenCalled();
expect(startCodexConversationThread).not.toHaveBeenCalled();
expect(steerCodexConversationTurn).not.toHaveBeenCalled();
expect(setCodexConversationModel).not.toHaveBeenCalled();
expect(setCodexConversationFastMode).not.toHaveBeenCalled();
expect(setCodexConversationPermissions).not.toHaveBeenCalled();
expect(stopCodexConversationTurn).not.toHaveBeenCalled();
});
it("still returns pre-native usage for malformed sandboxed native Codex commands", async () => {
const startCodexConversationThread = vi.fn();
const setCodexConversationModel = vi.fn();
await expect(
handleCodexCommand(createSandboxedContext("bind --help"), {
deps: createDeps({ startCodexConversationThread }),
}),
).resolves.toEqual({
text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
});
expect(startCodexConversationThread).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("model gpt-5.5 --help"), {
deps: createDeps({ setCodexConversationModel }),
}),
).resolves.toEqual({
text: "Usage: /codex model <model>",
});
expect(setCodexConversationModel).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("resume"), { deps: createDeps() }),
).resolves.toEqual({
text: "Usage: /codex resume <thread-id>",
});
const resolveCodexCliSessionForBindingOnNode = vi.fn();
await expect(
handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1"), {
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
}),
).resolves.toEqual({
text: "Usage: /codex resume <session-id> --host <node> --bind here",
});
expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1 --bind here extra"), {
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
}),
).resolves.toEqual({
text: "Usage: /codex resume <thread-id>\nUsage: /codex resume <session-id> --host <node> --bind here",
});
expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("steer", path.join(tempDir, "session.jsonl")), {
deps: createDeps(),
}),
).resolves.toEqual({
text: "Usage: /codex steer <message>",
});
await expect(
handleCodexCommand(createSandboxedContext("stop now"), {
deps: createDeps({ stopCodexConversationTurn: vi.fn() }),
}),
).resolves.toEqual({
text: "Usage: /codex stop",
});
});
it("allows local Codex binding status forms in sandboxed sessions", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-status",
cwd: tempDir,
model: "gpt-5.5",
approvalPolicy: "never",
sandbox: "danger-full-access",
serviceTier: "priority",
}),
);
await expect(
handleCodexCommand(createSandboxedContext("model", sessionFile), { deps: createDeps() }),
).resolves.toEqual({ text: "Codex model: gpt-5.5" });
await expect(
handleCodexCommand(createSandboxedContext("fast status", sessionFile), {
deps: createDeps(),
}),
).resolves.toEqual({ text: "Codex fast mode: on." });
await expect(
handleCodexCommand(createSandboxedContext("permissions status", sessionFile), {
deps: createDeps(),
}),
).resolves.toEqual({ text: "Codex permissions: full access." });
});
it("lists Codex CLI sessions from a requested node", async () => {
const listCodexCliSessionsOnNode = vi.fn(async () => ({
node: { nodeId: "mb-m5", displayName: "mb-m5" },
@@ -3248,6 +3401,26 @@ describe("codex command", () => {
});
});
it("stops the active bound Codex turn in sandboxed sessions", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const stopCodexConversationTurn = vi.fn(async () => ({
stopped: true,
message: "Codex stop requested.",
}));
await expect(
handleCodexCommand(createSandboxedContext("stop", sessionFile), {
deps: createDeps({ stopCodexConversationTurn }),
}),
).resolves.toEqual({ text: "Codex stop requested." });
expect(stopCodexConversationTurn).toHaveBeenCalledWith({
sessionFile,
pluginConfig: undefined,
agentDir: path.join(tempDir, "agents", "main", "agent"),
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
});
it("rejects malformed stop commands before interrupting Codex", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const stopCodexConversationTurn = vi.fn();

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