mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
140 Commits
codex/tele
...
codex-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9850eb63fc | ||
|
|
63825369a2 | ||
|
|
f2522a535d | ||
|
|
20964d3e3b | ||
|
|
75141775db | ||
|
|
999d44340f | ||
|
|
ca1a53aca4 | ||
|
|
46c12b6c54 | ||
|
|
bbfea21a18 | ||
|
|
1e0062b44a | ||
|
|
23589d9e7c | ||
|
|
15166e81ca | ||
|
|
4c9e7f6c61 | ||
|
|
840cfd69cd | ||
|
|
b037280ea9 | ||
|
|
6aff1e8f9e | ||
|
|
e06f5f2edc | ||
|
|
d7cebdc215 | ||
|
|
53da30dd98 | ||
|
|
e46bcb834f | ||
|
|
d2439d2f7d | ||
|
|
52280351bb | ||
|
|
e1d3f12d7f | ||
|
|
ce6fd93279 | ||
|
|
1884cedd35 | ||
|
|
610c76087b | ||
|
|
a664c44375 | ||
|
|
add00d747b | ||
|
|
bb164384c2 | ||
|
|
4a0e376d1f | ||
|
|
2196ea2930 | ||
|
|
6aa83374d9 | ||
|
|
59950f7b52 | ||
|
|
ccf83ace38 | ||
|
|
2b752ac0d1 | ||
|
|
01d3505d7c | ||
|
|
37636ac8e2 | ||
|
|
5a9396ef6d | ||
|
|
e934e1cad7 | ||
|
|
5afddf547e | ||
|
|
9a0aefb73f | ||
|
|
325d0208d0 | ||
|
|
983e0f2ba0 | ||
|
|
37c1dacac9 | ||
|
|
ca5c3e677a | ||
|
|
2fec8b12d5 | ||
|
|
a89c9937c2 | ||
|
|
9bdf89598e | ||
|
|
df17e01cac | ||
|
|
9d1dec4678 | ||
|
|
350f06362b | ||
|
|
a2bc7ab269 | ||
|
|
7ac2bbaaf0 | ||
|
|
96404a7bd5 | ||
|
|
484ee14273 | ||
|
|
88c9e4d644 | ||
|
|
99a398a4b1 | ||
|
|
ef3e5f5e31 | ||
|
|
6f53f84af3 | ||
|
|
d6eefa191f | ||
|
|
481652d78a | ||
|
|
9a86a2b30b | ||
|
|
300794520b | ||
|
|
f9376b16d4 | ||
|
|
ee81082f57 | ||
|
|
395a082348 | ||
|
|
8c108c294d | ||
|
|
48d96cd8a1 | ||
|
|
d1f6ca20a1 | ||
|
|
c9418b8afd | ||
|
|
0c657190ec | ||
|
|
6ffa0fb348 | ||
|
|
0fb0c2cb8e | ||
|
|
0e71ce1174 | ||
|
|
ffa736f713 | ||
|
|
b85ae9fb1b | ||
|
|
67c80e941e | ||
|
|
e850750754 | ||
|
|
7c6ad2327c | ||
|
|
d88f1bf217 | ||
|
|
6da2d6ac5a | ||
|
|
2b05bd7b0d | ||
|
|
eea350f2ff | ||
|
|
c8c94e15ad | ||
|
|
d7a09b13e6 | ||
|
|
88e4a0f0d5 | ||
|
|
db194a6887 | ||
|
|
00160ea6ee | ||
|
|
ffb67d2d2e | ||
|
|
d89ab2c014 | ||
|
|
11a0ad10e9 | ||
|
|
9b6bed7a75 | ||
|
|
f87d194b8b | ||
|
|
386b0e6c74 | ||
|
|
ee495abda1 | ||
|
|
147e979713 | ||
|
|
1ee788189a | ||
|
|
e71cf0ffcb | ||
|
|
3c65127827 | ||
|
|
a4e7d9a0db | ||
|
|
ac8a3f367c | ||
|
|
8694fe7e81 | ||
|
|
073343e2e2 | ||
|
|
aa0d710085 | ||
|
|
c70b9849d9 | ||
|
|
919c5b7c7b | ||
|
|
5296dc378f | ||
|
|
a447f9a43d | ||
|
|
04b7e192af | ||
|
|
450060d7a2 | ||
|
|
6bc57ca73a | ||
|
|
ea346f4361 | ||
|
|
d5c9e7ea99 | ||
|
|
9eed9c5758 | ||
|
|
1c2363def6 | ||
|
|
b7d53800d6 | ||
|
|
6326395c0a | ||
|
|
568f2d5631 | ||
|
|
e94b666e45 | ||
|
|
ee3b7eb7c0 | ||
|
|
2365a137d8 | ||
|
|
dc09d148bb | ||
|
|
55263b3dfa | ||
|
|
01acb34bdb | ||
|
|
03e3ef86af | ||
|
|
eac3e08cfd | ||
|
|
a375d6c849 | ||
|
|
9dbf8f718f | ||
|
|
fd806ada64 | ||
|
|
4ca8bf086c | ||
|
|
b41c0b6746 | ||
|
|
52d9d16e1b | ||
|
|
0ef8620746 | ||
|
|
74c6f175c7 | ||
|
|
0d50ec77de | ||
|
|
eccfacb02c | ||
|
|
f08b24e63c | ||
|
|
c32ba171db | ||
|
|
e64379dddb | ||
|
|
127e174c9e |
@@ -24,7 +24,7 @@ Use when:
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
|
||||
@@ -43,6 +43,42 @@ Use when:
|
||||
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Scope Governor
|
||||
|
||||
Autoreview is a closeout gate, not permission to rewrite the task.
|
||||
|
||||
Before the first review, freeze a scope baseline: original request or issue, target branch, intended behavior, owner boundary, changed files, and non-test LOC. For inherited or already-bloated branches, use the intended PR diff as the baseline rather than accepting all existing branch drift.
|
||||
|
||||
Before patching a finding, classify it:
|
||||
|
||||
- **In-scope blocker**: the finding is introduced by the current diff, affects the same owner boundary, and can be fixed without changing the task's contract.
|
||||
- **Follow-up**: the finding is real but belongs to an adjacent bug class, sibling surface, cleanup, or broader hardening track.
|
||||
- **Stop-and-escalate**: the finding requires a new protocol/config/storage/public API contract, a different owner boundary, a release-process change, or a design choice outside the original request.
|
||||
|
||||
Stop patching and report the scope break instead of continuing when:
|
||||
|
||||
- a narrow PR turns into an architecture change, protocol change, migration, or release-process change;
|
||||
- the diff grows past 2x the original files or non-test LOC without explicit approval to expand scope;
|
||||
- two review-triggered patch cycles have not converged; pause and reclassify every remaining finding before another edit;
|
||||
- the best fix is "define the canonical contract first" rather than another local inference layer;
|
||||
- fixing the accepted finding would make the PR no longer describe the same behavior, issue, or owner boundary.
|
||||
|
||||
After the two-cycle pause, continue only when every remaining accepted finding is still an in-scope blocker. Otherwise preserve the useful analysis, identify the smallest safe landed subset if one exists, and open or request a follow-up for the larger fix. Do not keep committing speculative fixes just to satisfy the reviewer.
|
||||
|
||||
Do not stack or push review-triggered fix commits while scope classification or focused proof is unresolved. Keep exploratory edits local until the cycle is proven in scope; if scope breaks, remove them from the landing lane instead of preserving them as branch history.
|
||||
|
||||
Critical exceptions must be explicit: active data loss, crash, broken install/upgrade, release blocker, or concrete security exposure. If the exception is not one of those, it is not critical enough to blow up scope.
|
||||
|
||||
## Release Branches And Release Process
|
||||
|
||||
On release, beta, stable, hotfix, signing, notarization, appcast, package-publish, or release-check work, use freeze discipline even when the branch name is not release-like:
|
||||
|
||||
- Fix only release blockers, failed release infrastructure, exact backports, install/upgrade breakage, data loss, crashes, or concrete security exposure.
|
||||
- Treat non-blocking autoreview findings as follow-ups for `main`, not reasons to broaden the release branch.
|
||||
- Do not introduce new product behavior, config surface, protocol shape, migration, plugin ownership, docs narrative, or process policy unless it directly unblocks the release.
|
||||
- Keep proof tied to the release target: exact branch/ref, failing check or shipped-risk reason, smallest command/proof, and whether the fix must also forward-port to `main`.
|
||||
- If review discovers a real but non-critical design problem during release closeout, stop with a follow-up issue/PR plan; do not use the release branch as the refactor lane.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
@@ -440,8 +440,36 @@ def load_datasets(args: argparse.Namespace) -> str:
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
def review_scope_policy() -> str:
|
||||
return textwrap.dedent(
|
||||
"""
|
||||
Review scope discipline:
|
||||
- This helper is a closeout gate. Do not turn a narrow patch into a broad
|
||||
redesign request.
|
||||
- Report a finding only when this diff introduces or exposes a concrete
|
||||
defect that must be fixed before this target can land.
|
||||
- If the best fix requires a new protocol, config, storage, public API,
|
||||
release process, migration, owner-boundary move, or canonical contract,
|
||||
say that directly in the finding and keep the finding tied to the
|
||||
smallest changed line that proves the current patch is not landable.
|
||||
- Do not ask for sibling-surface hardening, cleanup, refactors, or
|
||||
follow-up architecture work unless the current diff is incorrect
|
||||
without that work.
|
||||
- Prefer the smallest correct pre-merge fix. A broader ideal design is
|
||||
not an actionable finding unless the current patch cannot safely land.
|
||||
- If this is release-branch or release-process work, apply freeze
|
||||
discipline. Report only release blockers, exact backport regressions,
|
||||
install/upgrade breakage, crashes, data loss, concrete security
|
||||
exposure, or release-infrastructure failures. Non-blocking design,
|
||||
cleanup, and hardening concerns belong on main as follow-ups.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str:
|
||||
target_line = f"{target} {target_ref}" if target_ref else target
|
||||
branch = current_branch(repo)
|
||||
scope_policy = review_scope_policy()
|
||||
return textwrap.dedent(
|
||||
f"""
|
||||
You are a senior code reviewer. Review the provided git change bundle only.
|
||||
@@ -463,8 +491,11 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
|
||||
- If there are no actionable findings, return an empty findings array and mark the patch correct.
|
||||
|
||||
Review target: {target_line}
|
||||
Current branch: {branch}
|
||||
Repository: {repo}
|
||||
|
||||
{scope_policy}
|
||||
|
||||
{extra_prompt}
|
||||
|
||||
{datasets}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -145,8 +146,23 @@ def create_fixture_repo(repo: Path, fixture: str) -> None:
|
||||
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
|
||||
|
||||
|
||||
def validate_prompt_policy(repo: Path, autoreview: Path) -> None:
|
||||
namespace = runpy.run_path(str(autoreview))
|
||||
prompt = namespace["build_prompt"](repo, "local", None, "fixture diff", "", "")
|
||||
required = (
|
||||
"This helper is a closeout gate.",
|
||||
"Do not turn a narrow patch into a broad",
|
||||
"If this is release-branch or release-process work",
|
||||
"Non-blocking design,",
|
||||
)
|
||||
missing = [needle for needle in required if needle not in prompt]
|
||||
if missing:
|
||||
raise RuntimeError(f"autoreview prompt missing scope policy: {missing}")
|
||||
|
||||
|
||||
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
|
||||
autoreview = script_dir / "autoreview"
|
||||
validate_prompt_policy(repo, autoreview)
|
||||
for engine in engines:
|
||||
print(f"== {engine} ==", flush=True)
|
||||
command = [
|
||||
|
||||
@@ -284,7 +284,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- When an agent is landing or merging a PR targeting `main`, use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
|
||||
@@ -65,6 +65,13 @@ gh workflow run openclaw-performance.yml \
|
||||
|
||||
Prefer the trusted workflow on `main`, target the exact release SHA:
|
||||
|
||||
- Keep trusted-workflow checks compatible with frozen release targets. If
|
||||
`main` adds a target-owned guard script or package command after the release
|
||||
branch cut, make the trusted workflow skip only when that target surface is
|
||||
absent. Heal the trusted workflow before rerunning validation; do not port an
|
||||
unrelated runtime refactor or mutate the release candidate just to satisfy a
|
||||
newer `main`-only check.
|
||||
|
||||
```bash
|
||||
gh workflow run full-release-validation.yml \
|
||||
--repo openclaw/openclaw \
|
||||
|
||||
@@ -552,6 +552,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- `preflight_only=true` on the npm workflow is also the right way to validate an
|
||||
existing tag after publish; it should keep running the build checks even when
|
||||
the npm version is already published.
|
||||
- npm registry metadata is eventually consistent immediately after trusted
|
||||
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
|
||||
retries, and carry that verified tarball/integrity metadata into later proof
|
||||
steps instead of reading the registry again. If the OpenClaw npm child
|
||||
succeeded but the parent publish workflow failed on an immediate exact-version
|
||||
`E404`, verify the exact version with a cache-bypassed registry read, run the
|
||||
standalone postpublish verifier and the full beta verifier with the original
|
||||
successful child run IDs, then finalize the draft, dependency evidence asset,
|
||||
and release proof manually. Never rerun the publish workflow for that
|
||||
already-published version.
|
||||
- npm validation-only preflight may still be dispatched from ordinary branches
|
||||
when testing workflow changes before merge. Release checks and real publish
|
||||
use only `main` or `release/YYYY.M.PATCH`.
|
||||
@@ -720,8 +730,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
|
||||
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
|
||||
and exits red; do not undraft until the gap is repaired. The standalone
|
||||
verifier command remains the recovery probe:
|
||||
verifier command remains the first recovery probe:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
For a failed postpublish parent after successful publish children, also run
|
||||
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
|
||||
with the original child run IDs and an evidence output path before manually
|
||||
recreating the workflow's draft, dependency evidence asset, proof section,
|
||||
and publish step.
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
important low-risk fixes before starting expensive lanes, or increment to
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -1523,7 +1523,13 @@ jobs:
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
|
||||
echo "[skip] session transcript reader boundary check is not present in this checkout"
|
||||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
|
||||
echo "[skip] session transcript reader boundary script is not present in package.json"
|
||||
else
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
fi
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
|
||||
107
.github/workflows/full-release-validation.yml
vendored
107
.github/workflows/full-release-validation.yml
vendored
@@ -275,7 +275,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local dispatch_output run_id status conclusion url poll_count
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -298,8 +298,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -309,20 +307,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -423,7 +408,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local dispatch_output run_id status conclusion url poll_count
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -446,8 +431,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -457,20 +440,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -581,7 +551,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count run_json
|
||||
local dispatch_output run_id status conclusion url poll_count run_json
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -604,8 +574,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -615,20 +583,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -928,8 +883,6 @@ jobs:
|
||||
return "$status"
|
||||
}
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
|
||||
if [[ -z "${PACKAGE_SPEC// }" ]]; then
|
||||
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
|
||||
@@ -946,22 +899,16 @@ jobs:
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
|
||||
echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1073,31 +1020,23 @@ jobs:
|
||||
echo "- Release impact: advisory"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
gh_with_retry workflow run openclaw-performance.yml \
|
||||
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
|
||||
--ref "$CHILD_WORKFLOW_REF" \
|
||||
-f target_ref="$TARGET_SHA" \
|
||||
-f profile=release \
|
||||
-f repeat=3 \
|
||||
-f deep_profile=false \
|
||||
-f live_openai_candidate=false \
|
||||
-f fail_on_regression=false
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
-f fail_on_regression=false)"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
|
||||
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -1112,13 +1112,14 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
|
||||
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
|
||||
14
.github/workflows/windows-testbox-probe.yml
vendored
14
.github/workflows/windows-testbox-probe.yml
vendored
@@ -133,8 +133,9 @@ jobs:
|
||||
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
|
||||
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
|
||||
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
|
||||
wsl.exe --import UbuntuProbe $wslRoot $rootfs --version 2
|
||||
Write-Host "wsl_import_exit=$LASTEXITCODE"
|
||||
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
|
||||
Write-Host $import.Text
|
||||
Write-Host "wsl_import_exit=$($import.Code)"
|
||||
$list = Invoke-WslText -Arguments @("--list", "--verbose")
|
||||
Write-Host $list.Text
|
||||
Write-Host "wsl_list_after_import_exit=$($list.Code)"
|
||||
@@ -144,14 +145,15 @@ jobs:
|
||||
if ($distros.Count -gt 0) {
|
||||
$distro = $distros[0]
|
||||
Write-Host "wsl_probe_distro=$distro"
|
||||
wsl.exe -d $distro --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
|
||||
$exec = Invoke-WslText -Arguments @("-d", $distro, "--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
|
||||
} else {
|
||||
wsl.exe --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
|
||||
$exec = Invoke-WslText -Arguments @("--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
|
||||
}
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host $exec.Text
|
||||
if ($exec.Code -eq 0) {
|
||||
$ok = $true
|
||||
}
|
||||
Write-Host "wsl_exec_exit=$LASTEXITCODE"
|
||||
Write-Host "wsl_exec_exit=$($exec.Code)"
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
|
||||
3
.github/workflows/workflow-sanity.yml
vendored
3
.github/workflows/workflow-sanity.yml
vendored
@@ -251,3 +251,6 @@ jobs:
|
||||
|
||||
- name: Check plugin SDK API baseline drift
|
||||
run: pnpm plugin-sdk:api:check
|
||||
|
||||
- name: Check plugin SDK surface budget
|
||||
run: pnpm plugin-sdk:surface:check
|
||||
|
||||
@@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
|
||||
@@ -23,15 +23,23 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow.
|
||||
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
|
||||
- Auto-reply/groups: keep ordinary group text replies on automatic final-reply delivery while allowing `message(action=send)` for files, images, and other attachments to the same group or topic. Carries forward #43276; refs #48004. Thanks @NayukiChiba and @ShakaRover.
|
||||
- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
|
||||
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
|
||||
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
|
||||
- Agents/exec: default empty-success background completion notices on only for real chat channels, preserving explicit opt-outs and keeping generic providers silent while carrying forward the narrow UX intent from #39726 and #46926. Thanks @Sapientropic and @wenkang-xie.
|
||||
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
|
||||
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
|
||||
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
|
||||
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
|
||||
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
|
||||
- Gateway CLI: tolerate a single transient clean WebSocket close before `hello-ok` so one-shot RPC calls reconnect instead of failing noisily, while repeated clean pre-hello closes still surface. Carries forward source PRs #54475 and #54774; #85253 covered adjacent connect assembly diagnostics. Thanks @ruanrrn.
|
||||
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
|
||||
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
|
||||
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
|
||||
- Cron: preserve model, fallback, thinking, timeout, light-context, unsafe-content, and tool allow-list overrides on implicit text payloads by promoting them to agent turns, while explicit system events still prune those fields. Fixes #28905; carries forward #64060 and #73946. Thanks @liaoandi.
|
||||
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
@@ -306,6 +306,15 @@
|
||||
"fps",
|
||||
"screenIndex"
|
||||
]
|
||||
},
|
||||
"screen_snapshot": {
|
||||
"label": "screen snapshot",
|
||||
"detailKeys": [
|
||||
"node",
|
||||
"nodeId",
|
||||
"screenIndex",
|
||||
"maxWidth"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6156,6 +6156,7 @@ public struct CronListParams: Codable, Sendable {
|
||||
public let sortby: AnyCodable?
|
||||
public let sortdir: AnyCodable?
|
||||
public let agentid: String?
|
||||
public let compact: Bool?
|
||||
|
||||
public init(
|
||||
includedisabled: Bool?,
|
||||
@@ -6167,7 +6168,8 @@ public struct CronListParams: Codable, Sendable {
|
||||
lastrunstatus: AnyCodable?,
|
||||
sortby: AnyCodable?,
|
||||
sortdir: AnyCodable?,
|
||||
agentid: String? = nil)
|
||||
agentid: String? = nil,
|
||||
compact: Bool? = nil)
|
||||
{
|
||||
self.includedisabled = includedisabled
|
||||
self.limit = limit
|
||||
@@ -6179,6 +6181,7 @@ public struct CronListParams: Codable, Sendable {
|
||||
self.sortby = sortby
|
||||
self.sortdir = sortdir
|
||||
self.agentid = agentid
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -6192,6 +6195,7 @@ public struct CronListParams: Codable, Sendable {
|
||||
case sortby = "sortBy"
|
||||
case sortdir = "sortDir"
|
||||
case agentid = "agentId"
|
||||
case compact
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
|
||||
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
|
||||
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
|
||||
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
|
||||
64c09563ce090b8dda1e9794f021af3068db0ff4a59cf471e5ad094d2ae978b8 config-baseline.json
|
||||
bb9f42e7f1d6713af46d693dba5c43efd603c222d7d0adbd0f0d0e95cdec6790 config-baseline.core.json
|
||||
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
|
||||
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
|
||||
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
|
||||
ea92ef67bc01141e1f3b64edd025471c9c3439da50de3cecb208e7eca797f947 plugin-sdk-api-baseline.json
|
||||
28ed6df6ba46abfd252cd760b7f88a93c598b4256e6ea8dfc2c9005c327300fb plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -465,7 +465,9 @@ openclaw cron edit <jobId> --clear-agent
|
||||
|
||||
`openclaw cron run <jobId>` returns after enqueueing the manual run. Use `--wait` for shutdown hooks, maintenance scripts, or other automation that must block until the queued run finishes. Wait mode polls the exact returned `runId`; it exits `0` for status `ok` and non-zero for `error`, `skipped`, or a wait timeout.
|
||||
|
||||
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`.
|
||||
The agent `cron` tool returns compact job summaries (`id`, `name`, `enabled`, `nextRunAtMs`, `scheduleKind`, `lastRunStatus`) from `cron(action: "list")`; use `cron(action: "get", jobId: "...")` for one full job definition. Direct Gateway callers can pass `compact: true` to `cron.list`; omitting it preserves the existing full response with delivery previews.
|
||||
|
||||
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`. On `cron edit`, `--clear-channel`, `--clear-to`, `--clear-thread-id`, and `--clear-account` unset those routing fields individually (each rejected alongside its matching set flag), which is distinct from `--no-deliver` disabling runner fallback delivery.
|
||||
|
||||
<Note>
|
||||
Model override note:
|
||||
|
||||
@@ -50,6 +50,8 @@ Use `messages.groupChat.visibleReplies: "message_tool"` when a shared room shoul
|
||||
|
||||
Use `"automatic"` for weaker models or runtimes that do not reliably understand tool-only delivery. In automatic mode, the agent's final assistant text is the visible source reply path, so a model that cannot consistently call `message(action=send)` can still answer normally.
|
||||
|
||||
In automatic mode, normal text final replies are posted directly to the room. If the visible reply needs files, images, or other attachments, the agent may still use `message(action=send)` for that attachment instead of trying to force it through the final text reply.
|
||||
|
||||
If the message tool is unavailable under the active tool policy, OpenClaw falls
|
||||
back to automatic visible replies instead of silently suppressing the response.
|
||||
`openclaw doctor` warns about this mismatch.
|
||||
|
||||
@@ -111,6 +111,10 @@ After a successful startup, OpenClaw caches the bot identity in the state direct
|
||||
|
||||
## Access control and activation
|
||||
|
||||
### Group bot identity
|
||||
|
||||
In Telegram groups and forum topics, an explicit mention of the configured bot handle (for example `@my_bot`) is treated as addressing the selected OpenClaw agent, even when the agent persona name differs from the Telegram username. The group silence policy still applies to unrelated group traffic, but the bot handle itself is not considered "someone else."
|
||||
|
||||
<Tabs>
|
||||
<Tab title="DM policy">
|
||||
`channels.telegram.dmPolicy` controls direct message access:
|
||||
@@ -418,7 +422,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich message formatting">
|
||||
Outbound text uses Telegram rich messages.
|
||||
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
|
||||
|
||||
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
|
||||
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
|
||||
@@ -426,6 +442,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
|
||||
|
||||
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
|
||||
|
||||
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
|
||||
|
||||
</Accordion>
|
||||
@@ -1081,7 +1099,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
|
||||
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`)
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
|
||||
@@ -93,6 +93,8 @@ Isolated cron chat delivery is shared between the agent and the runner:
|
||||
|
||||
Use `cron add|create --webhook <url>` or `cron edit <job-id> --webhook <url>` to set webhook delivery. Do not combine `--webhook` with chat delivery flags such as `--announce`, `--no-deliver`, `--channel`, `--to`, `--thread-id`, or `--account`.
|
||||
|
||||
`cron edit <job-id>` can unset individual delivery routing fields with `--clear-channel`, `--clear-to`, `--clear-thread-id`, and `--clear-account` (each is rejected when combined with its matching set flag). Unlike `--no-deliver`, which only disables runner fallback delivery, these remove the stored field so the job resolves that part of its route from defaults again.
|
||||
|
||||
`--announce` is runner fallback delivery for the final reply. `--no-deliver` disables that fallback but does not remove the agent's `message` tool when a chat route is available.
|
||||
|
||||
Reminders created from an active chat preserve the live chat delivery target for fallback announce delivery. Internal session keys may be lowercase; do not use them as a source of truth for case-sensitive provider IDs such as Matrix room IDs.
|
||||
|
||||
@@ -54,7 +54,8 @@ doctor can report the missing artifact.
|
||||
Policy is authored, not generated from the user's current settings. A minimal
|
||||
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
|
||||
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
|
||||
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
data-handling posture, config secret provider/auth profile posture, exec approval
|
||||
file posture, and tool metadata looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -145,6 +146,15 @@ data-handling posture, config secret provider/auth profile posture, and tool met
|
||||
"allowModes": ["api_key", "token"],
|
||||
},
|
||||
},
|
||||
"execApprovals": {
|
||||
"requireFile": true,
|
||||
"defaults": { "allowSecurity": ["deny"] },
|
||||
"agents": {
|
||||
"allowSecurity": ["deny", "allowlist"],
|
||||
"allowAutoAllowSkills": false,
|
||||
"allowlist": { "expected": ["deploy", "status"] },
|
||||
},
|
||||
},
|
||||
"tools": {
|
||||
"requireMetadata": ["risk", "sensitivity", "owner"],
|
||||
"profiles": {
|
||||
@@ -187,9 +197,11 @@ and `group:runtime` covers shell/process tools. Tool posture policy observes
|
||||
`tools.profile`, `tools.allow`, `tools.alsoAllow`, `tools.deny`,
|
||||
`tools.fs.workspaceOnly`, `tools.exec.security`, `tools.exec.ask`,
|
||||
`tools.exec.host`, `tools.elevated.enabled`, and the same per-agent
|
||||
`agents.list[].tools.*` overrides. It does not read runtime/operator approval
|
||||
state such as exec-approvals.json, and it does not enforce tool calls at
|
||||
runtime. Secret evidence records
|
||||
`agents.list[].tools.*` overrides. Exec approval policy reads the named
|
||||
`exec-approvals.json` product artifact only when an `execApprovals` rule is
|
||||
present; evidence records defaults, per-agent posture, and allowlist patterns
|
||||
without socket tokens or last-used command text. Policy does not enforce tool
|
||||
calls at runtime. Secret evidence records
|
||||
provider/source posture and SecretRef metadata, never raw secret values. Policy
|
||||
does not read or attest per-agent credential stores such as `auth-profiles.json`;
|
||||
those stores remain owned by the existing auth and credential flows.
|
||||
@@ -218,8 +230,8 @@ its own finding against the same observed config.
|
||||
|
||||
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
|
||||
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
|
||||
`dataHandling.memory.*`. Channel-scoped
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, `dataHandling.memory.*`,
|
||||
and `execApprovals.*`. Channel-scoped
|
||||
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
|
||||
sections are rejected instead of being ignored. If an `agentIds` entry is not
|
||||
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
|
||||
@@ -304,10 +316,10 @@ groups where those fields cannot be observed.
|
||||
Top-level `ingress.session.requireDmScope` remains global because
|
||||
`session.dmScope` is not channel-attributable evidence.
|
||||
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, `dataHandling.memory`, and `execApprovals` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
|
||||
Every scope present in `policy.jsonc` must be valid and enforceable.
|
||||
|
||||
@@ -401,6 +413,69 @@ allowlist such as `["all"]`.
|
||||
| `secrets.denySources` | Secret provider sources and SecretRef sources | Deny sources such as `exec`, `file`, or another configured source name. |
|
||||
| `secrets.allowInsecureProviders` | Insecure secret-provider posture flags | Set to `false` to reject providers that opt into insecure posture. |
|
||||
|
||||
#### Exec approvals
|
||||
|
||||
Exec approvals policy observes the active runtime `exec-approvals.json`
|
||||
artifact. By default this is `~/.openclaw/exec-approvals.json`; when
|
||||
`OPENCLAW_STATE_DIR` is set, Policy reads
|
||||
`$OPENCLAW_STATE_DIR/exec-approvals.json`. Actual posture rules such as
|
||||
`execApprovals.defaults.*` or `execApprovals.agents.*` require readable artifact
|
||||
evidence; a missing or invalid artifact is reported as unobservable evidence
|
||||
instead of becoming a best-effort pass against synthetic runtime defaults. Once
|
||||
the artifact is readable, omitted approval fields inherit runtime defaults: missing
|
||||
`defaults.security` is `full`, and missing agent security inherits that
|
||||
default. Evidence includes `defaults`, `agents.*`, and
|
||||
`agents.*.allowlist[].pattern` plus optional `argPattern`, effective
|
||||
`autoAllowSkills` posture, and entry source. It does not include socket
|
||||
path/token, `commandText`, `lastUsedCommand`, resolved paths, or timestamps.
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
| ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `execApprovals.requireFile` | Active runtime `exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. |
|
||||
| `execApprovals.defaults.allowSecurity` | `defaults.security`, defaulting to `full` | Allow only approved default approval security modes. |
|
||||
| `execApprovals.agents.allowSecurity` | `agents.*.security`, inheriting defaults | Allow only approved per-agent effective approval security modes. |
|
||||
| `execApprovals.agents.allowAutoAllowSkills` | `defaults.autoAllowSkills` and `agents.*.autoAllowSkills`, inheriting runtime defaults | Set to `false` to require strict manual allowlists without implicit skill CLI approval. |
|
||||
| `execApprovals.agents.allowlist.expected` | Aggregate `agents.*.allowlist[]` pattern and optional argPattern entries | Require the approvals allowlist to match the reviewed pattern set. |
|
||||
|
||||
For example, require the approvals artifact, deny permissive defaults, and
|
||||
allow only reviewed exec approval posture for selected agents:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"execApprovals": {
|
||||
"requireFile": true,
|
||||
"defaults": {
|
||||
// Security modes: "deny", "allowlist", or "full".
|
||||
// This default permits only the locked-down deny posture.
|
||||
"allowSecurity": ["deny"],
|
||||
},
|
||||
},
|
||||
"scopes": {
|
||||
"restricted-shell": {
|
||||
"agentIds": ["family-agent", "groups-agent"],
|
||||
"execApprovals": {
|
||||
"agents": {
|
||||
// Selected agents may use reviewed allowlist posture, but not "full".
|
||||
"allowSecurity": ["allowlist"],
|
||||
// false means skill CLIs must appear in the reviewed allowlist instead of
|
||||
// being implicitly approved by autoAllowSkills.
|
||||
"allowAutoAllowSkills": false,
|
||||
"allowlist": {
|
||||
"expected": [
|
||||
// Simple entry: exact reviewed executable pattern with no argPattern.
|
||||
"travel-hub",
|
||||
// Constrained entry: pattern plus reviewed argument regex.
|
||||
{ "pattern": "calendar-cli", "argPattern": "^sync\\b" },
|
||||
"/bin/date",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Auth profiles
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
@@ -769,6 +844,13 @@ Policy currently verifies:
|
||||
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
|
||||
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
|
||||
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
|
||||
| `policy/exec-approvals-missing` | Policy requires `exec-approvals.json`, but the artifact is missing. |
|
||||
| `policy/exec-approvals-invalid` | The configured exec approvals artifact cannot be parsed. |
|
||||
| `policy/exec-approvals-default-security-unapproved` | Exec approval defaults use a security mode outside the policy allowlist. |
|
||||
| `policy/exec-approvals-agent-security-unapproved` | A per-agent effective exec approval security mode is outside the allowlist. |
|
||||
| `policy/exec-approvals-auto-allow-skills-enabled` | An exec approval agent implicitly auto-allows skill CLIs when policy denies it. |
|
||||
| `policy/exec-approvals-allowlist-missing` | The approvals allowlist is missing a pattern required by policy. |
|
||||
| `policy/exec-approvals-allowlist-unexpected` | The approvals allowlist includes a pattern not expected by policy. |
|
||||
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
|
||||
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
|
||||
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
|
||||
|
||||
@@ -44,7 +44,7 @@ For webhook ingress, startup logs a non-fatal security warning and audit flags `
|
||||
If Gateway password auth is supplied only at startup, pass the same value to `openclaw security audit --auth password --password <password>` so the audit can check it against `hooks.token`.
|
||||
Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
|
||||
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open DMs or groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
|
||||
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
|
||||
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
||||
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).
|
||||
|
||||
@@ -36,7 +36,7 @@ If `userTimezone` is unset, OpenClaw resolves the host timezone at runtime (no c
|
||||
|
||||
- **Use UTC envelopes** (`envelopeTimezone: "utc"`) when you want stable timestamps across hosts in different regions, or when you want UTC-aligned logs to match diagnostics output.
|
||||
- **Use a fixed IANA zone** (e.g. `"Europe/Vienna"`) when the gateway host is in one zone but the user is in another and you want envelopes to read in the user's zone regardless of host migration.
|
||||
- **Set `envelopeTimestamp: "off"`** for low-token envelopes when timestamp context is not useful for the conversation.
|
||||
- **Set `envelopeTimestamp: "off"`** when timestamp context is not useful for the conversation. This removes absolute timestamps from envelopes, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
|
||||
For the full behavior reference, examples per provider, and elapsed-time formatting, see [Date & Time](/date-time).
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ You can override this behavior:
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -1385,7 +1385,8 @@
|
||||
"pages": [
|
||||
"clawhub/api",
|
||||
"clawhub/http-api",
|
||||
"clawhub/acceptable-usage"
|
||||
"clawhub/acceptable-usage",
|
||||
"clawhub/content-rights"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -99,7 +99,7 @@ Optional request headers:
|
||||
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
|
||||
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
|
||||
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
|
||||
- `x-openclaw-session-key: <sessionKey>` explicitly controls session routing. The value must not use reserved internal session namespaces such as `subagent:`, `cron:`, or `acp:`; those requests are rejected with `400 invalid_request_error`.
|
||||
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
|
||||
|
||||
Compatibility aliases still accepted:
|
||||
@@ -145,7 +145,7 @@ By default the endpoint is **stateless per request** (a new session key is gener
|
||||
|
||||
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
|
||||
|
||||
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` when you need explicit routing control across multiple clients or threads.
|
||||
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients or threads, and choose application-owned keys that do not start with reserved internal namespaces such as `subagent:`, `cron:`, or `acp:`.
|
||||
|
||||
## Why this surface matters
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ exhaustive):
|
||||
| `skills.code_safety` | warn/critical | Skill installer metadata/code contains suspicious or dangerous patterns | skill install source | no |
|
||||
| `skills.code_safety.scan_failed` | warn | Skill code scan could not complete | skill scan environment | no |
|
||||
| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.list[].tools.exec.*` | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping`) | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
|
||||
@@ -103,8 +103,45 @@ Supported `appServer` fields:
|
||||
| `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"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
|
||||
| `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. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
|
||||
the Codex thread config so the generated permission profile can start Codex
|
||||
managed networking. The default generated profile is `openclaw-network`; use
|
||||
`profileName` to choose another local name.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. Codex app-server
|
||||
must report stable version `0.125.0` or newer.
|
||||
|
||||
|
||||
@@ -505,9 +505,22 @@ Codex dynamic tools default to `searchable` loading. OpenClaw does not expose
|
||||
dynamic tools that duplicate Codex-native workspace operations: `read`, `write`,
|
||||
`edit`, `apply_patch`, `exec`, `process`, and `update_plan`. Most remaining
|
||||
OpenClaw integration tools such as messaging, media, cron, browser, nodes,
|
||||
gateway, `heartbeat_respond`, and `web_search` are available through Codex tool
|
||||
search under the `openclaw` namespace, keeping the initial model context
|
||||
smaller.
|
||||
gateway, and `heartbeat_respond` are available through Codex tool search under
|
||||
the `openclaw` namespace, keeping the initial model context smaller. Web search
|
||||
uses Codex's hosted `web_search` tool by default when search is enabled and no
|
||||
managed provider is selected. Native hosted search and OpenClaw's managed
|
||||
`web_search` dynamic tool are mutually exclusive so managed search cannot bypass
|
||||
native domain restrictions. OpenClaw uses the managed tool when hosted search is
|
||||
unavailable, explicitly disabled, or replaced by a selected managed provider.
|
||||
OpenClaw keeps Codex's standalone `web.run` extension disabled because
|
||||
production app-server traffic rejects its user-defined `web` namespace.
|
||||
`tools.web.search.enabled: false` disables both paths, as do tool-disabled
|
||||
LLM-only runs. Codex treats `"cached"` as a preference and resolves it to live
|
||||
external access for unrestricted app-server turns. Automatic managed fallback
|
||||
fails closed when native `allowedDomains` are set so the allowlist cannot be
|
||||
bypassed. Persistent effective search-policy changes rotate the bound Codex
|
||||
thread before the next turn. Transient per-turn restrictions use a temporary
|
||||
restricted thread and preserve the existing binding for later resume.
|
||||
`sessions_yield` and message-tool-only source replies stay direct because
|
||||
those are turn-control contracts. `sessions_spawn` stays searchable so Codex's
|
||||
native `spawn_agent` remains the primary Codex subagent surface, while explicit
|
||||
@@ -548,8 +561,45 @@ Supported `appServer` fields:
|
||||
| `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"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
|
||||
| `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. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
|
||||
the Codex thread config so the generated permission profile can start Codex
|
||||
managed networking. The default generated profile is `openclaw-network`; use
|
||||
`profileName` to choose another local name.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
|
||||
|
||||
@@ -1278,6 +1278,7 @@ Important examples:
|
||||
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
|
||||
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
|
||||
|
||||
Manifest metadata decides which provider/channel/setup choices appear in
|
||||
@@ -1290,6 +1291,13 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
|
||||
newer-but-valid values skip external plugins on older hosts. Bundled source
|
||||
plugins are assumed to be co-versioned with the host checkout.
|
||||
|
||||
`openclaw.install.requiredPlatformPackages` is for npm packages that expose
|
||||
required native binaries through optional, platform-specific aliases. List the
|
||||
bare npm package name for every supported platform alias. During npm install,
|
||||
OpenClaw verifies only the declared alias whose lockfile constraints match the
|
||||
current host. If npm reports success but omits that alias, OpenClaw retries once
|
||||
with a fresh cache and rolls back the install if the alias is still missing.
|
||||
|
||||
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
|
||||
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
|
||||
package was built against. It can be stricter than `minHostVersion` when a
|
||||
|
||||
@@ -163,6 +163,7 @@ Example:
|
||||
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
|
||||
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
|
||||
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
|
||||
| `requiredPlatformPackages` | `string[]` | Required platform-specific npm aliases verified during npm install. |
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Onboarding behavior">
|
||||
|
||||
@@ -250,7 +250,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
|
||||
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
|
||||
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
|
||||
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
|
||||
| `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers |
|
||||
|
||||
@@ -60,6 +60,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
<Card title="Brave Search" icon="shield" href="/tools/brave-search">
|
||||
Structured results with snippets. Supports `llm-context` mode, country/language filters. Free tier available.
|
||||
</Card>
|
||||
<Card title="Codex Hosted Search" icon="search" href="/plugins/codex-harness">
|
||||
AI-synthesized grounded answers through your Codex app-server account.
|
||||
</Card>
|
||||
<Card title="DuckDuckGo" icon="bird" href="/tools/duckduckgo-search">
|
||||
Key-free fallback. No API key needed. Unofficial HTML-based integration.
|
||||
</Card>
|
||||
@@ -106,6 +109,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
| Provider | Result style | Filters | API key |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
|
||||
| [Codex Hosted Search](/plugins/codex-harness) | AI-synthesized + source URLs | Domains, context size, user location | None; uses Codex/OpenAI sign-in |
|
||||
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
|
||||
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
|
||||
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
|
||||
@@ -128,20 +132,52 @@ Direct OpenAI Responses models use OpenAI's hosted `web_search` tool automatical
|
||||
|
||||
## Native Codex web search
|
||||
|
||||
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.
|
||||
The Codex app-server runtime uses Codex's hosted `web_search` tool automatically
|
||||
when web search is enabled and no managed provider is selected. Native hosted
|
||||
search and OpenClaw's managed `web_search` dynamic tool are mutually exclusive,
|
||||
so managed search cannot bypass native domain restrictions. OpenClaw uses the
|
||||
managed tool when hosted search is unavailable, explicitly disabled, or
|
||||
replaced by a selected managed provider. OpenClaw keeps Codex's standalone
|
||||
`web.run` extension disabled because production app-server traffic rejects its
|
||||
user-defined `web` namespace.
|
||||
|
||||
- Configure it under `tools.web.search.openaiCodex`
|
||||
- It only activates for Codex-capable OpenAI models (`openai/*` models using `api: "openai-chatgpt-responses"`)
|
||||
- Managed `web_search` still applies to non-Codex models
|
||||
- `mode: "cached"` is the default and recommended setting
|
||||
- Configure native search under `tools.web.search.openaiCodex`
|
||||
- Set `tools.web.search.provider: "codex"` to provision Codex Hosted Search as
|
||||
the managed `web_search` provider for any parent model. Each call runs a
|
||||
bounded ephemeral Codex app-server turn and fails if Codex does not emit a
|
||||
hosted `webSearch` item.
|
||||
- `mode: "cached"` is the default preference, but Codex resolves it to live
|
||||
external access for unrestricted app-server turns; set `"live"` to request
|
||||
live access explicitly
|
||||
- Set `tools.web.search.provider` to a managed provider such as `brave` to use
|
||||
OpenClaw's managed `web_search` instead
|
||||
- Set `tools.web.search.openaiCodex.enabled: false` to opt out of Codex-hosted
|
||||
search; other managed providers remain available
|
||||
- Restricting the Codex native tool surface also keeps managed `web_search`
|
||||
available
|
||||
- When `allowedDomains` is set, automatic managed fallback fails closed if
|
||||
hosted search is unavailable so the native allowlist cannot be bypassed
|
||||
- Tool-disabled LLM-only runs disable both native and managed search
|
||||
- `tools.web.search.enabled: false` disables both managed and native search
|
||||
|
||||
Persistent effective Codex search-policy changes start a fresh bound thread so
|
||||
an already loaded app-server thread cannot keep stale hosted-search access.
|
||||
Transient per-turn restrictions use a temporary restricted thread and preserve
|
||||
the existing binding for later resume.
|
||||
|
||||
Direct OpenAI ChatGPT Responses traffic can also use OpenAI's hosted
|
||||
`web_search` tool. That separate path remains opt-in through
|
||||
`tools.web.search.openaiCodex.enabled: true` and only applies to eligible
|
||||
`openai/*` models using `api: "openai-chatgpt-responses"`.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
// Optional: use Codex Hosted Search from non-Codex parent models too.
|
||||
provider: "codex",
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
@@ -159,14 +195,25 @@ Codex-capable models can optionally use the provider-native Responses `web_searc
|
||||
}
|
||||
```
|
||||
|
||||
If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior.
|
||||
For runtimes and providers that do not support native Codex search, Codex can
|
||||
use the managed `web_search` fallback through OpenClaw's dynamic tool namespace.
|
||||
Use an explicit managed provider when you need OpenClaw's provider-specific
|
||||
network controls instead of Codex-hosted search.
|
||||
|
||||
Selecting `provider: "codex"` enables the bundled `codex` plugin and uses the
|
||||
same `tools.web.search.openaiCodex` restrictions shown above. Authenticate the
|
||||
Codex app-server first with `openclaw models auth login --provider openai`.
|
||||
The parent agent can use any model or runtime; only the bounded search worker
|
||||
runs through Codex.
|
||||
|
||||
## Network safety
|
||||
|
||||
Managed `web_search` provider calls use OpenClaw's guarded fetch path. For
|
||||
Managed HTTP `web_search` provider calls use OpenClaw's guarded fetch path. For
|
||||
trusted provider API hosts, OpenClaw allows Surge, Clash, and sing-box fake-IP
|
||||
DNS answers in `198.18.0.0/15` and `fc00::/7` only for that provider hostname.
|
||||
Other private, loopback, link-local, and metadata destinations remain blocked.
|
||||
Codex Hosted Search is the exception: its bounded worker delegates network
|
||||
access to Codex app-server's hosted `web_search` tool.
|
||||
|
||||
This automatic allowance does not apply to arbitrary `web_fetch` URLs. For
|
||||
`web_fetch`, enable `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` and
|
||||
@@ -200,6 +247,7 @@ Key-free fallbacks after that:
|
||||
12. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100)
|
||||
13. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110)
|
||||
14. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
|
||||
15. **Codex Hosted Search** -- key-free provider contract that uses the active Codex/OpenAI sign-in (order 900)
|
||||
|
||||
When no API-backed provider is configured, OpenClaw defaults to **Parallel
|
||||
Search (Free)**, so `web_search` works without an API key.
|
||||
|
||||
@@ -32,12 +32,13 @@ describe("codex plugin", () => {
|
||||
expect(manifest.enabledByDefault).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers the codex provider and agent harness", () => {
|
||||
it("registers the codex provider, agent harness, and hosted web search", () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const registerCommand = vi.fn();
|
||||
const registerMediaUnderstandingProvider = vi.fn();
|
||||
const registerMigrationProvider = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const registerWebSearchProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
@@ -54,6 +55,7 @@ describe("codex plugin", () => {
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerProvider,
|
||||
registerWebSearchProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
@@ -82,6 +84,13 @@ describe("codex plugin", () => {
|
||||
expect(mediaProviderRegistration?.defaultModels).toEqual({ image: "gpt-5.5" });
|
||||
expect(typeof mediaProviderRegistration?.describeImage).toBe("function");
|
||||
expect(typeof mediaProviderRegistration?.describeImages).toBe("function");
|
||||
const webSearchRegistration = mockCallArg(registerWebSearchProvider) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(webSearchRegistration?.id).toBe("codex");
|
||||
expect(webSearchRegistration?.label).toBe("Codex Hosted Search");
|
||||
expect(webSearchRegistration?.requiresCredential).toBe(false);
|
||||
expect(typeof webSearchRegistration?.createTool).toBe("function");
|
||||
const commandRegistration = mockCallArg(registerCommand) as Record<string, unknown> | undefined;
|
||||
expect(commandRegistration?.name).toBe("codex");
|
||||
expect(commandRegistration?.description).toBe(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resumeCodexCliSessionOnNode,
|
||||
resolveCodexCliSessionForBindingOnNode,
|
||||
} from "./src/node-cli-sessions.js";
|
||||
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
@@ -46,6 +47,9 @@ export default definePluginEntry({
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerWebSearchProvider(
|
||||
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
);
|
||||
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
|
||||
for (const command of createCodexCliSessionNodeHostCommands()) {
|
||||
api.registerNodeHostCommand(command);
|
||||
|
||||
@@ -229,6 +229,7 @@ describe("codex media understanding provider", () => {
|
||||
undefined,
|
||||
"/tmp/openclaw-agent",
|
||||
cfg,
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(requests[1]?.params).toEqual({
|
||||
model: "gpt-5.4",
|
||||
@@ -240,8 +241,14 @@ describe("codex media understanding provider", () => {
|
||||
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.apps": false,
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.image_generation": false,
|
||||
"features.multi_agent": false,
|
||||
"features.plugins": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
@@ -279,11 +286,51 @@ describe("codex media understanding provider", () => {
|
||||
agentDir: " ",
|
||||
});
|
||||
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
|
||||
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
|
||||
});
|
||||
|
||||
it("preserves configured WebSocket transport for media turns", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const clientFactory = vi.fn(async () => client);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:4501",
|
||||
},
|
||||
},
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
await provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 30_000,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
expect(clientFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:4501",
|
||||
}),
|
||||
undefined,
|
||||
"/tmp/openclaw-agent",
|
||||
{},
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: "/tmp/openclaw-agent" }));
|
||||
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: "/tmp/openclaw-agent" }));
|
||||
});
|
||||
|
||||
it("passes the scoped auth store into isolated app-server startup", async () => {
|
||||
const { client } = createFakeClient();
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue(client);
|
||||
@@ -486,8 +533,14 @@ describe("codex media understanding provider", () => {
|
||||
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.apps": false,
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.image_generation": false,
|
||||
"features.multi_agent": false,
|
||||
"features.plugins": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
|
||||
@@ -13,41 +13,19 @@ import type {
|
||||
StructuredExtractionRequest,
|
||||
StructuredExtractionResult,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
|
||||
import type { CodexAppServerClientFactory } from "./src/app-server/client-factory.js";
|
||||
import type { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./src/app-server/config.js";
|
||||
import { readModelListResult } from "./src/app-server/models.js";
|
||||
import {
|
||||
assertCodexThreadStartResponse,
|
||||
assertCodexTurnStartResponse,
|
||||
readCodexErrorNotification,
|
||||
readCodexTurnCompletedNotification,
|
||||
} from "./src/app-server/protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type CodexThreadItem,
|
||||
type CodexThreadStartParams,
|
||||
type CodexTurn,
|
||||
type CodexTurnStartParams,
|
||||
type CodexUserInput,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./src/app-server/protocol.js";
|
||||
import { buildCodexRuntimeThreadConfig } from "./src/app-server/thread-lifecycle.js";
|
||||
runBoundedCodexAppServerTurn,
|
||||
type CodexBoundedTurnOptions,
|
||||
} from "./src/app-server/bounded-turn.js";
|
||||
import type { CodexUserInput } from "./src/app-server/protocol.js";
|
||||
|
||||
const DEFAULT_CODEX_IMAGE_MODEL =
|
||||
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
|
||||
FALLBACK_CODEX_MODELS[0]?.id;
|
||||
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
|
||||
|
||||
/** Dependencies and plugin config for Codex media-understanding calls. */
|
||||
export type CodexMediaUnderstandingProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
};
|
||||
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
|
||||
|
||||
/**
|
||||
* Builds the media-understanding provider that delegates image tasks to an
|
||||
@@ -97,13 +75,13 @@ async function describeCodexImages(
|
||||
throw new Error("Codex image understanding requires model id.");
|
||||
}
|
||||
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
const { text } = await runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
authProfileStore: req.authStore,
|
||||
options,
|
||||
taskLabel: "image understanding",
|
||||
developerInstructions:
|
||||
@@ -116,117 +94,11 @@ async function describeCodexImages(
|
||||
})),
|
||||
],
|
||||
requiredModalities: ["text", "image"],
|
||||
isolation: "configured-transport",
|
||||
});
|
||||
return { text, model };
|
||||
}
|
||||
|
||||
type BoundedCodexVisionTurnParams = {
|
||||
model: string;
|
||||
profile?: string;
|
||||
timeoutMs: number;
|
||||
agentDir?: string;
|
||||
authStore?: ImagesDescriptionRequest["authStore"];
|
||||
cfg: ImagesDescriptionRequest["cfg"];
|
||||
options: CodexMediaUnderstandingProviderOptions;
|
||||
taskLabel: string;
|
||||
developerInstructions: string;
|
||||
input: CodexUserInput[];
|
||||
requiredModalities: string[];
|
||||
};
|
||||
|
||||
async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): Promise<string> {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.options.pluginConfig,
|
||||
});
|
||||
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
|
||||
const agentDir = params.agentDir?.trim() || undefined;
|
||||
const cwd = agentDir ?? process.cwd();
|
||||
const ownsClient = !params.options.clientFactory;
|
||||
// Tests inject a client factory; production creates an isolated app-server
|
||||
// client so media tasks cannot reuse the interactive attempt session.
|
||||
const client = params.options.clientFactory
|
||||
? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg)
|
||||
: await import("./src/app-server/shared-client.js").then(
|
||||
({ createIsolatedCodexAppServerClient }) =>
|
||||
createIsolatedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs,
|
||||
authProfileId: params.profile,
|
||||
agentDir,
|
||||
authProfileStore: params.authStore,
|
||||
config: params.cfg,
|
||||
}),
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort("timeout"), timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
try {
|
||||
await assertCodexModelSupportsInput({
|
||||
client,
|
||||
model: params.model,
|
||||
requiredModalities: params.requiredModalities,
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const thread = assertCodexThreadStartResponse(
|
||||
await client.request<unknown>(
|
||||
"thread/start",
|
||||
{
|
||||
model: params.model,
|
||||
modelProvider: "openai",
|
||||
cwd,
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
serviceName: "OpenClaw",
|
||||
developerInstructions: params.developerInstructions,
|
||||
// Media workers are bounded read-only turns; native code mode and
|
||||
// dynamic tools stay disabled to avoid side effects while inspecting media.
|
||||
config: buildCodexRuntimeThreadConfig(undefined, { nativeCodeModeEnabled: false }),
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: false,
|
||||
ephemeral: true,
|
||||
} satisfies CodexThreadStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
const collector = createCodexTurnCollector(thread.thread.id, params.taskLabel);
|
||||
const cleanup = client.addNotificationHandler(collector.handleNotification);
|
||||
const requestCleanup = client.addRequestHandler(denyCodexImageApprovalRequest);
|
||||
try {
|
||||
const turn = assertCodexTurnStartResponse(
|
||||
await client.request<unknown>(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: thread.thread.id,
|
||||
input: params.input,
|
||||
cwd,
|
||||
approvalPolicy: "on-request",
|
||||
model: params.model,
|
||||
effort: "low",
|
||||
} satisfies CodexTurnStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
const text = await collector.collect(turn.turn, {
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
return text;
|
||||
} finally {
|
||||
requestCleanup();
|
||||
cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (ownsClient) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCodexStructured(
|
||||
req: StructuredExtractionRequest,
|
||||
options: CodexMediaUnderstandingProviderOptions,
|
||||
@@ -246,73 +118,24 @@ async function extractCodexStructured(
|
||||
throw new Error("Codex structured extraction requires at least one image input.");
|
||||
}
|
||||
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
const { text } = await runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
authProfileStore: req.authStore,
|
||||
options,
|
||||
taskLabel: "structured extraction",
|
||||
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.",
|
||||
input: buildCodexStructuredInput(req),
|
||||
requiredModalities: requiredStructuredModalities(),
|
||||
isolation: "configured-transport",
|
||||
});
|
||||
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
|
||||
}
|
||||
|
||||
function denyCodexImageApprovalRequest(request: { method: string }): JsonValue | undefined {
|
||||
if (
|
||||
request.method === "item/commandExecution/requestApproval" ||
|
||||
request.method === "item/fileChange/requestApproval"
|
||||
) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw Codex image understanding does not grant tool or file approvals.",
|
||||
};
|
||||
}
|
||||
if (request.method === "item/permissions/requestApproval") {
|
||||
return { permissions: {}, scope: "turn" };
|
||||
}
|
||||
if (request.method.includes("requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw Codex image understanding does not grant native approvals.",
|
||||
};
|
||||
}
|
||||
if (request.method === "mcpServer/elicitation/request") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function assertCodexModelSupportsInput(params: {
|
||||
client: CodexAppServerClient;
|
||||
model: string;
|
||||
requiredModalities: string[];
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const result = await params.client.request<unknown>(
|
||||
"model/list",
|
||||
{ limit: 100, cursor: null, includeHidden: false },
|
||||
{ timeoutMs: Math.min(params.timeoutMs, 5_000), signal: params.signal },
|
||||
);
|
||||
const listed = readModelListResult(result).models;
|
||||
const match = listed.find((entry) => entry.model === params.model || entry.id === params.model);
|
||||
if (!match) {
|
||||
throw new Error(`Codex app-server model not found: ${params.model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("image") && !match.inputModalities.includes("image")) {
|
||||
throw new Error(`Codex app-server model does not support images: ${params.model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("text") && !match.inputModalities.includes("text")) {
|
||||
throw new Error(`Codex app-server model does not support text: ${params.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexImagePrompt(req: ImagesDescriptionRequest): string {
|
||||
const prompt = req.prompt?.trim() || DEFAULT_CODEX_IMAGE_PROMPT;
|
||||
if (req.images.length <= 1) {
|
||||
@@ -391,159 +214,3 @@ function normalizeStructuredExtractionResult(params: {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createCodexTurnCollector(threadId: string, taskLabel: string) {
|
||||
let turnId: string | undefined;
|
||||
let completedTurn: CodexTurn | undefined;
|
||||
let promptError: string | undefined;
|
||||
const pending: CodexServerNotification[] = [];
|
||||
const assistantTextByItem = new Map<string, string>();
|
||||
const assistantItemOrder: string[] = [];
|
||||
let resolveCompletion: (() => void) | undefined;
|
||||
const completion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
const rememberAssistantText = (itemId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (!assistantTextByItem.has(itemId)) {
|
||||
assistantItemOrder.push(itemId);
|
||||
}
|
||||
assistantTextByItem.set(itemId, text);
|
||||
};
|
||||
|
||||
const handleNotification = (notification: CodexServerNotification): void => {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || readString(params, "threadId") !== threadId) {
|
||||
return;
|
||||
}
|
||||
if (!turnId) {
|
||||
pending.push(notification);
|
||||
return;
|
||||
}
|
||||
const notificationTurnId = readNotificationTurnId(params);
|
||||
if (notificationTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/agentMessage/delta") {
|
||||
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
|
||||
const delta = readString(params, "delta") ?? "";
|
||||
rememberAssistantText(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
|
||||
return;
|
||||
}
|
||||
if (notification.method === "turn/completed") {
|
||||
completedTurn =
|
||||
readCodexTurnCompletedNotification(notification.params)?.turn ?? completedTurn;
|
||||
resolveCompletion?.();
|
||||
return;
|
||||
}
|
||||
if (notification.method === "error") {
|
||||
promptError =
|
||||
readCodexErrorNotification(notification.params)?.error.message ??
|
||||
`codex app-server ${taskLabel} turn failed`;
|
||||
resolveCompletion?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleNotification,
|
||||
async collect(
|
||||
startedTurn: CodexTurn,
|
||||
options: { timeoutMs: number; signal: AbortSignal },
|
||||
): Promise<string> {
|
||||
turnId = startedTurn.id;
|
||||
if (isTerminalTurn(startedTurn)) {
|
||||
completedTurn = startedTurn;
|
||||
}
|
||||
for (const notification of pending.splice(0)) {
|
||||
handleNotification(notification);
|
||||
}
|
||||
if (!completedTurn && !promptError) {
|
||||
await waitForTurnCompletion({
|
||||
completion,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal,
|
||||
taskLabel,
|
||||
});
|
||||
}
|
||||
if (promptError) {
|
||||
throw new Error(promptError);
|
||||
}
|
||||
if (completedTurn?.status === "failed") {
|
||||
throw new Error(
|
||||
completedTurn.error?.message ?? `codex app-server ${taskLabel} turn failed`,
|
||||
);
|
||||
}
|
||||
const itemText = collectAssistantTextFromItems(completedTurn?.items);
|
||||
const deltaText = assistantItemOrder
|
||||
.map((itemId) => assistantTextByItem.get(itemId)?.trim())
|
||||
.filter((text): text is string => Boolean(text))
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
const text = (itemText || deltaText).trim();
|
||||
if (!text) {
|
||||
throw new Error(`Codex app-server ${taskLabel} turn returned no text.`);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTurnCompletion(params: {
|
||||
completion: Promise<void>;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
taskLabel: string;
|
||||
}): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupAbort: (() => void) | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
params.completion,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`codex app-server ${params.taskLabel} turn timed out`)),
|
||||
params.timeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
const abortListener = () =>
|
||||
reject(new Error(`codex app-server ${params.taskLabel} turn aborted`));
|
||||
params.signal.addEventListener("abort", abortListener, { once: true });
|
||||
cleanupAbort = () => params.signal.removeEventListener("abort", abortListener);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
cleanupAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): string {
|
||||
return (items ?? [])
|
||||
.filter((item) => item.type === "agentMessage")
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function readNotificationTurnId(record: JsonObject): string | undefined {
|
||||
const direct = readString(record, "turnId");
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return isJsonObject(record.turn) ? readString(record.turn, "id") : undefined;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function isTerminalTurn(turn: CodexTurn): boolean {
|
||||
return turn.status === "completed" || turn.status === "interrupted" || turn.status === "failed";
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"providers": ["codex"],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["codex"],
|
||||
"migrationProviders": ["codex"]
|
||||
"migrationProviders": ["codex"],
|
||||
"webSearchProviders": ["codex"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"codex": {
|
||||
@@ -192,6 +193,47 @@
|
||||
"enum": ["user", "auto_review", "guardian_subagent"]
|
||||
},
|
||||
"serviceTier": { "type": ["string", "null"] },
|
||||
"networkProxy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"profileName": { "type": "string" },
|
||||
"baseProfile": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["limited", "full"]
|
||||
},
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"unixSockets": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"proxyUrl": { "type": "string" },
|
||||
"socksUrl": { "type": "string" },
|
||||
"enableSocks5": { "type": "boolean" },
|
||||
"enableSocks5Udp": { "type": "boolean" },
|
||||
"allowUpstreamProxy": { "type": "boolean" },
|
||||
"allowLocalBinding": { "type": "boolean" },
|
||||
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
|
||||
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"defaultWorkspaceDir": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -384,6 +426,81 @@
|
||||
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy": {
|
||||
"label": "Network Proxy",
|
||||
"help": "Enable Codex permissions-profile networking for app-server commands.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enabled": {
|
||||
"label": "Network Proxy Enabled",
|
||||
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it on thread start or resume instead of sandbox fields.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.profileName": {
|
||||
"label": "Network Proxy Profile",
|
||||
"help": "Codex permissions profile name generated for app-server network access.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.baseProfile": {
|
||||
"label": "Network Proxy Base",
|
||||
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.domains": {
|
||||
"label": "Network Domains",
|
||||
"help": "Domain allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.unixSockets": {
|
||||
"label": "Unix Sockets",
|
||||
"help": "Unix socket allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.proxyUrl": {
|
||||
"label": "HTTP Proxy URL",
|
||||
"help": "HTTP listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.socksUrl": {
|
||||
"label": "SOCKS Proxy URL",
|
||||
"help": "SOCKS listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5": {
|
||||
"label": "Enable SOCKS5",
|
||||
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5Udp": {
|
||||
"label": "Enable SOCKS5 UDP",
|
||||
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowUpstreamProxy": {
|
||||
"label": "Allow Upstream Proxy",
|
||||
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowLocalBinding": {
|
||||
"label": "Allow Local Binding",
|
||||
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.mode": {
|
||||
"label": "Network Mode",
|
||||
"help": "Codex sandboxed networking mode for subprocess traffic.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
|
||||
"label": "Allow Non-Loopback Proxy",
|
||||
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
|
||||
"label": "Allow All Unix Sockets",
|
||||
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.defaultWorkspaceDir": {
|
||||
"label": "Default Workspace",
|
||||
"help": "Workspace used by /codex bind when --cwd is omitted.",
|
||||
|
||||
@@ -23,7 +23,15 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/codex",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
"minHostVersion": ">=2026.5.1-beta.1",
|
||||
"requiredPlatformPackages": [
|
||||
"@openai/codex-linux-x64",
|
||||
"@openai/codex-linux-arm64",
|
||||
"@openai/codex-darwin-x64",
|
||||
"@openai/codex-darwin-arm64",
|
||||
"@openai/codex-win32-x64",
|
||||
"@openai/codex-win32-arm64"
|
||||
]
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.2"
|
||||
|
||||
@@ -116,10 +116,12 @@ function startThreadWithHarness(
|
||||
effectiveWorkspace: paths.workspaceDir,
|
||||
effectiveCwd: paths.cwd,
|
||||
dynamicTools: [],
|
||||
webSearchAllowed: false,
|
||||
developerInstructions: undefined,
|
||||
finalConfigPatch: undefined,
|
||||
bundleMcpThreadConfig,
|
||||
nativeToolSurfaceEnabled: true,
|
||||
nativeProviderWebSearchSupport: "supported",
|
||||
sandboxExecServerEnabled: false,
|
||||
sandbox: null,
|
||||
contextEngineProjection: undefined,
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
|
||||
|
||||
@@ -96,12 +97,15 @@ export async function startCodexAttemptThread(params: {
|
||||
effectiveWorkspace: string;
|
||||
effectiveCwd: string;
|
||||
dynamicTools: CodexDynamicToolSpec[];
|
||||
persistentWebSearchAllowed?: boolean;
|
||||
webSearchAllowed: boolean;
|
||||
developerInstructions: string | undefined;
|
||||
finalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["finalConfigPatch"];
|
||||
buildFinalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["buildFinalConfigPatch"];
|
||||
nativeHookRelayGeneration?: string;
|
||||
bundleMcpThreadConfig: CodexBundleMcpThreadConfig;
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
sandboxExecServerEnabled: boolean;
|
||||
sandbox: CodexSandboxContext;
|
||||
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
|
||||
@@ -300,6 +304,8 @@ export async function startCodexAttemptThread(params: {
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
dynamicTools: params.dynamicTools,
|
||||
persistentWebSearchAllowed: params.persistentWebSearchAllowed,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
appServer: pluginAppServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: threadConfig,
|
||||
@@ -307,6 +313,7 @@ export async function startCodexAttemptThread(params: {
|
||||
buildFinalConfigPatch: params.buildFinalConfigPatch,
|
||||
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
|
||||
nativeCodeModeEnabled: params.nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: params.appServer.codeModeOnly,
|
||||
userMcpServersEnabled: params.nativeToolSurfaceEnabled,
|
||||
mcpServersFingerprint: params.bundleMcpThreadConfig.fingerprint,
|
||||
|
||||
505
extensions/codex/src/app-server/bounded-turn.ts
Normal file
505
extensions/codex/src/app-server/bounded-turn.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
||||
import { readCodexNotificationItem } from "./attempt-notifications.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { readModelListResult } from "./models.js";
|
||||
import { mergeCodexThreadConfigs } from "./plugin-thread-config.js";
|
||||
import {
|
||||
assertCodexThreadStartResponse,
|
||||
assertCodexTurnStartResponse,
|
||||
readCodexErrorNotification,
|
||||
readCodexTurnCompletedNotification,
|
||||
} from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type CodexThreadItem,
|
||||
type CodexThreadStartParams,
|
||||
type CodexTurn,
|
||||
type CodexTurnStartParams,
|
||||
type CodexUserInput,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js";
|
||||
|
||||
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
|
||||
const OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR = "OPENCLAW_CODEX_APP_SERVER_ARGS";
|
||||
const CODEX_BOUNDED_THREAD_CONFIG: JsonObject = {
|
||||
"features.multi_agent": false,
|
||||
"features.apps": false,
|
||||
"features.plugins": false,
|
||||
"features.image_generation": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
};
|
||||
const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
|
||||
"features.hooks": false,
|
||||
notify: [],
|
||||
};
|
||||
|
||||
export type CodexBoundedTurnOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
};
|
||||
|
||||
export type CodexBoundedTurnResult = {
|
||||
text: string;
|
||||
items: CodexThreadItem[];
|
||||
model: string;
|
||||
};
|
||||
|
||||
type CodexBoundedTurnModelSelection = { mode: "required"; id: string } | { mode: "live-default" };
|
||||
|
||||
type CodexBoundedTurnParams = {
|
||||
config?: OpenClawConfig;
|
||||
model: CodexBoundedTurnModelSelection;
|
||||
profile?: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
agentDir?: string;
|
||||
authProfileStore?: AuthProfileStore;
|
||||
options: CodexBoundedTurnOptions;
|
||||
taskLabel: string;
|
||||
developerInstructions: string;
|
||||
input: CodexUserInput[];
|
||||
requiredModalities: string[];
|
||||
isolation: "configured-transport" | "private-stdio";
|
||||
threadConfig?: JsonObject;
|
||||
};
|
||||
|
||||
export async function runBoundedCodexAppServerTurn(
|
||||
params: CodexBoundedTurnParams,
|
||||
): Promise<CodexBoundedTurnResult> {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.options.pluginConfig,
|
||||
});
|
||||
if (params.isolation === "configured-transport") {
|
||||
return await runBoundedCodexAppServerTurnInWorkspace(params, appServer, {
|
||||
cwd: params.agentDir?.trim() || process.cwd(),
|
||||
});
|
||||
}
|
||||
if (appServer.start.transport !== "stdio") {
|
||||
throw new Error("Bounded Codex turns require stdio transport so native tools can be isolated.");
|
||||
}
|
||||
return await withTempWorkspace(
|
||||
{
|
||||
rootDir: resolvePreferredOpenClawTmpDir(),
|
||||
prefix: "codex-bounded-turn-",
|
||||
},
|
||||
async (workspace) => {
|
||||
const codexHome = path.join(workspace.dir, "codex-home");
|
||||
const cwd = path.join(workspace.dir, "workspace");
|
||||
await Promise.all([
|
||||
fs.mkdir(codexHome, { recursive: true }),
|
||||
fs.mkdir(cwd, { recursive: true }),
|
||||
]);
|
||||
return await runBoundedCodexAppServerTurnInWorkspace(params, appServer, { codexHome, cwd });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
params: CodexBoundedTurnParams,
|
||||
appServer: ReturnType<typeof resolveCodexAppServerRuntimeOptions>,
|
||||
workspace: { codexHome?: string; cwd: string },
|
||||
): Promise<CodexBoundedTurnResult> {
|
||||
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
|
||||
const agentDir = params.agentDir?.trim() || undefined;
|
||||
// Hosted search needs a private Codex home and cwd so inherited native tools
|
||||
// cannot escape the bounded turn. Media calls retain configured transport
|
||||
// compatibility while still using an isolated ephemeral thread.
|
||||
const startOptions = workspace.codexHome
|
||||
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
|
||||
: appServer.start;
|
||||
const ownsClient = !params.options.clientFactory;
|
||||
const client = params.options.clientFactory
|
||||
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
|
||||
timeoutMs,
|
||||
})
|
||||
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
|
||||
createIsolatedCodexAppServerClient({
|
||||
startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.profile,
|
||||
agentDir,
|
||||
authProfileStore: params.authProfileStore,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const abortFromCaller = () => abortController.abort(params.signal?.reason ?? "aborted");
|
||||
if (params.signal?.aborted) {
|
||||
abortFromCaller();
|
||||
} else {
|
||||
params.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
}
|
||||
const timeout = setTimeout(() => abortController.abort("timeout"), timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
try {
|
||||
const model = await resolveCodexBoundedTurnModel({
|
||||
client,
|
||||
selection: params.model,
|
||||
requiredModalities: params.requiredModalities,
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const thread = assertCodexThreadStartResponse(
|
||||
await client.request<unknown>(
|
||||
"thread/start",
|
||||
{
|
||||
model,
|
||||
modelProvider: "openai",
|
||||
cwd: workspace.cwd,
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
serviceName: "OpenClaw",
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: buildCodexRuntimeThreadConfig(resolveBoundedThreadConfig(params, workspace), {
|
||||
nativeCodeModeEnabled: false,
|
||||
}),
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: false,
|
||||
ephemeral: true,
|
||||
} satisfies CodexThreadStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
const collector = createCodexBoundedTurnCollector(thread.thread.id, params.taskLabel);
|
||||
const cleanup = client.addNotificationHandler(collector.handleNotification);
|
||||
const requestCleanup = client.addRequestHandler(
|
||||
createCodexBoundedApprovalHandler(params.taskLabel),
|
||||
);
|
||||
try {
|
||||
const turn = assertCodexTurnStartResponse(
|
||||
await client.request<unknown>(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: thread.thread.id,
|
||||
input: params.input,
|
||||
cwd: workspace.cwd,
|
||||
approvalPolicy: "on-request",
|
||||
model,
|
||||
effort: "low",
|
||||
} satisfies CodexTurnStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
return {
|
||||
...(await collector.collect(turn.turn, {
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
})),
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
requestCleanup();
|
||||
cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
params.signal?.removeEventListener("abort", abortFromCaller);
|
||||
if (ownsClient) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBoundedThreadConfig(
|
||||
params: CodexBoundedTurnParams,
|
||||
workspace: { codexHome?: string },
|
||||
): JsonObject {
|
||||
const boundedConfig = mergeCodexThreadConfigs(
|
||||
CODEX_BOUNDED_THREAD_CONFIG,
|
||||
params.threadConfig,
|
||||
) ?? { ...CODEX_BOUNDED_THREAD_CONFIG };
|
||||
if (!workspace.codexHome) {
|
||||
return boundedConfig;
|
||||
}
|
||||
return mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? {
|
||||
...boundedConfig,
|
||||
...CODEX_PRIVATE_BOUNDED_THREAD_CONFIG,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrivateCodexAppServerStartOptions(
|
||||
start: ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"],
|
||||
codexHome: string,
|
||||
): ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"] {
|
||||
const privateEnv = Object.fromEntries(
|
||||
Object.entries(start.env ?? {}).filter(
|
||||
([name]) => name.trim().toUpperCase() !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR,
|
||||
),
|
||||
);
|
||||
const clearEnv = (start.clearEnv ?? []).filter((name) => {
|
||||
const normalized = name.trim().toUpperCase();
|
||||
return normalized !== "CODEX_HOME" && normalized !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR;
|
||||
});
|
||||
return {
|
||||
...start,
|
||||
args: [...CODEX_PRIVATE_STDIO_ARGS],
|
||||
env: {
|
||||
...privateEnv,
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
clearEnv: [...clearEnv, OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR],
|
||||
};
|
||||
}
|
||||
|
||||
function createCodexBoundedApprovalHandler(taskLabel: string) {
|
||||
return (request: { method: string }): JsonValue | undefined => {
|
||||
if (
|
||||
request.method === "item/commandExecution/requestApproval" ||
|
||||
request.method === "item/fileChange/requestApproval"
|
||||
) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: `OpenClaw Codex ${taskLabel} does not grant tool or file approvals.`,
|
||||
};
|
||||
}
|
||||
if (request.method === "item/permissions/requestApproval") {
|
||||
return { permissions: {}, scope: "turn" };
|
||||
}
|
||||
if (request.method.includes("requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: `OpenClaw Codex ${taskLabel} does not grant native approvals.`,
|
||||
};
|
||||
}
|
||||
if (request.method === "mcpServer/elicitation/request") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCodexBoundedTurnModel(params: {
|
||||
client: CodexAppServerClient;
|
||||
selection: CodexBoundedTurnModelSelection;
|
||||
requiredModalities: string[];
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await params.client.request<unknown>(
|
||||
"model/list",
|
||||
{ limit: null, cursor: null, includeHidden: false },
|
||||
{ timeoutMs: Math.min(params.timeoutMs, 5_000), signal: params.signal },
|
||||
);
|
||||
const listed = readModelListResult(result).models;
|
||||
if (params.selection.mode === "live-default") {
|
||||
const supported = listed.filter((entry) =>
|
||||
params.requiredModalities.every((modality) => entry.inputModalities.includes(modality)),
|
||||
);
|
||||
const selected = supported.find((entry) => entry.isDefault) ?? supported[0];
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
`Codex app-server has no model supporting ${params.requiredModalities.join(" and ")} input.`,
|
||||
);
|
||||
}
|
||||
return selected.model;
|
||||
}
|
||||
|
||||
const model = params.selection.id;
|
||||
const match = listed.find((entry) => entry.model === model || entry.id === model);
|
||||
if (!match) {
|
||||
throw new Error(`Codex app-server model not found: ${model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("image") && !match.inputModalities.includes("image")) {
|
||||
throw new Error(`Codex app-server model does not support images: ${model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("text") && !match.inputModalities.includes("text")) {
|
||||
throw new Error(`Codex app-server model does not support text: ${model}`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function createCodexBoundedTurnCollector(threadId: string, taskLabel: string) {
|
||||
let turnId: string | undefined;
|
||||
let completedTurn: CodexTurn | undefined;
|
||||
let promptError: string | undefined;
|
||||
const pending: CodexServerNotification[] = [];
|
||||
const completedItems = new Map<string, CodexThreadItem>();
|
||||
const assistantTextByItem = new Map<string, string>();
|
||||
const assistantItemOrder: string[] = [];
|
||||
let resolveCompletion: (() => void) | undefined;
|
||||
const completion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
const rememberAssistantText = (itemId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (!assistantTextByItem.has(itemId)) {
|
||||
assistantItemOrder.push(itemId);
|
||||
}
|
||||
assistantTextByItem.set(itemId, text);
|
||||
};
|
||||
|
||||
const handleNotification = (notification: CodexServerNotification): void => {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || readString(params, "threadId") !== threadId) {
|
||||
return;
|
||||
}
|
||||
if (!turnId) {
|
||||
pending.push(notification);
|
||||
return;
|
||||
}
|
||||
const notificationTurnId = readNotificationTurnId(params);
|
||||
if (notificationTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/completed") {
|
||||
const item = readCodexNotificationItem(notification.params);
|
||||
if (item) {
|
||||
completedItems.set(item.id, item);
|
||||
if (item.type === "agentMessage" && typeof item.text === "string") {
|
||||
rememberAssistantText(item.id, item.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/agentMessage/delta") {
|
||||
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
|
||||
const delta = readString(params, "delta") ?? "";
|
||||
rememberAssistantText(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
|
||||
return;
|
||||
}
|
||||
if (notification.method === "turn/completed") {
|
||||
completedTurn =
|
||||
readCodexTurnCompletedNotification(notification.params)?.turn ?? completedTurn;
|
||||
resolveCompletion?.();
|
||||
return;
|
||||
}
|
||||
if (notification.method === "error") {
|
||||
promptError =
|
||||
readCodexErrorNotification(notification.params)?.error.message ??
|
||||
`codex app-server ${taskLabel} turn failed`;
|
||||
resolveCompletion?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleNotification,
|
||||
async collect(
|
||||
startedTurn: CodexTurn,
|
||||
options: { timeoutMs: number; signal: AbortSignal },
|
||||
): Promise<Omit<CodexBoundedTurnResult, "model">> {
|
||||
turnId = startedTurn.id;
|
||||
if (isTerminalTurn(startedTurn)) {
|
||||
completedTurn = startedTurn;
|
||||
}
|
||||
for (const notification of pending.splice(0)) {
|
||||
handleNotification(notification);
|
||||
}
|
||||
if (!completedTurn && !promptError) {
|
||||
await waitForTurnCompletion({
|
||||
completion,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal,
|
||||
taskLabel,
|
||||
});
|
||||
}
|
||||
if (promptError) {
|
||||
throw new Error(promptError);
|
||||
}
|
||||
if (completedTurn?.status === "failed") {
|
||||
throw new Error(
|
||||
completedTurn.error?.message ?? `codex app-server ${taskLabel} turn failed`,
|
||||
);
|
||||
}
|
||||
const items = collectCompletedItems(completedTurn?.items, completedItems);
|
||||
const itemText = collectAssistantTextFromItems(items);
|
||||
const deltaText = assistantItemOrder
|
||||
.map((itemId) => assistantTextByItem.get(itemId)?.trim())
|
||||
.filter((text): text is string => Boolean(text))
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
const text = (itemText || deltaText).trim();
|
||||
if (!text) {
|
||||
throw new Error(`Codex app-server ${taskLabel} turn returned no text.`);
|
||||
}
|
||||
return { text, items };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectCompletedItems(
|
||||
turnItems: CodexThreadItem[] | undefined,
|
||||
notificationItems: Map<string, CodexThreadItem>,
|
||||
): CodexThreadItem[] {
|
||||
const items = new Map(notificationItems);
|
||||
for (const item of turnItems ?? []) {
|
||||
items.set(item.id, item);
|
||||
}
|
||||
return [...items.values()];
|
||||
}
|
||||
|
||||
async function waitForTurnCompletion(params: {
|
||||
completion: Promise<void>;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
taskLabel: string;
|
||||
}): Promise<void> {
|
||||
if (params.signal.aborted) {
|
||||
throw new Error(`codex app-server ${params.taskLabel} turn aborted`);
|
||||
}
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupAbort: (() => void) | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
params.completion,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`codex app-server ${params.taskLabel} turn timed out`)),
|
||||
params.timeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
const abortListener = () =>
|
||||
reject(new Error(`codex app-server ${params.taskLabel} turn aborted`));
|
||||
params.signal.addEventListener("abort", abortListener, { once: true });
|
||||
cleanupAbort = () => params.signal.removeEventListener("abort", abortListener);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
cleanupAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): string {
|
||||
return (items ?? [])
|
||||
.filter((item) => item.type === "agentMessage")
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function readNotificationTurnId(record: JsonObject): string | undefined {
|
||||
const direct = readString(record, "turnId");
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return isJsonObject(record.turn) ? readString(record.turn, "id") : undefined;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function isTerminalTurn(turn: CodexTurn): boolean {
|
||||
return turn.status === "completed" || turn.status === "interrupted" || turn.status === "failed";
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export type CodexAppServerClientFactory = (
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
@@ -44,6 +45,7 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -63,5 +65,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -125,6 +125,89 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex permissions-profile config for app-server network proxy", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
profileName: "mock-proxy",
|
||||
mode: "limited",
|
||||
domains: {
|
||||
" api.openai.com ": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
" /tmp/mock-proxy.sock ": "allow",
|
||||
},
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
socksUrl: "socks5h://127.0.0.1:8081",
|
||||
enableSocks5: true,
|
||||
enableSocks5Udp: false,
|
||||
allowUpstreamProxy: true,
|
||||
allowLocalBinding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.networkProxy).toEqual({
|
||||
profileName: "mock-proxy",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
mode: "limited",
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
enable_socks5: true,
|
||||
enable_socks5_udp: false,
|
||||
allow_upstream_proxy: true,
|
||||
allow_local_binding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "read-only",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "example.com": "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
|
||||
string,
|
||||
{ filesystem: { ":workspace_roots": { ".": string } } }
|
||||
>;
|
||||
|
||||
expect(runtime.networkProxy?.profileName).toBe("openclaw-network");
|
||||
expect(permissions["openclaw-network"]?.filesystem[":workspace_roots"]["."]).toBe("read");
|
||||
});
|
||||
|
||||
it("clamps oversized app-server timer config", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
@@ -111,6 +111,32 @@ export type CodexAppServerExperimentalConfig = {
|
||||
sandboxExecServer?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerNetworkProxyPermission = "allow" | "deny";
|
||||
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
|
||||
export type CodexAppServerNetworkProxyMode = "limited" | "full";
|
||||
|
||||
export type CodexAppServerNetworkProxyConfig = {
|
||||
enabled?: boolean;
|
||||
profileName?: string;
|
||||
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
|
||||
mode?: CodexAppServerNetworkProxyMode;
|
||||
domains?: Record<string, CodexAppServerNetworkProxyPermission>;
|
||||
unixSockets?: Record<string, CodexAppServerNetworkProxyPermission>;
|
||||
proxyUrl?: string;
|
||||
socksUrl?: string;
|
||||
enableSocks5?: boolean;
|
||||
enableSocks5Udp?: boolean;
|
||||
allowUpstreamProxy?: boolean;
|
||||
allowLocalBinding?: boolean;
|
||||
dangerouslyAllowNonLoopbackProxy?: boolean;
|
||||
dangerouslyAllowAllUnixSockets?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedCodexAppServerNetworkProxyConfig = {
|
||||
profileName: string;
|
||||
configPatch: JsonObject;
|
||||
};
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
@@ -151,6 +177,7 @@ export type CodexAppServerRuntimeOptions = {
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
@@ -188,6 +215,7 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
networkProxy?: CodexAppServerNetworkProxyConfig;
|
||||
defaultWorkspaceDir?: string;
|
||||
experimental?: CodexAppServerExperimentalConfig;
|
||||
};
|
||||
@@ -216,6 +244,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"networkProxy",
|
||||
"defaultWorkspaceDir",
|
||||
"experimental",
|
||||
] as const;
|
||||
@@ -249,6 +278,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
|
||||
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
@@ -273,6 +303,25 @@ const codexAppServerExperimentalSchema = z
|
||||
sandboxExecServer: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const codexAppServerNetworkProxyPermissionSchema = z.enum(["allow", "deny"]);
|
||||
const codexAppServerNetworkProxySchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
profileName: z.string().trim().min(1).optional(),
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
enableSocks5Udp: z.boolean().optional(),
|
||||
allowUpstreamProxy: z.boolean().optional(),
|
||||
allowLocalBinding: z.boolean().optional(),
|
||||
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
|
||||
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
@@ -334,6 +383,7 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
networkProxy: codexAppServerNetworkProxySchema.optional(),
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
experimental: codexAppServerExperimentalSchema.optional(),
|
||||
})
|
||||
@@ -549,6 +599,11 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
? normalizedPolicyMode
|
||||
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
const resolvedSandbox =
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
|
||||
@@ -597,17 +652,14 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
sandbox: resolvedSandbox,
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -821,6 +873,69 @@ export function codexSandboxPolicyForTurn(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNetworkProxy(
|
||||
config: CodexAppServerNetworkProxyConfig | undefined,
|
||||
sandbox: CodexAppServerSandboxMode,
|
||||
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
|
||||
if (config?.enabled !== true) {
|
||||
return {};
|
||||
}
|
||||
const profileName =
|
||||
readNonEmptyString(config.profileName) ?? DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE;
|
||||
const fileSystemMode =
|
||||
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
|
||||
? "read"
|
||||
: "write";
|
||||
const networkConfig = removeUndefinedJsonFields({
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
enable_socks5_udp: config.enableSocks5Udp,
|
||||
allow_upstream_proxy: config.allowUpstreamProxy,
|
||||
allow_local_binding: config.allowLocalBinding,
|
||||
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
|
||||
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
|
||||
});
|
||||
return {
|
||||
networkProxy: {
|
||||
profileName,
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
[profileName]: {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": fileSystemMode,
|
||||
},
|
||||
},
|
||||
network: networkConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyPermissionMap(
|
||||
value: Record<string, CodexAppServerNetworkProxyPermission> | undefined,
|
||||
): Record<string, CodexAppServerNetworkProxyPermission> | undefined {
|
||||
const entries = Object.entries(value ?? {})
|
||||
.map(([key, permission]) => [key.trim(), permission] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function withMcpElicitationsApprovalPolicy(
|
||||
policy: CodexAppServerEffectiveApprovalPolicy,
|
||||
): CodexAppServerEffectiveApprovalPolicy {
|
||||
|
||||
@@ -171,6 +171,199 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { allowedDomains: ["example.com"] } },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
let webSearchAllowed = false;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(webSearchAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("reports hosted search denied when effective tool policy removes web_search", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("separates persistent search policy from a runtime toolsAllow restriction", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.toolsAllow = ["message"];
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
let persistentWebSearchAllowed = false;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(true);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persistent search denied when runtime toolsAllow also excludes it", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.toolsAllow = ["message"];
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = true;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(false);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("treats sender-scoped web_search denial as transient", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.senderId = "restricted-sender";
|
||||
params.config = {
|
||||
tools: {
|
||||
toolsBySender: {
|
||||
"id:restricted-sender": { deny: ["web_search"] },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = false;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(true);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persistent search denied when global and sender policy both deny it", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.senderId = "restricted-sender";
|
||||
params.config = {
|
||||
tools: {
|
||||
deny: ["web_search"],
|
||||
toolsBySender: {
|
||||
"id:restricted-sender": { deny: ["web_search"] },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = true;
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(persistentWebSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps managed web_search when a managed provider is explicitly selected", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { provider: "brave" },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir);
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
|
||||
});
|
||||
|
||||
it("keeps managed web_search when the active Codex provider lacks hosted search", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
nativeProviderWebSearchSupport: "unsupported",
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
|
||||
});
|
||||
|
||||
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {
|
||||
const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name }));
|
||||
|
||||
@@ -282,6 +475,7 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
createRuntimeDynamicTool("process"),
|
||||
createRuntimeDynamicTool("apply_patch"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
];
|
||||
});
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
@@ -292,11 +486,19 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
params.trigger = "memory";
|
||||
params.memoryFlushWritePath = "memory/2026-05-22.md";
|
||||
const sandbox = { enabled: true, backendId: "docker" } as never;
|
||||
let persistentWebSearchAllowed = false;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox);
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
sandbox,
|
||||
nativeToolSurfaceEnabled,
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeToolSurfaceEnabled).toBe(false);
|
||||
@@ -306,6 +508,33 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
memoryFlushWritePath: "memory/2026-05-22.md",
|
||||
});
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read", "write"]);
|
||||
expect(persistentWebSearchAllowed).toBe(true);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persistent search disabled during a memory flush when config disables it", async () => {
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("read"),
|
||||
createRuntimeDynamicTool("write"),
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
]);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.trigger = "memory";
|
||||
params.memoryFlushWritePath = "memory/2026-05-22.md";
|
||||
params.config = { tools: { web: { search: { enabled: false } } } };
|
||||
let persistentWebSearchAllowed = true;
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(persistentWebSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes OpenClaw sandbox shell tools under distinct names for non-Docker sandbox backends", async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { resolveCodexNativeExecutionPolicy } from "./native-execution-policy.js"
|
||||
import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js";
|
||||
import type { CodexSandboxExecEnvironment } from "./sandbox-exec-server.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
type OpenClawCodingToolsOptions = NonNullable<
|
||||
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
|
||||
@@ -62,6 +63,7 @@ export type DynamicToolBuildParams = {
|
||||
sandboxSessionKey: string;
|
||||
sandbox: OpenClawSandboxContext;
|
||||
nativeToolSurfaceEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
runAbortController: AbortController;
|
||||
sessionAgentId: string;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
@@ -70,6 +72,8 @@ export type DynamicToolBuildParams = {
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
|
||||
onWebSearchPolicyResolved?: (allowed: boolean) => void;
|
||||
};
|
||||
|
||||
let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined;
|
||||
@@ -192,7 +196,13 @@ export function formatCodexDynamicToolBuildStageSummary(
|
||||
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
|
||||
export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
const { params } = input;
|
||||
if (params.disableTools || !supportsModelTools(params.model)) {
|
||||
if (params.disableTools) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
}
|
||||
if (!supportsModelTools(params.model)) {
|
||||
input.onPersistentWebSearchPolicyResolved?.(false);
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
}
|
||||
// Dynamic tool construction is on the reply hot path, so per-stage
|
||||
@@ -202,9 +212,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
});
|
||||
const modelHasVision = params.model.input?.includes("image") ?? false;
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
|
||||
const agentHarness = await import("openclaw/plugin-sdk/agent-harness");
|
||||
const createOpenClawCodingTools =
|
||||
openClawCodingToolsFactoryForTests ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
openClawCodingToolsFactoryForTests ?? agentHarness.createOpenClawCodingTools;
|
||||
toolBuildStages.mark("load-agent-harness-tools");
|
||||
const sessionKeys = resolveOpenClawCodingToolsSessionKeys(params, input.sandboxSessionKey);
|
||||
const allTools = createOpenClawCodingTools({
|
||||
@@ -297,6 +307,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||
const webSearchPlan = resolveCodexWebSearchPlan({
|
||||
config: params.config,
|
||||
disableTools: params.disableTools,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
|
||||
});
|
||||
const readableAllTools = [...readableAllToolProjection.tools];
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
@@ -315,6 +331,40 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
hasInboundImages: (params.images?.length ?? 0) > 0,
|
||||
});
|
||||
toolBuildStages.mark("vision-filtering");
|
||||
const webSearchPresent = visionFilteredTools.some((tool) => tool.name === "web_search");
|
||||
const webSearchPolicy = agentHarness.resolveWebSearchToolPolicy({
|
||||
config: params.config,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
agentId: input.sessionAgentId,
|
||||
sessionKey: input.sandboxSessionKey,
|
||||
sandboxToolPolicy: input.sandbox?.tools,
|
||||
messageProvider: resolveCodexMessageToolProvider(params),
|
||||
agentAccountId: params.agentAccountId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
const senderScopedWebSearchRestriction =
|
||||
!webSearchPolicy.allowed && webSearchPolicy.persistentAllowed;
|
||||
const transientWebSearchRestriction =
|
||||
senderScopedWebSearchRestriction || isCodexMemoryFlushRun(params);
|
||||
const persistentCodexWebSearchSurface =
|
||||
params.config?.tools?.web?.search?.enabled !== false &&
|
||||
!(input.pluginConfig.codexDynamicToolsExclude ?? []).some(
|
||||
(name) => normalizeCodexDynamicToolName(name) === "web_search",
|
||||
);
|
||||
input.onPersistentWebSearchPolicyResolved?.(
|
||||
webSearchPresent ||
|
||||
(persistentCodexWebSearchSurface &&
|
||||
transientWebSearchRestriction &&
|
||||
webSearchPolicy.persistentAllowed),
|
||||
);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
|
||||
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
|
||||
toolBuildStages.mark("allowlist-filter");
|
||||
@@ -332,6 +382,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
preNormalizationDiagnostics.push(...diagnostics),
|
||||
});
|
||||
toolBuildStages.mark("runtime-normalization");
|
||||
// Resolve policy before hiding the managed tool. Hosted search follows the
|
||||
// same effective policy, while only one search implementation is exposed.
|
||||
input.onWebSearchPolicyResolved?.(normalizedTools.some((tool) => tool.name === "web_search"));
|
||||
const exposedTools = webSearchPlan.suppressManagedWebSearch
|
||||
? normalizedTools.filter((tool) => tool.name !== "web_search")
|
||||
: normalizedTools;
|
||||
if (preNormalizationDiagnostics.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
|
||||
@@ -362,14 +418,14 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
normalizedToolCount: normalizedTools.length,
|
||||
normalizedToolCount: exposedTools.length,
|
||||
forceHeartbeatTool: input.forceHeartbeatTool === true,
|
||||
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return normalizedTools;
|
||||
return exposedTools;
|
||||
}
|
||||
|
||||
/** Preserves delivery-critical tools when a narrow allowlist would otherwise hide them. */
|
||||
|
||||
@@ -1150,6 +1150,222 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.assistantTexts).toEqual(["final answer"]);
|
||||
});
|
||||
|
||||
it("does not double-deliver a commentary note echoed on the raw response lane", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
// Typed agentMessage lane streams the note, keyed by the thread item id.
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
agentMessageDelta("Checking the workspace", "msg-commentary"),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-commentary",
|
||||
phase: "commentary",
|
||||
text: "Checking the workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Raw response lane echoes the same note. Codex omits the message id on the
|
||||
// wire (ResponseItem::Message.id is skip_serializing), so the projector
|
||||
// synthesizes a `raw-assistant-*` id that never matches the thread item id.
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Checking the workspace" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const preambles = onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
|
||||
|
||||
expect(preambles.map((event) => event.data.progressText)).toEqual(["Checking the workspace"]);
|
||||
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
|
||||
});
|
||||
|
||||
it("delivers distinct same-text commentary notes from the same lane within a turn", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
// Two separate notes that happen to share text must each be delivered.
|
||||
for (const id of ["msg-1", "msg-2"]) {
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id, phase: "commentary", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(agentMessageDelta("Checking the workspace", id));
|
||||
}
|
||||
|
||||
const preambles = onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
|
||||
|
||||
expect(preambles.map((event) => event.data.itemId)).toEqual(["msg-1", "msg-2"]);
|
||||
expect(preambles.map((event) => event.data.progressText)).toEqual([
|
||||
"Checking the workspace",
|
||||
"Checking the workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers a later raw-only commentary note after consuming a same-text typed echo", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
});
|
||||
const rawCommentary = () =>
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Checking the workspace" }],
|
||||
},
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
agentMessageDelta("Checking the workspace", "msg-commentary"),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-commentary",
|
||||
phase: "commentary",
|
||||
text: "Checking the workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(rawCommentary());
|
||||
await projector.handleNotification(rawCommentary());
|
||||
|
||||
const preambles = onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
|
||||
|
||||
expect(preambles.map((event) => event.data.itemId)).toEqual([
|
||||
"msg-commentary",
|
||||
"raw-assistant-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs a raw commentary echo after a rewritten typed completion", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-commentary",
|
||||
phase: "commentary",
|
||||
text: "Contributor-rewritten note",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Original model note" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const preambles = onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
|
||||
|
||||
expect(preambles.map((event) => event.data.progressText)).toEqual([
|
||||
"Contributor-rewritten note",
|
||||
]);
|
||||
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears a pending commentary echo when the raw envelope has no text", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-commentary",
|
||||
phase: "commentary",
|
||||
text: " ",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Later raw-only note" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const preambles = onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
|
||||
|
||||
expect(preambles.map((event) => event.data.progressText)).toEqual(["Later raw-only note"]);
|
||||
});
|
||||
|
||||
it("does not resolve commentary-phase assistant text as the final reply", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
|
||||
@@ -149,6 +149,9 @@ export class CodexAppServerEventProjector {
|
||||
private readonly assistantItemOrder: string[] = [];
|
||||
private readonly assistantPhaseByItem = new Map<string, string>();
|
||||
private readonly lastCommentaryProgressTextByItem = new Map<string, string>();
|
||||
// Codex emits each typed item completion before its matching raw response item.
|
||||
// Pair by protocol order because contributors may rewrite only the typed text.
|
||||
private pendingRawCommentaryEchoes = 0;
|
||||
private readonly reasoningTextByGroup = new Map<string, ReasoningTextGroup>();
|
||||
private readonly reasoningItemOrder = new Map<string, number>();
|
||||
private readonly planTextByItem = new Map<string, string>();
|
||||
@@ -653,6 +656,7 @@ export class CodexAppServerEventProjector {
|
||||
this.assistantTextByItem.set(item.id, item.text);
|
||||
if (item.text && this.isCommentaryAssistantItem(item.id)) {
|
||||
this.emitCommentaryProgress({ itemId: item.id, text: item.text });
|
||||
this.pendingRawCommentaryEchoes += 1;
|
||||
}
|
||||
}
|
||||
this.recordNativeGeneratedMedia(item);
|
||||
@@ -914,12 +918,16 @@ export class CodexAppServerEventProjector {
|
||||
if (readString(item, "role") !== "assistant") {
|
||||
return;
|
||||
}
|
||||
const phase = readString(item, "phase");
|
||||
if (phase === "commentary" && this.pendingRawCommentaryEchoes > 0) {
|
||||
this.pendingRawCommentaryEchoes -= 1;
|
||||
return;
|
||||
}
|
||||
const text = extractRawAssistantText(item);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const itemId = readString(item, "id") ?? `raw-assistant-${this.assistantItemOrder.length + 1}`;
|
||||
const phase = readString(item, "phase");
|
||||
if (phase) {
|
||||
this.assistantPhaseByItem.set(itemId, phase);
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
|
||||
|
||||
it("records successful Codex messaging text, media, and target telemetry", async () => {
|
||||
const hooks = installOpenClawOwnedToolHooks();
|
||||
const execute = vi.fn(async () => textToolResult("Sent."));
|
||||
const execute = vi.fn(async () => textToolResult("Sent.", { messageId: "message-1" }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createContractTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
|
||||
@@ -76,6 +76,12 @@ export type CodexTurnEnvironmentParams = JsonObject & {
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type CodexPermissionProfileSelection = JsonObject & {
|
||||
type: "profile";
|
||||
id: string;
|
||||
modifications?: JsonValue[] | null;
|
||||
};
|
||||
|
||||
export type CodexThreadStartParams = JsonObject & {
|
||||
input?: CodexUserInput[];
|
||||
cwd?: string;
|
||||
@@ -85,6 +91,7 @@ export type CodexThreadStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
dynamicTools?: CodexDynamicToolSpec[] | null;
|
||||
developerInstructions?: string;
|
||||
@@ -102,6 +109,7 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
config?: JsonObject;
|
||||
developerInstructions?: string;
|
||||
@@ -153,6 +161,7 @@ export type CodexTurnStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandboxPolicy?: CodexSandboxPolicy;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
effort?: string | null;
|
||||
personality?: string | null;
|
||||
@@ -337,6 +346,12 @@ export type CodexGetAccountResponse = {
|
||||
requiresOpenaiAuth?: boolean;
|
||||
};
|
||||
|
||||
export type CodexModelProviderCapabilitiesReadResponse = {
|
||||
namespaceTools: boolean;
|
||||
imageGeneration: boolean;
|
||||
webSearch: boolean;
|
||||
};
|
||||
|
||||
export type CodexChatgptAuthTokensRefreshResponse = {
|
||||
accessToken: string;
|
||||
chatgptAccountId: string;
|
||||
@@ -542,6 +557,7 @@ type CodexAppServerRequestResultMap = {
|
||||
"marketplace/add": JsonValue;
|
||||
"mcpServerStatus/list": CodexListMcpServerStatusResponse;
|
||||
"model/list": CodexModelListResponse;
|
||||
"modelProvider/capabilities/read": CodexModelProviderCapabilitiesReadResponse;
|
||||
"plugin/install": CodexPluginInstallResponse;
|
||||
"plugin/list": CodexPluginListResponse;
|
||||
"plugin/read": CodexPluginReadResponse;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
|
||||
|
||||
const appServer = {
|
||||
start: {},
|
||||
requestTimeoutMs: 1_000,
|
||||
} as CodexAppServerRuntimeOptions;
|
||||
|
||||
function createClientFactory(webSearch: boolean | boolean[]) {
|
||||
const values = Array.isArray(webSearch) ? [...webSearch] : [webSearch];
|
||||
const request = vi.fn(async () => ({ webSearch: values.shift() ?? false }));
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
return { clientFactory, request };
|
||||
}
|
||||
|
||||
function resolveSupport(
|
||||
clientFactory: CodexAppServerClientFactory,
|
||||
modelProviderOverride?: string,
|
||||
) {
|
||||
return resolveCodexProviderWebSearchSupport({
|
||||
clientFactory,
|
||||
appServer,
|
||||
authProfileId: undefined,
|
||||
agentDir: "/tmp/agent",
|
||||
config: undefined,
|
||||
modelProviderOverride,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveCodexProviderWebSearchSupport", () => {
|
||||
it("reads the latest configured provider capability for each attempt", async () => {
|
||||
const { clientFactory, request } = createClientFactory([true, false]);
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("supported");
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unsupported");
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"modelProvider/capabilities/read",
|
||||
{},
|
||||
expect.objectContaining({ timeoutMs: 1_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unknown support when app-server startup fails", async () => {
|
||||
const clientFactory = vi.fn(async () => {
|
||||
throw new Error("old app-server");
|
||||
}) as unknown as CodexAppServerClientFactory;
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
|
||||
});
|
||||
|
||||
it("reports unknown support when the capability read fails", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("transient rpc failure");
|
||||
});
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps managed search when the configured provider reports no hosted support", async () => {
|
||||
const { clientFactory, request } = createClientFactory(false);
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unsupported");
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses hosted search for the built-in OpenAI provider override", async () => {
|
||||
const { clientFactory, request } = createClientFactory(false);
|
||||
|
||||
await expect(resolveSupport(clientFactory, " OpenAI ")).resolves.toBe("supported");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps managed search for provider overrides the capability RPC cannot target", async () => {
|
||||
const { clientFactory, request } = createClientFactory(true);
|
||||
|
||||
await expect(resolveSupport(clientFactory, "amazon-bedrock")).resolves.toBe("unsupported");
|
||||
await expect(resolveSupport(clientFactory, "custom-provider")).resolves.toBe("unsupported");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
78
extensions/codex/src/app-server/provider-capabilities.ts
Normal file
78
extensions/codex/src/app-server/provider-capabilities.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
async function readConfiguredProviderWebSearchSupport(params: {
|
||||
client: CodexAppServerClient;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexNativeWebSearchSupport> {
|
||||
const response = await params.client.request(
|
||||
"modelProvider/capabilities/read",
|
||||
{},
|
||||
{
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
return response.webSearch ? "supported" : "unsupported";
|
||||
}
|
||||
|
||||
export async function resolveCodexProviderWebSearchSupportForClient(params: {
|
||||
client: CodexAppServerClient;
|
||||
timeoutMs: number;
|
||||
modelProviderOverride: string | undefined;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexNativeWebSearchSupport> {
|
||||
const modelProviderOverride = params.modelProviderOverride?.trim().toLowerCase();
|
||||
if (modelProviderOverride === "openai") {
|
||||
return "supported";
|
||||
}
|
||||
if (modelProviderOverride) {
|
||||
// Codex's capability RPC only reports the configured provider, not a
|
||||
// thread-scoped override. Keep managed search for overrides whose hosted
|
||||
// capability cannot be proven from the configured-provider response.
|
||||
return "unsupported";
|
||||
}
|
||||
try {
|
||||
return await readConfiguredProviderWebSearchSupport(params);
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
clientFactory: CodexAppServerClientFactory;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
authProfileId: string | undefined;
|
||||
agentDir: string;
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined;
|
||||
modelProviderOverride: string | undefined;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexNativeWebSearchSupport> {
|
||||
let client: CodexAppServerClient | undefined;
|
||||
try {
|
||||
client = await params.clientFactory(
|
||||
params.appServer.start,
|
||||
params.authProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{ timeoutMs: params.appServer.requestTimeoutMs },
|
||||
);
|
||||
return await resolveCodexProviderWebSearchSupportForClient({
|
||||
client,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
modelProviderOverride: params.modelProviderOverride,
|
||||
signal: params.signal,
|
||||
});
|
||||
} catch {
|
||||
return "unknown";
|
||||
} finally {
|
||||
if (client) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5281,6 +5281,41 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(turnRequestParams?.approvalsReviewer).toBe("user");
|
||||
});
|
||||
|
||||
it("keeps managed web_search for provider-qualified Codex model overrides", async () => {
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("web_search")]);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
|
||||
if (method === "modelProvider/capabilities/read") {
|
||||
return { webSearch: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.modelId = "lmstudio/local-model";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(requests.map((request) => request.method)).not.toContain(
|
||||
"modelProvider/capabilities/read",
|
||||
);
|
||||
const startRequest = requests.find((request) => request.method === "thread/start");
|
||||
const startRequestParams = startRequest?.params as Record<string, unknown> | undefined;
|
||||
const startConfig = startRequestParams?.config as Record<string, unknown> | undefined;
|
||||
const dynamicToolNames = (
|
||||
startRequestParams?.dynamicTools as Array<{ name?: string }> | undefined
|
||||
)?.map((tool) => tool.name);
|
||||
expect(startRequestParams?.model).toBe("local-model");
|
||||
expect(startRequestParams?.modelProvider).toBe("lmstudio");
|
||||
expect(startConfig?.web_search).toBe("disabled");
|
||||
expect(dynamicToolNames).toContain("web_search");
|
||||
});
|
||||
|
||||
it("uses bound local model providers when disabling Guardian on resumed threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -217,6 +217,7 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
|
||||
import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
@@ -234,6 +235,7 @@ import {
|
||||
buildTurnCollaborationMode,
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
resolveCodexAppServerThreadModelSelection,
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
@@ -261,6 +263,7 @@ import {
|
||||
refreshCodexUsageLimitPromptError,
|
||||
} from "./usage-limit-error.js";
|
||||
import { createCodexUserInputBridge } from "./user-input-bridge.js";
|
||||
import { resolveCodexWebSearchPlan } from "./web-search.js";
|
||||
|
||||
const CODEX_NATIVE_HOOK_RELAY_RENEW_INTERVAL_MS = 60_000;
|
||||
const CODEX_APP_SERVER_PROJECTED_CHARS_PER_TOKEN = 4;
|
||||
@@ -650,6 +653,31 @@ export async function runCodexAppServerAttempt(
|
||||
sandboxExecServerEnabled,
|
||||
});
|
||||
preDynamicStartupStages.mark("native-tool-surface");
|
||||
const nativeProviderWebSearchSupport =
|
||||
resolveCodexWebSearchPlan({
|
||||
config: params.config,
|
||||
disableTools: params.disableTools,
|
||||
nativeToolSurfaceEnabled,
|
||||
}).kind === "native-hosted"
|
||||
? await resolveCodexProviderWebSearchSupport({
|
||||
clientFactory: attemptClientFactory,
|
||||
appServer,
|
||||
authProfileId: startupAuthProfileId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
modelProviderOverride: resolveCodexAppServerThreadModelSelection({
|
||||
provider: params.provider,
|
||||
model: params.modelId,
|
||||
binding: startupBinding,
|
||||
authProfileId: startupAuthProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
}).modelProvider,
|
||||
signal: runAbortController.signal,
|
||||
})
|
||||
: "unsupported";
|
||||
preDynamicStartupStages.mark("provider-capabilities");
|
||||
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
|
||||
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
@@ -716,6 +744,8 @@ export async function runCodexAppServerAttempt(
|
||||
...(onCodexToolOutcome ? { onToolOutcome: onCodexToolOutcome } : {}),
|
||||
}
|
||||
: params;
|
||||
let persistentWebSearchAllowed: boolean | undefined;
|
||||
let webSearchAllowed = false;
|
||||
const tools = await buildDynamicTools({
|
||||
params: dynamicToolParams,
|
||||
resolvedWorkspace,
|
||||
@@ -724,6 +754,7 @@ export async function runCodexAppServerAttempt(
|
||||
sandboxSessionKey,
|
||||
sandbox,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
runAbortController,
|
||||
sessionAgentId,
|
||||
pluginConfig,
|
||||
@@ -732,6 +763,12 @@ export async function runCodexAppServerAttempt(
|
||||
yieldDetected = true;
|
||||
},
|
||||
onCodexAppServerEvent: (event) => emitCodexAppServerEvent(params, event),
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
const registeredTools = await buildDynamicTools({
|
||||
params: dynamicToolParams,
|
||||
@@ -741,6 +778,7 @@ export async function runCodexAppServerAttempt(
|
||||
sandboxSessionKey,
|
||||
sandbox,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
runAbortController,
|
||||
sessionAgentId,
|
||||
pluginConfig,
|
||||
@@ -1269,10 +1307,13 @@ export async function runCodexAppServerAttempt(
|
||||
effectiveWorkspace,
|
||||
effectiveCwd,
|
||||
dynamicTools: toolBridge.specs,
|
||||
persistentWebSearchAllowed,
|
||||
webSearchAllowed,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
|
||||
bundleMcpThreadConfig,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
sandboxExecServerEnabled,
|
||||
sandbox,
|
||||
contextEngineProjection,
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("codex app-server session binding", () => {
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "tools-v1",
|
||||
webSearchThreadConfigFingerprint: "web-search-v1",
|
||||
userMcpServersFingerprint: "user-mcp-v1",
|
||||
nativeHookRelayGeneration: "generation-v1",
|
||||
});
|
||||
@@ -74,6 +75,7 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.model).toBe("gpt-5.4-codex");
|
||||
expect(binding?.modelProvider).toBe("openai");
|
||||
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
|
||||
expect(binding?.webSearchThreadConfigFingerprint).toBe("web-search-v1");
|
||||
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
|
||||
expect(binding?.nativeHookRelayGeneration).toBe("generation-v1");
|
||||
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));
|
||||
|
||||
@@ -66,8 +66,10 @@ export type CodexAppServerThreadBinding = {
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxyProfileName?: string;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
nativeHookRelayGeneration?: string;
|
||||
@@ -180,6 +182,10 @@ export async function readCodexAppServerBinding(
|
||||
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
|
||||
sandbox: readSandboxMode(parsed.sandbox),
|
||||
serviceTier: readServiceTier(parsed.serviceTier),
|
||||
networkProxyProfileName:
|
||||
typeof parsed.networkProxyProfileName === "string"
|
||||
? parsed.networkProxyProfileName
|
||||
: undefined,
|
||||
dynamicToolsFingerprint:
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
@@ -188,6 +194,10 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.dynamicToolsContainDeferred === "boolean"
|
||||
? parsed.dynamicToolsContainDeferred
|
||||
: undefined,
|
||||
webSearchThreadConfigFingerprint:
|
||||
typeof parsed.webSearchThreadConfigFingerprint === "string"
|
||||
? parsed.webSearchThreadConfigFingerprint
|
||||
: undefined,
|
||||
userMcpServersFingerprint:
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
@@ -251,8 +261,10 @@ export async function writeCodexAppServerBinding(
|
||||
approvalPolicy: binding.approvalPolicy,
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
networkProxyProfileName: binding.networkProxyProfileName,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,
|
||||
|
||||
@@ -20,6 +20,7 @@ const refreshCodexAppServerAuthTokensMock = vi.fn();
|
||||
const createOpenClawCodingToolsMock = vi.fn();
|
||||
const toolExecuteMock = vi.fn();
|
||||
const handleCodexAppServerApprovalRequestMock = vi.fn();
|
||||
const resolveCodexProviderWebSearchSupportForClientMock = vi.fn();
|
||||
|
||||
vi.mock("./session-binding.js", () => ({
|
||||
clearCodexAppServerBinding: vi.fn(),
|
||||
@@ -46,6 +47,11 @@ vi.mock("./approval-bridge.js", () => ({
|
||||
handleCodexAppServerApprovalRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./provider-capabilities.js", () => ({
|
||||
resolveCodexProviderWebSearchSupportForClient: (...args: unknown[]) =>
|
||||
resolveCodexProviderWebSearchSupportForClientMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||
createOpenClawCodingTools: (...args: unknown[]) => createOpenClawCodingToolsMock(...args),
|
||||
}));
|
||||
@@ -322,6 +328,61 @@ function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQu
|
||||
} satisfies Parameters<typeof runCodexAppServerSideQuestion>[0];
|
||||
}
|
||||
|
||||
async function runSideQuestionWithManagedWebSearchCall(
|
||||
params: Parameters<typeof runCodexAppServerSideQuestion>[0] = sideParams(),
|
||||
options: { preserveToolFactory?: boolean } = {},
|
||||
) {
|
||||
const client = createFakeClient();
|
||||
let toolResponse: unknown;
|
||||
if (!options.preserveToolFactory) {
|
||||
createOpenClawCodingToolsMock.mockReturnValue([
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
]);
|
||||
}
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
toolResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "web_search",
|
||||
arguments: { query: "service providers" },
|
||||
},
|
||||
});
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Search answer."));
|
||||
})();
|
||||
}, 0);
|
||||
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(params);
|
||||
const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork");
|
||||
const forkConfig = (forkCall?.[1] as { config?: Record<string, unknown> } | undefined)?.config;
|
||||
return { forkConfig, result, toolResponse };
|
||||
}
|
||||
|
||||
describe("runCodexAppServerSideQuestion", () => {
|
||||
beforeEach(() => {
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
@@ -332,6 +393,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
createOpenClawCodingToolsMock.mockReset();
|
||||
toolExecuteMock.mockReset();
|
||||
handleCodexAppServerApprovalRequestMock.mockReset();
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockReset();
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("supported");
|
||||
|
||||
toolExecuteMock.mockResolvedValue({
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
@@ -343,6 +406,12 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
]);
|
||||
|
||||
readCodexAppServerBindingMock.mockResolvedValue({
|
||||
@@ -380,7 +449,21 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
sideParams({
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
sessionKey: "agent:main:conversation",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
currentChannelId: "voice-room",
|
||||
agentAccountId: "account-1",
|
||||
messageTo: "channel-1",
|
||||
messageThreadId: "thread-1",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#ops",
|
||||
groupSpace: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
senderId: "sender-1",
|
||||
senderName: "Rosita",
|
||||
senderUsername: "rosita",
|
||||
senderE164: "+15550001",
|
||||
senderIsOwner: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -414,6 +497,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
expect(forkParams?.developerInstructions).toContain("You are in a side conversation");
|
||||
expect(forkParams?.developerInstructions).toContain(
|
||||
@@ -481,9 +566,211 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(toolOptions).toHaveProperty("messageProvider", "discord");
|
||||
expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice");
|
||||
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
|
||||
expect(toolOptions).toMatchObject({
|
||||
agentAccountId: "account-1",
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
runSessionKey: "agent:main:conversation",
|
||||
messageTo: "channel-1",
|
||||
messageThreadId: "thread-1",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#ops",
|
||||
groupSpace: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
senderId: "sender-1",
|
||||
senderName: "Rosita",
|
||||
senderUsername: "rosita",
|
||||
senderE164: "+15550001",
|
||||
senderIsOwner: true,
|
||||
});
|
||||
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
|
||||
});
|
||||
|
||||
it("disables hosted search when side-question sender policy removes managed web_search", async () => {
|
||||
createOpenClawCodingToolsMock.mockImplementation((options: { senderId?: string }) =>
|
||||
options.senderId === "restricted-sender"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const { forkConfig } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({ senderId: "restricted-sender" }),
|
||||
{ preserveToolFactory: true },
|
||||
);
|
||||
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "deny all", toolsAllow: [] },
|
||||
{ name: "narrow allowlist", toolsAllow: ["message"] },
|
||||
])("rejects /btw before forking when effective toolsAllow is $name", async ({ toolsAllow }) => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
senderId: "restricted-sender",
|
||||
toolsAllow,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Codex-native /btw side-question mode is unavailable because the effective tool policy restricts Codex native tools for this session.",
|
||||
);
|
||||
|
||||
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies native search restrictions to side forks and suppresses managed search", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
allowedDomains: ["example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
"tools.web_search.allowed_domains": ["example.com"],
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves managed web_search while planning hosted search for Responses side questions", async () => {
|
||||
createOpenClawCodingToolsMock.mockImplementation(
|
||||
(options: { suppressManagedWebSearch?: boolean }) =>
|
||||
options.suppressManagedWebSearch === false
|
||||
? [
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const { forkConfig, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
runtimeModel: {
|
||||
id: "gpt-5.5",
|
||||
provider: "openai",
|
||||
api: "openai-chatgpt-responses",
|
||||
} as never,
|
||||
}),
|
||||
{ preserveToolFactory: true },
|
||||
);
|
||||
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables search for side forks when the configured provider lacks hosted search", async () => {
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("unsupported");
|
||||
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall();
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables search for side forks when a managed provider is selected", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables both search surfaces for side forks when web search is disabled", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns side-thread completions scoped by nested turn thread id", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -526,6 +813,27 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks /btw native execution against the runtime-policy session", async () => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: { sandbox: { mode: "non-main", scope: "agent" } },
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
} as never,
|
||||
sessionKey: "agent:main:main",
|
||||
sandboxSessionKey: "agent:main:whatsapp:personal:direct:15555550123",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Codex-native /btw side-question mode is unavailable because OpenClaw sandboxing is active for this session.",
|
||||
);
|
||||
|
||||
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects /btw before forking when exec host=node is active", async () => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
@@ -883,6 +1191,11 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(forkParams?.approvalPolicy).toBe("on-request");
|
||||
expect(forkParams?.sandbox).toBe("workspace-write");
|
||||
expect(forkParams?.approvalsReviewer).toBe("user");
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProviderOverride: "lmstudio",
|
||||
}),
|
||||
);
|
||||
expect(config?.["features.code_mode"]).toBe(true);
|
||||
expect(config?.["features.code_mode_only"]).toBe(true);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
shouldAutoApproveCodexAppServerApprovals,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
} from "./config.js";
|
||||
import { resolveCodexMessageToolProvider } from "./dynamic-tool-build.js";
|
||||
import {
|
||||
resolveCodexMessageToolProvider,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
} from "./dynamic-tool-build.js";
|
||||
import {
|
||||
emitDynamicToolErrorDiagnostic,
|
||||
emitDynamicToolStartedDiagnostic,
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { resolveCodexProviderWebSearchSupportForClient } from "./provider-capabilities.js";
|
||||
import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
@@ -86,6 +90,11 @@ import {
|
||||
resolveReasoningEffort,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
import {
|
||||
resolveCodexWebSearchPlan,
|
||||
type CodexNativeWebSearchSupport,
|
||||
type CodexWebSearchPlan,
|
||||
} from "./web-search.js";
|
||||
|
||||
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 90_000;
|
||||
const CODEX_SIDE_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000;
|
||||
@@ -144,16 +153,6 @@ export async function runCodexAppServerSideQuestion(
|
||||
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
|
||||
);
|
||||
}
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: "/btw side-question mode",
|
||||
});
|
||||
if (nativeExecutionBlock) {
|
||||
throw new Error(nativeExecutionBlock);
|
||||
}
|
||||
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -205,6 +204,23 @@ export async function runCodexAppServerSideQuestion(
|
||||
config: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
const cwd = binding.cwd || params.workspaceDir || process.cwd();
|
||||
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: sideRunParams.config,
|
||||
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
surface: "/btw side-question mode",
|
||||
});
|
||||
if (nativeExecutionBlock) {
|
||||
throw new Error(nativeExecutionBlock);
|
||||
}
|
||||
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(sideRunParams);
|
||||
if (!nativeToolSurfaceEnabled) {
|
||||
throw new Error(
|
||||
"Codex-native /btw side-question mode is unavailable because the effective tool policy restricts Codex native tools for this session.",
|
||||
);
|
||||
}
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
@@ -230,8 +246,6 @@ export async function runCodexAppServerSideQuestion(
|
||||
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
|
||||
|
||||
try {
|
||||
const cwd = binding.cwd || params.workspaceDir || process.cwd();
|
||||
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
|
||||
const modelScopedAppServer = resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: reviewerPolicyContext.modelProvider,
|
||||
@@ -253,11 +267,25 @@ export async function runCodexAppServerSideQuestion(
|
||||
const sandbox = useModelScopedPolicy
|
||||
? modelScopedAppServer.sandbox
|
||||
: (binding.sandbox ?? modelScopedAppServer.sandbox);
|
||||
const toolBridge = await createCodexSideToolBridge({
|
||||
const nativeProviderWebSearchSupport =
|
||||
resolveCodexWebSearchPlan({
|
||||
config: params.cfg,
|
||||
nativeToolSurfaceEnabled,
|
||||
}).kind === "native-hosted"
|
||||
? await resolveCodexProviderWebSearchSupportForClient({
|
||||
client,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
modelProviderOverride: modelSelection.modelProvider,
|
||||
signal: runAbortController.signal,
|
||||
})
|
||||
: "unsupported";
|
||||
const { toolBridge, webSearchPlan } = await createCodexSideToolBridge({
|
||||
params,
|
||||
cwd,
|
||||
pluginConfig,
|
||||
sessionAgentId,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
removeRequestHandler = client.addRequestHandler(async (request) => {
|
||||
@@ -383,7 +411,8 @@ export async function runCodexAppServerSideQuestion(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(undefined, {
|
||||
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(webSearchPlan.threadConfig, {
|
||||
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
|
||||
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
|
||||
});
|
||||
const threadConfig =
|
||||
@@ -562,10 +591,25 @@ function buildSideRunAttemptParams(
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.sandboxSessionKey ? { sandboxSessionKey: params.sandboxSessionKey } : {}),
|
||||
agentId: params.agentId,
|
||||
...(params.messageChannel ? { messageChannel: params.messageChannel } : {}),
|
||||
...(params.messageProvider ? { messageProvider: params.messageProvider } : {}),
|
||||
...(params.agentAccountId ? { agentAccountId: params.agentAccountId } : {}),
|
||||
...(params.messageTo ? { messageTo: params.messageTo } : {}),
|
||||
...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}),
|
||||
...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
|
||||
...(params.groupChannel !== undefined ? { groupChannel: params.groupChannel } : {}),
|
||||
...(params.groupSpace !== undefined ? { groupSpace: params.groupSpace } : {}),
|
||||
...(params.memberRoleIds ? { memberRoleIds: params.memberRoleIds } : {}),
|
||||
...(params.spawnedBy !== undefined ? { spawnedBy: params.spawnedBy } : {}),
|
||||
...(params.senderId !== undefined ? { senderId: params.senderId } : {}),
|
||||
...(params.senderName !== undefined ? { senderName: params.senderName } : {}),
|
||||
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
|
||||
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
|
||||
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
|
||||
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
|
||||
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
|
||||
workspaceDir: options.cwd,
|
||||
authProfileId: options.authProfileId,
|
||||
authProfileIdSource: params.authProfileIdSource,
|
||||
@@ -592,8 +636,10 @@ async function createCodexSideToolBridge(input: {
|
||||
cwd: string;
|
||||
pluginConfig: ReturnType<typeof readCodexPluginConfig>;
|
||||
sessionAgentId: string;
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexDynamicToolBridge> {
|
||||
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
|
||||
const runtimeModel =
|
||||
input.params.runtimeModel ??
|
||||
({ id: input.params.model, provider: input.params.provider } as never);
|
||||
@@ -603,7 +649,10 @@ async function createCodexSideToolBridge(input: {
|
||||
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
|
||||
.createOpenClawCodingTools;
|
||||
const sandboxSessionKey =
|
||||
input.params.sessionKey?.trim() || input.params.sessionId || input.sessionAgentId;
|
||||
input.params.sandboxSessionKey?.trim() ||
|
||||
input.params.sessionKey?.trim() ||
|
||||
input.params.sessionId ||
|
||||
input.sessionAgentId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: input.params.cfg,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -638,12 +687,34 @@ async function createCodexSideToolBridge(input: {
|
||||
modelAuthMode: resolveModelAuthMode(runtimeModel.provider, input.params.cfg, undefined, {
|
||||
workspaceDir: input.cwd,
|
||||
}),
|
||||
suppressManagedWebSearch: false,
|
||||
...(input.params.messageProvider || input.params.messageChannel
|
||||
? {
|
||||
messageProvider: messageToolProvider,
|
||||
toolPolicyMessageProvider: input.params.messageProvider ?? input.params.messageChannel,
|
||||
}
|
||||
: {}),
|
||||
...(input.params.agentAccountId ? { agentAccountId: input.params.agentAccountId } : {}),
|
||||
...(input.params.messageTo ? { messageTo: input.params.messageTo } : {}),
|
||||
...(input.params.messageThreadId !== undefined
|
||||
? { messageThreadId: input.params.messageThreadId }
|
||||
: {}),
|
||||
...(input.params.groupId !== undefined ? { groupId: input.params.groupId } : {}),
|
||||
...(input.params.groupChannel !== undefined
|
||||
? { groupChannel: input.params.groupChannel }
|
||||
: {}),
|
||||
...(input.params.groupSpace !== undefined ? { groupSpace: input.params.groupSpace } : {}),
|
||||
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
|
||||
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
|
||||
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
|
||||
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
|
||||
...(input.params.senderUsername !== undefined
|
||||
? { senderUsername: input.params.senderUsername }
|
||||
: {}),
|
||||
...(input.params.senderE164 !== undefined ? { senderE164: input.params.senderE164 } : {}),
|
||||
...(input.params.senderIsOwner !== undefined
|
||||
? { senderIsOwner: input.params.senderIsOwner }
|
||||
: {}),
|
||||
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
|
||||
hookChannelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
@@ -662,26 +733,45 @@ async function createCodexSideToolBridge(input: {
|
||||
hasInboundImages: false,
|
||||
});
|
||||
}
|
||||
const requestedWebSearchPlan = resolveCodexWebSearchPlan({
|
||||
config: input.params.cfg,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
|
||||
webSearchAllowed: tools.some((tool) => tool.name === "web_search"),
|
||||
});
|
||||
// Codex forks do not accept dynamicTools, so managed web_search cannot be
|
||||
// registered on a side thread. Keep it only as the native-search policy signal.
|
||||
const webSearchPlan =
|
||||
requestedWebSearchPlan.kind === "managed"
|
||||
? resolveCodexWebSearchPlan({
|
||||
config: input.params.cfg,
|
||||
webSearchAllowed: false,
|
||||
})
|
||||
: requestedWebSearchPlan;
|
||||
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
|
||||
const hookChannelFields = buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
});
|
||||
return createCodexDynamicToolBridge({
|
||||
tools,
|
||||
signal: input.signal,
|
||||
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
|
||||
hookContext: {
|
||||
agentId: input.sessionAgentId,
|
||||
config: input.params.cfg,
|
||||
sessionId: input.params.sessionId,
|
||||
sessionKey: input.params.sessionKey,
|
||||
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
|
||||
currentChannelProvider: messageToolProvider,
|
||||
...hookChannelFields,
|
||||
},
|
||||
});
|
||||
return {
|
||||
toolBridge: createCodexDynamicToolBridge({
|
||||
tools: exposedTools,
|
||||
signal: input.signal,
|
||||
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
|
||||
hookContext: {
|
||||
agentId: input.sessionAgentId,
|
||||
config: input.params.cfg,
|
||||
sessionId: input.params.sessionId,
|
||||
sessionKey: input.params.sessionKey,
|
||||
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
|
||||
currentChannelProvider: messageToolProvider,
|
||||
...hookChannelFields,
|
||||
},
|
||||
}),
|
||||
webSearchPlan,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSideDynamicToolCallWithTimeout(params: {
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createParams,
|
||||
createParams as createRunAttemptParams,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
threadStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
@@ -29,6 +32,37 @@ function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
};
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string) {
|
||||
const params = createRunAttemptParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
return params;
|
||||
}
|
||||
|
||||
const DEFAULT_CODEX_RUNTIME_THREAD_CONFIG = {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
function createMessageDynamicTool(
|
||||
description: string,
|
||||
actions: string[] = ["send"],
|
||||
@@ -388,6 +422,540 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.modelProvider).toBe("lmstudio");
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when web search switches to a managed provider", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
appServer,
|
||||
});
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { provider: "brave" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-2");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: { web_search: "cached" },
|
||||
});
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a transient Codex thread when runtime toolsAllow denies web_search", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
params.toolsAllow = ["message"];
|
||||
const restrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
|
||||
params.toolsAllow = undefined;
|
||||
const resumedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(restrictedBinding.threadId).toBe("thread-2");
|
||||
expect(savedAfterRestriction?.threadId).toBe("thread-1");
|
||||
expect(resumedBinding.threadId).toBe("thread-1");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: { web_search: "cached" },
|
||||
});
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the native-search binding when provider capability support is unknown", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult((requestParams as { threadId: string }).threadId);
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeProviderWebSearchSupport: "supported",
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
const transientBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeProviderWebSearchSupport: "unknown",
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
const savedAfterUnknownSupport = await readCodexAppServerBinding(sessionFile);
|
||||
const resumedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeProviderWebSearchSupport: "supported",
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(transientBinding.threadId).toBe("thread-2");
|
||||
expect(savedAfterUnknownSupport?.threadId).toBe("thread-1");
|
||||
expect(resumedBinding.threadId).toBe("thread-1");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: { web_search: "cached" },
|
||||
});
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist a first-turn managed fallback when provider capability support is unknown", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-transient");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeProviderWebSearchSupport: "unknown",
|
||||
webSearchAllowed: true,
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-transient");
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("persists a restricted Codex thread when effective config policy denies web_search", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult((requestParams as { threadId: string }).threadId);
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
params.config = { tools: { deny: ["web_search"] } };
|
||||
params.toolsAllow = [];
|
||||
const restrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
persistentWebSearchAllowed: false,
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
const resumedRestrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
persistentWebSearchAllowed: false,
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(restrictedBinding.threadId).toBe("thread-2");
|
||||
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists config-denied search when runtime toolsAllow also excludes web_search", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult((requestParams as { threadId: string }).threadId);
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
persistentWebSearchAllowed: true,
|
||||
webSearchAllowed: true,
|
||||
appServer,
|
||||
});
|
||||
params.config = { tools: { deny: ["web_search"] } };
|
||||
params.toolsAllow = ["message"];
|
||||
const restrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeCodeModeEnabled: false,
|
||||
persistentWebSearchAllowed: false,
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
const resumedRestrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
nativeCodeModeEnabled: false,
|
||||
persistentWebSearchAllowed: false,
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(restrictedBinding.threadId).toBe("thread-2");
|
||||
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the Codex binding when web search is persistently disabled", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
appServer,
|
||||
});
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
webSearchAllowed: false,
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-2");
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread for default hosted search on a legacy binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeRawCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-legacy",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread for a restrictive web search policy on a legacy binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeRawCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-legacy",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { enabled: false } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread for hosted search restrictions on a legacy binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeRawCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-legacy",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { allowedDomains: ["example.com"] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({
|
||||
config: {
|
||||
web_search: "cached",
|
||||
"tools.web_search.allowed_domains": ["example.com"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when an existing session enters tool-disabled mode", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
params.disableTools = true;
|
||||
const restrictedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
|
||||
params.disableTools = false;
|
||||
const resumedBinding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(restrictedBinding.threadId).toBe("thread-2");
|
||||
expect(savedAfterRestriction?.threadId).toBe("thread-1");
|
||||
expect(resumedBinding.threadId).toBe("thread-existing");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
config: { web_search: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -856,9 +1424,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
};
|
||||
const expectedConfig = {
|
||||
...config,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
};
|
||||
|
||||
await startOrResumeThread({
|
||||
@@ -925,9 +1491,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
hooks: { PreToolUse: [] },
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
@@ -1004,17 +1568,13 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
expect(requestCalls[0]?.[1].config).toMatchObject({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
expect(requestCalls[1]?.[1].config).toMatchObject({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
});
|
||||
});
|
||||
@@ -1074,16 +1634,12 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
expect(requestCalls[1]?.[1].config).toEqual({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1144,9 +1700,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
@@ -1203,9 +1757,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
@@ -1263,9 +1815,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createPluginAppConfigPatch(),
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -1329,9 +1879,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1385,9 +1933,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createTwoPluginAppConfigPatch(),
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -1450,9 +1996,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createTwoCalendarAppConfigPatch(),
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -1504,9 +2048,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createPluginAppConfigPatch(),
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-plugins");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildThreadStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
formatCodexThreadLifecycleTimingSummary,
|
||||
resolveCodexAppServerThreadModelSelection,
|
||||
resolveReasoningEffort,
|
||||
shouldWarnCodexThreadLifecycleTimingSummary,
|
||||
startOrResumeThread,
|
||||
@@ -85,6 +86,36 @@ function createAppServerOptions() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createNetworkProxyAppServerOptions() {
|
||||
return {
|
||||
...createAppServerOptions(),
|
||||
networkProxy: {
|
||||
profileName: "mock-proxy",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createThreadLifecycleParams(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
@@ -318,10 +349,133 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
expect(request.personality).toBe("none");
|
||||
});
|
||||
|
||||
it("enables hosted Codex web search on thread/start by default", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.config).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables hosted Codex web search for tool-disabled runs", () => {
|
||||
const params = createAttemptParams({ provider: "codex" });
|
||||
params.disableTools = true;
|
||||
const request = buildThreadStartParams(params, {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.config).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables hosted Codex web search when effective tool policy denies web_search", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
webSearchAllowed: false,
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.config).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables native Codex search when runtime policy disables native tools", () => {
|
||||
const request = buildThreadResumeParams(createAttemptParams({ provider: "codex" }), {
|
||||
threadId: "thread-1",
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
nativeCodeModeEnabled: false,
|
||||
});
|
||||
|
||||
expect(request.config).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables hosted Codex web search when the active provider lacks support", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
nativeProviderWebSearchSupport: "unsupported",
|
||||
});
|
||||
|
||||
expect(request.config).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a Codex permissions profile for network-proxy thread/start requests", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
enabled: true,
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a Codex permissions profile for network-proxy thread/resume requests", () => {
|
||||
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Codex tool-search features for nano models", () => {
|
||||
const request = buildThreadStartParams(
|
||||
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
|
||||
@@ -338,6 +492,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.multi_agent": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,6 +532,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +553,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,6 +569,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -430,6 +592,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
expect(request.config).toEqual({
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,6 +611,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
expect(request.config).toEqual({
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,6 +641,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,6 +664,8 @@ describe("Codex app-server native code mode config", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -524,6 +694,35 @@ describe("Codex app-server turn input image sanitizing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy turn/start requests", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
sandboxPolicy: {
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request.sandboxPolicy).toEqual({
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches turn-scoped developer instructions without changing thread config", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
@@ -614,6 +813,8 @@ describe("Codex app-server turn params", () => {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
},
|
||||
sandbox: "danger-full-access",
|
||||
serviceTier: "flex",
|
||||
@@ -815,6 +1016,52 @@ describe("Codex app-server model provider selection", () => {
|
||||
expect(request.modelProvider).toBe("lmstudio");
|
||||
});
|
||||
|
||||
it("uses provider-qualified model refs for thread capability selection", () => {
|
||||
expect(
|
||||
resolveCodexAppServerThreadModelSelection({
|
||||
provider: "codex",
|
||||
model: "amazon-bedrock/local-model",
|
||||
}),
|
||||
).toEqual({
|
||||
model: "local-model",
|
||||
modelProvider: "amazon-bedrock",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a matching bound provider for thread capability selection", () => {
|
||||
expect(
|
||||
resolveCodexAppServerThreadModelSelection({
|
||||
provider: "codex",
|
||||
model: "local-model",
|
||||
binding: {
|
||||
threadId: "thread-1",
|
||||
model: "local-model",
|
||||
modelProvider: "amazon-bedrock",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
model: "local-model",
|
||||
modelProvider: "amazon-bedrock",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers provider-qualified models over bound providers for thread capability selection", () => {
|
||||
expect(
|
||||
resolveCodexAppServerThreadModelSelection({
|
||||
provider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
binding: {
|
||||
threadId: "thread-1",
|
||||
model: "local-model",
|
||||
modelProvider: "amazon-bedrock",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes provider-qualified model refs for turn/start metadata", () => {
|
||||
const request = buildTurnStartParams(
|
||||
createAttemptParams({ provider: "codex", modelId: "lmstudio/local-model" }),
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
} from "./context-engine-projection.js";
|
||||
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
|
||||
import {
|
||||
normalizeCodexDynamicToolName,
|
||||
shouldDisableCodexToolSearchForModel,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
|
||||
import {
|
||||
isCodexPluginThreadBindingStale,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexDynamicToolSpec,
|
||||
type CodexPermissionProfileSelection,
|
||||
type CodexSandboxPolicy,
|
||||
type CodexThreadResumeParams,
|
||||
type CodexThreadStartParams,
|
||||
@@ -55,6 +59,7 @@ import {
|
||||
type CodexAppServerContextEngineProjectionBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
export type CodexAppServerThreadLifecycle = {
|
||||
action: "started" | "resumed";
|
||||
@@ -287,6 +292,8 @@ export async function startOrResumeThread(params: {
|
||||
agentId?: string;
|
||||
cwd: string;
|
||||
dynamicTools: CodexDynamicToolSpec[];
|
||||
persistentWebSearchAllowed?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
@@ -296,6 +303,7 @@ export async function startOrResumeThread(params: {
|
||||
) => CodexThreadFinalConfigPatchResult;
|
||||
nativeHookRelayGeneration?: string;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
userMcpServersEnabled?: boolean;
|
||||
mcpServersFingerprint?: string;
|
||||
@@ -318,6 +326,16 @@ export async function startOrResumeThread(params: {
|
||||
const dynamicToolsContainDeferred = params.dynamicTools.some(
|
||||
(tool) => tool.deferLoading === true,
|
||||
);
|
||||
const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () =>
|
||||
resolveCodexWebSearchPlan({
|
||||
config: params.params.config,
|
||||
disableTools: params.params.disableTools,
|
||||
nativeToolSurfaceEnabled: params.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
}),
|
||||
);
|
||||
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
@@ -338,25 +356,20 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
}),
|
||||
);
|
||||
let startModelProvider: string | undefined;
|
||||
if (binding?.threadId) {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
startModelProvider =
|
||||
resolveCodexAppServerModelProvider({
|
||||
provider: params.params.provider,
|
||||
authProfileId,
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
}) ??
|
||||
resolveCodexBindingModelProviderFallback({
|
||||
provider: params.params.provider,
|
||||
currentModel: params.params.modelId,
|
||||
bindingModel: binding.model,
|
||||
bindingModelProvider: binding.modelProvider,
|
||||
});
|
||||
}
|
||||
let preserveExistingBinding = false;
|
||||
const startModelSelection = resolveCodexAppServerThreadModelSelection({
|
||||
provider: params.params.provider,
|
||||
model: params.params.modelId,
|
||||
binding,
|
||||
authProfileId: params.params.authProfileId,
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
const startModelProvider = startModelSelection.modelProvider;
|
||||
// Capability read failures use managed search for this turn but must not
|
||||
// create a binding that later looks like a confirmed provider-policy change.
|
||||
let preserveExistingBinding =
|
||||
params.nativeProviderWebSearchSupport === "unknown" && !binding?.threadId;
|
||||
let rotatedContextEngineBinding = false;
|
||||
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
|
||||
const throwIfAborted = () => {
|
||||
@@ -375,7 +388,47 @@ export async function startOrResumeThread(params: {
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
|
||||
const webSearchBindingChanged =
|
||||
binding?.threadId &&
|
||||
binding.webSearchThreadConfigFingerprint !== webSearchThreadConfigFingerprint;
|
||||
const persistentWebSearchRestriction =
|
||||
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
|
||||
// A transient native-tool restriction must not replace a legacy binding just
|
||||
// because that binding predates search fingerprints. Explicit persistent
|
||||
// search denial still rotates first so the restricted thread can persist.
|
||||
const deferLegacyWebSearchRotationToTransientNativeSurface =
|
||||
params.nativeCodeModeEnabled === false &&
|
||||
binding?.webSearchThreadConfigFingerprint === undefined &&
|
||||
!persistentWebSearchRestriction;
|
||||
if (
|
||||
binding?.threadId &&
|
||||
webSearchBindingChanged &&
|
||||
!deferLegacyWebSearchRotationToTransientNativeSurface
|
||||
) {
|
||||
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
|
||||
if (transientWebSearchRestriction) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server web search restricted for turn; starting transient thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
preserveExistingBinding = true;
|
||||
} else {
|
||||
// Codex can ignore resume overrides for a loaded thread, so persistent
|
||||
// search-policy changes and legacy bindings without metadata rotate first.
|
||||
embeddedAgentLog.debug("codex app-server web search config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
}
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.nativeCodeModeEnabled === false &&
|
||||
!persistentWebSearchRestriction
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server native tool surface disabled for turn; starting transient thread",
|
||||
{
|
||||
@@ -553,13 +606,16 @@ export async function startOrResumeThread(params: {
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: binding.threadId,
|
||||
authProfileId,
|
||||
model: startModelSelection.model,
|
||||
modelProvider: startModelProvider,
|
||||
appServer: params.appServer,
|
||||
dynamicTools: params.dynamicTools,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: resumeConfig,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
}),
|
||||
);
|
||||
const requestModelProvider =
|
||||
@@ -588,8 +644,10 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -635,8 +693,10 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -687,8 +747,11 @@ export async function startOrResumeThread(params: {
|
||||
developerInstructions: params.developerInstructions,
|
||||
config,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: params.webSearchAllowed,
|
||||
environmentSelection: params.environmentSelection,
|
||||
model: startModelSelection.model,
|
||||
modelProvider: startModelProvider,
|
||||
}),
|
||||
);
|
||||
@@ -731,8 +794,10 @@ export async function startOrResumeThread(params: {
|
||||
response.modelProvider ?? requestModelProvider ?? startModelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -781,6 +846,7 @@ export async function startOrResumeThread(params: {
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -796,6 +862,45 @@ export async function startOrResumeThread(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function isTransientWebSearchRestriction(
|
||||
params: Pick<
|
||||
Parameters<typeof startOrResumeThread>[0],
|
||||
| "params"
|
||||
| "nativeCodeModeEnabled"
|
||||
| "nativeProviderWebSearchSupport"
|
||||
| "persistentWebSearchAllowed"
|
||||
| "webSearchAllowed"
|
||||
>,
|
||||
): boolean {
|
||||
if (params.nativeProviderWebSearchSupport === "unknown") {
|
||||
return true;
|
||||
}
|
||||
if (params.params.config?.tools?.web?.search?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
if (params.params.disableTools === true) {
|
||||
return true;
|
||||
}
|
||||
const persistentWebSearchRestriction =
|
||||
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
|
||||
if (params.nativeCodeModeEnabled === false && !persistentWebSearchRestriction) {
|
||||
return true;
|
||||
}
|
||||
if (params.webSearchAllowed !== false) {
|
||||
return false;
|
||||
}
|
||||
if (params.persistentWebSearchAllowed !== undefined) {
|
||||
return params.persistentWebSearchAllowed;
|
||||
}
|
||||
if (params.params.toolsAllow === undefined) {
|
||||
return false;
|
||||
}
|
||||
return !params.params.toolsAllow.some((name) => {
|
||||
const normalized = normalizeCodexDynamicToolName(name);
|
||||
return normalized === "*" || normalized === "web_search";
|
||||
});
|
||||
}
|
||||
|
||||
export function buildContextEngineBinding(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
projection?: CodexContextEngineThreadBootstrapProjection,
|
||||
@@ -922,8 +1027,11 @@ export function buildThreadStartParams(
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
environmentSelection?: CodexTurnEnvironmentParams[];
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
},
|
||||
): CodexThreadStartParams {
|
||||
@@ -935,7 +1043,7 @@ export function buildThreadStartParams(
|
||||
config: params.config,
|
||||
});
|
||||
const modelSelection = resolveCodexAppServerRequestModelSelection({
|
||||
model: params.modelId,
|
||||
model: options.model ?? params.modelId,
|
||||
modelProvider: options.modelProvider ?? resolvedModelProvider,
|
||||
authProfileId: params.authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
@@ -948,13 +1056,16 @@ export function buildThreadStartParams(
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandbox: options.appServer.sandbox,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
serviceName: "OpenClaw",
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
...resolveCodexThreadEnvironmentSelection(options),
|
||||
developerInstructions:
|
||||
@@ -977,7 +1088,10 @@ export function buildThreadResumeParams(
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
model?: string | null;
|
||||
},
|
||||
): CodexThreadResumeParams {
|
||||
const resolvedModelProvider = resolveCodexAppServerModelProvider({
|
||||
@@ -988,7 +1102,7 @@ export function buildThreadResumeParams(
|
||||
config: params.config,
|
||||
});
|
||||
const modelSelection = resolveCodexAppServerRequestModelSelection({
|
||||
model: params.modelId,
|
||||
model: options.model ?? params.modelId,
|
||||
modelProvider: options.modelProvider ?? resolvedModelProvider,
|
||||
authProfileId: options.authProfileId ?? params.authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
@@ -1001,12 +1115,15 @@ export function buildThreadResumeParams(
|
||||
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandbox: options.appServer.sandbox,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
developerInstructions:
|
||||
options.developerInstructions ??
|
||||
@@ -1038,6 +1155,44 @@ export function resolveCodexBindingModelProviderFallback(params: {
|
||||
return hasProviderQualifiedModelRef(currentModel) ? undefined : params.bindingModelProvider;
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerThreadModelSelection(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
binding?: Pick<
|
||||
CodexAppServerThreadBinding,
|
||||
"threadId" | "authProfileId" | "model" | "modelProvider"
|
||||
>;
|
||||
authProfileId?: string;
|
||||
authProfileStore?: CodexAppServerAuthProfileLookup["authProfileStore"];
|
||||
agentDir?: string;
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
}): { model: string; modelProvider?: string } {
|
||||
const authProfileId = params.authProfileId ?? params.binding?.authProfileId;
|
||||
const explicitModelProvider = resolveCodexAppServerModelProvider({
|
||||
provider: params.provider,
|
||||
authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const bindingModelProvider = params.binding?.threadId
|
||||
? resolveCodexBindingModelProviderFallback({
|
||||
provider: params.provider,
|
||||
currentModel: params.model,
|
||||
bindingModel: params.binding.model,
|
||||
bindingModelProvider: params.binding.modelProvider,
|
||||
})
|
||||
: undefined;
|
||||
return resolveCodexAppServerRequestModelSelection({
|
||||
model: params.model,
|
||||
modelProvider: explicitModelProvider ?? bindingModelProvider,
|
||||
authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerRequestModelSelection(params: {
|
||||
model: string;
|
||||
modelProvider?: string | null;
|
||||
@@ -1117,12 +1272,29 @@ export function buildCodexRuntimeThreadConfig(
|
||||
function buildCodexRuntimeThreadConfigForRun(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
config: JsonObject | undefined,
|
||||
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
|
||||
options: {
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
|
||||
} = {},
|
||||
): JsonObject {
|
||||
const baseConfig = buildCodexRuntimeThreadConfig(config, options);
|
||||
const webSearchConfig = resolveCodexWebSearchPlan({
|
||||
config: params.config,
|
||||
disableTools: params.disableTools,
|
||||
nativeToolSurfaceEnabled: options.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
}).threadConfig;
|
||||
const baseConfig = buildCodexRuntimeThreadConfig(
|
||||
mergeCodexThreadConfigs(config, webSearchConfig),
|
||||
options,
|
||||
);
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
options.appServer?.networkProxy?.configPatch,
|
||||
shouldDisableCodexToolSearchForModel(params.modelId)
|
||||
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
|
||||
: undefined,
|
||||
@@ -1163,14 +1335,20 @@ export function buildTurnStartParams(
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
|
||||
return {
|
||||
threadId: options.threadId,
|
||||
input: buildUserInput(params, options.promptText),
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
...(useThreadPermissionProfile
|
||||
? {}
|
||||
: {
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ??
|
||||
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
}),
|
||||
model: modelSelection.model,
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
@@ -1186,6 +1364,20 @@ export function buildTurnStartParams(
|
||||
};
|
||||
}
|
||||
|
||||
function codexThreadSandboxOrPermissions(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
|
||||
): Pick<CodexThreadStartParams, "permissions" | "sandbox"> {
|
||||
const permissionProfile = appServer.networkProxy?.profileName;
|
||||
if (permissionProfile) {
|
||||
return { permissions: codexPermissionProfileSelection(permissionProfile) };
|
||||
}
|
||||
return { sandbox: appServer.sandbox };
|
||||
}
|
||||
|
||||
function codexPermissionProfileSelection(profileName: string): CodexPermissionProfileSelection {
|
||||
return { type: "profile", id: profileName };
|
||||
}
|
||||
|
||||
function resolveCodexThreadEnvironmentSelection(options: {
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
environmentSelection?: CodexTurnEnvironmentParams[];
|
||||
@@ -1315,6 +1507,10 @@ function fingerprintUserMcpServersConfigPatch(
|
||||
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
|
||||
}
|
||||
|
||||
function fingerprintJsonObject(value: JsonObject): string {
|
||||
return JSON.stringify(stabilizeJsonValue(value));
|
||||
}
|
||||
|
||||
function fingerprintEnvironmentSelection(
|
||||
environments: CodexTurnEnvironmentParams[] | undefined,
|
||||
): string | undefined {
|
||||
|
||||
195
extensions/codex/src/app-server/web-search.test.ts
Normal file
195
extensions/codex/src/app-server/web-search.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCodexWebSearchPlan } from "./web-search.js";
|
||||
|
||||
describe("resolveCodexWebSearchPlan", () => {
|
||||
it("uses Codex hosted web search by default when no managed provider is selected", () => {
|
||||
expect(resolveCodexWebSearchPlan({})).toEqual({
|
||||
kind: "native-hosted",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects Codex native web search tuning into thread config", () => {
|
||||
const plan = resolveCodexWebSearchPlan({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
allowedDomains: [" example.com ", "example.com", ""],
|
||||
contextSize: "high",
|
||||
userLocation: {
|
||||
country: " CA ",
|
||||
region: " Alberta ",
|
||||
city: " Edmonton ",
|
||||
timezone: "America/Edmonton",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
kind: "native-hosted",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "live",
|
||||
"tools.web_search.allowed_domains": ["example.com"],
|
||||
"tools.web_search.context_size": "high",
|
||||
"tools.web_search.location.country": "CA",
|
||||
"tools.web_search.location.region": "Alberta",
|
||||
"tools.web_search.location.city": "Edmonton",
|
||||
"tools.web_search.location.timezone": "America/Edmonton",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed web_search when an explicit managed provider is selected", () => {
|
||||
expect(
|
||||
resolveCodexWebSearchPlan({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: { provider: "brave" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed web_search for an explicit Codex native search opt-out", () => {
|
||||
expect(
|
||||
resolveCodexWebSearchPlan({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed web_search when runtime policy disables Codex native tools", () => {
|
||||
expect(resolveCodexWebSearchPlan({ nativeToolSurfaceEnabled: false })).toEqual({
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed web_search when the active Codex provider lacks hosted search", () => {
|
||||
expect(resolveCodexWebSearchPlan({ nativeProviderWebSearchSupport: "unsupported" })).toEqual({
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed web_search when active provider support is unknown", () => {
|
||||
expect(resolveCodexWebSearchPlan({ nativeProviderWebSearchSupport: "unknown" })).toEqual({
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed instead of bypassing native domain restrictions through managed fallback", () => {
|
||||
expect(
|
||||
resolveCodexWebSearchPlan({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { allowedDomains: ["example.com"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeProviderWebSearchSupport: "unsupported",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables native and managed search for tool-disabled runs", () => {
|
||||
expect(resolveCodexWebSearchPlan({ disableTools: true })).toEqual({
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables native and managed search when effective tool policy denies web_search", () => {
|
||||
expect(resolveCodexWebSearchPlan({ webSearchAllowed: false })).toEqual({
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables both native and managed search when OpenClaw web search is disabled", () => {
|
||||
expect(
|
||||
resolveCodexWebSearchPlan({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
132
extensions/codex/src/app-server/web-search.ts
Normal file
132
extensions/codex/src/app-server/web-search.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
|
||||
export type CodexWebSearchPlan = {
|
||||
kind: "native-hosted" | "managed" | "disabled";
|
||||
suppressManagedWebSearch: boolean;
|
||||
threadConfig: JsonObject;
|
||||
};
|
||||
|
||||
export type CodexNativeWebSearchSupport = "supported" | "unsupported" | "unknown";
|
||||
|
||||
const CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG: JsonObject = {
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
};
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function normalizeUniqueStrings(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = [
|
||||
...new Set(
|
||||
value.map(normalizeOptionalString).filter((entry): entry is string => Boolean(entry)),
|
||||
),
|
||||
];
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function hasManagedSearchProvider(config: OpenClawConfig | undefined): boolean {
|
||||
return normalizeOptionalString(config?.tools?.web?.search?.provider) !== undefined;
|
||||
}
|
||||
|
||||
function hasNativeDomainRestrictions(config: OpenClawConfig | undefined): boolean {
|
||||
return (
|
||||
normalizeUniqueStrings(config?.tools?.web?.search?.openaiCodex?.allowedDomains) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCodexNativeWebSearchThreadConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
): JsonObject {
|
||||
const nativeConfig = config?.tools?.web?.search?.openaiCodex;
|
||||
const threadConfig: JsonObject = {
|
||||
// Production app-server traffic rejects standalone web.run's user-defined
|
||||
// `web` namespace. Hosted web_search emits the same native search items.
|
||||
"features.standalone_web_search": false,
|
||||
// Codex treats cached as a preference and resolves it to live for
|
||||
// unrestricted permission profiles.
|
||||
web_search: nativeConfig?.mode === "live" ? "live" : "cached",
|
||||
};
|
||||
const allowedDomains = normalizeUniqueStrings(nativeConfig?.allowedDomains);
|
||||
if (allowedDomains) {
|
||||
threadConfig["tools.web_search.allowed_domains"] = allowedDomains;
|
||||
}
|
||||
if (nativeConfig?.contextSize) {
|
||||
threadConfig["tools.web_search.context_size"] = nativeConfig.contextSize;
|
||||
}
|
||||
const location = nativeConfig?.userLocation;
|
||||
const country = normalizeOptionalString(location?.country);
|
||||
const region = normalizeOptionalString(location?.region);
|
||||
const city = normalizeOptionalString(location?.city);
|
||||
const timezone = normalizeOptionalString(location?.timezone);
|
||||
if (country) {
|
||||
threadConfig["tools.web_search.location.country"] = country;
|
||||
}
|
||||
if (region) {
|
||||
threadConfig["tools.web_search.location.region"] = region;
|
||||
}
|
||||
if (city) {
|
||||
threadConfig["tools.web_search.location.city"] = city;
|
||||
}
|
||||
if (timezone) {
|
||||
threadConfig["tools.web_search.location.timezone"] = timezone;
|
||||
}
|
||||
return threadConfig;
|
||||
}
|
||||
|
||||
export function resolveCodexWebSearchPlan(params: {
|
||||
config?: OpenClawConfig;
|
||||
disableTools?: boolean;
|
||||
nativeToolSurfaceEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
webSearchAllowed?: boolean;
|
||||
}): CodexWebSearchPlan {
|
||||
if (
|
||||
params.disableTools === true ||
|
||||
params.webSearchAllowed === false ||
|
||||
params.config?.tools?.web?.search?.enabled === false
|
||||
) {
|
||||
return {
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
|
||||
};
|
||||
}
|
||||
const nativeConfig = params.config?.tools?.web?.search?.openaiCodex;
|
||||
const managedSearchExplicit =
|
||||
hasManagedSearchProvider(params.config) || nativeConfig?.enabled === false;
|
||||
const nativeProviderSupportsSearch =
|
||||
params.nativeProviderWebSearchSupport === undefined ||
|
||||
params.nativeProviderWebSearchSupport === "supported";
|
||||
const nativeSearchEnabled =
|
||||
params.nativeToolSurfaceEnabled !== false &&
|
||||
nativeProviderSupportsSearch &&
|
||||
nativeConfig?.enabled !== false &&
|
||||
!hasManagedSearchProvider(params.config);
|
||||
if (!nativeSearchEnabled) {
|
||||
if (!managedSearchExplicit && hasNativeDomainRestrictions(params.config)) {
|
||||
return {
|
||||
kind: "disabled",
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "managed",
|
||||
suppressManagedWebSearch: false,
|
||||
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "native-hosted",
|
||||
// Native and managed search must stay mutually exclusive. In particular,
|
||||
// exposing managed web_search here could bypass native allowed_domains.
|
||||
suppressManagedWebSearch: true,
|
||||
threadConfig: buildCodexNativeWebSearchThreadConfig(params.config),
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,54 @@ describe("codex conversation binding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy app-server bind threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.method).toBe("thread/start");
|
||||
expect(requests[0]?.params.permissions).toEqual({ type: "profile", id: "openclaw-network" });
|
||||
expect(requests[0]?.params).not.toHaveProperty("sandbox");
|
||||
expect(requests[0]?.params.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"openclaw-network": {
|
||||
network: {
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
@@ -937,7 +985,7 @@ describe("codex conversation binding", () => {
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
@@ -1126,6 +1174,7 @@ describe("codex conversation binding", () => {
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
@@ -1203,6 +1252,92 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy bound app-server turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
const turnStartParams: Record<string, unknown>[] = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
if (method === "turn/start") {
|
||||
turnStartParams.push(requestParams);
|
||||
setImmediate(() =>
|
||||
notificationHandler?.({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { turn: { id: "turn-1" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hello",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, reply: { text: "done" } });
|
||||
expect(turnStartParams[0]).not.toHaveProperty("permissions");
|
||||
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
} from "./app-server/config.js";
|
||||
import type {
|
||||
CodexServiceTier,
|
||||
CodexPermissionProfileSelection,
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurnStartResponse,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import {
|
||||
@@ -415,22 +417,43 @@ function buildThreadRequestRuntimeOptions(
|
||||
): {
|
||||
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
|
||||
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
serviceTier?: CodexServiceTier;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
|
||||
const sandbox = resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox);
|
||||
return {
|
||||
approvalPolicy: resolved.execPolicy?.touched
|
||||
? resolved.runtime.approvalPolicy
|
||||
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
|
||||
approvalsReviewer: resolved.runtime.approvalsReviewer,
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexConversationSandboxOrPermissions(
|
||||
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
|
||||
): {
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const networkProxy = runtime.networkProxy;
|
||||
if (networkProxy) {
|
||||
return {
|
||||
permissions: { type: "profile", id: networkProxy.profileName },
|
||||
config: networkProxy.configPatch,
|
||||
};
|
||||
}
|
||||
return { sandbox };
|
||||
}
|
||||
|
||||
async function writeThreadBindingFromResponse(
|
||||
params: CodexThreadBindingParams,
|
||||
resolved: CodexThreadBindingRuntime,
|
||||
@@ -459,6 +482,7 @@ async function writeThreadBindingFromResponse(
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
},
|
||||
{
|
||||
...resolved.agentLookup,
|
||||
@@ -568,6 +592,9 @@ async function runBoundTurn(params: {
|
||||
const sandbox = useModelScopedPolicy
|
||||
? modelScopedRuntime.sandbox
|
||||
: (binding.sandbox ?? modelScopedRuntime.sandbox);
|
||||
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
|
||||
const useStickyNetworkProfile =
|
||||
permissionProfile !== undefined && binding.networkProxyProfileName === permissionProfile;
|
||||
assertNativeConversationApprovalPolicySupported({
|
||||
execPolicy,
|
||||
approvalPolicy,
|
||||
@@ -641,7 +668,9 @@ async function runBoundTurn(params: {
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
|
||||
...(useStickyNetworkProfile
|
||||
? {}
|
||||
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
|
||||
...(modelSelection?.model ? { model: modelSelection.model } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...((binding.serviceTier ?? runtime.serviceTier)
|
||||
|
||||
@@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j
|
||||
type CodexPackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
install?: {
|
||||
requiredPlatformPackages?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
describe("codex package manifest", () => {
|
||||
@@ -18,5 +23,13 @@ describe("codex package manifest", () => {
|
||||
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
|
||||
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
|
||||
);
|
||||
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
|
||||
"@openai/codex-linux-x64",
|
||||
"@openai/codex-linux-arm64",
|
||||
"@openai/codex-darwin-x64",
|
||||
"@openai/codex-darwin-arm64",
|
||||
"@openai/codex-win32-x64",
|
||||
"@openai/codex-win32-arm64",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
99
extensions/codex/src/web-search-provider.runtime.ts
Normal file
99
extensions/codex/src/web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
readStringParam,
|
||||
resolveSearchTimeoutSeconds,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderToolExecutionContext,
|
||||
wrapWebContent,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import {
|
||||
runBoundedCodexAppServerTurn,
|
||||
type CodexBoundedTurnOptions,
|
||||
} from "./app-server/bounded-turn.js";
|
||||
import { isJsonObject, type CodexThreadItem, type JsonObject } from "./app-server/protocol.js";
|
||||
import { buildCodexNativeWebSearchThreadConfig } from "./app-server/web-search.js";
|
||||
|
||||
type WebSearchProviderContext = Parameters<WebSearchProviderPlugin["createTool"]>[0];
|
||||
|
||||
export async function executeCodexWebSearchProviderTool(
|
||||
ctx: WebSearchProviderContext,
|
||||
args: Record<string, unknown>,
|
||||
executionContext: WebSearchProviderToolExecutionContext | undefined,
|
||||
options: CodexBoundedTurnOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const start = Date.now();
|
||||
const result = await runBoundedCodexAppServerTurn({
|
||||
config: ctx.config,
|
||||
model: { mode: "live-default" },
|
||||
timeoutMs: resolveSearchTimeoutSeconds(ctx.searchConfig as SearchConfigRecord) * 1_000,
|
||||
signal: executionContext?.signal,
|
||||
agentDir: ctx.agentDir,
|
||||
options,
|
||||
taskLabel: "hosted search",
|
||||
developerInstructions:
|
||||
"You are OpenClaw's bounded web-search worker. You must use Codex hosted web_search to answer the user's search query. Return a concise grounded answer with source URLs. Do not call other tools, edit files, or ask follow-up questions.",
|
||||
input: [{ type: "text", text: query, text_elements: [] }],
|
||||
requiredModalities: ["text"],
|
||||
isolation: "private-stdio",
|
||||
threadConfig: buildCodexNativeWebSearchThreadConfig(ctx.config),
|
||||
});
|
||||
const searches = result.items
|
||||
.filter((item) => item.type === "webSearch")
|
||||
.map(summarizeCodexWebSearchItem);
|
||||
if (searches.length === 0) {
|
||||
throw new Error("Codex hosted search completed without invoking web search.");
|
||||
}
|
||||
return {
|
||||
query,
|
||||
provider: "codex",
|
||||
model: result.model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "codex",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.text, "web_search"),
|
||||
searches,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCodexWebSearchItem(item: CodexThreadItem): Record<string, unknown> {
|
||||
const action = isJsonObject(item.action) ? item.action : undefined;
|
||||
const actionType = readNonEmptyString(action, "type");
|
||||
const queries = actionType === "search" ? readNonEmptyStringArray(action, "queries") : [];
|
||||
const query =
|
||||
normalizeNonEmptyString(item.query) ??
|
||||
(actionType === "search" ? readNonEmptyString(action, "query") : undefined) ??
|
||||
queries[0];
|
||||
const url = readNonEmptyString(action, "url");
|
||||
const pattern = readNonEmptyString(action, "pattern");
|
||||
return {
|
||||
...(query ? { query } : {}),
|
||||
...(queries.length > 0 ? { queries } : {}),
|
||||
...(actionType && actionType !== "search" ? { action: actionType } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(pattern ? { pattern } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readNonEmptyString(record: JsonObject | undefined, key: string): string | undefined {
|
||||
return record ? normalizeNonEmptyString(record[key]) : undefined;
|
||||
}
|
||||
|
||||
function readNonEmptyStringArray(record: JsonObject | undefined, key: string): string[] {
|
||||
const value = record?.[key];
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((entry) => {
|
||||
const normalized = normalizeNonEmptyString(entry);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
36
extensions/codex/src/web-search-provider.shared.ts
Normal file
36
extensions/codex/src/web-search-provider.shared.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
export function createCodexWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
|
||||
return {
|
||||
id: "codex",
|
||||
label: "Codex Hosted Search",
|
||||
hint: "Grounded answers through your Codex app-server account",
|
||||
onboardingScopes: ["text-inference"],
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(uses Codex sign-in)",
|
||||
signupUrl: "https://chatgpt.com/codex",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 900,
|
||||
credentialPath: "",
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: "",
|
||||
searchCredential: { type: "none" },
|
||||
selectionPluginId: "codex",
|
||||
}),
|
||||
runSetup: async (ctx) => {
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
"Codex Hosted Search uses the bundled Codex app-server and your Codex/OpenAI sign-in.",
|
||||
"If needed, sign in with: openclaw models auth login --provider openai",
|
||||
"Verify the app-server account with /codex status.",
|
||||
].join("\n"),
|
||||
"Codex Hosted Search",
|
||||
);
|
||||
return ctx.config;
|
||||
},
|
||||
};
|
||||
}
|
||||
384
extensions/codex/src/web-search-provider.test.ts
Normal file
384
extensions/codex/src/web-search-provider.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCodexWebSearchProvider as createContractCodexWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import type { CodexAppServerStartOptions } from "./app-server/config.js";
|
||||
import type { CodexServerNotification, JsonValue } from "./app-server/protocol.js";
|
||||
import { createCodexWebSearchProvider } from "./web-search-provider.js";
|
||||
|
||||
function codexModel(
|
||||
options: {
|
||||
id?: string;
|
||||
model?: string;
|
||||
inputModalities?: string[];
|
||||
isDefault?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const id = options.id ?? "gpt-5.5";
|
||||
return {
|
||||
id,
|
||||
model: options.model ?? id,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null,
|
||||
displayName: "gpt-5.5",
|
||||
description: "GPT-5.5",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "low", description: "fast" }],
|
||||
defaultReasoningEffort: "low",
|
||||
inputModalities: options.inputModalities ?? ["text", "image"],
|
||||
supportsPersonality: false,
|
||||
additionalSpeedTiers: [],
|
||||
isDefault: options.isDefault ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function threadStartResult() {
|
||||
return {
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: true,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function turnStartResult(status = "inProgress") {
|
||||
return {
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status,
|
||||
items: [],
|
||||
error: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeClient(options?: {
|
||||
emitWebSearch?: boolean;
|
||||
models?: ReturnType<typeof codexModel>[];
|
||||
}) {
|
||||
const notifications = new Set<(notification: CodexServerNotification) => void>();
|
||||
const requests: Array<{ method: string; params?: JsonValue }> = [];
|
||||
const request = vi.fn(async (method: string, params?: JsonValue) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "model/list") {
|
||||
return { data: options?.models ?? [codexModel()], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
for (const notify of notifications) {
|
||||
if (options?.emitWebSearch !== false) {
|
||||
notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
id: "search-1",
|
||||
type: "webSearch",
|
||||
query: "plumbers in Edmonton Alberta",
|
||||
action: {
|
||||
type: "search",
|
||||
query: "plumbers in Edmonton Alberta",
|
||||
queries: ["plumbers in Edmonton Alberta"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "msg-1",
|
||||
delta: "Two current providers: Example One and Example Two.",
|
||||
},
|
||||
});
|
||||
notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: turnStartResult("completed").turn,
|
||||
},
|
||||
});
|
||||
}
|
||||
return turnStartResult();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const client = {
|
||||
request,
|
||||
addNotificationHandler(handler: (notification: CodexServerNotification) => void) {
|
||||
notifications.add(handler);
|
||||
return () => notifications.delete(handler);
|
||||
},
|
||||
addRequestHandler() {
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "codex",
|
||||
timeoutSeconds: 30,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
allowedDomains: ["example.com"],
|
||||
contextSize: "high",
|
||||
userLocation: {
|
||||
country: "CA",
|
||||
region: "Alberta",
|
||||
city: "Edmonton",
|
||||
timezone: "America/Edmonton",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("codex web search provider", () => {
|
||||
it("registers a selectable keyless provider contract", () => {
|
||||
const provider = createContractCodexWebSearchProvider();
|
||||
|
||||
expect(provider.id).toBe("codex");
|
||||
expect(provider.label).toBe("Codex Hosted Search");
|
||||
expect(provider.requiresCredential).toBe(false);
|
||||
expect(provider.envVars).toEqual([]);
|
||||
expect(provider.autoDetectOrder).toBe(900);
|
||||
expect(provider.applySelectionConfig?.({}).plugins?.entries?.codex?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("honors the explicit Codex hosted-search opt-out", () => {
|
||||
const provider = createCodexWebSearchProvider();
|
||||
|
||||
expect(
|
||||
provider.createTool({
|
||||
searchConfig: { provider: "codex", openaiCodex: { enabled: false } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when configured app-server transport cannot be isolated", async () => {
|
||||
const { client } = createFakeClient();
|
||||
const provider = createCodexWebSearchProvider({
|
||||
resolvePluginConfig: () => ({
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:4501",
|
||||
},
|
||||
}),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
const config = createConfig();
|
||||
const tool = provider.createTool({
|
||||
config,
|
||||
searchConfig: config.tools?.web?.search,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
|
||||
"Bounded Codex turns require stdio transport so native tools can be isolated.",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an isolated grounded Codex search with configured restrictions", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
let isolatedStartOptions: CodexAppServerStartOptions | undefined;
|
||||
const provider = createCodexWebSearchProvider({
|
||||
resolvePluginConfig: () => ({
|
||||
appServer: {
|
||||
args: [
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
"-c",
|
||||
"mcp_servers.external.command='unsafe'",
|
||||
],
|
||||
clearEnv: ["CODEX_HOME", "KEEP_CLEARED"],
|
||||
},
|
||||
}),
|
||||
clientFactory: async (startOptions) => {
|
||||
isolatedStartOptions = startOptions;
|
||||
return client;
|
||||
},
|
||||
});
|
||||
const config = createConfig();
|
||||
const tool = provider.createTool({
|
||||
config,
|
||||
searchConfig: config.tools?.web?.search,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
const result = await tool?.execute({ query: "plumbers in Edmonton Alberta" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
query: "plumbers in Edmonton Alberta",
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "codex",
|
||||
wrapped: true,
|
||||
},
|
||||
searches: [
|
||||
{
|
||||
query: "plumbers in Edmonton Alberta",
|
||||
queries: ["plumbers in Edmonton Alberta"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result?.content).toContain("Two current providers");
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expect(requests[1]?.params).toMatchObject({
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
cwd: expect.any(String),
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
ephemeral: true,
|
||||
config: {
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.hooks": false,
|
||||
"features.standalone_web_search": false,
|
||||
notify: [],
|
||||
web_search: "live",
|
||||
"tools.web_search.allowed_domains": ["example.com"],
|
||||
"tools.web_search.context_size": "high",
|
||||
"tools.web_search.location.country": "CA",
|
||||
"tools.web_search.location.region": "Alberta",
|
||||
"tools.web_search.location.city": "Edmonton",
|
||||
"tools.web_search.location.timezone": "America/Edmonton",
|
||||
},
|
||||
});
|
||||
const threadStartCwd = (requests[1]?.params as { cwd?: string } | undefined)?.cwd;
|
||||
const isolatedCodexHome = isolatedStartOptions?.env?.CODEX_HOME;
|
||||
expect(threadStartCwd).not.toBe("/tmp/openclaw-agent");
|
||||
expect(isolatedStartOptions?.args).toEqual(["app-server", "--listen", "stdio://"]);
|
||||
expect(isolatedStartOptions?.clearEnv).toEqual([
|
||||
"KEEP_CLEARED",
|
||||
"OPENCLAW_CODEX_APP_SERVER_ARGS",
|
||||
]);
|
||||
expect(isolatedCodexHome).toEqual(expect.any(String));
|
||||
if (!threadStartCwd || !isolatedCodexHome) {
|
||||
throw new Error("expected isolated Codex home and workspace");
|
||||
}
|
||||
expect(path.dirname(threadStartCwd)).toBe(path.dirname(isolatedCodexHome));
|
||||
});
|
||||
|
||||
it("selects the live default text-capable model", async () => {
|
||||
const { client, requests } = createFakeClient({
|
||||
models: [
|
||||
codexModel({ id: "available-first", isDefault: false }),
|
||||
codexModel({ id: "available-default", model: "available-default-wire" }),
|
||||
],
|
||||
});
|
||||
const provider = createCodexWebSearchProvider({
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
const config = createConfig();
|
||||
const tool = provider.createTool({
|
||||
config,
|
||||
searchConfig: config.tools?.web?.search,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
const result = await tool?.execute({ query: "plumbers in Edmonton Alberta" });
|
||||
|
||||
expect(result?.model).toBe("available-default-wire");
|
||||
expect(requests[1]?.params).toEqual(
|
||||
expect.objectContaining({ model: "available-default-wire" }),
|
||||
);
|
||||
expect(requests[2]?.params).toEqual(
|
||||
expect.objectContaining({ model: "available-default-wire" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the live catalog has no text-capable model", async () => {
|
||||
const { client, requests } = createFakeClient({
|
||||
models: [codexModel({ id: "image-only", inputModalities: ["image"] })],
|
||||
});
|
||||
const provider = createCodexWebSearchProvider({
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
const config = createConfig();
|
||||
const tool = provider.createTool({
|
||||
config,
|
||||
searchConfig: config.tools?.web?.search,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
|
||||
"Codex app-server has no model supporting text input.",
|
||||
);
|
||||
expect(requests.map((entry) => entry.method)).toEqual(["model/list"]);
|
||||
});
|
||||
|
||||
it("fails closed when Codex returns an ungrounded answer", async () => {
|
||||
const { client } = createFakeClient({ emitWebSearch: false });
|
||||
const provider = createCodexWebSearchProvider({
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
const config = createConfig();
|
||||
const tool = provider.createTool({
|
||||
config,
|
||||
searchConfig: config.tools?.web?.search,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
|
||||
"Codex hosted search completed without invoking web search.",
|
||||
);
|
||||
});
|
||||
});
|
||||
62
extensions/codex/src/web-search-provider.ts
Normal file
62
extensions/codex/src/web-search-provider.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import type { CodexAppServerClientFactory } from "./app-server/client-factory.js";
|
||||
import { createCodexWebSearchProviderBase } from "./web-search-provider.shared.js";
|
||||
|
||||
type CodexWebSearchRuntime = typeof import("./web-search-provider.runtime.js");
|
||||
|
||||
let codexWebSearchRuntimePromise: Promise<CodexWebSearchRuntime> | undefined;
|
||||
|
||||
function loadCodexWebSearchRuntime(): Promise<CodexWebSearchRuntime> {
|
||||
codexWebSearchRuntimePromise ??= import("./web-search-provider.runtime.js");
|
||||
return codexWebSearchRuntimePromise;
|
||||
}
|
||||
|
||||
const CodexWebSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query. Include the desired region, time range, and constraints.",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
export type CodexWebSearchProviderOptions = {
|
||||
resolvePluginConfig?: () => unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
};
|
||||
|
||||
export function createCodexWebSearchProvider(
|
||||
options: CodexWebSearchProviderOptions = {},
|
||||
): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createCodexWebSearchProviderBase(),
|
||||
createTool: (ctx) => {
|
||||
const nativeConfig = ctx.searchConfig?.openaiCodex;
|
||||
if (
|
||||
nativeConfig &&
|
||||
typeof nativeConfig === "object" &&
|
||||
!Array.isArray(nativeConfig) &&
|
||||
(nativeConfig as { enabled?: unknown }).enabled === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
description:
|
||||
"Search the current web through Codex hosted search and return a grounded answer with source URLs.",
|
||||
parameters: CodexWebSearchSchema,
|
||||
execute: async (args, executionContext) => {
|
||||
const { executeCodexWebSearchProviderTool } = await loadCodexWebSearchRuntime();
|
||||
return await executeCodexWebSearchProviderTool(ctx, args, executionContext, {
|
||||
pluginConfig:
|
||||
options.resolvePluginConfig?.() ?? resolvePluginConfigObject(ctx.config, "codex"),
|
||||
clientFactory: options.clientFactory,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
9
extensions/codex/web-search-contract-api.ts
Normal file
9
extensions/codex/web-search-contract-api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createCodexWebSearchProviderBase } from "./src/web-search-provider.shared.js";
|
||||
|
||||
export function createCodexWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createCodexWebSearchProviderBase(),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { handleDiscordAction } from "../../action-runtime-api.js";
|
||||
import { isTrustedRequesterGuildAdminAction } from "../trusted-requester-actions.js";
|
||||
import {
|
||||
isDiscordModerationAction,
|
||||
readDiscordModerationCommand,
|
||||
@@ -26,15 +27,24 @@ type Ctx = Pick<
|
||||
| "cfg"
|
||||
| "accountId"
|
||||
| "requesterSenderId"
|
||||
| "senderIsOwner"
|
||||
| "toolContext"
|
||||
| "mediaLocalRoots"
|
||||
| "mediaReadFile"
|
||||
>;
|
||||
|
||||
function readDiscordRequesterSenderId(ctx: Ctx): string | undefined {
|
||||
return ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
|
||||
? normalizeOptionalString(ctx.requesterSenderId)
|
||||
: undefined;
|
||||
const currentProvider = normalizeOptionalString(ctx.toolContext?.currentChannelProvider);
|
||||
if (currentProvider?.toLowerCase() === "discord") {
|
||||
return normalizeOptionalString(ctx.requesterSenderId);
|
||||
}
|
||||
if (
|
||||
isTrustedRequesterGuildAdminAction(ctx.action) &&
|
||||
(currentProvider || ctx.senderIsOwner !== true)
|
||||
) {
|
||||
throw new Error("Discord guild admin actions require a trusted Discord sender identity.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function senderParam(senderUserId: string | undefined) {
|
||||
@@ -356,7 +366,6 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
message: "deleteDays must be an integer from 0 to 7",
|
||||
}),
|
||||
});
|
||||
const senderUserIdLocal = normalizeOptionalString(ctx.requesterSenderId);
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: moderation.action,
|
||||
@@ -367,7 +376,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
until: moderation.until,
|
||||
reason: moderation.reason,
|
||||
deleteMessageDays: moderation.deleteMessageDays,
|
||||
senderUserId: senderUserIdLocal,
|
||||
senderUserId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
@@ -122,7 +122,24 @@ describe("handleDiscordMessageAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat non-Discord requester ids as Discord guild admin sender ids", async () => {
|
||||
it("rejects non-Discord requester ids for Discord guild admin actions", async () => {
|
||||
const cfg = discordConfig({ channels: true });
|
||||
await expect(
|
||||
handleDiscordMessageAction({
|
||||
action: "channel-delete",
|
||||
params: {
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
requesterSenderId: "telegram-user-id",
|
||||
toolContext: { currentChannelProvider: "telegram" },
|
||||
}),
|
||||
).rejects.toThrow("trusted Discord sender identity");
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps no-context Discord guild admin actions on the manual runtime path", async () => {
|
||||
const cfg = discordConfig({ channels: true });
|
||||
await handleDiscordMessageAction({
|
||||
action: "channel-delete",
|
||||
@@ -130,16 +147,67 @@ describe("handleDiscordMessageAction", () => {
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
senderIsOwner: true,
|
||||
});
|
||||
|
||||
expectDiscordActionCall({
|
||||
payload: {
|
||||
action: "channelDelete",
|
||||
accountId: undefined,
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects no-context Discord guild admin actions without owner trust", async () => {
|
||||
const cfg = discordConfig({ channels: true });
|
||||
await expect(
|
||||
handleDiscordMessageAction({
|
||||
action: "channel-delete",
|
||||
params: {
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow("trusted Discord sender identity");
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-Discord requester ids for Discord moderation actions", async () => {
|
||||
const cfg = discordConfig({ moderation: true });
|
||||
await expect(
|
||||
handleDiscordMessageAction({
|
||||
action: "timeout",
|
||||
params: {
|
||||
guildId: "guild-1",
|
||||
userId: "user-2",
|
||||
durationMin: 5,
|
||||
},
|
||||
cfg,
|
||||
requesterSenderId: "telegram-user-id",
|
||||
toolContext: { currentChannelProvider: "telegram" },
|
||||
}),
|
||||
).rejects.toThrow("trusted Discord sender identity");
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps read-only guild lookups available from non-Discord requesters", async () => {
|
||||
const cfg = discordConfig({ channelInfo: true });
|
||||
await handleDiscordMessageAction({
|
||||
action: "channel-info",
|
||||
params: {
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
requesterSenderId: "telegram-user-id",
|
||||
toolContext: { currentChannelProvider: "telegram" },
|
||||
});
|
||||
|
||||
expectDiscordActionCall({
|
||||
payload: {
|
||||
action: "channelDelete",
|
||||
accountId: undefined,
|
||||
channelId: "channel-1",
|
||||
},
|
||||
payload: { action: "channelInfo", accountId: undefined, channelId: "channel-1" },
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function handleDiscordMessageAction(
|
||||
| "cfg"
|
||||
| "accountId"
|
||||
| "requesterSenderId"
|
||||
| "senderIsOwner"
|
||||
| "toolContext"
|
||||
| "mediaAccess"
|
||||
| "mediaLocalRoots"
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("discordMessageActions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires trusted requester sender for privileged guild admin actions only from Discord turns", () => {
|
||||
it("requires trusted requester sender for privileged guild admin actions from tool contexts", () => {
|
||||
for (const action of ["channel-delete", "timeout", "kick", "ban"] as const) {
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
@@ -148,13 +148,18 @@ describe("discordMessageActions", () => {
|
||||
toolContext: { currentChannelProvider: "discord" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action: "channel-delete",
|
||||
toolContext: { currentChannelProvider: "telegram" },
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action: "read",
|
||||
@@ -513,6 +518,7 @@ describe("discordMessageActions", () => {
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
requesterSenderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
@@ -525,6 +531,7 @@ describe("discordMessageActions", () => {
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
requesterSenderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -12,24 +12,7 @@ import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
|
||||
import { readDiscordComponentSpec } from "./components.js";
|
||||
import { withDiscordInboundEventDeliveryMetadata } from "./inbound-event-delivery.js";
|
||||
|
||||
const trustedRequesterGuildAdminActions = new Set<ChannelMessageActionName>([
|
||||
"emoji-upload",
|
||||
"sticker-upload",
|
||||
"role-add",
|
||||
"role-remove",
|
||||
"channel-create",
|
||||
"channel-edit",
|
||||
"channel-delete",
|
||||
"channel-move",
|
||||
"category-create",
|
||||
"category-edit",
|
||||
"category-delete",
|
||||
"event-create",
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
]);
|
||||
import { isTrustedRequesterGuildAdminAction } from "./trusted-requester-actions.js";
|
||||
|
||||
const localExecutionActions = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
@@ -202,8 +185,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
resolveExecutionMode: resolveDiscordActionExecutionMode,
|
||||
describeMessageTool: describeDiscordMessageTool,
|
||||
requiresTrustedRequesterSender: ({ action, toolContext }) =>
|
||||
normalizeOptionalString(toolContext?.currentChannelProvider)?.toLowerCase() === "discord" &&
|
||||
trustedRequesterGuildAdminActions.has(action),
|
||||
Boolean(toolContext) && isTrustedRequesterGuildAdminAction(action),
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = normalizeOptionalString(args.action) ?? "";
|
||||
if (action === "sendMessage") {
|
||||
@@ -266,6 +248,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg,
|
||||
accountId,
|
||||
requesterSenderId,
|
||||
senderIsOwner,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
@@ -281,6 +264,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg,
|
||||
accountId,
|
||||
requesterSenderId,
|
||||
senderIsOwner,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -44,10 +44,8 @@ export function createDiscordDraftPreviewController(params: {
|
||||
const accountBlockStreamingEnabled =
|
||||
resolveChannelStreamingBlockEnabled(params.discordConfig) ??
|
||||
params.cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
const canStreamProgressDraftForToolOnlySource =
|
||||
params.sourceRepliesAreToolOnly && discordStreamMode === "progress";
|
||||
const canStreamDraft =
|
||||
(!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) &&
|
||||
!params.sourceRepliesAreToolOnly &&
|
||||
discordStreamMode !== "off" &&
|
||||
!accountBlockStreamingEnabled;
|
||||
const draftStream = canStreamDraft
|
||||
|
||||
@@ -184,6 +184,7 @@ type DispatchInboundParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
|
||||
allowToolLifecycleWhenProgressHidden?: boolean;
|
||||
onTypingCleanup?: () => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
@@ -984,6 +985,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBe(true);
|
||||
const emojis = getReactionEmojis();
|
||||
expect(emojis).toContain("👀");
|
||||
expect(emojis).toContain(DEFAULT_EMOJIS.done);
|
||||
@@ -1152,6 +1154,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBeUndefined();
|
||||
expect(getReactionEmojis()).toEqual(["👀"]);
|
||||
});
|
||||
|
||||
@@ -2154,12 +2157,12 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(
|
||||
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
|
||||
).toBeUndefined();
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
|
||||
return createNoQueuedDispatchResult();
|
||||
@@ -2179,7 +2182,36 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done");
|
||||
expect(createDiscordDraftStream).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves explicitly enabled status reactions without exposing tool progress drafts", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(params?.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createBaseContext({
|
||||
cfg: {
|
||||
tools: { profile: "coding" },
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
groupChat: { visibleReplies: "message_tool" },
|
||||
statusReactions: { enabled: true, timing: { debounceMs: 0 } },
|
||||
},
|
||||
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
|
||||
},
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getReactionEmojis()).toContain(DEFAULT_EMOJIS.done);
|
||||
expect(createDiscordDraftStream).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -981,9 +981,7 @@ async function processDiscordMessageInner(
|
||||
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
|
||||
suppressTyping: isRoomEvent ? true : undefined,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
|
||||
? true
|
||||
: undefined,
|
||||
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
|
||||
disableBlockStreaming: sourceRepliesAreToolOnly
|
||||
? true
|
||||
: (draftPreview.disableBlockStreamingForDraft ??
|
||||
@@ -1001,9 +999,12 @@ async function processDiscordMessageInner(
|
||||
? () => draftPreview.handleAssistantMessageBoundary()
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
suppressDefaultToolProgressMessages: draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
suppressDefaultToolProgressMessages:
|
||||
(sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled) ||
|
||||
draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
allowToolLifecycleWhenProgressHidden: statusReactionsEnabled ? true : undefined,
|
||||
commentaryProgressEnabled: draftPreview.isProgressMode
|
||||
? draftPreview.commentaryProgressEnabled
|
||||
: undefined,
|
||||
|
||||
24
extensions/discord/src/trusted-requester-actions.ts
Normal file
24
extensions/discord/src/trusted-requester-actions.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Discord guild-admin actions need a Discord sender identity for permission checks.
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
const trustedRequesterGuildAdminActions = new Set<ChannelMessageActionName>([
|
||||
"emoji-upload",
|
||||
"sticker-upload",
|
||||
"role-add",
|
||||
"role-remove",
|
||||
"channel-create",
|
||||
"channel-edit",
|
||||
"channel-delete",
|
||||
"channel-move",
|
||||
"category-create",
|
||||
"category-edit",
|
||||
"category-delete",
|
||||
"event-create",
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
]);
|
||||
|
||||
export function isTrustedRequesterGuildAdminAction(action: ChannelMessageActionName): boolean {
|
||||
return trustedRequesterGuildAdminActions.has(action);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
|
||||
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
||||
mockResolveStorePath(params);
|
||||
const resolveEnvelopeFormatOptionsMock = () => ({});
|
||||
const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const withReplyDispatcherMock = async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
||||
@@ -422,6 +422,7 @@ async function dispatchMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
currentCfg?: ClawdbotConfig;
|
||||
event: FeishuMessageEvent;
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
}) {
|
||||
const runtime = createRuntimeEnv();
|
||||
const feishuConfig = params.cfg.channels?.feishu;
|
||||
@@ -443,6 +444,7 @@ async function dispatchMessage(params: {
|
||||
cfg,
|
||||
event: params.event,
|
||||
runtime,
|
||||
channelRuntime: params.channelRuntime,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
@@ -960,6 +962,32 @@ describe("handleFeishuMessage ACP routing", () => {
|
||||
);
|
||||
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => {
|
||||
const partialChannelRuntime = {
|
||||
runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"],
|
||||
} as PluginRuntime["channel"];
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-partial-runtime",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
channelRuntime: partialChannelRuntime,
|
||||
});
|
||||
|
||||
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
@@ -734,7 +734,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
try {
|
||||
const core = {
|
||||
channel: channelRuntime ?? getFeishuRuntime().channel,
|
||||
channel: channelRuntime?.inbound ? channelRuntime : getFeishuRuntime().channel,
|
||||
} as ReturnType<typeof getFeishuRuntime>;
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
@@ -1602,6 +1602,7 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: agentSessionKey,
|
||||
});
|
||||
@@ -1779,6 +1780,7 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
@@ -549,18 +549,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach automatic mentions to non-streaming plain text replies", async () => {
|
||||
it("passes mention-forward targets to non-streaming plain text replies without rewriting body text", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
mentionTargets: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
|
||||
});
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
await options.deliver(
|
||||
{ text: 'plain text <at user_id="ou_body">Body User</at>' },
|
||||
{ kind: "final" },
|
||||
);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
|
||||
"mentions",
|
||||
);
|
||||
expectMockArgFields(sendMessageFeishuMock, "message send params", {
|
||||
text: 'plain text <at user_id="ou_body">Body User</at>',
|
||||
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach automatic mentions to card replies", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
|
||||
import type { MentionTarget } from "./mention-target.types.js";
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
type ClawdbotConfig,
|
||||
@@ -129,6 +130,7 @@ type CreateFeishuReplyDispatcherParams = {
|
||||
rootId?: string;
|
||||
accountId?: string;
|
||||
identity?: OutboundIdentity;
|
||||
mentionTargets?: MentionTarget[];
|
||||
/** Epoch ms when the inbound message was created. Used to suppress typing
|
||||
* indicators on old/replayed messages after context compaction (#30418). */
|
||||
messageCreateTimeMs?: number;
|
||||
@@ -149,6 +151,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
rootId,
|
||||
accountId,
|
||||
identity,
|
||||
mentionTargets,
|
||||
} = params;
|
||||
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
||||
const typingTargetMessageId = explicitTypingTargetMessageId?.trim() || replyToMessageId;
|
||||
@@ -743,7 +746,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
text,
|
||||
useCard: false,
|
||||
infoKind: info?.kind,
|
||||
sendChunk: async ({ chunk }) => {
|
||||
sendChunk: async ({ chunk, isFirst }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
@@ -752,6 +755,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
replyInThread: effectiveReplyInThread,
|
||||
allowTopLevelReplyFallback,
|
||||
accountId,
|
||||
...(info?.kind === "final" && isFirst && mentionTargets?.length
|
||||
? { mentions: mentionTargets }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Feishu tests cover send plugin behavior.
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
import { buildMarkdownCard } from "./send.js";
|
||||
import { buildFeishuPostMessagePayload, buildMarkdownCard } from "./send.js";
|
||||
|
||||
const {
|
||||
mockConvertMarkdownTables,
|
||||
@@ -64,6 +64,49 @@ let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessage
|
||||
let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate;
|
||||
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
|
||||
|
||||
describe("buildFeishuPostMessagePayload", () => {
|
||||
it("prepends structured mention targets as native post at elements", () => {
|
||||
const payload = buildFeishuPostMessagePayload({
|
||||
messageText: "hello **world**",
|
||||
mentions: [
|
||||
{ openId: "ou_alice", name: "Alice", key: "@_user_1" },
|
||||
{ openId: " ou_bob ", name: " Bob ", key: "@_user_2" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload.msgType).toBe("post");
|
||||
expect(JSON.parse(payload.content)).toEqual({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{ tag: "at", user_id: "ou_alice", user_name: "Alice" },
|
||||
{ tag: "at", user_id: "ou_bob", user_name: "Bob" },
|
||||
{ tag: "md", text: "hello **world**" },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves body-supplied at tags literal in the markdown element", () => {
|
||||
const payload = buildFeishuPostMessagePayload({
|
||||
messageText: 'please keep <at user_id="ou_body">Body User</at> literal',
|
||||
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
|
||||
});
|
||||
|
||||
expect(JSON.parse(payload.content)).toEqual({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
|
||||
{ tag: "md", text: 'please keep <at user_id="ou_body">Body User</at> literal' },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessageFeishu", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -173,6 +216,51 @@ describe("getMessageFeishu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends automatic mentions as native post elements without rewriting body text", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_mentions" } });
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
im: {
|
||||
message: {
|
||||
create,
|
||||
reply: vi.fn(),
|
||||
get: mockClientGet,
|
||||
list: mockClientList,
|
||||
patch: mockClientPatch,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendMessageFeishu({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
to: "oc_send",
|
||||
text: 'body <at user_id="ou_body">Body User</at>',
|
||||
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
|
||||
});
|
||||
|
||||
expect(mockConvertMarkdownTables).toHaveBeenCalledWith(
|
||||
'body <at user_id="ou_body">Body User</at>',
|
||||
"preserve",
|
||||
);
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "oc_send",
|
||||
msg_type: "post",
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
|
||||
{ tag: "md", text: 'body <at user_id="ou_body">Body User</at>' },
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
|
||||
});
|
||||
|
||||
it("extracts text content from interactive card elements", async () => {
|
||||
mockClientGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { requestFeishuApi } from "./comment-shared.js";
|
||||
import type { MentionTarget } from "./mention-target.types.js";
|
||||
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
|
||||
import { buildMentionedCardContent } from "./mention.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
import {
|
||||
assertFeishuMessageApiSuccess,
|
||||
@@ -546,22 +546,50 @@ export type SendFeishuMessageParams = {
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
||||
type FeishuPostMessageElement =
|
||||
| { tag: "at"; user_id: string; user_name?: string }
|
||||
| { tag: "md"; text: string };
|
||||
|
||||
function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostMessageElement[] {
|
||||
if (!mentions?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elements: FeishuPostMessageElement[] = [];
|
||||
for (const mention of mentions) {
|
||||
const userId = mention.openId.trim();
|
||||
if (!userId) {
|
||||
continue;
|
||||
}
|
||||
const userName = mention.name.trim();
|
||||
elements.push({
|
||||
tag: "at",
|
||||
user_id: userId,
|
||||
...(userName ? { user_name: userName } : {}),
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function buildFeishuPostMessagePayload(params: {
|
||||
messageText: string;
|
||||
mentions?: MentionTarget[];
|
||||
}): {
|
||||
content: string;
|
||||
msgType: string;
|
||||
} {
|
||||
const { messageText } = params;
|
||||
const { messageText, mentions } = params;
|
||||
const content: FeishuPostMessageElement[] = [
|
||||
...buildFeishuPostMentionElements(mentions),
|
||||
{
|
||||
tag: "md",
|
||||
text: messageText,
|
||||
},
|
||||
];
|
||||
return {
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "md",
|
||||
text: messageText,
|
||||
},
|
||||
],
|
||||
],
|
||||
content: [content],
|
||||
},
|
||||
}),
|
||||
msgType: "post",
|
||||
@@ -587,14 +615,9 @@ export async function sendMessageFeishu(
|
||||
channel: "feishu",
|
||||
});
|
||||
|
||||
// Build message content (with @mention support)
|
||||
let rawText = text ?? "";
|
||||
if (mentions && mentions.length > 0) {
|
||||
rawText = buildMentionedMessage(mentions, rawText);
|
||||
}
|
||||
const messageText = convertMarkdownTables(rawText, tableMode);
|
||||
const messageText = convertMarkdownTables(text ?? "", tableMode);
|
||||
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText, mentions });
|
||||
|
||||
const directParams = { receiveId, receiveIdType, content, msgType };
|
||||
return sendReplyOrFallbackDirect(client, {
|
||||
|
||||
@@ -34,17 +34,28 @@ export type SentMessageCache = {
|
||||
// duplicate delivery (noisy but not lossy) — never message loss.
|
||||
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
|
||||
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
||||
const LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS = /^[\uFEFF\uFFFD\uFFFE\uFFFF]+/u;
|
||||
|
||||
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
|
||||
return (
|
||||
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
|
||||
);
|
||||
}
|
||||
|
||||
function stripLeadingEchoTextCorruptionMarkers(text: string): string {
|
||||
let offset = 0;
|
||||
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
|
||||
offset += 1;
|
||||
}
|
||||
return offset === 0 ? text : text.slice(offset);
|
||||
}
|
||||
|
||||
function normalizeEchoTextKey(text: string | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim()
|
||||
.replace(LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS, "")
|
||||
.trim();
|
||||
const normalized = stripLeadingEchoTextCorruptionMarkers(
|
||||
text.replace(/\r\n?/g, "\n").trim(),
|
||||
).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,20 @@ describe("iMessage sent-message echo cache", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches delayed reflected echoes with leading NUL corruption markers", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||
const cache = createSentMessageCache();
|
||||
|
||||
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
|
||||
|
||||
expect(
|
||||
cache.has("acct:imessage:+1555", {
|
||||
text: "\u0000\u0000Delayed echo reply",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps attributedBody corruption cleanup leading-only", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||
@@ -67,6 +81,16 @@ describe("iMessage sent-message echo cache", () => {
|
||||
expect(cache.has("acct:imessage:+1555", { text: "Delayed\necho reply" })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps NUL corruption cleanup leading-only", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||
const cache = createSentMessageCache();
|
||||
|
||||
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
|
||||
|
||||
expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches by outbound message id and ignores placeholder ids", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||
|
||||
@@ -51,13 +51,13 @@ describe("createButtonTemplate", () => {
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("keeps longer text when thumbnail is provided", () => {
|
||||
it("truncates text to 60 chars when title and thumbnail are provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
});
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(100);
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,67 @@ describe("createCarouselColumn", () => {
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters", () => {
|
||||
it("truncates text to 120 characters when no title or image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
|
||||
|
||||
expect(column.text.length).toBe(120);
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a title is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text: longText,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
|
||||
const text = `${"x".repeat(59)}👨👩👧👦after`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(59));
|
||||
});
|
||||
|
||||
it("keeps required text when the first grapheme exceeds the limit", () => {
|
||||
const text = `😀${"\u0301".repeat(59)}`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
expect(column.text.startsWith("😀")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the compact limit when a whitespace-only title is present", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: " ",
|
||||
text: "x".repeat(150),
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(60));
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a thumbnail image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
text: longText,
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carousel column limits", () => {
|
||||
@@ -131,6 +186,20 @@ describe("createProductCarousel", () => {
|
||||
.columns;
|
||||
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||
});
|
||||
|
||||
it("preserves the complete price when truncating a long description", () => {
|
||||
const template = createProductCarousel([
|
||||
{
|
||||
title: "Product",
|
||||
description: "x".repeat(59),
|
||||
price: "$12.99",
|
||||
},
|
||||
]);
|
||||
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
|
||||
|
||||
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
|
||||
expect(columns[0].text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flex cards", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ type CarouselColumn = messagingApi.CarouselColumn;
|
||||
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
|
||||
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
|
||||
|
||||
const COMPACT_TEMPLATE_TEXT_LIMIT = 60;
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
type TemplatePayloadAction = {
|
||||
type?: "uri" | "postback" | "message";
|
||||
uri?: string;
|
||||
@@ -30,6 +33,48 @@ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
}
|
||||
|
||||
function resolveTemplateTextLimit(params: {
|
||||
title?: string;
|
||||
thumbnailImageUrl?: string;
|
||||
textOnlyLimit: number;
|
||||
}): number {
|
||||
return params.title !== undefined || params.thumbnailImageUrl !== undefined
|
||||
? COMPACT_TEMPLATE_TEXT_LIMIT
|
||||
: params.textOnlyLimit;
|
||||
}
|
||||
|
||||
function truncateTemplateText(text: string, limit: number): string {
|
||||
let result = "";
|
||||
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||
if (result.length + segment.length > limit) {
|
||||
// A pathological grapheme can exceed LINE's whole field limit. Preserve
|
||||
// graphemes normally, but keep required text non-empty without splitting
|
||||
// a surrogate pair when the first grapheme alone cannot fit.
|
||||
if (!result) {
|
||||
for (const codePoint of segment) {
|
||||
if (result.length + codePoint.length > limit) {
|
||||
break;
|
||||
}
|
||||
result += codePoint;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
result += segment;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatProductCarouselText(description: string, price?: string): string {
|
||||
if (!price) {
|
||||
return description;
|
||||
}
|
||||
const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT);
|
||||
const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1);
|
||||
const descriptionText = truncateTemplateText(description, descriptionLimit);
|
||||
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirm template (yes/no style dialog)
|
||||
*/
|
||||
@@ -68,12 +113,15 @@ export function createButtonTemplate(
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
|
||||
const textLimit = hasThumbnail ? 160 : 60;
|
||||
const textLimit = resolveTemplateTextLimit({
|
||||
title,
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
textOnlyLimit: 160,
|
||||
});
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: title.slice(0, 40), // LINE limit
|
||||
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
@@ -125,9 +173,14 @@ export function createCarouselColumn(params: {
|
||||
imageBackgroundColor?: string;
|
||||
defaultAction?: Action;
|
||||
}): CarouselColumn {
|
||||
// LINE caps a carousel column's text at 60 chars when the column carries a
|
||||
// title or thumbnail image, and 120 chars otherwise. Sending an over-length
|
||||
// text makes LINE reject the whole carousel, so mirror the conditional limit
|
||||
// the buttons template already applies above.
|
||||
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
|
||||
return {
|
||||
title: params.title?.slice(0, 40),
|
||||
text: params.text.slice(0, 120), // LINE limit
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
imageBackgroundColor: params.imageBackgroundColor,
|
||||
@@ -256,9 +309,7 @@ export function createProductCarousel(
|
||||
|
||||
return createCarouselColumn({
|
||||
title: product.title,
|
||||
text: product.price
|
||||
? `${product.description}\n${product.price}`.slice(0, 120)
|
||||
: product.description,
|
||||
text: formatProductCarouselText(product.description, product.price),
|
||||
thumbnailImageUrl: product.imageUrl,
|
||||
actions,
|
||||
});
|
||||
|
||||
@@ -40,6 +40,15 @@ describe("stripMatrixMentionPrefix", () => {
|
||||
expect(result).toBe("/model");
|
||||
});
|
||||
|
||||
it("strips bracketed @display name syntax", () => {
|
||||
const result = stripMatrixMentionPrefix({
|
||||
text: "@[OpenClaw Bot] /model",
|
||||
displayName: "OpenClaw Bot",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result).toBe("/model");
|
||||
});
|
||||
|
||||
it("returns original text when text is empty", () => {
|
||||
const result = stripMatrixMentionPrefix({
|
||||
text: "",
|
||||
|
||||
@@ -913,6 +913,31 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
expect(recordInboundSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes room messages mentioned via bracketed @displayName in formatted_body", async () => {
|
||||
const recordInboundSession = vi.fn(async () => {});
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: false,
|
||||
getMemberDisplayName: async () => "Display Name",
|
||||
recordInboundSession,
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createMatrixRoomMessageEvent({
|
||||
eventId: "$bracketed-display-name-mention",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "@[Display Name] please reply",
|
||||
formatted_body:
|
||||
'<a href="https://matrix.to/#/@bot:example.org">@[Display Name]</a> please reply',
|
||||
"m.mentions": { user_ids: ["@bot:example.org"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch self displayName for plain-text room mentions", async () => {
|
||||
const getMemberDisplayName = vi.fn(async () => "Tom Servo");
|
||||
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
||||
|
||||
@@ -228,6 +228,24 @@ describe("resolveMentions", () => {
|
||||
expect(result.hasExplicitMention).toBe(true);
|
||||
});
|
||||
|
||||
it("detects mention when the visible label is bracketed @displayName text", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "@[Display Name] please reply",
|
||||
formatted_body:
|
||||
'<a href="https://matrix.to/#/@bot:matrix.org">@[Display Name]</a> please reply',
|
||||
"m.mentions": { user_ids: ["@bot:matrix.org"] },
|
||||
},
|
||||
userId,
|
||||
displayName: "Display Name",
|
||||
text: "@[Display Name] please reply",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.hasExplicitMention).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores out-of-range hexadecimal HTML entities in visible labels", () => {
|
||||
expect(
|
||||
resolveMentions({
|
||||
|
||||
@@ -89,6 +89,7 @@ function resolveMatrixMentionPrefixCandidates(params: {
|
||||
append(localpart ? `@${localpart}` : null);
|
||||
append(params.displayName);
|
||||
append(params.displayName ? `@${params.displayName}` : null);
|
||||
append(params.displayName ? `@[${params.displayName}]` : null);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
@@ -158,6 +159,7 @@ function isVisibleMentionLabel(params: {
|
||||
localpart ? extractVisibleMentionText(`@${localpart}`) : null,
|
||||
params.displayName ? extractVisibleMentionText(params.displayName) : null,
|
||||
params.displayName ? extractVisibleMentionText(`@${params.displayName}`) : null,
|
||||
params.displayName ? extractVisibleMentionText(`@[${params.displayName}]`) : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return candidates.includes(cleaned);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ function requireMattermostReplyToModeResolver() {
|
||||
return resolveReplyToMode;
|
||||
}
|
||||
|
||||
function requireMattermostThreadTargetMatcher() {
|
||||
const matchesToolContextTarget = mattermostPlugin.threading?.matchesToolContextTarget;
|
||||
if (!matchesToolContextTarget) {
|
||||
throw new Error("mattermost threading.matchesToolContextTarget missing");
|
||||
}
|
||||
return matchesToolContextTarget;
|
||||
}
|
||||
|
||||
function requireMattermostSendText() {
|
||||
const sendText = mattermostPlugin.outbound?.sendText;
|
||||
if (!sendText) {
|
||||
@@ -236,6 +244,27 @@ describe("mattermostPlugin", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("matches bare Mattermost channel ids against the active channel target", () => {
|
||||
const matchesToolContextTarget = requireMattermostThreadTargetMatcher();
|
||||
|
||||
expect(
|
||||
matchesToolContextTarget({
|
||||
target: "tqfek9psh7fw8mpa5berwyytqw",
|
||||
toolContext: {
|
||||
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesToolContextTarget({
|
||||
target: "tqfek9psh7fw8mpa5berwyytqw",
|
||||
toolContext: {
|
||||
currentChannelId: "channel:kqfek9psh7fw8mpa5berwyytqw",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the effective reply root as the transport thread", () => {
|
||||
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
|
||||
if (!resolveReplyTransport) {
|
||||
@@ -249,8 +278,30 @@ describe("mattermostPlugin", () => {
|
||||
threadId: "other-thread",
|
||||
}),
|
||||
).toEqual({
|
||||
replyToId: "post-parent",
|
||||
threadId: "post-parent",
|
||||
replyToId: "other-thread",
|
||||
threadId: "other-thread",
|
||||
});
|
||||
expect(
|
||||
resolveReplyTransport({
|
||||
cfg: {},
|
||||
replyToId: "child-post",
|
||||
replyToIsExplicit: true,
|
||||
threadId: "root-post",
|
||||
}),
|
||||
).toEqual({
|
||||
replyToId: "root-post",
|
||||
threadId: "root-post",
|
||||
});
|
||||
expect(
|
||||
resolveReplyTransport({
|
||||
cfg: {},
|
||||
replyToId: "child-post",
|
||||
replyToIsExplicit: false,
|
||||
threadId: "root-post",
|
||||
}),
|
||||
).toEqual({
|
||||
replyToId: "root-post",
|
||||
threadId: "root-post",
|
||||
});
|
||||
expect(
|
||||
resolveReplyTransport({
|
||||
@@ -402,6 +453,17 @@ describe("mattermostPlugin", () => {
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveAutoThreadId({
|
||||
cfg: {},
|
||||
to: "tqfek9psh7fw8mpa5berwyytqw",
|
||||
toolContext: {
|
||||
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
|
||||
currentThreadTs: "root-1",
|
||||
replyToMode: "all",
|
||||
},
|
||||
}),
|
||||
).toBe("root-1");
|
||||
expect(
|
||||
resolveAutoThreadId({
|
||||
cfg: {},
|
||||
@@ -714,7 +776,7 @@ describe("mattermostPlugin", () => {
|
||||
expect(options.replyToId).toBe("post-root");
|
||||
});
|
||||
|
||||
it("keeps explicit reply precedence when threadId is also provided", async () => {
|
||||
it("uses threadId as the Mattermost root when generic replyTo names a child post", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.(
|
||||
@@ -732,7 +794,29 @@ describe("mattermostPlugin", () => {
|
||||
);
|
||||
|
||||
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
|
||||
expect(options.replyToId).toBe("child-post");
|
||||
expect(options.replyToId).toBe("post-root");
|
||||
});
|
||||
|
||||
it("keeps explicit replyToId precedence when threadId is also provided", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.(
|
||||
createMattermostActionContext({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:CHAN1",
|
||||
message: "hello",
|
||||
replyToId: "explicit-root",
|
||||
threadId: "post-root",
|
||||
replyTo: "child-post",
|
||||
},
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
|
||||
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
|
||||
expect(options.replyToId).toBe("explicit-root");
|
||||
});
|
||||
|
||||
it("routes filePath send actions through Mattermost media upload options", async () => {
|
||||
|
||||
@@ -258,10 +258,8 @@ function resolveMattermostAutoThreadId(params: {
|
||||
typeof context?.currentMessageId === "number"
|
||||
? String(context.currentMessageId)
|
||||
: normalizeOptionalString(context?.currentMessageId);
|
||||
const currentTarget = context?.currentChannelId
|
||||
? normalizeMattermostMessagingTarget(context.currentChannelId)
|
||||
: undefined;
|
||||
if (currentThreadId && currentTarget === normalizeMattermostMessagingTarget(params.to)) {
|
||||
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
|
||||
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
|
||||
if (replyToId === currentMessageId) {
|
||||
return currentThreadId;
|
||||
}
|
||||
@@ -276,6 +274,28 @@ function resolveMattermostAutoThreadId(params: {
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
function normalizeMattermostThreadTarget(raw: string | undefined): string | undefined {
|
||||
const normalized = raw ? normalizeMattermostMessagingTarget(raw) : undefined;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
return trimmed && /^[a-z0-9]{26}$/i.test(trimmed) ? `channel:${trimmed}` : undefined;
|
||||
}
|
||||
|
||||
function matchesMattermostToolContextTarget(params: {
|
||||
target: string;
|
||||
toolContext: ChannelThreadingToolContext;
|
||||
}): boolean {
|
||||
const target = normalizeMattermostThreadTarget(params.target);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return [params.toolContext.currentChannelId, params.toolContext.currentMessagingTarget].some(
|
||||
(currentTarget) => normalizeMattermostThreadTarget(currentTarget) === target,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
|
||||
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
|
||||
}
|
||||
@@ -420,12 +440,13 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
: typeof params.message === "string"
|
||||
? params.message
|
||||
: "";
|
||||
// Match the shared runner semantics: trim empty reply IDs away before
|
||||
// falling back from replyToId to replyTo on direct plugin calls.
|
||||
// Mattermost post root_id is the thread root. A generic replyTo can name
|
||||
// the current child post, so prefer threadId unless the caller supplied the
|
||||
// Mattermost-specific replyToId root directly.
|
||||
const replyToId =
|
||||
normalizeOptionalString(params.replyToId) ??
|
||||
normalizeOptionalString(params.replyTo) ??
|
||||
normalizeOptionalString(params.threadId);
|
||||
normalizeOptionalString(params.threadId) ??
|
||||
normalizeOptionalString(params.replyTo);
|
||||
const resolvedAccountId = accountId || undefined;
|
||||
|
||||
const attachmentMedia = collectMattermostAttachmentMedia(params);
|
||||
@@ -896,16 +917,18 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
},
|
||||
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
|
||||
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
|
||||
matchesToolContextTarget: ({ target, toolContext }) =>
|
||||
matchesMattermostToolContextTarget({ target, toolContext }),
|
||||
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
|
||||
const ambientThreadId = threadId != null ? String(threadId) : undefined;
|
||||
const resolvedThreadId =
|
||||
replyDelivery?.chatType === "direct"
|
||||
? undefined
|
||||
: replyToIsExplicit
|
||||
? (replyToId ?? ambientThreadId)
|
||||
: replyDelivery
|
||||
? (ambientThreadId ?? replyToId ?? undefined)
|
||||
: (replyToId ?? ambientThreadId);
|
||||
: replyDelivery
|
||||
? replyToIsExplicit
|
||||
? (replyToId ?? ambientThreadId)
|
||||
: (ambientThreadId ?? replyToId ?? undefined)
|
||||
: (ambientThreadId ?? replyToId);
|
||||
return {
|
||||
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
|
||||
threadId: resolvedThreadId ?? null,
|
||||
|
||||
@@ -445,13 +445,14 @@ describe("mattermost inbound user posts", () => {
|
||||
expect(ctx?.Provider).toBe("mattermost");
|
||||
});
|
||||
|
||||
it("merges Mattermost progress preview updates by line identity", async () => {
|
||||
it("merges Mattermost progress preview updates and clears after message-tool delivery", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const abortController = new AbortController();
|
||||
mockState.abortController = abortController;
|
||||
const draftStream = {
|
||||
update: vi.fn(),
|
||||
flush: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
mockState.createMattermostDraftStream.mockReturnValue(draftStream);
|
||||
@@ -505,6 +506,7 @@ describe("mattermost inbound user posts", () => {
|
||||
status: "completed",
|
||||
progressText: "done",
|
||||
});
|
||||
await params.replyOptions?.onObservedReplyDelivery?.();
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
@@ -543,6 +545,9 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
||||
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||
expect(updates.at(-1)).toContain("Read");
|
||||
expect(updates.at(-1)).toContain("Exec");
|
||||
|
||||
@@ -1922,6 +1922,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
draftToolProgressEnabled ? true : undefined,
|
||||
onObservedReplyDelivery: draftToolProgressEnabled
|
||||
? () => draftStream.clear()
|
||||
: undefined,
|
||||
disableBlockStreaming: true,
|
||||
...(suppressDefaultToolProgressMessages
|
||||
? { suppressDefaultToolProgressMessages: true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user