mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 03:28:57 +08:00
Compare commits
1 Commits
codex/matt
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dedb8e40 |
@@ -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 only while the work remains inside the original task scope.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- 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,42 +43,6 @@ 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,36 +440,8 @@ 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.
|
||||
@@ -491,11 +463,8 @@ 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,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -146,23 +145,8 @@ 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 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>`.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- 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.
|
||||
|
||||
@@ -16,15 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
||||
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
|
||||
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
|
||||
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
|
||||
- Anthropic release lanes support both API keys and OAuth. When API keys are
|
||||
exhausted but a maintainer-owned OAuth token passes a live Anthropic probe,
|
||||
set `ANTHROPIC_OAUTH_TOKEN` for provider/runtime lanes and
|
||||
refreshable `OPENCLAW_CLAUDE_CREDENTIALS_JSON` or
|
||||
`CLAUDE_CODE_OAUTH_TOKEN` for Claude CLI subscription lanes before rerunning
|
||||
the matrix. Revalidate short-lived OAuth immediately before dispatch. Never
|
||||
keep retrying a known exhausted API key. Live-cache validation must prefer
|
||||
the proven OAuth token instead of leaving an exhausted API key first in the
|
||||
runtime key pool.
|
||||
- Full Release Validation parent monitors fail fast: once a required child job
|
||||
fails, the parent cancels the remaining child matrix and prints the failed
|
||||
job summary. Inspect that first red job instead of waiting for unrelated
|
||||
@@ -45,8 +36,6 @@ git rev-parse HEAD
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
For Anthropic it prefers `ANTHROPIC_OAUTH_TOKEN` and validates it with bearer
|
||||
OAuth headers when present; otherwise it checks API-key-shaped credentials.
|
||||
|
||||
## Dispatch
|
||||
|
||||
@@ -76,13 +65,6 @@ 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 \
|
||||
@@ -125,10 +107,6 @@ Stop watchers before ending the turn or switching strategy.
|
||||
```
|
||||
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
|
||||
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
|
||||
For Docker CLI-backend failures, also validate
|
||||
`OPENCLAW_CLAUDE_CREDENTIALS_JSON` or `CLAUDE_CODE_OAUTH_TOKEN` in a
|
||||
clean-home Claude CLI probe; that lane should use subscription mode when
|
||||
either credential exists.
|
||||
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
|
||||
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async function checkProvider(id, config) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const headers = config.headers(secret);
|
||||
const headers = config.headers(secret.value);
|
||||
const response = await fetch(config.url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -69,32 +69,25 @@ const providers = {
|
||||
openai: {
|
||||
env: ["OPENAI_API_KEY"],
|
||||
url: "https://api.openai.com/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
anthropic: {
|
||||
env: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
headers: ({ name, value }) =>
|
||||
name === "ANTHROPIC_OAUTH_TOKEN"
|
||||
? {
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-version": "2023-06-01",
|
||||
authorization: `Bearer ${value}`,
|
||||
}
|
||||
: {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": value,
|
||||
},
|
||||
headers: (token) => ({
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": token,
|
||||
}),
|
||||
},
|
||||
fireworks: {
|
||||
env: ["FIREWORKS_API_KEY"],
|
||||
url: "https://api.fireworks.ai/inference/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
openrouter: {
|
||||
env: ["OPENROUTER_API_KEY"],
|
||||
url: "https://openrouter.ai/api/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -552,16 +552,6 @@ 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`.
|
||||
@@ -730,13 +720,8 @@ 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 first recovery probe:
|
||||
verifier command remains the 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
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -210,7 +210,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
|
||||
5
.github/workflows/ci-check-arm-testbox.yml
vendored
5
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -128,7 +128,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
5
.github/workflows/ci-check-testbox.yml
vendored
5
.github/workflows/ci-check-testbox.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -113,7 +113,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -499,7 +499,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -564,7 +564,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -810,7 +810,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -899,7 +899,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -979,7 +979,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1056,7 +1056,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1131,7 +1131,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1258,7 +1258,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1399,7 +1399,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1523,13 +1523,7 @@ jobs:
|
||||
fi
|
||||
;;
|
||||
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
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
@@ -1584,7 +1578,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1630,7 +1624,7 @@ jobs:
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
@@ -1677,7 +1671,7 @@ jobs:
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -2083,7 +2077,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
1
.github/workflows/crabbox-hydrate.yml
vendored
1
.github/workflows/crabbox-hydrate.yml
vendored
@@ -663,7 +663,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
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 dispatch_output run_id status conclusion url poll_count
|
||||
local before_json 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,6 +298,8 @@ 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="$(
|
||||
@@ -307,7 +309,20 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -408,7 +423,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local dispatch_output run_id status conclusion url poll_count
|
||||
local before_json 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
|
||||
@@ -431,6 +446,8 @@ 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="$(
|
||||
@@ -440,7 +457,20 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -551,7 +581,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local dispatch_output run_id status conclusion url poll_count run_json
|
||||
local before_json 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
|
||||
@@ -574,6 +604,8 @@ 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="$(
|
||||
@@ -583,7 +615,20 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -883,6 +928,8 @@ 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
|
||||
@@ -899,16 +946,22 @@ jobs:
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
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
|
||||
)"
|
||||
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
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
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
|
||||
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1020,23 +1073,31 @@ jobs:
|
||||
echo "- Release impact: advisory"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
|
||||
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 \
|
||||
--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)"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
tail -n 1
|
||||
)"
|
||||
-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
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
|
||||
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
6
.github/workflows/install-smoke.yml
vendored
6
.github/workflows/install-smoke.yml
vendored
@@ -476,21 +476,19 @@ jobs:
|
||||
- name: Run Rocky Linux installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
|
||||
|
||||
- name: Run Rocky Linux CLI installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
|
||||
|
||||
bun_global_install_smoke:
|
||||
|
||||
@@ -229,8 +229,6 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -521,7 +519,6 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
steps:
|
||||
@@ -544,13 +541,10 @@ jobs:
|
||||
echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_OAUTH_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "ANTHROPIC_API_KEY=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
run: |
|
||||
@@ -686,7 +680,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
@@ -951,7 +944,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
@@ -1663,7 +1655,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -1755,7 +1746,7 @@ jobs:
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
@@ -1787,7 +1778,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -1931,7 +1921,7 @@ jobs:
|
||||
IFS=',' read -r -a providers <<<"${OPENCLAW_LIVE_PROVIDERS}"
|
||||
for provider in "${providers[@]}"; do
|
||||
case "$provider" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
@@ -2150,7 +2140,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -2233,11 +2222,7 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2371,7 +2356,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -2463,11 +2447,7 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2588,7 +2568,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
@@ -631,7 +631,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -725,7 +724,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
@@ -1112,14 +1112,13 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
local release_version body_file notes_file 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"
|
||||
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}")"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
3
.github/workflows/package-acceptance.yml
vendored
3
.github/workflows/package-acceptance.yml
vendored
@@ -203,8 +203,6 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -590,7 +588,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
14
.github/workflows/windows-testbox-probe.yml
vendored
14
.github/workflows/windows-testbox-probe.yml
vendored
@@ -133,9 +133,8 @@ 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
|
||||
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
|
||||
Write-Host $import.Text
|
||||
Write-Host "wsl_import_exit=$($import.Code)"
|
||||
wsl.exe --import UbuntuProbe $wslRoot $rootfs --version 2
|
||||
Write-Host "wsl_import_exit=$LASTEXITCODE"
|
||||
$list = Invoke-WslText -Arguments @("--list", "--verbose")
|
||||
Write-Host $list.Text
|
||||
Write-Host "wsl_list_after_import_exit=$($list.Code)"
|
||||
@@ -145,15 +144,14 @@ jobs:
|
||||
if ($distros.Count -gt 0) {
|
||||
$distro = $distros[0]
|
||||
Write-Host "wsl_probe_distro=$distro"
|
||||
$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')
|
||||
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'
|
||||
} else {
|
||||
$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')
|
||||
wsl.exe --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
|
||||
}
|
||||
Write-Host $exec.Text
|
||||
if ($exec.Code -eq 0) {
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$ok = $true
|
||||
}
|
||||
Write-Host "wsl_exec_exit=$($exec.Code)"
|
||||
Write-Host "wsl_exec_exit=$LASTEXITCODE"
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
|
||||
3
.github/workflows/workflow-sanity.yml
vendored
3
.github/workflows/workflow-sanity.yml
vendored
@@ -251,6 +251,3 @@ 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.
|
||||
- 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`.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
|
||||
@@ -23,24 +23,15 @@ 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.
|
||||
- TUI: reload the active session after external `/new` or `/reset` session-change events so stale transcript and stream state clear promptly. Fixes #38966; carries forward #40472. Thanks @yizhanzjz and @wsyjh8.
|
||||
- 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,15 +306,6 @@
|
||||
"fps",
|
||||
"screenIndex"
|
||||
]
|
||||
},
|
||||
"screen_snapshot": {
|
||||
"label": "screen snapshot",
|
||||
"detailKeys": [
|
||||
"node",
|
||||
"nodeId",
|
||||
"screenIndex",
|
||||
"maxWidth"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6156,7 +6156,6 @@ 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?,
|
||||
@@ -6168,8 +6167,7 @@ public struct CronListParams: Codable, Sendable {
|
||||
lastrunstatus: AnyCodable?,
|
||||
sortby: AnyCodable?,
|
||||
sortdir: AnyCodable?,
|
||||
agentid: String? = nil,
|
||||
compact: Bool? = nil)
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.includedisabled = includedisabled
|
||||
self.limit = limit
|
||||
@@ -6181,7 +6179,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
self.sortby = sortby
|
||||
self.sortdir = sortdir
|
||||
self.agentid = agentid
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -6195,7 +6192,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
case sortby = "sortBy"
|
||||
case sortdir = "sortDir"
|
||||
case agentid = "agentId"
|
||||
case compact
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
64c09563ce090b8dda1e9794f021af3068db0ff4a59cf471e5ad094d2ae978b8 config-baseline.json
|
||||
bb9f42e7f1d6713af46d693dba5c43efd603c222d7d0adbd0f0d0e95cdec6790 config-baseline.core.json
|
||||
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
|
||||
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json
|
||||
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
|
||||
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
|
||||
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
|
||||
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
9eaddb6e9ad300ea9cb113c78a84e8a104cb3efab843ec0a0edd9947c7731fc8 plugin-sdk-api-baseline.json
|
||||
8e7b3e5c86a9039a0ce8a134dad79ef65fc72c085d044346f8dbfa88d6fccf1b plugin-sdk-api-baseline.jsonl
|
||||
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
|
||||
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -465,9 +465,7 @@ 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.
|
||||
|
||||
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.
|
||||
`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`.
|
||||
|
||||
<Note>
|
||||
Model override note:
|
||||
|
||||
@@ -50,8 +50,6 @@ 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,10 +111,6 @@ 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:
|
||||
@@ -422,19 +418,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich message formatting">
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
Outbound text uses Telegram rich messages.
|
||||
|
||||
- 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.
|
||||
@@ -442,8 +426,6 @@ 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>
|
||||
@@ -1099,7 +1081,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`, `richMessages`, `linkPreview`, `responsePrefix`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `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,8 +93,6 @@ 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,8 +54,7 @@ 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, exec approval
|
||||
file posture, and tool metadata looks like this:
|
||||
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -146,15 +145,6 @@ file posture, and tool metadata looks like this:
|
||||
"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": {
|
||||
@@ -197,11 +187,9 @@ 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. 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
|
||||
`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
|
||||
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.
|
||||
@@ -230,8 +218,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.*`, `dataHandling.memory.*`,
|
||||
and `execApprovals.*`. Channel-scoped
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
|
||||
`dataHandling.memory.*`. 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
|
||||
@@ -316,10 +304,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`, `dataHandling.memory`, and `execApprovals` | 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`, and `dataHandling.memory` | 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.
|
||||
|
||||
@@ -413,69 +401,6 @@ 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 |
|
||||
@@ -844,13 +769,6 @@ 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 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 warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
|
||||
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
|
||||
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
||||
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).
|
||||
|
||||
@@ -122,7 +122,7 @@ openclaw sessions cleanup --json
|
||||
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
|
||||
|
||||
- `--dry-run`: preview how many entries would be pruned/capped without writing.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) plus a summary grouped by session label so you can see what would be kept vs removed.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing or header-only/empty, even if they would not normally age/count out yet.
|
||||
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
|
||||
|
||||
@@ -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"`** when timestamp context is not useful for the conversation. This removes absolute timestamps from envelopes, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
- **Set `envelopeTimestamp: "off"`** for low-token envelopes when timestamp context is not useful for the conversation.
|
||||
|
||||
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, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -1385,8 +1385,7 @@
|
||||
"pages": [
|
||||
"clawhub/api",
|
||||
"clawhub/http-api",
|
||||
"clawhub/acceptable-usage",
|
||||
"clawhub/content-rights"
|
||||
"clawhub/acceptable-usage"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>` 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-session-key: <sessionKey>` fully controls session routing.
|
||||
- `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` 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:`.
|
||||
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.
|
||||
|
||||
## 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 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.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.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 |
|
||||
|
||||
@@ -824,7 +824,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
|
||||
- Release user journey smoke: `pnpm test:docker:release-user-journey` installs the packed OpenClaw tarball globally in a clean Docker home, runs onboarding, configures a mocked OpenAI provider, runs an agent turn, installs/uninstalls external plugins, configures ClickClack against a local fixture, verifies outbound/inbound messaging, restarts Gateway, and runs doctor.
|
||||
- Release typed onboarding smoke: `pnpm test:docker:release-typed-onboarding` installs the packed tarball, drives `openclaw onboard` through a real TTY, configures OpenAI as an env-ref provider, verifies no raw key persistence, and runs a mocked agent turn.
|
||||
- Release media/memory smoke: `pnpm test:docker:release-media-memory` installs the packed tarball, verifies image understanding from a PNG attachment, OpenAI-compatible image generation output, memory search recall, and recall survival across Gateway restart.
|
||||
- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs the newest published baseline older than the candidate tarball by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. If no older published baseline exists, it reuses the candidate version. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@<version>`.
|
||||
- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs `openclaw@latest` by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@<version>`.
|
||||
- Release plugin marketplace smoke: `pnpm test:docker:release-plugin-marketplace` installs from a local fixture marketplace, updates the installed plugin, uninstalls it, and verifies the plugin CLI disappears with install metadata pruned.
|
||||
- Skill install smoke: `pnpm test:docker:skill-install` installs the packed OpenClaw tarball globally in Docker, disables uploaded archive installs in config, resolves the current live ClawHub skill slug from search, installs it with `openclaw skills install`, and verifies the installed skill plus `.clawhub` origin/lock metadata.
|
||||
- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status.
|
||||
|
||||
@@ -505,22 +505,9 @@ 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, 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.
|
||||
gateway, `heartbeat_respond`, and `web_search` are available through Codex tool
|
||||
search under the `openclaw` namespace, keeping the initial model context
|
||||
smaller.
|
||||
`sessions_yield` and message-tool-only source replies stay direct because
|
||||
those are turn-control contracts. `sessions_spawn` stays searchable so Codex's
|
||||
native `spawn_agent` remains the primary Codex subagent surface, while explicit
|
||||
|
||||
@@ -1278,7 +1278,6 @@ 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
|
||||
@@ -1291,13 +1290,6 @@ 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,7 +163,6 @@ 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 plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
|
||||
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
|
||||
| `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,9 +60,6 @@ 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>
|
||||
@@ -109,7 +106,6 @@ 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` |
|
||||
@@ -132,52 +128,20 @@ Direct OpenAI Responses models use OpenAI's hosted `web_search` tool automatical
|
||||
|
||||
## Native Codex web search
|
||||
|
||||
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.
|
||||
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- `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",
|
||||
@@ -195,25 +159,14 @@ Direct OpenAI ChatGPT Responses traffic can also use OpenAI's hosted
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior.
|
||||
|
||||
## Network safety
|
||||
|
||||
Managed HTTP `web_search` provider calls use OpenClaw's guarded fetch path. For
|
||||
Managed `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
|
||||
@@ -247,7 +200,6 @@ 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.
|
||||
|
||||
@@ -108,47 +108,3 @@ describe("bedrock embedding response parsers", () => {
|
||||
).toThrow("Amazon Bedrock embedding response returned malformed JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripInferenceProfilePrefix", () => {
|
||||
it("strips global prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("global.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips us prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("us.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips eu prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("eu.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips ap prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("ap.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips apac prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("apac.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips au prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("au.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips jp prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("jp.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID without prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID for amazon.titan-embed-text-v2:0", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("amazon.titan-embed-text-v2:0")).toBe(
|
||||
"amazon.titan-embed-text-v2:0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,18 +69,12 @@ const MODELS: Record<string, ModelSpec> = {
|
||||
"twelvelabs.marengo-embed-3-0-v1:0": { maxTokens: 512, dims: 512, family: "twelvelabs" },
|
||||
};
|
||||
|
||||
/** Strip AWS inference profile prefix (us., eu., ap., apac., au., jp., global.) from model ID. */
|
||||
function stripInferenceProfilePrefix(modelId: string): string {
|
||||
return modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
|
||||
}
|
||||
|
||||
/** Resolve spec, stripping throughput suffixes like `:2:8k` or `:0:512`. */
|
||||
function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
const bare = stripInferenceProfilePrefix(modelId);
|
||||
if (MODELS[bare]) {
|
||||
return MODELS[bare];
|
||||
if (MODELS[modelId]) {
|
||||
return MODELS[modelId];
|
||||
}
|
||||
const parts = bare.split(":");
|
||||
const parts = modelId.split(":");
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const spec = MODELS[parts.slice(0, i).join(":")];
|
||||
if (spec) {
|
||||
@@ -92,7 +86,7 @@ function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
|
||||
/** Infer family from model ID prefix when not in catalog. */
|
||||
function inferFamily(modelId: string): Family {
|
||||
const id = normalizeLowercaseStringOrEmpty(stripInferenceProfilePrefix(modelId));
|
||||
const id = normalizeLowercaseStringOrEmpty(modelId);
|
||||
if (id.startsWith("amazon.titan-embed-text-v2")) {
|
||||
return "titan-v2";
|
||||
}
|
||||
@@ -318,7 +312,6 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
export const testing = {
|
||||
parseCohereBatch,
|
||||
parseSingle,
|
||||
stripInferenceProfilePrefix,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,13 +32,12 @@ describe("codex plugin", () => {
|
||||
expect(manifest.enabledByDefault).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers the codex provider, agent harness, and hosted web search", () => {
|
||||
it("registers the codex provider and agent harness", () => {
|
||||
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();
|
||||
|
||||
@@ -55,7 +54,6 @@ describe("codex plugin", () => {
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerProvider,
|
||||
registerWebSearchProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
@@ -84,13 +82,6 @@ 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,7 +23,6 @@ import {
|
||||
resumeCodexCliSessionOnNode,
|
||||
resolveCodexCliSessionForBindingOnNode,
|
||||
} from "./src/node-cli-sessions.js";
|
||||
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
@@ -47,9 +46,6 @@ 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,7 +229,6 @@ describe("codex media understanding provider", () => {
|
||||
undefined,
|
||||
"/tmp/openclaw-agent",
|
||||
cfg,
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(requests[1]?.params).toEqual({
|
||||
model: "gpt-5.4",
|
||||
@@ -241,14 +240,8 @@ 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: [],
|
||||
@@ -286,51 +279,11 @@ describe("codex media understanding provider", () => {
|
||||
agentDir: " ",
|
||||
});
|
||||
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
|
||||
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);
|
||||
@@ -533,14 +486,8 @@ 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,19 +13,41 @@ 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 {
|
||||
runBoundedCodexAppServerTurn,
|
||||
type CodexBoundedTurnOptions,
|
||||
} from "./src/app-server/bounded-turn.js";
|
||||
import type { CodexUserInput } from "./src/app-server/protocol.js";
|
||||
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";
|
||||
|
||||
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.";
|
||||
|
||||
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
|
||||
/** Dependencies and plugin config for Codex media-understanding calls. */
|
||||
export type CodexMediaUnderstandingProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the media-understanding provider that delegates image tasks to an
|
||||
@@ -75,13 +97,13 @@ async function describeCodexImages(
|
||||
throw new Error("Codex image understanding requires model id.");
|
||||
}
|
||||
|
||||
const { text } = await runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authProfileStore: req.authStore,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
options,
|
||||
taskLabel: "image understanding",
|
||||
developerInstructions:
|
||||
@@ -94,11 +116,117 @@ 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,
|
||||
@@ -118,24 +246,73 @@ async function extractCodexStructured(
|
||||
throw new Error("Codex structured extraction requires at least one image input.");
|
||||
}
|
||||
|
||||
const { text } = await runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authProfileStore: req.authStore,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
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) {
|
||||
@@ -214,3 +391,159 @@ 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,8 +5,7 @@
|
||||
"providers": ["codex"],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["codex"],
|
||||
"migrationProviders": ["codex"],
|
||||
"webSearchProviders": ["codex"]
|
||||
"migrationProviders": ["codex"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"codex": {
|
||||
|
||||
@@ -23,15 +23,7 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/codex",
|
||||
"defaultChoice": "npm",
|
||||
"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"
|
||||
]
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.2"
|
||||
|
||||
@@ -116,12 +116,10 @@ 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,7 +59,6 @@ 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;
|
||||
|
||||
@@ -97,15 +96,12 @@ 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;
|
||||
@@ -304,8 +300,6 @@ 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,
|
||||
@@ -313,7 +307,6 @@ 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,
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
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;
|
||||
return workspace.codexHome
|
||||
? (mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? boundedConfig)
|
||||
: boundedConfig;
|
||||
}
|
||||
|
||||
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,7 +18,6 @@ export type CodexAppServerClientFactory = (
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
@@ -45,7 +44,6 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -65,6 +63,5 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -171,199 +171,6 @@ 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 }));
|
||||
|
||||
@@ -475,7 +282,6 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
createRuntimeDynamicTool("process"),
|
||||
createRuntimeDynamicTool("apply_patch"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
];
|
||||
});
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
@@ -486,19 +292,11 @@ 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);
|
||||
@@ -508,33 +306,6 @@ 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,7 +29,6 @@ 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]
|
||||
@@ -63,7 +62,6 @@ export type DynamicToolBuildParams = {
|
||||
sandboxSessionKey: string;
|
||||
sandbox: OpenClawSandboxContext;
|
||||
nativeToolSurfaceEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
runAbortController: AbortController;
|
||||
sessionAgentId: string;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
@@ -72,8 +70,6 @@ export type DynamicToolBuildParams = {
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
|
||||
onWebSearchPolicyResolved?: (allowed: boolean) => void;
|
||||
};
|
||||
|
||||
let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined;
|
||||
@@ -196,13 +192,7 @@ 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) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
}
|
||||
if (!supportsModelTools(params.model)) {
|
||||
input.onPersistentWebSearchPolicyResolved?.(false);
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
if (params.disableTools || !supportsModelTools(params.model)) {
|
||||
return [];
|
||||
}
|
||||
// Dynamic tool construction is on the reply hot path, so per-stage
|
||||
@@ -212,9 +202,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 ?? agentHarness.createOpenClawCodingTools;
|
||||
openClawCodingToolsFactoryForTests ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
toolBuildStages.mark("load-agent-harness-tools");
|
||||
const sessionKeys = resolveOpenClawCodingToolsSessionKeys(params, input.sandboxSessionKey);
|
||||
const allTools = createOpenClawCodingTools({
|
||||
@@ -307,12 +297,6 @@ 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(
|
||||
@@ -331,40 +315,6 @@ 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");
|
||||
@@ -382,12 +332,6 @@ 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`,
|
||||
@@ -418,14 +362,14 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
normalizedToolCount: exposedTools.length,
|
||||
normalizedToolCount: normalizedTools.length,
|
||||
forceHeartbeatTool: input.forceHeartbeatTool === true,
|
||||
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return exposedTools;
|
||||
return normalizedTools;
|
||||
}
|
||||
|
||||
/** Preserves delivery-critical tools when a narrow allowlist would otherwise hide them. */
|
||||
|
||||
@@ -1150,222 +1150,6 @@ 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,9 +149,6 @@ 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>();
|
||||
@@ -656,7 +653,6 @@ 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);
|
||||
@@ -918,16 +914,12 @@ 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.", { messageId: "message-1" }));
|
||||
const execute = vi.fn(async () => textToolResult("Sent."));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createContractTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
|
||||
@@ -337,12 +337,6 @@ export type CodexGetAccountResponse = {
|
||||
requiresOpenaiAuth?: boolean;
|
||||
};
|
||||
|
||||
export type CodexModelProviderCapabilitiesReadResponse = {
|
||||
namespaceTools: boolean;
|
||||
imageGeneration: boolean;
|
||||
webSearch: boolean;
|
||||
};
|
||||
|
||||
export type CodexChatgptAuthTokensRefreshResponse = {
|
||||
accessToken: string;
|
||||
chatgptAccountId: string;
|
||||
@@ -548,7 +542,6 @@ type CodexAppServerRequestResultMap = {
|
||||
"marketplace/add": JsonValue;
|
||||
"mcpServerStatus/list": CodexListMcpServerStatusResponse;
|
||||
"model/list": CodexModelListResponse;
|
||||
"modelProvider/capabilities/read": CodexModelProviderCapabilitiesReadResponse;
|
||||
"plugin/install": CodexPluginInstallResponse;
|
||||
"plugin/list": CodexPluginListResponse;
|
||||
"plugin/read": CodexPluginReadResponse;
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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,41 +5281,6 @@ 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,7 +217,6 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
|
||||
import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
@@ -235,7 +234,6 @@ import {
|
||||
buildTurnCollaborationMode,
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
resolveCodexAppServerThreadModelSelection,
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
@@ -263,7 +261,6 @@ 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;
|
||||
@@ -653,31 +650,6 @@ 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}`);
|
||||
}
|
||||
@@ -744,8 +716,6 @@ export async function runCodexAppServerAttempt(
|
||||
...(onCodexToolOutcome ? { onToolOutcome: onCodexToolOutcome } : {}),
|
||||
}
|
||||
: params;
|
||||
let persistentWebSearchAllowed: boolean | undefined;
|
||||
let webSearchAllowed = false;
|
||||
const tools = await buildDynamicTools({
|
||||
params: dynamicToolParams,
|
||||
resolvedWorkspace,
|
||||
@@ -754,7 +724,6 @@ export async function runCodexAppServerAttempt(
|
||||
sandboxSessionKey,
|
||||
sandbox,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
runAbortController,
|
||||
sessionAgentId,
|
||||
pluginConfig,
|
||||
@@ -763,12 +732,6 @@ 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,
|
||||
@@ -778,7 +741,6 @@ export async function runCodexAppServerAttempt(
|
||||
sandboxSessionKey,
|
||||
sandbox,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
runAbortController,
|
||||
sessionAgentId,
|
||||
pluginConfig,
|
||||
@@ -1307,13 +1269,10 @@ export async function runCodexAppServerAttempt(
|
||||
effectiveWorkspace,
|
||||
effectiveCwd,
|
||||
dynamicTools: toolBridge.specs,
|
||||
persistentWebSearchAllowed,
|
||||
webSearchAllowed,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
|
||||
bundleMcpThreadConfig,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
sandboxExecServerEnabled,
|
||||
sandbox,
|
||||
contextEngineProjection,
|
||||
|
||||
@@ -61,7 +61,6 @@ 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",
|
||||
});
|
||||
@@ -75,7 +74,6 @@ 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));
|
||||
|
||||
@@ -68,7 +68,6 @@ export type CodexAppServerThreadBinding = {
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
nativeHookRelayGeneration?: string;
|
||||
@@ -189,10 +188,6 @@ 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
|
||||
@@ -258,7 +253,6 @@ export async function writeCodexAppServerBinding(
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
// Codex tests cover mirrored session-history branch selection.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function writeSession(records: unknown[]): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-session-history-"));
|
||||
tempDirs.push(dir);
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: "codex-session",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[header, ...records].map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
function messageEntry(params: {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}) {
|
||||
return {
|
||||
type: "message",
|
||||
id: params.id,
|
||||
parentId: params.parentId,
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
message: {
|
||||
role: params.role,
|
||||
content: params.content,
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
it("replays only the branch selected by a leaf control", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "root", parentId: null, role: "user", content: "root prompt" }),
|
||||
messageEntry({
|
||||
id: "active",
|
||||
parentId: "root",
|
||||
role: "assistant",
|
||||
content: "active answer",
|
||||
}),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "root",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "active",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "root prompt" },
|
||||
{ role: "assistant", content: "active answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors explicit navigation to an empty branch", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "old", parentId: null, role: "user", content: "old prompt" }),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "empty-leaf",
|
||||
parentId: "old",
|
||||
targetId: null,
|
||||
appendParentId: "old",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "visible", parentId: null, role: "user", content: "visible prompt" }),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "visible",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "metadata",
|
||||
id: "append-metadata",
|
||||
parentId: "inactive",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "visible",
|
||||
appendParentId: "append-metadata",
|
||||
},
|
||||
messageEntry({
|
||||
id: "continued",
|
||||
parentId: "append-metadata",
|
||||
role: "assistant",
|
||||
content: "continued answer",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps visible history when a continuation references the leaf marker", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "visible", parentId: null, role: "user", content: "visible prompt" }),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "visible",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "visible",
|
||||
},
|
||||
messageEntry({
|
||||
id: "continued",
|
||||
parentId: "active-leaf",
|
||||
role: "assistant",
|
||||
content: "continued answer",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ 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(),
|
||||
@@ -47,11 +46,6 @@ 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),
|
||||
}));
|
||||
@@ -328,61 +322,6 @@ 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();
|
||||
@@ -393,8 +332,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
createOpenClawCodingToolsMock.mockReset();
|
||||
toolExecuteMock.mockReset();
|
||||
handleCodexAppServerApprovalRequestMock.mockReset();
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockReset();
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("supported");
|
||||
|
||||
toolExecuteMock.mockResolvedValue({
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
@@ -406,12 +343,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
]);
|
||||
|
||||
readCodexAppServerBindingMock.mockResolvedValue({
|
||||
@@ -449,21 +380,7 @@ 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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -497,8 +414,6 @@ 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(
|
||||
@@ -566,211 +481,9 @@ 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) => {
|
||||
@@ -813,27 +526,6 @@ 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(
|
||||
@@ -1191,11 +883,6 @@ 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,10 +31,7 @@ import {
|
||||
shouldAutoApproveCodexAppServerApprovals,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
} from "./config.js";
|
||||
import {
|
||||
resolveCodexMessageToolProvider,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
} from "./dynamic-tool-build.js";
|
||||
import { resolveCodexMessageToolProvider } from "./dynamic-tool-build.js";
|
||||
import {
|
||||
emitDynamicToolErrorDiagnostic,
|
||||
emitDynamicToolStartedDiagnostic,
|
||||
@@ -72,7 +69,6 @@ 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";
|
||||
@@ -90,11 +86,6 @@ 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;
|
||||
@@ -153,6 +144,16 @@ 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,
|
||||
@@ -204,23 +205,6 @@ 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,
|
||||
@@ -246,6 +230,8 @@ 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,
|
||||
@@ -267,25 +253,11 @@ export async function runCodexAppServerSideQuestion(
|
||||
const sandbox = useModelScopedPolicy
|
||||
? modelScopedAppServer.sandbox
|
||||
: (binding.sandbox ?? modelScopedAppServer.sandbox);
|
||||
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({
|
||||
const toolBridge = await createCodexSideToolBridge({
|
||||
params,
|
||||
cwd,
|
||||
pluginConfig,
|
||||
sessionAgentId,
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
removeRequestHandler = client.addRequestHandler(async (request) => {
|
||||
@@ -411,8 +383,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(webSearchPlan.threadConfig, {
|
||||
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
|
||||
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(undefined, {
|
||||
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
|
||||
});
|
||||
const threadConfig =
|
||||
@@ -591,25 +562,10 @@ 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,
|
||||
@@ -636,10 +592,8 @@ async function createCodexSideToolBridge(input: {
|
||||
cwd: string;
|
||||
pluginConfig: ReturnType<typeof readCodexPluginConfig>;
|
||||
sessionAgentId: string;
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
signal: AbortSignal;
|
||||
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
|
||||
}): Promise<CodexDynamicToolBridge> {
|
||||
const runtimeModel =
|
||||
input.params.runtimeModel ??
|
||||
({ id: input.params.model, provider: input.params.provider } as never);
|
||||
@@ -649,10 +603,7 @@ async function createCodexSideToolBridge(input: {
|
||||
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
|
||||
.createOpenClawCodingTools;
|
||||
const sandboxSessionKey =
|
||||
input.params.sandboxSessionKey?.trim() ||
|
||||
input.params.sessionKey?.trim() ||
|
||||
input.params.sessionId ||
|
||||
input.sessionAgentId;
|
||||
input.params.sessionKey?.trim() || input.params.sessionId || input.sessionAgentId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: input.params.cfg,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -687,34 +638,12 @@ 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,
|
||||
@@ -733,45 +662,26 @@ 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 {
|
||||
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,
|
||||
};
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSideDynamicToolCallWithTimeout(params: {
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createParams as createRunAttemptParams,
|
||||
createParams,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
threadStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
@@ -32,37 +29,6 @@ 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"],
|
||||
@@ -422,540 +388,6 @@ 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");
|
||||
@@ -1424,7 +856,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
};
|
||||
const expectedConfig = {
|
||||
...config,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
};
|
||||
|
||||
await startOrResumeThread({
|
||||
@@ -1491,7 +925,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
"features.hooks": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
hooks: { PreToolUse: [] },
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
@@ -1568,13 +1004,17 @@ 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,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
expect(requestCalls[1]?.[1].config).toMatchObject({
|
||||
"features.hooks": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
});
|
||||
});
|
||||
@@ -1634,12 +1074,16 @@ 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,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
expect(requestCalls[1]?.[1].config).toEqual({
|
||||
"features.hooks": true,
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1700,7 +1144,9 @@ 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({
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
@@ -1757,7 +1203,9 @@ 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({
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
@@ -1815,7 +1263,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createPluginAppConfigPatch(),
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -1879,7 +1329,9 @@ 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({
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1933,7 +1385,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createTwoPluginAppConfigPatch(),
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -1996,7 +1450,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createTwoCalendarAppConfigPatch(),
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-recovered");
|
||||
@@ -2048,7 +1504,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1].config).toEqual({
|
||||
...createPluginAppConfigPatch(),
|
||||
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-plugins");
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
buildThreadStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
formatCodexThreadLifecycleTimingSummary,
|
||||
resolveCodexAppServerThreadModelSelection,
|
||||
resolveReasoningEffort,
|
||||
shouldWarnCodexThreadLifecycleTimingSummary,
|
||||
startOrResumeThread,
|
||||
@@ -319,86 +318,10 @@ 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("disables Codex tool-search features for nano models", () => {
|
||||
const request = buildThreadStartParams(
|
||||
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
|
||||
@@ -415,8 +338,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,8 +376,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -476,8 +395,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -492,8 +409,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,8 +430,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,8 +447,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -564,8 +475,6 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,8 +496,6 @@ 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -707,8 +614,6 @@ 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",
|
||||
@@ -910,52 +815,6 @@ 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,10 +21,7 @@ import {
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
} from "./context-engine-projection.js";
|
||||
import {
|
||||
normalizeCodexDynamicToolName,
|
||||
shouldDisableCodexToolSearchForModel,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
|
||||
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
|
||||
import {
|
||||
isCodexPluginThreadBindingStale,
|
||||
@@ -58,7 +55,6 @@ import {
|
||||
type CodexAppServerContextEngineProjectionBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
export type CodexAppServerThreadLifecycle = {
|
||||
action: "started" | "resumed";
|
||||
@@ -291,8 +287,6 @@ export async function startOrResumeThread(params: {
|
||||
agentId?: string;
|
||||
cwd: string;
|
||||
dynamicTools: CodexDynamicToolSpec[];
|
||||
persistentWebSearchAllowed?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
@@ -302,7 +296,6 @@ export async function startOrResumeThread(params: {
|
||||
) => CodexThreadFinalConfigPatchResult;
|
||||
nativeHookRelayGeneration?: string;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
userMcpServersEnabled?: boolean;
|
||||
mcpServersFingerprint?: string;
|
||||
@@ -325,16 +318,6 @@ 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),
|
||||
);
|
||||
@@ -355,20 +338,25 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
}),
|
||||
);
|
||||
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 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;
|
||||
let rotatedContextEngineBinding = false;
|
||||
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
|
||||
const throwIfAborted = () => {
|
||||
@@ -387,47 +375,7 @@ export async function startOrResumeThread(params: {
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
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
|
||||
) {
|
||||
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server native tool surface disabled for turn; starting transient thread",
|
||||
{
|
||||
@@ -605,16 +553,13 @@ 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 =
|
||||
@@ -643,7 +588,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
@@ -691,7 +635,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
@@ -744,11 +687,8 @@ 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,
|
||||
}),
|
||||
);
|
||||
@@ -791,7 +731,6 @@ export async function startOrResumeThread(params: {
|
||||
response.modelProvider ?? requestModelProvider ?? startModelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
@@ -857,45 +796,6 @@ 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,
|
||||
@@ -1022,11 +922,8 @@ export function buildThreadStartParams(
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
environmentSelection?: CodexTurnEnvironmentParams[];
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
},
|
||||
): CodexThreadStartParams {
|
||||
@@ -1038,7 +935,7 @@ export function buildThreadStartParams(
|
||||
config: params.config,
|
||||
});
|
||||
const modelSelection = resolveCodexAppServerRequestModelSelection({
|
||||
model: options.model ?? params.modelId,
|
||||
model: params.modelId,
|
||||
modelProvider: options.modelProvider ?? resolvedModelProvider,
|
||||
authProfileId: params.authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
@@ -1057,9 +954,7 @@ export function buildThreadStartParams(
|
||||
serviceName: "OpenClaw",
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
}),
|
||||
...resolveCodexThreadEnvironmentSelection(options),
|
||||
developerInstructions:
|
||||
@@ -1082,10 +977,7 @@ export function buildThreadResumeParams(
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
model?: string | null;
|
||||
},
|
||||
): CodexThreadResumeParams {
|
||||
const resolvedModelProvider = resolveCodexAppServerModelProvider({
|
||||
@@ -1096,7 +988,7 @@ export function buildThreadResumeParams(
|
||||
config: params.config,
|
||||
});
|
||||
const modelSelection = resolveCodexAppServerRequestModelSelection({
|
||||
model: options.model ?? params.modelId,
|
||||
model: params.modelId,
|
||||
modelProvider: options.modelProvider ?? resolvedModelProvider,
|
||||
authProfileId: options.authProfileId ?? params.authProfileId,
|
||||
authProfileStore: params.authProfileStore,
|
||||
@@ -1114,9 +1006,7 @@ export function buildThreadResumeParams(
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
}),
|
||||
developerInstructions:
|
||||
options.developerInstructions ??
|
||||
@@ -1148,44 +1038,6 @@ 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;
|
||||
@@ -1265,24 +1117,9 @@ export function buildCodexRuntimeThreadConfig(
|
||||
function buildCodexRuntimeThreadConfigForRun(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
config: JsonObject | undefined,
|
||||
options: {
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
} = {},
|
||||
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
|
||||
): JsonObject {
|
||||
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 baseConfig = buildCodexRuntimeThreadConfig(config, options);
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
@@ -1478,10 +1315,6 @@ 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 {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,6 @@ 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", () => {
|
||||
@@ -23,13 +18,5 @@ 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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,7 +9,6 @@ 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,
|
||||
@@ -27,24 +26,15 @@ type Ctx = Pick<
|
||||
| "cfg"
|
||||
| "accountId"
|
||||
| "requesterSenderId"
|
||||
| "senderIsOwner"
|
||||
| "toolContext"
|
||||
| "mediaLocalRoots"
|
||||
| "mediaReadFile"
|
||||
>;
|
||||
|
||||
function readDiscordRequesterSenderId(ctx: Ctx): string | 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;
|
||||
return ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
|
||||
? normalizeOptionalString(ctx.requesterSenderId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function senderParam(senderUserId: string | undefined) {
|
||||
@@ -366,6 +356,7 @@ 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,
|
||||
@@ -376,7 +367,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
until: moderation.until,
|
||||
reason: moderation.reason,
|
||||
deleteMessageDays: moderation.deleteMessageDays,
|
||||
senderUserId,
|
||||
senderUserId: senderUserIdLocal,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
@@ -436,32 +427,18 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
}
|
||||
|
||||
if (action === "search") {
|
||||
const guildId = readStringParam(actionParams, "guildId");
|
||||
const query =
|
||||
readStringParam(actionParams, "query") ?? readStringParam(actionParams, "content");
|
||||
if (!query) {
|
||||
throw new Error("Discord search requires query text. Provide query or content.");
|
||||
}
|
||||
// Fall back to the current session channel when no explicit channelId,
|
||||
// channelIds, or guildId is provided. This lets the runtime resolve
|
||||
// guildId from the channel without broadening explicitly-filtered or
|
||||
// explicitly guild-scoped searches.
|
||||
const explicitChannelIds = readStringArrayParam(actionParams, "channelIds");
|
||||
const channelId =
|
||||
readStringParam(actionParams, "channelId") ??
|
||||
(!guildId &&
|
||||
!explicitChannelIds?.length &&
|
||||
ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
|
||||
? ctx.toolContext?.currentChannelId?.trim() || undefined
|
||||
: undefined);
|
||||
const guildId = readStringParam(actionParams, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const query = readStringParam(actionParams, "query", { required: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "searchMessages",
|
||||
accountId: accountId ?? undefined,
|
||||
...(guildId ? { guildId } : {}),
|
||||
guildId,
|
||||
content: query,
|
||||
channelId,
|
||||
channelIds: explicitChannelIds,
|
||||
channelId: readStringParam(actionParams, "channelId"),
|
||||
channelIds: readStringArrayParam(actionParams, "channelIds"),
|
||||
authorId: readStringParam(actionParams, "authorId"),
|
||||
authorIds: readStringArrayParam(actionParams, "authorIds"),
|
||||
limit: readPositiveIntegerParam(actionParams, "limit"),
|
||||
|
||||
@@ -122,24 +122,7 @@ describe("handleDiscordMessageAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("does not treat non-Discord requester ids as Discord guild admin sender ids", async () => {
|
||||
const cfg = discordConfig({ channels: true });
|
||||
await handleDiscordMessageAction({
|
||||
action: "channel-delete",
|
||||
@@ -147,67 +130,16 @@ 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: "channelInfo", accountId: undefined, channelId: "channel-1" },
|
||||
payload: {
|
||||
action: "channelDelete",
|
||||
accountId: undefined,
|
||||
channelId: "channel-1",
|
||||
},
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
@@ -614,59 +546,4 @@ describe("handleDiscordMessageAction", () => {
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not add session channel to search when explicit channelIds are provided", async () => {
|
||||
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
|
||||
await handleDiscordMessageAction({
|
||||
action: "search",
|
||||
params: {
|
||||
query: "test query",
|
||||
channelIds: ["ch-1", "ch-2"],
|
||||
guildId: "g1",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
toolContext: {
|
||||
currentChannelProvider: "discord",
|
||||
currentChannelId: "session-ch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
|
||||
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
action: "searchMessages",
|
||||
content: "test query",
|
||||
guildId: "g1",
|
||||
channelIds: ["ch-1", "ch-2"],
|
||||
});
|
||||
// Session channel must NOT appear as channelId when explicit channelIds exist.
|
||||
expect(payload.channelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inject session channel when guildId is explicit and no channel filters are provided", async () => {
|
||||
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
|
||||
await handleDiscordMessageAction({
|
||||
action: "search",
|
||||
params: {
|
||||
query: "guild-wide query",
|
||||
guildId: "g1",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
toolContext: {
|
||||
currentChannelProvider: "discord",
|
||||
currentChannelId: "session-ch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
|
||||
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
action: "searchMessages",
|
||||
content: "guild-wide query",
|
||||
guildId: "g1",
|
||||
});
|
||||
// Guild-wide search must NOT be narrowed to the session channel.
|
||||
expect(payload.channelId).toBeUndefined();
|
||||
expect(payload.channelIds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ export async function handleDiscordMessageAction(
|
||||
| "cfg"
|
||||
| "accountId"
|
||||
| "requesterSenderId"
|
||||
| "senderIsOwner"
|
||||
| "toolContext"
|
||||
| "mediaAccess"
|
||||
| "mediaLocalRoots"
|
||||
|
||||
@@ -184,51 +184,18 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
|
||||
if (!ctx.isActionEnabled("search")) {
|
||||
throw new Error("Discord search is disabled.");
|
||||
}
|
||||
let guildId = readStringParam(ctx.params, "guildId");
|
||||
const content =
|
||||
readStringParam(ctx.params, "content") ?? readStringParam(ctx.params, "query");
|
||||
if (!content) {
|
||||
throw new Error("Discord search requires content or query text.");
|
||||
}
|
||||
const guildId = readStringParam(ctx.params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(ctx.params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(ctx.params, "channelId");
|
||||
const channelIds = readStringArrayParam(ctx.params, "channelIds");
|
||||
// Resolve guildId from channel info when not explicitly provided.
|
||||
if (!guildId) {
|
||||
const rawInferChannelId = channelId ?? channelIds?.[0];
|
||||
if (rawInferChannelId) {
|
||||
try {
|
||||
const inferChannelId =
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(rawInferChannelId);
|
||||
const channelInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
|
||||
inferChannelId,
|
||||
ctx.withOpts(),
|
||||
);
|
||||
if (channelInfo && typeof channelInfo === "object") {
|
||||
const record = channelInfo as unknown as Record<string, unknown>;
|
||||
const resolved = record.guild_id ?? record.guildId;
|
||||
if (typeof resolved === "string" && resolved.trim()) {
|
||||
guildId = resolved.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Channel info fetch failed; fall through to descriptive error.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!guildId) {
|
||||
throw new Error(
|
||||
"Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
|
||||
);
|
||||
}
|
||||
const authorId = readStringParam(ctx.params, "authorId");
|
||||
const authorIds = readStringArrayParam(ctx.params, "authorIds");
|
||||
const limit = readPositiveIntegerParam(ctx.params, "limit");
|
||||
const channelIdList = [
|
||||
...(channelIds ?? []).map((id) =>
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(id),
|
||||
),
|
||||
...(channelId ? [discordMessagingActionRuntime.resolveDiscordChannelId(channelId)] : []),
|
||||
];
|
||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||
if (channelIdList.length > 0) {
|
||||
for (const targetChannelId of channelIdList) {
|
||||
await ctx.assertReadTargetAllowed({ guildId, channelId: targetChannelId });
|
||||
|
||||
@@ -1139,72 +1139,6 @@ describe("handleDiscordMessagingAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves guildId from channel info when guildId is omitted in searchMessages", async () => {
|
||||
fetchChannelInfoDiscord.mockResolvedValueOnce({
|
||||
id: "C1",
|
||||
type: 0,
|
||||
guild_id: "resolved-guild",
|
||||
});
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ channelId: "C1", content: "hello" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "resolved-guild", content: "hello" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes channel: prefixed channelId before resolving guildId in searchMessages", async () => {
|
||||
fetchChannelInfoDiscord.mockResolvedValueOnce({
|
||||
id: "C1",
|
||||
type: 0,
|
||||
guild_id: "resolved-guild",
|
||||
});
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ channelId: "channel:C1", content: "hello" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "resolved-guild", content: "hello", channelIds: ["C1"] }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts query as alias for content in searchMessages", async () => {
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ guildId: "G1", query: "find this" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "G1", content: "find this" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws descriptive error when guildId cannot be resolved in searchMessages", async () => {
|
||||
await expect(
|
||||
handleMessagingAction("searchMessages", { content: "hello" }, enableAllActions),
|
||||
).rejects.toThrow(
|
||||
"Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
|
||||
);
|
||||
expect(searchMessagesDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends voice messages from a local file path", async () => {
|
||||
sendVoiceMessageDiscord.mockClear();
|
||||
sendMessageDiscord.mockClear();
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("discordMessageActions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires trusted requester sender for privileged guild admin actions from tool contexts", () => {
|
||||
it("requires trusted requester sender for privileged guild admin actions only from Discord turns", () => {
|
||||
for (const action of ["channel-delete", "timeout", "kick", "ban"] as const) {
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
@@ -148,18 +148,13 @@ describe("discordMessageActions", () => {
|
||||
toolContext: { currentChannelProvider: "discord" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action: "channel-delete",
|
||||
toolContext: { currentChannelProvider: "telegram" },
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
discordMessageActions.requiresTrustedRequesterSender?.({
|
||||
action: "read",
|
||||
@@ -518,7 +513,6 @@ describe("discordMessageActions", () => {
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
requesterSenderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
@@ -531,7 +525,6 @@ describe("discordMessageActions", () => {
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
requesterSenderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -12,7 +12,24 @@ import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
|
||||
import { readDiscordComponentSpec } from "./components.js";
|
||||
import { withDiscordInboundEventDeliveryMetadata } from "./inbound-event-delivery.js";
|
||||
import { isTrustedRequesterGuildAdminAction } from "./trusted-requester-actions.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",
|
||||
]);
|
||||
|
||||
const localExecutionActions = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
@@ -185,7 +202,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
resolveExecutionMode: resolveDiscordActionExecutionMode,
|
||||
describeMessageTool: describeDiscordMessageTool,
|
||||
requiresTrustedRequesterSender: ({ action, toolContext }) =>
|
||||
Boolean(toolContext) && isTrustedRequesterGuildAdminAction(action),
|
||||
normalizeOptionalString(toolContext?.currentChannelProvider)?.toLowerCase() === "discord" &&
|
||||
trustedRequesterGuildAdminActions.has(action),
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = normalizeOptionalString(args.action) ?? "";
|
||||
if (action === "sendMessage") {
|
||||
@@ -248,7 +266,6 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg,
|
||||
accountId,
|
||||
requesterSenderId,
|
||||
senderIsOwner,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
@@ -264,7 +281,6 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg,
|
||||
accountId,
|
||||
requesterSenderId,
|
||||
senderIsOwner,
|
||||
toolContext,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -44,8 +44,10 @@ 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 &&
|
||||
(!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) &&
|
||||
discordStreamMode !== "off" &&
|
||||
!accountBlockStreamingEnabled;
|
||||
const draftStream = canStreamDraft
|
||||
|
||||
@@ -184,7 +184,6 @@ type DispatchInboundParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
|
||||
allowToolLifecycleWhenProgressHidden?: boolean;
|
||||
onTypingCleanup?: () => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
@@ -852,39 +851,6 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records accepted mention ingress before acking and dispatching", async () => {
|
||||
const events: string[] = [];
|
||||
recordInboundSession.mockImplementationOnce(async () => {
|
||||
events.push("record");
|
||||
});
|
||||
sendMocks.reactMessageDiscord.mockImplementationOnce(async () => {
|
||||
events.push("ack");
|
||||
});
|
||||
dispatchInboundMessage.mockImplementationOnce(async () => {
|
||||
events.push("dispatch");
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
accountId: "ops",
|
||||
shouldRequireMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "ops",
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(events).toEqual(["record", "ack", "dispatch"]);
|
||||
expect(recordInboundSession).toHaveBeenCalledTimes(1);
|
||||
expect(sendMocks.reactMessageDiscord).toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
message: {
|
||||
@@ -1018,7 +984,6 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBe(true);
|
||||
const emojis = getReactionEmojis();
|
||||
expect(emojis).toContain("👀");
|
||||
expect(emojis).toContain(DEFAULT_EMOJIS.done);
|
||||
@@ -1187,7 +1152,6 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBeUndefined();
|
||||
expect(getReactionEmojis()).toEqual(["👀"]);
|
||||
});
|
||||
|
||||
@@ -2190,12 +2154,12 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
|
||||
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(
|
||||
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
|
||||
).toBeUndefined();
|
||||
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
|
||||
return createNoQueuedDispatchResult();
|
||||
@@ -2215,36 +2179,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
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(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done");
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -415,24 +415,14 @@ async function processDiscordMessageInner(
|
||||
statusReactionsActive = true;
|
||||
void statusReactions.setQueued();
|
||||
};
|
||||
let initialAckReactionQueued = false;
|
||||
const queueInitialAckReactionAfterRecord = () => {
|
||||
if (initialAckReactionQueued) {
|
||||
return;
|
||||
}
|
||||
initialAckReactionQueued = true;
|
||||
if (statusReactionsEnabled) {
|
||||
statusReactionsActive = true;
|
||||
}
|
||||
queueInitialDiscordAckReaction({
|
||||
enabled: statusReactionsEnabled,
|
||||
shouldSendAckReaction,
|
||||
ackReaction,
|
||||
statusReactions,
|
||||
reactionAdapter: discordAdapter,
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
});
|
||||
};
|
||||
queueInitialDiscordAckReaction({
|
||||
enabled: statusReactionsEnabled,
|
||||
shouldSendAckReaction,
|
||||
ackReaction,
|
||||
statusReactions,
|
||||
reactionAdapter: discordAdapter,
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
});
|
||||
const processContext = await buildDiscordMessageProcessContext({
|
||||
ctx,
|
||||
text,
|
||||
@@ -963,7 +953,6 @@ async function processDiscordMessageInner(
|
||||
storePath: turn.storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
afterRecord: queueInitialAckReactionAfterRecord,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
@@ -992,7 +981,9 @@ async function processDiscordMessageInner(
|
||||
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
|
||||
suppressTyping: isRoomEvent ? true : undefined,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
|
||||
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
|
||||
? true
|
||||
: undefined,
|
||||
disableBlockStreaming: sourceRepliesAreToolOnly
|
||||
? true
|
||||
: (draftPreview.disableBlockStreamingForDraft ??
|
||||
@@ -1010,12 +1001,9 @@ async function processDiscordMessageInner(
|
||||
? () => draftPreview.handleAssistantMessageBoundary()
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
suppressDefaultToolProgressMessages:
|
||||
(sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled) ||
|
||||
draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
allowToolLifecycleWhenProgressHidden: statusReactionsEnabled ? true : undefined,
|
||||
suppressDefaultToolProgressMessages: draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
commentaryProgressEnabled: draftPreview.isProgressMode
|
||||
? draftPreview.commentaryProgressEnabled
|
||||
: undefined,
|
||||
|
||||
@@ -26,12 +26,6 @@ const mockMessage = {
|
||||
timestamp: "123",
|
||||
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];
|
||||
|
||||
function createMockMessage(overrides: Record<string, unknown>) {
|
||||
return Object.assign({}, mockMessage, overrides) as Parameters<
|
||||
MaybeCreateDiscordAutoThreadFn
|
||||
>[0]["message"];
|
||||
}
|
||||
|
||||
function createBaseParams(
|
||||
overrides: Partial<Parameters<MaybeCreateDiscordAutoThreadFn>[0]> = {},
|
||||
): Parameters<MaybeCreateDiscordAutoThreadFn>[0] {
|
||||
@@ -132,63 +126,15 @@ describe("maybeCreateDiscordAutoThread", () => {
|
||||
|
||||
it("creates auto-thread if channelType is GuildText", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
expect(result).toBe("thread1");
|
||||
expect(postMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing message thread before creating a new one", async () => {
|
||||
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing message thread before skipping bot-authored messages", async () => {
|
||||
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
||||
const result = await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
message: createMockMessage({
|
||||
author: { bot: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips creating new auto-threads for bot-authored messages", async () => {
|
||||
getMock.mockResolvedValueOnce({});
|
||||
const result = await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
message: createMockMessage({
|
||||
author: { bot: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still creates an auto-thread when the existing-thread lookup fails", async () => {
|
||||
getMock.mockRejectedValueOnce(new Error("transient fetch failure"));
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
|
||||
expect(result).toBe("thread1");
|
||||
expect(postMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
it("uses configured autoArchiveDuration", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" },
|
||||
@@ -199,7 +145,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
|
||||
it("accepts numeric autoArchiveDuration", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 },
|
||||
@@ -210,7 +155,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
|
||||
it("defaults to 60 when autoArchiveDuration not set", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
expectRestBodyField(postMock, "auto_archive_duration", 60);
|
||||
});
|
||||
@@ -219,7 +163,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
it("renames created thread when generated mode is enabled", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -250,7 +193,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not block thread creation while title summary is pending", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
let resolveTitle: ((value: string | null) => void) | undefined;
|
||||
generateThreadTitleMock.mockReturnValueOnce(
|
||||
@@ -277,7 +219,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("uses channel-specific thread override for generated title model", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -307,7 +248,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("falls back to parent channel override for generated title model", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -337,7 +277,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("skips summarization when cfg or agentId is missing", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
||||
@@ -350,7 +289,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not rename when autoThreadName is not set", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true },
|
||||
@@ -363,7 +301,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not rename when generated title sanitizes to fallback thread name", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("<@123456789012345678> <#987654321098765432>");
|
||||
|
||||
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
|
||||
|
||||
@@ -147,28 +147,6 @@ export async function maybeCreateDiscordAutoThread(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const existingThreadId = (
|
||||
(await getChannelMessage(params.client.rest, messageChannelId, params.message.id)) as {
|
||||
thread?: { id?: string };
|
||||
}
|
||||
)?.thread?.id;
|
||||
if (existingThreadId) {
|
||||
logVerbose(
|
||||
`discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`,
|
||||
);
|
||||
return existingThreadId;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only. A failed message refetch must not block creating the thread.
|
||||
}
|
||||
if (params.message.author?.bot) {
|
||||
logVerbose(
|
||||
`discord: autoThread skipped for bot-authored message ${messageChannelId}/${params.message.id}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawThreadSource = params.baseText || params.combinedBody || "Thread";
|
||||
const threadName = sanitizeDiscordThreadName(rawThreadSource, params.message.id);
|
||||
const archiveDuration = params.channelConfig?.autoArchiveDuration
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import { parseMergeForwardContent } from "./bot-content.js";
|
||||
import type { FeishuMessageEvent } from "./bot.js";
|
||||
import { handleFeishuMessage } from "./bot.js";
|
||||
import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
|
||||
import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
|
||||
@@ -237,7 +236,7 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
|
||||
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
||||
mockResolveStorePath(params);
|
||||
const resolveEnvelopeFormatOptionsMock = () => ({});
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
|
||||
const withReplyDispatcherMock = async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
||||
@@ -423,7 +422,6 @@ async function dispatchMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
currentCfg?: ClawdbotConfig;
|
||||
event: FeishuMessageEvent;
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
}) {
|
||||
const runtime = createRuntimeEnv();
|
||||
const feishuConfig = params.cfg.channels?.feishu;
|
||||
@@ -445,7 +443,6 @@ async function dispatchMessage(params: {
|
||||
cfg,
|
||||
event: params.event,
|
||||
runtime,
|
||||
channelRuntime: params.channelRuntime,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
@@ -963,32 +960,6 @@ 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", () => {
|
||||
@@ -4167,70 +4138,6 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
});
|
||||
|
||||
describe("createFeishuMessageReceiveHandler media dedupe", () => {
|
||||
it("preserves the original dispatch dedupe key when debounce merges text content", async () => {
|
||||
const handleMessage = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: vi.fn(() => 10),
|
||||
createInboundDebouncer: vi.fn(
|
||||
(options: { onFlush: (entries: FeishuMessageEvent[]) => Promise<void> | void }) => {
|
||||
const entries: FeishuMessageEvent[] = [];
|
||||
return {
|
||||
enqueue: async (event: FeishuMessageEvent) => {
|
||||
entries.push(event);
|
||||
if (entries.length === 2) {
|
||||
await options.onFlush(entries);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
commands: {
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
const createTextEvent = (messageId: string, createTime: string, text: string) =>
|
||||
({
|
||||
sender: { sender_id: { open_id: "ou-text-debounce" } },
|
||||
message: {
|
||||
message_id: messageId,
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
create_time: createTime,
|
||||
},
|
||||
}) satisfies FeishuMessageEvent;
|
||||
const last = createTextEvent("msg-text-last", "1710000001000", "second");
|
||||
const handler = createFeishuMessageReceiveHandler({
|
||||
cfg: { channels: { feishu: { dmPolicy: "open" } } } as ClawdbotConfig,
|
||||
channelRuntime: core.channel,
|
||||
accountId: "receive-text-debounce",
|
||||
chatHistories: new Map(),
|
||||
handleMessage,
|
||||
resolveDebounceText: ({ event }) =>
|
||||
(JSON.parse(event.message.content) as { text: string }).text,
|
||||
hasProcessedMessage: vi.fn(async () => false),
|
||||
recordProcessedMessage: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
await handler(createTextEvent("msg-text-first", "1710000000000", "first"));
|
||||
await handler(last);
|
||||
|
||||
const call = mockCallArg<{
|
||||
event?: FeishuMessageEvent;
|
||||
messageDedupeKey?: string;
|
||||
}>(handleMessage, 0, 0);
|
||||
expect(call.event?.message.content).toBe(JSON.stringify({ text: "first\nsecond" }));
|
||||
expect(call.messageDedupeKey).toBe(resolveFeishuMessageDedupeKey(last));
|
||||
expect(resolveFeishuMessageDedupeKey(call.event as FeishuMessageEvent)).not.toBe(
|
||||
call.messageDedupeKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps same-id media variants distinct at receive time", async () => {
|
||||
const handleMessage = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
|
||||
@@ -466,7 +466,6 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
processingClaimHeld?: boolean;
|
||||
messageDedupeKey?: string;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
cfg,
|
||||
@@ -478,7 +477,6 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories,
|
||||
accountId,
|
||||
processingClaimHeld = false,
|
||||
messageDedupeKey: messageDedupeKeyOverride,
|
||||
} = params;
|
||||
|
||||
// Resolve account with merged config
|
||||
@@ -489,7 +487,7 @@ export async function handleFeishuMessage(params: {
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
const messageId = event.message.message_id;
|
||||
const messageDedupeKey = messageDedupeKeyOverride ?? resolveFeishuMessageDedupeKey(event);
|
||||
const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
|
||||
if (
|
||||
!(await finalizeFeishuMessageProcessing({
|
||||
messageId: messageDedupeKey,
|
||||
@@ -736,7 +734,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
try {
|
||||
const core = {
|
||||
channel: channelRuntime?.inbound ? channelRuntime : getFeishuRuntime().channel,
|
||||
channel: channelRuntime ?? getFeishuRuntime().channel,
|
||||
} as ReturnType<typeof getFeishuRuntime>;
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
@@ -1604,7 +1602,6 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: agentSessionKey,
|
||||
});
|
||||
@@ -1782,7 +1779,6 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
|
||||
function textEvent(overrides: {
|
||||
messageId: string;
|
||||
createTime?: string;
|
||||
senderOpenId?: string;
|
||||
chatId?: string;
|
||||
text?: string;
|
||||
}): FeishuMessageEvent {
|
||||
return {
|
||||
sender: { sender_id: { open_id: overrides.senderOpenId ?? "ou-user" } },
|
||||
message: {
|
||||
message_id: overrides.messageId,
|
||||
chat_id: overrides.chatId ?? "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: overrides.text ?? "hello" }),
|
||||
create_time: overrides.createTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveFeishuMessageDedupeKey", () => {
|
||||
it("collapses redelivered text with a fresh message_id but identical sender/chat/create_time/content (#46778)", () => {
|
||||
const first = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_first", createTime: "1710000000000" }),
|
||||
);
|
||||
const retry = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_second", createTime: "1710000000000" }),
|
||||
);
|
||||
expect(first).toBeDefined();
|
||||
expect(retry).toBe(first);
|
||||
});
|
||||
|
||||
it("keeps genuine repeat sends distinct via create_time", () => {
|
||||
const a = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_a", createTime: "1710000000000" }),
|
||||
);
|
||||
const b = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_b", createTime: "1710000001000" }),
|
||||
);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("does not collide across senders, chats, or content", () => {
|
||||
const base = textEvent({ messageId: "om_1", createTime: "1710000000000" });
|
||||
const otherSender = textEvent({
|
||||
messageId: "om_2",
|
||||
createTime: "1710000000000",
|
||||
senderOpenId: "ou-other",
|
||||
});
|
||||
const otherChat = textEvent({ messageId: "om_3", createTime: "1710000000000", chatId: "oc-2" });
|
||||
const otherText = textEvent({ messageId: "om_4", createTime: "1710000000000", text: "bye" });
|
||||
const baseKey = resolveFeishuMessageDedupeKey(base);
|
||||
expect(resolveFeishuMessageDedupeKey(otherSender)).not.toBe(baseKey);
|
||||
expect(resolveFeishuMessageDedupeKey(otherChat)).not.toBe(baseKey);
|
||||
expect(resolveFeishuMessageDedupeKey(otherText)).not.toBe(baseKey);
|
||||
});
|
||||
|
||||
it("falls back to message_id for text without a stable retry anchor", () => {
|
||||
const key = resolveFeishuMessageDedupeKey(textEvent({ messageId: "om_no_time" }));
|
||||
expect(key).toBe("om_no_time");
|
||||
});
|
||||
|
||||
it("falls back to message_id for malformed create_time", () => {
|
||||
const key = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_bad_time", createTime: "1710000000000ms" }),
|
||||
);
|
||||
expect(key).toBe("om_bad_time");
|
||||
});
|
||||
|
||||
it("keeps media keyed by message_id plus media key", () => {
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-user" } },
|
||||
message: {
|
||||
message_id: "om_media",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ image_key: "img_123" }),
|
||||
create_time: "1710000000000",
|
||||
},
|
||||
};
|
||||
expect(resolveFeishuMessageDedupeKey(event)).toBe(
|
||||
JSON.stringify(["om_media", "image_key:img_123"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
// Feishu plugin module implements dedupe key behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { asNullableRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
|
||||
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message" | "sender">;
|
||||
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message">;
|
||||
|
||||
function readExternalKey(value: unknown): string | undefined {
|
||||
return normalizeFeishuExternalKey(typeof value === "string" ? value : "");
|
||||
@@ -59,42 +57,6 @@ function resolveMessageMediaParts(messageType: string, content: string): string[
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSenderIdentity(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const senderId = event.sender?.sender_id;
|
||||
return (
|
||||
senderId?.open_id?.trim() ||
|
||||
senderId?.union_id?.trim() ||
|
||||
senderId?.user_id?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Feishu can redeliver the same logical text message with a fresh message_id
|
||||
// (retry/reconnect), defeating message_id-based dedupe (#46778). For text we key
|
||||
// on a stable retry identity instead: same sender + chat + create_time + content
|
||||
// is the same logical message. create_time is the message's own server timestamp
|
||||
// and stays fixed across redeliveries, so genuine repeat sends (which get a new
|
||||
// create_time) keep distinct keys and are never suppressed. Falls back to
|
||||
// message_id when any field is missing so behavior is unchanged then.
|
||||
function resolveTextRetryDedupeKey(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const createTime = event.message.create_time?.trim();
|
||||
const chatId = event.message.chat_id?.trim();
|
||||
const senderId = resolveSenderIdentity(event);
|
||||
if (
|
||||
!createTime ||
|
||||
parseStrictNonNegativeInteger(createTime) === undefined ||
|
||||
!chatId ||
|
||||
!senderId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const contentHash = createHash("sha256")
|
||||
.update(event.message.content, "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return JSON.stringify(["text-retry", senderId, chatId, createTime, contentHash]);
|
||||
}
|
||||
|
||||
export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const messageId = event.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
@@ -102,11 +64,5 @@ export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput):
|
||||
}
|
||||
const messageType = event.message.message_type.trim();
|
||||
const mediaParts = resolveMessageMediaParts(messageType, event.message.content);
|
||||
if (mediaParts.length > 0) {
|
||||
return buildMediaDedupeKey(messageId, mediaParts);
|
||||
}
|
||||
if (messageType === "text") {
|
||||
return resolveTextRetryDedupeKey(event) ?? messageId;
|
||||
}
|
||||
return messageId;
|
||||
return mediaParts.length > 0 ? buildMediaDedupeKey(messageId, mediaParts) : messageId;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user