Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
9850eb63fc feat(codex): support app-server network proxy profiles 2026-06-16 15:15:02 +08:00
200 changed files with 2050 additions and 15792 deletions

View File

@@ -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
@@ -125,10 +114,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.

View File

@@ -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}` }),
},
};

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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
@@ -1584,7 +1584,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 +1630,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 +1677,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 +2083,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

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -34,7 +34,6 @@ Docs: https://docs.openclaw.ai
- 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)

View File

@@ -1,2 +1,2 @@
9eaddb6e9ad300ea9cb113c78a84e8a104cb3efab843ec0a0edd9947c7731fc8 plugin-sdk-api-baseline.json
8e7b3e5c86a9039a0ce8a134dad79ef65fc72c085d044346f8dbfa88d6fccf1b plugin-sdk-api-baseline.jsonl
ea92ef67bc01141e1f3b64edd025471c9c3439da50de3cecb208e7eca797f947 plugin-sdk-api-baseline.json
28ed6df6ba46abfd252cd760b7f88a93c598b4256e6ea8dfc2c9005c327300fb plugin-sdk-api-baseline.jsonl

View File

@@ -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.

View File

@@ -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.

View File

@@ -103,8 +103,45 @@ Supported `appServer` fields:
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
the Codex thread config so the generated permission profile can start Codex
managed networking. The default generated profile is `openclaw-network`; use
`profileName` to choose another local name.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.

View File

@@ -561,8 +561,45 @@ Supported `appServer` fields:
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
the Codex thread config so the generated permission profile can start Codex
managed networking. The default generated profile is `openclaw-network`; use
`profileName` to choose another local name.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends

View File

@@ -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",
);
});
});

View File

@@ -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,
};
// ---------------------------------------------------------------------------

View File

@@ -193,6 +193,47 @@
"enum": ["user", "auto_review", "guardian_subagent"]
},
"serviceTier": { "type": ["string", "null"] },
"networkProxy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"profileName": { "type": "string" },
"baseProfile": {
"type": "string",
"enum": ["read-only", "workspace"]
},
"mode": {
"type": "string",
"enum": ["limited", "full"]
},
"domains": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
}
},
"unixSockets": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
}
},
"proxyUrl": { "type": "string" },
"socksUrl": { "type": "string" },
"enableSocks5": { "type": "boolean" },
"enableSocks5Udp": { "type": "boolean" },
"allowUpstreamProxy": { "type": "boolean" },
"allowLocalBinding": { "type": "boolean" },
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
}
},
"defaultWorkspaceDir": {
"type": "string"
},
@@ -385,6 +426,81 @@
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
"advanced": true
},
"appServer.networkProxy": {
"label": "Network Proxy",
"help": "Enable Codex permissions-profile networking for app-server commands.",
"advanced": true
},
"appServer.networkProxy.enabled": {
"label": "Network Proxy Enabled",
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it on thread start or resume instead of sandbox fields.",
"advanced": true
},
"appServer.networkProxy.profileName": {
"label": "Network Proxy Profile",
"help": "Codex permissions profile name generated for app-server network access.",
"advanced": true
},
"appServer.networkProxy.baseProfile": {
"label": "Network Proxy Base",
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
"advanced": true
},
"appServer.networkProxy.domains": {
"label": "Network Domains",
"help": "Domain allow and deny rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.unixSockets": {
"label": "Unix Sockets",
"help": "Unix socket allow and deny rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.proxyUrl": {
"label": "HTTP Proxy URL",
"help": "HTTP listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.socksUrl": {
"label": "SOCKS Proxy URL",
"help": "SOCKS listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.enableSocks5": {
"label": "Enable SOCKS5",
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
"advanced": true
},
"appServer.networkProxy.enableSocks5Udp": {
"label": "Enable SOCKS5 UDP",
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
"advanced": true
},
"appServer.networkProxy.allowUpstreamProxy": {
"label": "Allow Upstream Proxy",
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
"advanced": true
},
"appServer.networkProxy.allowLocalBinding": {
"label": "Allow Local Binding",
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.mode": {
"label": "Network Mode",
"help": "Codex sandboxed networking mode for subprocess traffic.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
"label": "Allow Non-Loopback Proxy",
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
"label": "Allow All Unix Sockets",
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
"advanced": true
},
"appServer.defaultWorkspaceDir": {
"label": "Default Workspace",
"help": "Workspace used by /codex bind when --cwd is omitted.",

View File

@@ -218,12 +218,17 @@ 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;
const boundedConfig = mergeCodexThreadConfigs(
CODEX_BOUNDED_THREAD_CONFIG,
params.threadConfig,
) ?? { ...CODEX_BOUNDED_THREAD_CONFIG };
if (!workspace.codexHome) {
return boundedConfig;
}
return mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? {
...boundedConfig,
...CODEX_PRIVATE_BOUNDED_THREAD_CONFIG,
};
}
function buildPrivateCodexAppServerStartOptions(

View File

@@ -125,6 +125,89 @@ describe("Codex app-server config", () => {
});
});
it("builds Codex permissions-profile config for app-server network proxy", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
profileName: "mock-proxy",
mode: "limited",
domains: {
" api.openai.com ": "allow",
"blocked.example.com": "deny",
},
unixSockets: {
" /tmp/mock-proxy.sock ": "allow",
},
proxyUrl: "http://127.0.0.1:3128",
socksUrl: "socks5h://127.0.0.1:8081",
enableSocks5: true,
enableSocks5Udp: false,
allowUpstreamProxy: true,
allowLocalBinding: false,
},
},
},
});
expect(runtime.networkProxy).toEqual({
profileName: "mock-proxy",
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": "write",
},
},
network: {
enabled: true,
mode: "limited",
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
enable_socks5: true,
enable_socks5_udp: false,
allow_upstream_proxy: true,
allow_local_binding: false,
},
},
},
},
});
});
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "read-only",
networkProxy: {
enabled: true,
domains: { "example.com": "allow" },
},
},
},
});
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
string,
{ filesystem: { ":workspace_roots": { ".": string } } }
>;
expect(runtime.networkProxy?.profileName).toBe("openclaw-network");
expect(permissions["openclaw-network"]?.filesystem[":workspace_roots"]["."]).toBe("read");
});
it("clamps oversized app-server timer config", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {

View File

@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
import { z } from "zod";
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
@@ -111,6 +111,32 @@ export type CodexAppServerExperimentalConfig = {
sandboxExecServer?: boolean;
};
export type CodexAppServerNetworkProxyPermission = "allow" | "deny";
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
export type CodexAppServerNetworkProxyMode = "limited" | "full";
export type CodexAppServerNetworkProxyConfig = {
enabled?: boolean;
profileName?: string;
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
mode?: CodexAppServerNetworkProxyMode;
domains?: Record<string, CodexAppServerNetworkProxyPermission>;
unixSockets?: Record<string, CodexAppServerNetworkProxyPermission>;
proxyUrl?: string;
socksUrl?: string;
enableSocks5?: boolean;
enableSocks5Udp?: boolean;
allowUpstreamProxy?: boolean;
allowLocalBinding?: boolean;
dangerouslyAllowNonLoopbackProxy?: boolean;
dangerouslyAllowAllUnixSockets?: boolean;
};
export type ResolvedCodexAppServerNetworkProxyConfig = {
profileName: string;
configPatch: JsonObject;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
@@ -151,6 +177,7 @@ export type CodexAppServerRuntimeOptions = {
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};
export type CodexModelBackedReviewerContext = {
@@ -188,6 +215,7 @@ export type CodexPluginConfig = {
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier | null;
networkProxy?: CodexAppServerNetworkProxyConfig;
defaultWorkspaceDir?: string;
experimental?: CodexAppServerExperimentalConfig;
};
@@ -216,6 +244,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"sandbox",
"approvalsReviewer",
"serviceTier",
"networkProxy",
"defaultWorkspaceDir",
"experimental",
] as const;
@@ -249,6 +278,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE = "openclaw-network";
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
@@ -273,6 +303,25 @@ const codexAppServerExperimentalSchema = z
sandboxExecServer: z.boolean().optional(),
})
.strict();
const codexAppServerNetworkProxyPermissionSchema = z.enum(["allow", "deny"]);
const codexAppServerNetworkProxySchema = z
.object({
enabled: z.boolean().optional(),
profileName: z.string().trim().min(1).optional(),
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
enableSocks5Udp: z.boolean().optional(),
allowUpstreamProxy: z.boolean().optional(),
allowLocalBinding: z.boolean().optional(),
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
})
.strict();
const codexPluginEntryConfigSchema = z
.object({
@@ -334,6 +383,7 @@ const codexPluginConfigSchema = z
sandbox: codexAppServerSandboxSchema.optional(),
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: codexAppServerServiceTierSchema,
networkProxy: codexAppServerNetworkProxySchema.optional(),
defaultWorkspaceDir: z.string().optional(),
experimental: codexAppServerExperimentalSchema.optional(),
})
@@ -549,6 +599,11 @@ export function resolveCodexAppServerRuntimeOptions(
? normalizedPolicyMode
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
const resolvedSandbox =
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
if (transport === "websocket" && !url) {
throw new Error(
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
@@ -597,17 +652,14 @@ export function resolveCodexAppServerRuntimeOptions(
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox:
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
};
}
@@ -821,6 +873,69 @@ export function codexSandboxPolicyForTurn(
};
}
function resolveCodexAppServerNetworkProxy(
config: CodexAppServerNetworkProxyConfig | undefined,
sandbox: CodexAppServerSandboxMode,
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
if (config?.enabled !== true) {
return {};
}
const profileName =
readNonEmptyString(config.profileName) ?? DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE;
const fileSystemMode =
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
? "read"
: "write";
const networkConfig = removeUndefinedJsonFields({
enabled: true,
mode: config.mode,
domains: normalizeNetworkProxyPermissionMap(config.domains),
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
proxy_url: readNonEmptyString(config.proxyUrl),
socks_url: readNonEmptyString(config.socksUrl),
enable_socks5: config.enableSocks5,
enable_socks5_udp: config.enableSocks5Udp,
allow_upstream_proxy: config.allowUpstreamProxy,
allow_local_binding: config.allowLocalBinding,
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
});
return {
networkProxy: {
profileName,
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
[profileName]: {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": fileSystemMode,
},
},
network: networkConfig,
},
},
},
},
};
}
function normalizeNetworkProxyPermissionMap(
value: Record<string, CodexAppServerNetworkProxyPermission> | undefined,
): Record<string, CodexAppServerNetworkProxyPermission> | undefined {
const entries = Object.entries(value ?? {})
.map(([key, permission]) => [key.trim(), permission] as const)
.filter(([key]) => key.length > 0);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
);
}
export function withMcpElicitationsApprovalPolicy(
policy: CodexAppServerEffectiveApprovalPolicy,
): CodexAppServerEffectiveApprovalPolicy {

View File

@@ -76,6 +76,12 @@ export type CodexTurnEnvironmentParams = JsonObject & {
cwd: string;
};
export type CodexPermissionProfileSelection = JsonObject & {
type: "profile";
id: string;
modifications?: JsonValue[] | null;
};
export type CodexThreadStartParams = JsonObject & {
input?: CodexUserInput[];
cwd?: string;
@@ -85,6 +91,7 @@ export type CodexThreadStartParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
developerInstructions?: string;
@@ -102,6 +109,7 @@ export type CodexThreadResumeParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
@@ -153,6 +161,7 @@ export type CodexTurnStartParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandboxPolicy?: CodexSandboxPolicy;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
effort?: string | null;
personality?: string | null;

View File

@@ -66,6 +66,7 @@ export type CodexAppServerThreadBinding = {
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
networkProxyProfileName?: string;
dynamicToolsFingerprint?: string;
dynamicToolsContainDeferred?: boolean;
webSearchThreadConfigFingerprint?: string;
@@ -181,6 +182,10 @@ export async function readCodexAppServerBinding(
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
sandbox: readSandboxMode(parsed.sandbox),
serviceTier: readServiceTier(parsed.serviceTier),
networkProxyProfileName:
typeof parsed.networkProxyProfileName === "string"
? parsed.networkProxyProfileName
: undefined,
dynamicToolsFingerprint:
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
@@ -256,6 +261,7 @@ export async function writeCodexAppServerBinding(
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
networkProxyProfileName: binding.networkProxyProfileName,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,

View File

@@ -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" },
]);
});
});

View File

@@ -86,6 +86,36 @@ function createAppServerOptions() {
} as const;
}
function createNetworkProxyAppServerOptions() {
return {
...createAppServerOptions(),
networkProxy: {
profileName: "mock-proxy",
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": "write",
},
},
network: {
enabled: true,
domains: {
"api.openai.com": "allow",
},
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
},
},
} as const;
}
function createThreadLifecycleParams(
sessionFile: string,
workspaceDir: string,
@@ -399,6 +429,53 @@ describe("Codex app-server native code mode config", () => {
});
});
it("uses a Codex permissions profile for network-proxy thread/start requests", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
network: {
enabled: true,
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
});
it("uses a Codex permissions profile for network-proxy thread/resume requests", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
network: {
domains: {
"api.openai.com": "allow",
},
},
},
},
});
});
it("disables Codex tool-search features for nano models", () => {
const request = buildThreadStartParams(
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
@@ -617,6 +694,35 @@ describe("Codex app-server turn input image sanitizing", () => {
});
});
it("uses Codex permissions for network-proxy turn/start requests", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
});
expect(request).not.toHaveProperty("permissions");
expect(request).not.toHaveProperty("sandboxPolicy");
});
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
sandboxPolicy: {
type: "externalSandbox",
networkAccess: "enabled",
},
});
expect(request).not.toHaveProperty("permissions");
expect(request.sandboxPolicy).toEqual({
type: "externalSandbox",
networkAccess: "enabled",
});
});
it("attaches turn-scoped developer instructions without changing thread config", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",

View File

@@ -39,6 +39,7 @@ import {
import {
isJsonObject,
type CodexDynamicToolSpec,
type CodexPermissionProfileSelection,
type CodexSandboxPolicy,
type CodexThreadResumeParams,
type CodexThreadStartParams,
@@ -646,6 +647,7 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
@@ -694,6 +696,7 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
@@ -794,6 +797,7 @@ export async function startOrResumeThread(params: {
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -842,6 +846,7 @@ export async function startOrResumeThread(params: {
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -1051,7 +1056,7 @@ export function buildThreadStartParams(
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
serviceName: "OpenClaw",
@@ -1060,6 +1065,7 @@ export function buildThreadStartParams(
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
...resolveCodexThreadEnvironmentSelection(options),
developerInstructions:
@@ -1109,7 +1115,7 @@ export function buildThreadResumeParams(
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
@@ -1117,6 +1123,7 @@ export function buildThreadResumeParams(
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
developerInstructions:
options.developerInstructions ??
@@ -1270,6 +1277,7 @@ function buildCodexRuntimeThreadConfigForRun(
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
webSearchAllowed?: boolean;
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
} = {},
): JsonObject {
const webSearchConfig = resolveCodexWebSearchPlan({
@@ -1286,6 +1294,7 @@ function buildCodexRuntimeThreadConfigForRun(
const runtimeConfig =
mergeCodexThreadConfigs(
baseConfig,
options.appServer?.networkProxy?.configPatch,
shouldDisableCodexToolSearchForModel(params.modelId)
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
: undefined,
@@ -1326,14 +1335,20 @@ export function buildTurnStartParams(
agentDir: params.agentDir,
config: params.config,
});
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
return {
threadId: options.threadId,
input: buildUserInput(params, options.promptText),
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandboxPolicy:
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
...(useThreadPermissionProfile
? {}
: {
sandboxPolicy:
options.sandboxPolicy ??
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
}),
model: modelSelection.model,
personality: CODEX_NATIVE_PERSONALITY_NONE,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
@@ -1349,6 +1364,20 @@ export function buildTurnStartParams(
};
}
function codexThreadSandboxOrPermissions(
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
): Pick<CodexThreadStartParams, "permissions" | "sandbox"> {
const permissionProfile = appServer.networkProxy?.profileName;
if (permissionProfile) {
return { permissions: codexPermissionProfileSelection(permissionProfile) };
}
return { sandbox: appServer.sandbox };
}
function codexPermissionProfileSelection(profileName: string): CodexPermissionProfileSelection {
return { type: "profile", id: profileName };
}
function resolveCodexThreadEnvironmentSelection(options: {
nativeCodeModeEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];

View File

@@ -180,6 +180,54 @@ describe("codex conversation binding", () => {
);
});
it("uses Codex permissions for network-proxy app-server bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
model: "gpt-5.4-mini",
};
}),
});
await startCodexConversationThread({
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(requests).toHaveLength(1);
expect(requests[0]?.method).toBe("thread/start");
expect(requests[0]?.params.permissions).toEqual({ type: "profile", id: "openclaw-network" });
expect(requests[0]?.params).not.toHaveProperty("sandbox");
expect(requests[0]?.params.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"openclaw-network": {
network: {
domains: { "api.openai.com": "allow" },
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
});
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
@@ -937,7 +985,7 @@ describe("codex conversation binding", () => {
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
approvalPolicy: "never",
@@ -1126,6 +1174,7 @@ describe("codex conversation binding", () => {
schemaVersion: 1,
threadId: "thread-1",
cwd: tempDir,
networkProxyProfileName: "openclaw-network",
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
@@ -1203,6 +1252,92 @@ describe("codex conversation binding", () => {
});
});
it("uses Codex permissions for network-proxy bound app-server turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
networkProxyProfileName: "openclaw-network",
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
const turnStartParams: Record<string, unknown>[] = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
if (method === "turn/start") {
turnStartParams.push(requestParams);
setImmediate(() =>
notificationHandler?.({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
},
},
}),
);
return { turn: { id: "turn-1" } };
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
notificationHandler = handler;
return () => undefined;
}),
addRequestHandler: vi.fn(() => () => undefined),
});
const result = await handleCodexConversationInboundClaim(
{
content: "hello",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
timeoutMs: 50,
},
);
expect(result).toEqual({ handled: true, reply: { text: "done" } });
expect(turnStartParams[0]).not.toHaveProperty("permissions");
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
});
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(

View File

@@ -30,9 +30,11 @@ import {
} from "./app-server/config.js";
import type {
CodexServiceTier,
CodexPermissionProfileSelection,
CodexThreadResumeResponse,
CodexThreadStartResponse,
CodexTurnStartResponse,
JsonObject,
JsonValue,
} from "./app-server/protocol.js";
import {
@@ -415,22 +417,43 @@ function buildThreadRequestRuntimeOptions(
): {
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
serviceTier?: CodexServiceTier;
permissions?: CodexPermissionProfileSelection;
config?: JsonObject;
} {
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
const sandbox = resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox);
return {
approvalPolicy: resolved.execPolicy?.touched
? resolved.runtime.approvalPolicy
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
approvalsReviewer: resolved.runtime.approvalsReviewer,
sandbox: resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
...(serviceTier ? { serviceTier } : {}),
};
}
function codexConversationSandboxOrPermissions(
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
): {
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
permissions?: CodexPermissionProfileSelection;
config?: JsonObject;
} {
const networkProxy = runtime.networkProxy;
if (networkProxy) {
return {
permissions: { type: "profile", id: networkProxy.profileName },
config: networkProxy.configPatch,
};
}
return { sandbox };
}
async function writeThreadBindingFromResponse(
params: CodexThreadBindingParams,
resolved: CodexThreadBindingRuntime,
@@ -459,6 +482,7 @@ async function writeThreadBindingFromResponse(
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
},
{
...resolved.agentLookup,
@@ -568,6 +592,9 @@ async function runBoundTurn(params: {
const sandbox = useModelScopedPolicy
? modelScopedRuntime.sandbox
: (binding.sandbox ?? modelScopedRuntime.sandbox);
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
const useStickyNetworkProfile =
permissionProfile !== undefined && binding.networkProxyProfileName === permissionProfile;
assertNativeConversationApprovalPolicySupported({
execPolicy,
approvalPolicy,
@@ -641,7 +668,9 @@ async function runBoundTurn(params: {
cwd: workspaceDir,
approvalPolicy,
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
...(useStickyNetworkProfile
? {}
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
...(modelSelection?.model ? { model: modelSelection.model } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...((binding.serviceTier ?? runtime.serviceTier)

View File

@@ -436,32 +436,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"),

View File

@@ -614,59 +614,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();
});
});

View File

@@ -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 });

View File

@@ -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();

View File

@@ -852,39 +852,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: {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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";
@@ -4167,70 +4166,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 = {

View File

@@ -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,

View File

@@ -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"]),
);
});
});

View File

@@ -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;
}

View File

@@ -1,112 +0,0 @@
// Feishu tests cover monitor.message handler plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import type { FeishuMessageEvent } from "./event-types.js";
import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
type MessageReceiveHandlerContext = Parameters<typeof createFeishuMessageReceiveHandler>[0];
type HandleMessageParams = Parameters<MessageReceiveHandlerContext["handleMessage"]>[0];
function createTextEvent(params: {
messageId: string;
senderOpenId: string;
senderType: "bot" | "user";
}): FeishuMessageEvent {
return {
sender: {
sender_id: { open_id: params.senderOpenId },
sender_type: params.senderType,
},
message: {
message_id: params.messageId,
chat_id: "oc_chat_1",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
}
function createHandler() {
let onFlush: ((entries: FeishuMessageEvent[]) => Promise<void>) | undefined;
const enqueue = vi.fn(async (event: FeishuMessageEvent) => {
await onFlush?.([event]);
});
const channelRuntime = {
commands: {
isControlCommandMessage: () => false,
},
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: vi.fn((params: { onFlush: typeof onFlush }) => {
onFlush = params.onFlush;
return { enqueue };
}),
},
} as unknown as PluginRuntime["channel"];
const handleMessage = vi.fn(async (_params: HandleMessageParams) => {});
const handler = createFeishuMessageReceiveHandler({
cfg: {} as ClawdbotConfig,
channelRuntime,
accountId: "default",
chatHistories: new Map(),
handleMessage,
resolveDebounceText: () => "hello",
hasProcessedMessage: vi.fn(async () => false),
recordProcessedMessage: vi.fn(async () => true),
getBotOpenId: () => "ou_bot",
});
return { handler, handleMessage, enqueue };
}
describe("createFeishuMessageReceiveHandler self-message filtering", () => {
it("drops the current bot before debounce and processing claims", async () => {
const { handler, handleMessage, enqueue } = createHandler();
await handler(
createTextEvent({
messageId: "om_reused",
senderOpenId: "ou_bot",
senderType: "bot",
}),
);
await handler(
createTextEvent({
messageId: "om_reused",
senderOpenId: "ou_user",
senderType: "user",
}),
);
expect(enqueue).toHaveBeenCalledTimes(1);
expect(handleMessage).toHaveBeenCalledTimes(1);
expect(handleMessage.mock.calls[0]?.[0]?.event.sender.sender_id.open_id).toBe("ou_user");
});
it("keeps peer bot and user messages flowing to dispatch", async () => {
const { handler, handleMessage, enqueue } = createHandler();
await handler(
createTextEvent({
messageId: "om_other_bot",
senderOpenId: "ou_other_bot",
senderType: "bot",
}),
);
await handler(
createTextEvent({
messageId: "om_user",
senderOpenId: "ou_user",
senderType: "user",
}),
);
expect(enqueue).toHaveBeenCalledTimes(2);
expect(handleMessage).toHaveBeenCalledTimes(2);
expect(
handleMessage.mock.calls.map(([params]) => params.event.sender.sender_id.open_id),
).toEqual(["ou_other_bot", "ou_user"]);
});
});

View File

@@ -28,7 +28,6 @@ type FeishuMessageReceiveHandlerContext = {
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
processingClaimHeld?: boolean;
messageDedupeKey?: string;
}) => Promise<void>;
resolveDebounceText: (params: {
event: FeishuMessageEvent;
@@ -185,7 +184,7 @@ export function createFeishuMessageReceiveHandler({
},
});
const dispatchFeishuMessage = async (event: FeishuMessageEvent, messageDedupeKey?: string) => {
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const sequentialKey = resolveSequentialKey({
accountId,
event,
@@ -203,7 +202,6 @@ export function createFeishuMessageReceiveHandler({
chatHistories,
accountId,
processingClaimHeld: true,
messageDedupeKey,
});
await enqueue(sequentialKey, task);
};
@@ -268,7 +266,7 @@ export function createFeishuMessageReceiveHandler({
return;
}
if (entries.length === 1) {
await dispatchFeishuMessage(last, resolveFeishuMessageDedupeKey(last));
await dispatchFeishuMessage(last);
return;
}
const dedupedEntries = dedupeFeishuDebounceEntriesByDedupeKey(entries);
@@ -282,8 +280,10 @@ export function createFeishuMessageReceiveHandler({
if (!dispatchEntry) {
return;
}
const dispatchDedupeKey = resolveFeishuMessageDedupeKey(dispatchEntry);
await recordSuppressedMessageIds(dedupedEntries, dispatchDedupeKey);
await recordSuppressedMessageIds(
dedupedEntries,
resolveFeishuMessageDedupeKey(dispatchEntry),
);
const combinedText = freshEntries
.map((entry) => resolveDebounceText(entry))
.filter(Boolean)
@@ -292,22 +292,19 @@ export function createFeishuMessageReceiveHandler({
entries: freshEntries,
botOpenId: getBotOpenId(accountId),
});
await dispatchFeishuMessage(
{
...dispatchEntry,
message: {
...dispatchEntry.message,
...(combinedText.trim()
? {
message_type: "text",
content: JSON.stringify({ text: combinedText }),
}
: {}),
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
await dispatchFeishuMessage({
...dispatchEntry,
message: {
...dispatchEntry.message,
...(combinedText.trim()
? {
message_type: "text",
content: JSON.stringify({ text: combinedText }),
}
: {}),
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
dispatchDedupeKey,
);
});
},
onError: (err, entries) => {
for (const entry of entries) {
@@ -324,14 +321,6 @@ export function createFeishuMessageReceiveHandler({
return;
}
const messageId = event.message?.message_id?.trim();
const botOpenId = getBotOpenId(accountId)?.trim();
const senderOpenId = event.sender.sender_id.open_id?.trim();
if (botOpenId && senderOpenId === botOpenId) {
// Feishu bot receive events identify their sender by open_id. Drop this
// account's bot before it can consume a claim or debounce slot.
log(`feishu[${accountId}]: dropping self-authored message ${messageId ?? "unknown"}`);
return;
}
const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
if (!tryBeginFeishuMessageProcessing(messageDedupeKey, accountId)) {
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);

View File

@@ -858,7 +858,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
extra: {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
dmPolicy: account.config.dmPolicy ?? "pairing",
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,

View File

@@ -30,21 +30,6 @@ describe("MattermostConfigSchema", () => {
expect(result.success).toBe(true);
});
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
});
expect(result.success).toBe(false);
});
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["*"],
});
expect(result.success).toBe(true);
});
it("accepts documented streaming modes and progress config", () => {
const result = MattermostConfigSchema.safeParse({
streaming: {

View File

@@ -11,7 +11,6 @@ vi.mock("./runtime-api.js", () => ({
describe("mattermost monitor auth", () => {
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
@@ -19,7 +18,6 @@ describe("mattermost monitor auth", () => {
beforeAll(async () => {
({
authorizeMattermostCommandInvocation,
formatMattermostDirectMessageDropLog,
isMattermostSenderAllowed,
normalizeMattermostAllowEntry,
normalizeMattermostAllowList,
@@ -60,18 +58,6 @@ describe("mattermost monitor auth", () => {
});
});
it("formats direct-message drops with the ingress reason and open-policy hint", () => {
expect(
formatMattermostDirectMessageDropLog({
senderId: "alice-id",
dmPolicy: "open",
reasonCode: "dm_policy_not_allowlisted",
}),
).toBe(
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
);
});
it("resolves direct command authorization from shared ingress", async () => {
isDangerousNameMatchingEnabled.mockReturnValue(false);
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });

View File

@@ -61,19 +61,6 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
return uniqueStrings(normalized);
}
export function formatMattermostDirectMessageDropLog(params: {
senderId: string;
dmPolicy: string;
reasonCode?: string;
}): string {
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
const hint =
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
? " hint=add-allowFrom-wildcard"
: "";
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
}
export function isMattermostSenderAllowed(params: {
senderId: string;
senderName?: string;

View File

@@ -57,7 +57,6 @@ import {
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
formatMattermostDirectMessageDropLog,
normalizeMattermostAllowEntry,
resolveMattermostMonitorInboundAccess,
} from "./monitor-auth.js";
@@ -1390,13 +1389,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
return;
}
logVerboseMessage(
formatMattermostDirectMessageDropLog({
senderId,
dmPolicy,
reasonCode: accessDecision.senderAccess.reasonCode,
}),
);
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
return;
}
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {

View File

@@ -11,8 +11,6 @@ import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 19;
const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
const MIN_COMPILE_CACHE_NODE_24_MINOR = 15;
const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED";
const parseNodeVersion = (rawVersion) => {
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split(".");
@@ -26,15 +24,6 @@ const isSupportedNodeVersion = (version) =>
version.major > MIN_NODE_MAJOR ||
(version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR);
const isNodeVersionAffectedByCompileCacheDeadlock = (rawVersion) => {
const version = parseNodeVersion(rawVersion);
return version.major === 24 && version.minor < MIN_COMPILE_CACHE_NODE_24_MINOR;
};
const shouldSkipCompileCacheForWindowsNode24 = () =>
process.platform === "win32" &&
isNodeVersionAffectedByCompileCacheDeadlock(process.versions.node);
const ensureSupportedNodeVersion = () => {
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
return;
@@ -205,12 +194,10 @@ const runRespawnedChild = (command, args, env) => {
};
const respawnWithoutCompileCacheIfNeeded = () => {
const needsDisabledCompileCacheRespawn =
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
if (!needsDisabledCompileCacheRespawn) {
if (!isSourceCheckoutLauncher()) {
return false;
}
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
if (process.env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED === "1") {
return false;
}
if (!module.getCompileCacheDir?.() && !isNodeCompileCacheRequested()) {
@@ -219,7 +206,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
const env = {
...process.env,
NODE_DISABLE_COMPILE_CACHE: "1",
[COMPILE_CACHE_DISABLED_RESPAWNED_ENV]: "1",
OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1",
};
delete env.NODE_COMPILE_CACHE;
return runRespawnedChild(
@@ -230,11 +217,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
};
const respawnWithPackagedCompileCacheIfNeeded = () => {
if (
isSourceCheckoutLauncher() ||
isNodeCompileCacheDisabled() ||
shouldSkipCompileCacheForWindowsNode24()
) {
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
return false;
}
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
@@ -268,8 +251,7 @@ if (
!waitingForCompileCacheRespawn &&
module.enableCompileCache &&
!isNodeCompileCacheDisabled() &&
!isSourceCheckoutLauncher() &&
!shouldSkipCompileCacheForWindowsNode24()
!isSourceCheckoutLauncher()
) {
try {
module.enableCompileCache(resolvePackagedCompileCacheDirectory());

View File

@@ -2,7 +2,6 @@
import { describe, expect, it } from "vitest";
import { ok, type FileSystem } from "../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
import { Session } from "./session.js";
type JsonlStorageFs = Pick<
FileSystem,
@@ -56,209 +55,4 @@ describe("JsonlSessionStorage timestamps", () => {
"line 2 has invalid timestamp",
);
});
it("uses a leaf control's opaque append parent for the next entry", async () => {
let content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "root",
},
{
type: "metadata",
id: "plugin-metadata",
parentId: null,
timestamp: "2026-06-15T00:00:02.000Z",
},
{
type: "leaf",
id: "active-leaf",
parentId: "inactive-tail",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "plugin-metadata",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
content += "\n";
const fs: JsonlStorageFs = {
...createReadOnlyFs(content),
readTextFile: async () => ok(content),
appendFile: async (_path, appended) => {
content += String(appended);
return ok(undefined);
},
};
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
const session = new Session(storage);
expect(await session.getLeafId()).toBe("active-root");
const entryId = await session.appendCustomEntry("continued");
const entry = await session.getEntry(entryId);
expect(entry).toMatchObject({ parentId: "plugin-metadata" });
expect((await storage.getPathToRoot(entryId)).map((pathEntry) => pathEntry.id)).toEqual([
"active-root",
entryId,
]);
expect(content.trim().split(/\r?\n/).at(-1)).toContain('"parentId":"plugin-metadata"');
});
it("keeps a terminal side append off the visible branch", async () => {
let content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "custom",
id: "side-one",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
customType: "side",
},
{
type: "leaf",
id: "side-leaf",
parentId: "side-one",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "side-one",
appendMode: "side",
},
{
type: "custom",
id: "side-two",
parentId: "side-one",
timestamp: "2026-06-15T00:00:04.000Z",
customType: "side",
appendMode: "side",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
content += "\n";
const fs: JsonlStorageFs = {
...createReadOnlyFs(content),
readTextFile: async () => ok(content),
appendFile: async (_path, appended) => {
content += String(appended);
return ok(undefined);
},
};
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
const session = new Session(storage);
expect(await storage.getLeafId()).toBe("active-root");
expect(await storage.getAppendParentId()).toBe("side-two");
const entryId = await session.appendCustomEntry("continued");
expect(await storage.getEntry(entryId)).toMatchObject({ parentId: "side-two" });
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
"active-root",
entryId,
]);
});
it("does not let opaque rows replace the selected visible leaf", async () => {
const content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "custom",
id: "inactive-root",
parentId: null,
timestamp: "2026-06-15T00:00:02.000Z",
customType: "inactive",
},
{
type: "leaf",
id: "active-leaf",
parentId: "inactive-root",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
},
{
type: "metadata",
id: "plugin-metadata",
parentId: "inactive-root",
timestamp: "2026-06-15T00:00:04.000Z",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
const storage = await JsonlSessionStorage.open(
createReadOnlyFs(`${content}\n`),
"/sessions/session.jsonl",
);
const session = new Session(storage);
expect(await session.getLeafId()).toBe("active-root");
expect((await session.getBranch()).map((entry) => entry.id)).toEqual(["active-root"]);
});
it("rejects a leaf control with a missing append parent", async () => {
const content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "leaf",
id: "active-leaf",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
targetId: "active-root",
appendParentId: "missing",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
await expect(
JsonlSessionStorage.open(createReadOnlyFs(`${content}\n`), "/sessions/session.jsonl"),
).rejects.toThrow("Append parent missing not found");
});
});

View File

@@ -2,11 +2,7 @@
import type { FileSystem, JsonlSessionMetadata, SessionTreeEntry } from "../types.js";
import { SessionError, toError } from "../types.js";
import { getFileSystemResultOrThrow } from "./repo-utils.js";
import {
appendParentIdAfterEntry,
BaseSessionStorage,
leafIdUpdateAfterEntry,
} from "./storage-base.js";
import { BaseSessionStorage, leafIdAfterEntry } from "./storage-base.js";
import { parseSessionTimestampMs } from "./timestamps.js";
type JsonlSessionStorageFileSystem = Pick<
@@ -117,17 +113,6 @@ function parseEntryLine(line: string, filePath: string, lineNumber: number): Ses
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
if (
parsed.type === "leaf" &&
parsed.appendParentId !== undefined &&
parsed.appendParentId !== null &&
typeof parsed.appendParentId !== "string"
) {
throw invalidEntry(filePath, lineNumber, "has invalid appendParentId");
}
if (parsed.appendMode !== undefined && parsed.appendMode !== "side") {
throw invalidEntry(filePath, lineNumber, "has invalid appendMode");
}
return parsed as unknown as SessionTreeEntry;
}
@@ -164,7 +149,6 @@ async function loadJsonlStorage(
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
appendParentId: string | null;
}> {
const content = getFileSystemResultOrThrow(
await fs.readTextFile(filePath),
@@ -178,17 +162,12 @@ async function loadJsonlStorage(
const header = parseHeaderLine(lines[0], filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
let appendParentId: string | null = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i], filePath, i + 1);
entries.push(entry);
const leafUpdate = leafIdUpdateAfterEntry(entry);
if (leafUpdate !== undefined) {
leafId = leafUpdate;
}
appendParentId = appendParentIdAfterEntry(entry);
leafId = leafIdAfterEntry(entry);
}
return { header, entries, leafId, appendParentId };
return { header, entries, leafId };
}
/** Append-only JSONL-backed storage for one session tree. */
@@ -202,9 +181,8 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
appendParentId: string | null,
) {
super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId);
super(headerToSessionMetadata(header, filePath), entries, leafId);
this.fs = fs;
this.filePath = filePath;
}
@@ -214,14 +192,7 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
filePath: string,
): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(
fs,
filePath,
loaded.header,
loaded.entries,
loaded.leafId,
loaded.appendParentId,
);
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
/** Create a new JSONL file with a session header and no entries. */
@@ -246,7 +217,7 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
`Failed to create session ${filePath}`,
);
return new JsonlSessionStorage(fs, filePath, header, [], null, null);
return new JsonlSessionStorage(fs, filePath, header, [], null);
}
override async setLeafId(leafId: string | null): Promise<void> {
@@ -259,7 +230,6 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
}
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
this.validateEntryForAppend(entry);
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,

View File

@@ -2,7 +2,6 @@
import { describe, expect, it } from "vitest";
import type { SessionTreeEntry } from "../types.js";
import { InMemorySessionStorage } from "./memory-storage.js";
import { Session } from "./session.js";
const rootEntry: SessionTreeEntry = {
type: "custom",
@@ -61,120 +60,4 @@ describe("InMemorySessionStorage", () => {
targetId: "root",
});
});
it("traverses descendants of leaf markers through the selected target", async () => {
const leafEntry: SessionTreeEntry = {
type: "leaf",
id: "leaf-1",
parentId: "child",
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "root",
};
const replacementEntry: SessionTreeEntry = {
type: "custom",
id: "replacement",
parentId: leafEntry.id,
timestamp: "2026-01-01T00:00:03.000Z",
customType: "replacement",
};
const storage = new InMemorySessionStorage({
entries: [rootEntry, childEntry, leafEntry, replacementEntry],
});
expect((await storage.getPathToRoot(replacementEntry.id)).map((entry) => entry.id)).toEqual([
"root",
"replacement",
]);
expect((await storage.getPathToRoot(leafEntry.id)).map((entry) => entry.id)).toEqual(["root"]);
});
it("honors an explicit root append parent after a visible leaf selection", async () => {
const storage = new InMemorySessionStorage({
entries: [
rootEntry,
{
type: "leaf",
id: "leaf-1",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "root",
appendParentId: null,
},
],
});
const session = new Session(storage);
const entryId = await session.appendCustomEntry("new-root");
expect(await session.getEntry(entryId)).toMatchObject({ parentId: null });
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
"root",
entryId,
]);
});
it("keeps marked side ancestry separate from the next active append", async () => {
const sideOne: SessionTreeEntry = {
type: "custom",
id: "side-one",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
customType: "side",
};
const sideTwo: SessionTreeEntry = {
type: "custom",
id: "side-two",
parentId: sideOne.id,
timestamp: "2026-01-01T00:00:03.000Z",
appendMode: "side",
customType: "side",
};
const storage = new InMemorySessionStorage({
entries: [
rootEntry,
sideOne,
{
type: "leaf",
id: "first-leaf",
parentId: sideOne.id,
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "root",
appendParentId: sideOne.id,
appendMode: "side",
},
sideTwo,
],
});
const session = new Session(storage);
expect(await storage.getLeafId()).toBe("root");
expect(await storage.getAppendParentId()).toBe(sideTwo.id);
expect((await storage.getPathToRoot(sideTwo.id)).map((entry) => entry.id)).toEqual([
"root",
sideOne.id,
sideTwo.id,
]);
const nextEntryId = await session.appendCustomEntry("active");
expect((await storage.getPathToRoot(nextEntryId)).map((entry) => entry.id)).toEqual([
"root",
nextEntryId,
]);
});
it("rejects a leaf entry with a missing append parent before recording it", async () => {
const storage = new InMemorySessionStorage({ entries: [rootEntry] });
await expect(
storage.appendEntry({
type: "leaf",
id: "leaf-1",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "root",
appendParentId: "missing",
}),
).rejects.toThrow("Append parent missing not found");
expect(await storage.getEntries()).toEqual([rootEntry]);
});
});

View File

@@ -122,10 +122,6 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.storage.getLeafId();
}
private getAppendParentId(): Promise<string | null> {
return this.storage.getAppendParentId?.() ?? this.storage.getLeafId();
}
getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.storage.getEntry(id);
}
@@ -161,7 +157,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "message",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
message,
} satisfies MessageEntry);
@@ -171,7 +167,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "thinking_level_change",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
thinkingLevel,
} satisfies ThinkingLevelChangeEntry);
@@ -181,7 +177,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "model_change",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
provider,
modelId,
@@ -198,7 +194,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "compaction",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
@@ -213,7 +209,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "custom",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
customType,
data,
@@ -230,7 +226,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "custom_message",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
customType,
content,
@@ -247,7 +243,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "label",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
targetId,
label,
@@ -258,7 +254,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.appendTypedEntry({
type: "session_info",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
name: name.trim(),
} satisfies SessionInfoEntry);

View File

@@ -28,10 +28,6 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
return labelsById;
}
function isSideAppendEntry(entry: SessionTreeEntry): boolean {
return entry.appendMode === "side";
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = uuidv7().slice(0, 8);
@@ -42,81 +38,19 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
return uuidv7();
}
/** Return the visible-leaf update represented by one session tree entry. */
export function leafIdUpdateAfterEntry(entry: SessionTreeEntry): string | null | undefined {
if (entry.type !== "leaf" && isSideAppendEntry(entry)) {
return undefined;
}
switch (entry.type) {
case "leaf":
return entry.targetId;
case "message":
case "thinking_level_change":
case "model_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
case "session_info":
return entry.id;
default:
// JSONL transcripts may contain parent-linked plugin rows that advance
// the raw append cursor without selecting a model-visible branch.
return undefined;
}
}
/** Return the raw parent for the next append after applying a tree entry. */
export function appendParentIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf"
? entry.appendParentId === undefined
? entry.targetId
: entry.appendParentId
: entry.id;
/** Return the effective branch leaf after applying a session tree entry. */
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null {
let leafId: string | null = null;
for (const entry of entries) {
const update = leafIdUpdateAfterEntry(entry);
if (update !== undefined) {
leafId = update;
}
leafId = leafIdAfterEntry(entry);
}
return leafId;
}
function resolveAppendParentId(entries: readonly SessionTreeEntry[]): string | null {
let appendParentId: string | null = null;
for (const entry of entries) {
appendParentId = appendParentIdAfterEntry(entry);
}
return appendParentId;
}
function buildLogicalParentsById(entries: readonly SessionTreeEntry[]): Map<string, string | null> {
const logicalParentsById = new Map<string, string | null>();
let leafId: string | null = null;
let appendParentId: string | null = null;
for (const entry of entries) {
const leafUpdate = leafIdUpdateAfterEntry(entry);
if (
leafUpdate === entry.id &&
!isSideAppendEntry(entry) &&
entry.parentId === appendParentId &&
leafId !== appendParentId
) {
logicalParentsById.set(entry.id, leafId);
}
if (leafUpdate !== undefined) {
leafId = leafUpdate;
}
appendParentId = appendParentIdAfterEntry(entry);
}
return logicalParentsById;
}
export abstract class BaseSessionStorage<
TMetadata extends SessionMetadata = SessionMetadata,
> implements SessionStorage<TMetadata> {
@@ -124,29 +58,21 @@ export abstract class BaseSessionStorage<
private readonly entries: SessionTreeEntry[];
private readonly byId: Map<string, SessionTreeEntry>;
private readonly labelsById: Map<string, string>;
private readonly logicalParentsById: Map<string, string | null>;
private leafId: string | null;
private appendParentId: string | null;
protected constructor(
metadata: TMetadata,
entries: SessionTreeEntry[],
leafId: string | null = resolveLeafId(entries),
appendParentId: string | null = resolveAppendParentId(entries),
) {
this.metadata = metadata;
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.logicalParentsById = buildLogicalParentsById(entries);
this.leafId = leafId;
this.appendParentId = appendParentId;
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
}
}
async getMetadata(): Promise<TMetadata> {
@@ -160,13 +86,6 @@ export abstract class BaseSessionStorage<
return this.leafId;
}
async getAppendParentId(): Promise<string | null> {
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
}
return this.appendParentId;
}
protected createLeafEntry(leafId: string | null): LeafEntry {
if (leafId !== null && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
@@ -174,7 +93,7 @@ export abstract class BaseSessionStorage<
return {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
@@ -184,40 +103,13 @@ export abstract class BaseSessionStorage<
return generateEntryId(this.byId);
}
protected validateEntryForAppend(entry: SessionTreeEntry): void {
const leafId = leafIdUpdateAfterEntry(entry);
const leafIsNewEntry = entry.type !== "leaf" && leafId === entry.id;
if (leafId !== undefined && leafId !== null && !leafIsNewEntry && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const appendParentId = appendParentIdAfterEntry(entry);
const appendParentIsNewEntry = entry.type !== "leaf" && appendParentId === entry.id;
if (appendParentId !== null && !appendParentIsNewEntry && !this.byId.has(appendParentId)) {
throw new SessionError("not_found", `Append parent ${appendParentId} not found`);
}
}
protected recordEntry(entry: SessionTreeEntry): void {
// Leaf and label entries are append-only state changes; keep derived indexes
// synchronized here so memory and JSONL storage expose identical behavior.
this.validateEntryForAppend(entry);
const leafId = leafIdUpdateAfterEntry(entry);
if (
leafId === entry.id &&
!isSideAppendEntry(entry) &&
entry.parentId === this.appendParentId &&
this.leafId !== this.appendParentId
) {
this.logicalParentsById.set(entry.id, this.leafId);
}
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
if (leafId !== undefined) {
this.leafId = leafId;
}
this.appendParentId = appendParentIdAfterEntry(entry);
this.leafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
@@ -245,29 +137,14 @@ export abstract class BaseSessionStorage<
if (!current) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const seen = new Set<string>();
while (current) {
if (seen.has(current.id)) {
throw new SessionError("invalid_session", `Cycle found at entry ${current.id}`);
}
seen.add(current.id);
if (current.type !== "leaf") {
path.unshift(current);
}
// Leaf rows are control records. Descendants written by older appenders
// may point at the marker, but their visible ancestry starts at its target.
const parentId =
current.type === "leaf"
? current.targetId
: this.logicalParentsById.has(current.id)
? (this.logicalParentsById.get(current.id) ?? null)
: current.parentId;
if (!parentId) {
path.unshift(current);
if (!current.parentId) {
break;
}
const parent = this.byId.get(parentId);
const parent = this.byId.get(current.parentId);
if (!parent) {
throw new SessionError("invalid_session", `Entry ${parentId} not found`);
throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
}
current = parent;
}

View File

@@ -374,8 +374,6 @@ export interface SessionTreeEntryBase {
parentId: string | null;
/** ISO timestamp string used for persistence and sorting. */
timestamp: string;
/** This row consumes the raw side cursor instead of the visible leaf. */
appendMode?: "side";
}
/** Persisted transcript message entry. */
@@ -450,8 +448,6 @@ export interface SessionInfoEntry extends SessionTreeEntryBase {
export interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null;
/** Raw parent for the next append when it differs from the visible leaf. */
appendParentId?: string | null;
}
/** All persisted session tree entry variants. */
@@ -487,7 +483,6 @@ export interface JsonlSessionMetadata extends SessionMetadata {
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
getMetadata(): Promise<TMetadata>;
getLeafId(): Promise<string | null>;
getAppendParentId?(): Promise<string | null>;
/** Persist a leaf entry that records the active session-tree leaf. */
setLeafId(leafId: string | null): Promise<void>;
createEntryId(): Promise<string>;

View File

@@ -34,7 +34,6 @@ for env_key in \
ANTHROPIC_API_KEY \
ANTHROPIC_API_KEY_OLD \
ANTHROPIC_API_TOKEN \
ANTHROPIC_OAUTH_TOKEN \
BYTEPLUS_API_KEY \
CEREBRAS_API_KEY \
DEEPINFRA_API_KEY \

View File

@@ -43,6 +43,7 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"src/plugins/contracts/tts-contract-suites.ts",
"src/plugins/runtime-sidecar-paths-baseline.ts",
"src/tasks/task-registry-control.runtime.ts",
"ui/src/ui/browser-redact.ts",
"extensions/qa-lab/src/auth-profile.fixture.ts",
"extensions/qa-lab/src/codex-plugin.fixture.ts",
];

View File

@@ -40,17 +40,13 @@ CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
GATEWAY_LOG="$LOG_DIR/gateway.log"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
BASELINE_SPEC="${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}"
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
candidate_version="$(
tar -xOf "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" package/package.json |
node -e 'let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(raw).version); });'
)"
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
BASELINE_SPEC="$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC"
else
BASELINE_SPEC="$(node scripts/lib/release-upgrade-baseline.mjs --candidate-version "$candidate_version")"
fi
mock_pid=""
clickclack_pid=""

View File

@@ -53,9 +53,9 @@ import {
// Older published baselines predate this warning, but still need update coverage.
const BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION = "2026.5.7";
// Restored Ubuntu snapshots may immediately run package maintenance for hours.
// Reuse an existing downloader before touching apt, then bound the fallback.
const APT_LOCK_RETRY_SECONDS = 900;
// Restored Ubuntu snapshots may immediately run unattended-upgrades. Let that
// legitimate maintenance finish instead of racing or disabling the OS service.
const APT_LOCK_TIMEOUT_SECONDS = 900;
const BOOTSTRAP_TIMEOUT_SECONDS = 1200;
function parseOpenClawPackageVersion(value: string): string | null {
@@ -445,44 +445,27 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
this.guestExec(["hwclock", "--systohc"], { check: false });
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
this.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
exit 0
fi
deadline=$((SECONDS + ${APT_LOCK_RETRY_SECONDS}))
run_apt_with_lock_retry() {
local output status
while true; do
if output="$("$@" 2>&1)"; then
status=0
else
status=$?
fi
printf '%s\n' "$output"
if [ "$status" -eq 0 ]; then
return 0
fi
case "$output" in
*"Could not get lock"*|*"Unable to acquire the dpkg frontend lock"*|*"Unable to lock directory"*)
if [ "$SECONDS" -ge "$deadline" ]; then
printf 'Timed out waiting for Ubuntu package maintenance locks\n' >&2
return "$status"
fi
sleep 5
;;
*)
return "$status"
;;
esac
done
}
run_apt_with_lock_retry apt-get -o Acquire::Check-Date=false -o DPkg::Lock::Timeout=30 update
run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-certificates`);
this.guestExec([
"apt-get",
"-o",
"Acquire::Check-Date=false",
"-o",
`DPkg::Lock::Timeout=${APT_LOCK_TIMEOUT_SECONDS}`,
"update",
]);
this.guestExec([
"apt-get",
"-o",
`DPkg::Lock::Timeout=${APT_LOCK_TIMEOUT_SECONDS}`,
"install",
"-y",
"curl",
"ca-certificates",
]);
}
private installLatestRelease(): void {
this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh");
this.guestExec(["curl", "-fsSL", this.options.installUrl, "-o", "/tmp/openclaw-install.sh"]);
if (this.options.installVersion) {
this.guestExec([
"/usr/bin/env",
@@ -505,22 +488,12 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
this.guestExec(["openclaw", "--version"]);
}
private downloadGuestFile(url: string, outputPath: string): void {
this.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1; then
curl -fsSL ${shellQuote(url)} -o ${shellQuote(outputPath)}
else
wget -q -O ${shellQuote(outputPath)} ${shellQuote(url)}
fi`);
}
private installMainTgz(tempName: string): void {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
this.guestExec(["curl", "-fsSL", tgzUrl, "-o", `/tmp/${tempName}`]);
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
this.guestExec(["openclaw", "--version"]);
}

View File

@@ -24,17 +24,11 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" release-upgrade-user-journey "$ROOT_DIR/
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-upgrade-user-journey empty)"
run_log="$(docker_e2e_run_log release-upgrade-user-journey)"
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
)
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
DOCKER_ENV_ARGS+=(-e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC")
fi
echo "Running release upgrade user journey Docker E2E..."
if ! docker_e2e_run_with_harness \
"${DOCKER_ENV_ARGS[@]}" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-upgrade-user-journey/scenario.sh >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"

View File

@@ -1,115 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseReleaseVersion } from "./npm-publish-plan.mjs";
function parseVersion(version) {
return parseReleaseVersion(String(version ?? "").trim()) ?? undefined;
}
export function compareOpenClawVersions(leftVersion, rightVersion) {
const left = parseVersion(leftVersion);
const right = parseVersion(rightVersion);
if (!left || !right) {
throw new Error(`cannot compare OpenClaw versions: ${leftVersion} ${rightVersion}`);
}
for (const key of ["year", "month", "patch"]) {
const delta = left[key] - right[key];
if (delta !== 0) {
return delta;
}
}
const channelRank = { alpha: 0, beta: 1, stable: 2 };
const channelDelta = channelRank[left.channel] - channelRank[right.channel];
if (channelDelta !== 0) {
return channelDelta;
}
if (left.channel === "alpha") {
return (left.alphaNumber ?? 0) - (right.alphaNumber ?? 0);
}
if (left.channel === "beta") {
return (left.betaNumber ?? 0) - (right.betaNumber ?? 0);
}
return (left.correctionNumber ?? 0) - (right.correctionNumber ?? 0);
}
function normalizePublishedVersions(publishedVersions) {
return [...new Set(publishedVersions.map((version) => String(version).trim()).filter(Boolean))]
.filter((version) => parseVersion(version))
.toSorted((left, right) => compareOpenClawVersions(right, left));
}
export function resolveDefaultReleaseUpgradeBaseline(candidateVersion, publishedVersions) {
const candidate = parseVersion(candidateVersion);
if (!candidate) {
throw new Error(`invalid candidate OpenClaw version: ${candidateVersion}`);
}
const versions = normalizePublishedVersions(publishedVersions);
const older = versions.find((version) => compareOpenClawVersions(version, candidate.version) < 0);
if (older) {
return `openclaw@${older}`;
}
const same = versions.find(
(version) => compareOpenClawVersions(version, candidate.version) === 0,
);
if (same) {
return `openclaw@${same}`;
}
throw new Error(`no published OpenClaw baseline is <= candidate ${candidate.version}`);
}
function parseArgs(argv) {
const args = new Map();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected argument: ${arg}`);
}
const key = arg.slice(2);
const value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`missing value for --${key}`);
}
args.set(key, value);
index += 1;
}
return args;
}
function readPublishedVersions(args) {
const versionsJson = args.get("versions-json");
if (versionsJson) {
const parsed = JSON.parse(readFileSync(versionsJson, "utf8"));
if (!Array.isArray(parsed)) {
throw new Error(`npm versions list must be a JSON array: ${versionsJson}`);
}
return parsed;
}
const raw = execFileSync("npm", ["view", "openclaw", "versions", "--json", "--silent"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("npm returned a non-array openclaw versions payload");
}
return parsed;
}
const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
if (isMain) {
const args = parseArgs(process.argv.slice(2));
const candidateVersion = args.get("candidate-version");
if (!candidateVersion) {
throw new Error("--candidate-version is required");
}
const baseline = resolveDefaultReleaseUpgradeBaseline(
candidateVersion,
readPublishedVersions(args),
);
process.stdout.write(`${baseline}\n`);
}

View File

@@ -161,7 +161,7 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 319),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10271),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10270),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5161),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",

View File

@@ -103,3 +103,23 @@ export function resolveAgentCredentialMapFromStore(
}
return credentials;
}
/** Compare agent runtime credential values without broad object equality. */
export function agentCredentialsEqual(a: AgentCredential | undefined, b: AgentCredential): boolean {
if (!a || typeof a !== "object") {
return false;
}
if (a.type !== b.type) {
return false;
}
if (a.type === "api_key" && b.type === "api_key") {
return a.key === b.key;
}
if (a.type === "oauth" && b.type === "oauth") {
return a.access === b.access && a.refresh === b.refresh && a.expires === b.expires;
}
return false;
}

View File

@@ -9,7 +9,6 @@ import path from "node:path";
import type { AgentTool, AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import * as windowsEncoding from "../infra/windows-encoding.js";
import { createOpenClawReadTool, createSandboxedReadTool } from "./agent-tools.read.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
@@ -33,34 +32,6 @@ function extractToolText(result: unknown): string {
}
describe("createOpenClawCodingTools read behavior", () => {
it("uses host decoding only for host-backed sandbox paths", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-encoding-"));
await fs.writeFile(path.join(tmpDir, "notes.txt"), "hello", "utf8");
const hostBridge = createHostSandboxFsBridge(tmpDir);
const remoteBridge = {
...hostBridge,
resolvePath: (params: Parameters<typeof hostBridge.resolvePath>[0]) => {
const { relativePath, containerPath } = hostBridge.resolvePath(params);
return { relativePath, containerPath };
},
};
const decodeSpy = vi.spyOn(windowsEncoding, "decodeWindowsTextFileBuffer");
try {
const hostTool = createSandboxedReadTool({ root: tmpDir, bridge: hostBridge });
await hostTool.execute("host-read", { path: "notes.txt" });
expect(decodeSpy).toHaveBeenCalledTimes(1);
decodeSpy.mockClear();
const remoteTool = createSandboxedReadTool({ root: tmpDir, bridge: remoteBridge });
await remoteTool.execute("remote-read", { path: "notes.txt" });
expect(decodeSpy).not.toHaveBeenCalled();
} finally {
decodeSpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("applies sandbox path guards to canonical path", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-"));
const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt");

View File

@@ -15,7 +15,6 @@ import {
} from "../infra/fs-safe.js";
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js";
import { decodeWindowsTextFileBuffer } from "../infra/windows-encoding.js";
import {
classifyMediaReferenceSource,
normalizeMediaReferenceSource,
@@ -918,10 +917,6 @@ function createSandboxReadOperations(params: SandboxToolParams) {
}
return resolveContainerPathCandidate(filePath) ?? filePath;
},
decodeText: ({ buffer, absolutePath }: { buffer: Buffer; absolutePath: string }) =>
params.bridge.resolvePath({ filePath: absolutePath, cwd: params.root }).hostPath
? decodeWindowsTextFileBuffer({ buffer })
: buffer.toString("utf8"),
readFile: (absolutePath: string) =>
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
access: (absolutePath: string) => assertSandboxFileExists(params, absolutePath),

View File

@@ -14,7 +14,6 @@ import {
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js";
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
import {
openOpenClawAgentDatabase,
@@ -68,7 +67,8 @@ export function resolveAuthProfileDatabasePath(agentDir?: string): string {
/** Resolves the SQLite database and sidecar paths used by auth profiles. */
export function resolveAuthProfileDatabaseFilePaths(agentDir?: string): string[] {
return resolveSqliteDatabaseFilePaths(resolveAuthProfileDatabasePath(agentDir));
const databasePath = resolveAuthProfileDatabasePath(agentDir);
return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`];
}
// Read-only probes must tolerate old/corrupt/missing rows. Coercion happens

View File

@@ -323,6 +323,11 @@ export function listFinishedSessions() {
return Array.from(finishedSessions.values());
}
/** Clears retained finished sessions without touching running processes. */
export function clearFinished() {
finishedSessions.clear();
}
/** Test-only reset for in-memory registry state and retention timers. */
export function resetProcessRegistryForTests() {
runningSessions.clear();

View File

@@ -68,6 +68,16 @@ export async function getOrLoadBootstrapFiles(params: {
return files;
}
/** Test helper exposing the bounded snapshot cache size. */
export function getBootstrapSnapshotCacheSizeForTest(): number {
return cache.size;
}
/** Test helper for asserting one session snapshot is cached. */
export function hasBootstrapSnapshotForTest(sessionKey: string): boolean {
return cache.has(sessionKey);
}
/** Drop one cached bootstrap snapshot. */
export function clearBootstrapSnapshot(sessionKey: string): void {
cache.delete(sessionKey);

View File

@@ -7,10 +7,6 @@ import {
resolveSessionFilePathOptions,
type SessionEntry as StoredSessionEntry,
} from "../config/sessions.js";
import {
scanSessionTranscriptTree,
type SessionTranscriptTree,
} from "../config/sessions/transcript-tree.js";
import { diagnosticLogger as diag } from "../logging/diagnostic.js";
import {
buildSessionContext,
@@ -48,18 +44,36 @@ function readSessionEntryId(entry: AgentSessionEntry): string | undefined {
return typeof id === "string" && id.trim().length > 0 ? id : undefined;
}
function readSessionEntryParentId(entry: AgentSessionEntry): string | null | undefined {
const parentId = (entry as { parentId?: unknown }).parentId;
if (parentId === null) {
return null;
}
return typeof parentId === "string" && parentId.trim().length > 0 ? parentId : undefined;
}
// Parent links mark fork-aware transcripts. Without them, the flat session
// context builder preserves the legacy append-only transcript behavior.
function hasParentLinkedEntries(entries: AgentSessionEntry[]): boolean {
return entries.some((entry) => Boolean(readSessionEntryId(entry) && "parentId" in entry));
}
// Reconstructs the selected branch from leaf to root. Missing links or cycles
// mean the snapshot cannot be trusted, so callers fall back to a safe branch.
function buildSessionBranchEntries(
tree: SessionTranscriptTree<AgentSessionEntry>,
leafId: string | null | undefined,
entries: AgentSessionEntry[],
leafId: string | undefined,
): AgentSessionEntry[] | undefined {
if (leafId === null) {
return [];
}
if (!leafId) {
return undefined;
}
const byId = new Map<string, AgentSessionEntry>();
for (const entry of entries) {
const id = readSessionEntryId(entry);
if (id) {
byId.set(id, entry);
}
}
const branch: AgentSessionEntry[] = [];
const seen = new Set<string>();
let currentId: string | undefined = leafId;
@@ -68,22 +82,26 @@ function buildSessionBranchEntries(
return undefined;
}
seen.add(currentId);
const node = tree.byId.get(currentId);
if (!node) {
const entry = byId.get(currentId);
if (!entry) {
return undefined;
}
if ((node.entry as { type?: unknown }).type !== "leaf") {
branch.push(
node.entry.parentId === node.parentId
? node.entry
: ({ ...node.entry, parentId: node.parentId } as AgentSessionEntry),
);
}
currentId = node.parentId ?? undefined;
branch.push(entry);
currentId = readSessionEntryParentId(entry) ?? undefined;
}
return branch.toReversed();
}
function readDefaultLeafId(entries: AgentSessionEntry[]): string | undefined {
for (let index = entries.length - 1; index >= 0; index -= 1) {
const id = readSessionEntryId(entries[index]);
if (id) {
return id;
}
}
return undefined;
}
function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean {
return (
entry?.type === "message" &&
@@ -109,27 +127,24 @@ export async function readBtwTranscriptMessages(params: {
const sessionEntries = entries.filter(
(entry): entry is AgentSessionEntry => entry.type !== "session",
);
const tree = scanSessionTranscriptTree(sessionEntries);
if (!tree.hasLeafUpdate) {
if (!hasParentLinkedEntries(sessionEntries)) {
return buildSessionContext(sessionEntries).messages;
}
const hasSnapshotLeaf = params.snapshotLeafId !== undefined;
let branchEntries = hasSnapshotLeaf
? buildSessionBranchEntries(tree, params.snapshotLeafId)
let branchEntries = params.snapshotLeafId
? buildSessionBranchEntries(sessionEntries, params.snapshotLeafId)
: undefined;
if (hasSnapshotLeaf && branchEntries === undefined) {
if (params.snapshotLeafId && !branchEntries) {
diag.debug(
`btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`,
);
}
branchEntries ??= buildSessionBranchEntries(tree, tree.leafId);
if (!hasSnapshotLeaf && isTrailingUserMessage(branchEntries?.at(-1))) {
branchEntries ??= buildSessionBranchEntries(sessionEntries, readDefaultLeafId(sessionEntries));
if (!params.snapshotLeafId && isTrailingUserMessage(branchEntries?.at(-1))) {
// Auto-selecting the newest branch must not include the current user turn
// that triggered BTW handoff; the subagent should continue from its parent.
const trailingId = readSessionEntryId(branchEntries!.at(-1)!);
const parentId = trailingId ? tree.byId.get(trailingId)?.parentId : null;
branchEntries = parentId ? (buildSessionBranchEntries(tree, parentId) ?? []) : [];
const parentId = readSessionEntryParentId(branchEntries!.at(-1)!);
branchEntries = parentId ? (buildSessionBranchEntries(sessionEntries, parentId) ?? []) : [];
}
const sessionContext = buildSessionContext(branchEntries ?? sessionEntries);
return Array.isArray(sessionContext.messages) ? sessionContext.messages : [];

View File

@@ -1482,144 +1482,6 @@ describe("runBtwSideQuestion", () => {
);
});
it("honors an explicitly empty active run snapshot", async () => {
const userEntry = createTranscriptEntry({
id: "user-seed",
message: createUserTranscriptMessage(),
});
const assistantEntry = createTranscriptEntry({
id: "assistant-seed",
parentId: "user-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
});
mockTranscriptEntries([userEntry, assistantEntry]);
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
transcriptLeafId: null,
});
await expect(runMathSideQuestion()).rejects.toThrow("No active session context.");
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
expect(buildSessionContextMock).toHaveBeenCalledWith([]);
});
it("uses the branch selected by a terminal transcript leaf control", async () => {
const userEntry = createTranscriptEntry({
id: "user-seed",
message: createUserTranscriptMessage(),
});
const assistantEntry = createTranscriptEntry({
id: "assistant-seed",
parentId: "user-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
});
const sideEntry = createTranscriptEntry({
id: "side-delivery",
parentId: "assistant-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
});
const leafEntry = {
type: "leaf",
id: "active-leaf",
parentId: "side-delivery",
targetId: "assistant-seed",
};
mockTranscriptEntries([userEntry, assistantEntry, sideEntry, leafEntry]);
mockDoneAnswer(MATH_ANSWER);
const result = await runMathSideQuestion();
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
expect(buildSessionContextMock).toHaveBeenCalledWith([userEntry, assistantEntry]);
expect(result).toEqual({ text: MATH_ANSWER });
});
it("keeps parentless history addressed by a terminal leaf control", async () => {
const userEntry = {
type: "message",
id: "user-seed",
message: createUserTranscriptMessage(),
};
const assistantEntry = {
type: "message",
id: "assistant-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
};
const sideEntry = createTranscriptEntry({
id: "side-delivery",
parentId: "assistant-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
});
const leafEntry = {
type: "leaf",
id: "active-leaf",
parentId: "side-delivery",
targetId: "assistant-seed",
};
mockTranscriptEntries([userEntry, assistantEntry, sideEntry, leafEntry]);
mockDoneAnswer(MATH_ANSWER);
const result = await runMathSideQuestion();
expect(buildSessionContextMock).toHaveBeenCalledWith([
{ ...userEntry, parentId: null },
{ ...assistantEntry, parentId: "user-seed" },
]);
expect(result).toEqual({ text: MATH_ANSWER });
});
it("keeps visible history after continuing from a disjoint opaque append cursor", async () => {
const userEntry = createTranscriptEntry({
id: "user-seed",
message: createUserTranscriptMessage(),
});
const assistantEntry = createTranscriptEntry({
id: "assistant-seed",
parentId: "user-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
});
const sideEntry = createTranscriptEntry({
id: "side-delivery",
parentId: "assistant-seed",
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
});
const metadataEntry = {
type: "metadata",
id: "plugin-metadata",
parentId: "side-delivery",
};
const leafEntry = {
type: "leaf",
id: "active-leaf",
parentId: "side-delivery",
targetId: "assistant-seed",
appendParentId: "plugin-metadata",
};
const continuationEntry = createTranscriptEntry({
id: "assistant-continuation",
parentId: "plugin-metadata",
message: createAssistantTranscriptMessage([{ type: "text", text: "continued answer" }]),
});
mockTranscriptEntries([
userEntry,
assistantEntry,
sideEntry,
metadataEntry,
leafEntry,
continuationEntry,
]);
mockDoneAnswer(MATH_ANSWER);
const result = await runMathSideQuestion();
expect(buildSessionContextMock).toHaveBeenCalledWith([
userEntry,
assistantEntry,
{ ...continuationEntry, parentId: "assistant-seed" },
]);
expect(result).toEqual({ text: MATH_ANSWER });
});
it("returns the BTW answer without appending transcript custom entries", async () => {
mockDoneAnswer(MATH_ANSWER);

View File

@@ -204,65 +204,6 @@ describe("loadCliSessionHistoryMessages", () => {
}
});
it("loads only the branch selected by transcript leaf controls", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
const sessionFile = createSessionTranscript({
rootDir: stateDir,
sessionId: "session-leaf-control",
messages: ["active root"],
});
fs.appendFileSync(
sessionFile,
[
{
type: "message",
id: "side-entry",
parentId: "msg-0",
timestamp: new Date(2).toISOString(),
message: { role: "assistant", content: "side delivery", timestamp: 2 },
},
{
type: "leaf",
id: "active-leaf",
parentId: "side-entry",
timestamp: new Date(3).toISOString(),
targetId: "msg-0",
},
{
type: "message",
id: "active-tail",
parentId: "msg-0",
timestamp: new Date(4).toISOString(),
message: { role: "assistant", content: "active tail", timestamp: 4 },
},
{
type: "metadata",
id: "opaque-after-active-tail",
parentId: "side-entry",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
try {
await withCliSessionState(stateDir, async () => {
const history = await loadCliSessionHistoryMessages({
sessionId: "session-leaf-control",
sessionFile,
sessionKey: "agent:main:main",
agentId: "main",
});
expect(history).toHaveLength(2);
expectMessageFields(history[0], { role: "user", content: "active root" });
expectMessageFields(history[1], { role: "assistant", content: "active tail" });
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("keeps complete history for context-engine snapshots", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
const sessionFile = createSessionTranscript({

View File

@@ -8,7 +8,6 @@ import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
} from "../../config/sessions/paths.js";
import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isPathInside } from "../../infra/path-guards.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
@@ -330,8 +329,7 @@ async function loadCliSessionEntries(params: {
}
const entries = parseSessionEntries(await fsp.readFile(realSessionFile, "utf-8"));
migrateSessionEntries(entries);
const sessionEntries = entries.filter((entry) => entry.type !== "session");
return selectSessionTranscriptLeafControlledPath(sessionEntries) ?? sessionEntries;
return entries.filter((entry) => entry.type !== "session");
} catch {
return [];
}

View File

@@ -312,43 +312,6 @@ const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessi
});
};
const addAnthropicProvider = (
cfg: ReturnType<typeof createEmbeddedAgentRunnerOpenAiConfig>,
modelIds: string[],
) => ({
...cfg,
models: {
providers: {
...cfg.models?.providers,
anthropic: {
api: "anthropic-messages" as const,
apiKey: "sk-test",
baseUrl: "https://example.com",
models: modelIds.map((id) => ({
id,
name: `Mock ${id}`,
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_000,
maxTokens: 2048,
})),
},
},
},
});
const mockSuccessfulEmbeddedAttempt = () => {
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
};
function firstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] {
const call = mock.mock.calls[0];
if (!call) {
@@ -375,7 +338,14 @@ describe("runEmbeddedAgent", () => {
list: [{ id: "research", model: "openrouter/research-default" }],
},
};
mockSuccessfulEmbeddedAttempt();
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedAgent({
sessionId: "configured-default-model",
@@ -413,7 +383,14 @@ describe("runEmbeddedAgent", () => {
},
};
setRuntimeConfigSnapshot(cfg);
mockSuccessfulEmbeddedAttempt();
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedAgent({
sessionId: "runtime-config-default-model",
@@ -438,85 +415,6 @@ describe("runEmbeddedAgent", () => {
);
});
it("uses the session-key agent default when agentId is inferred", async () => {
const sessionFile = nextSessionFile();
const cfg = {
...addAnthropicProvider(createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]), [
"claude-opus-4-7",
]),
agents: {
defaults: {
model: { primary: "openai/mock-1" },
},
list: [
{
id: "research",
model: { primary: "anthropic/claude-opus-4-7" },
},
],
},
};
mockSuccessfulEmbeddedAttempt();
await runEmbeddedAgent({
sessionId: "session-key-agent-default",
sessionKey: "agent:research:embedded:session-key-agent-default",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("session-key-agent-default"),
enqueue: immediateEnqueue,
});
expect(resolveModelAsyncMock).toHaveBeenNthCalledWith(
1,
"anthropic",
"claude-opus-4-7",
agentDir,
cfg,
expect.objectContaining({ skipAgentDiscovery: true }),
);
expect(
(firstRunEmbeddedAttemptParams() as { model?: { provider?: string; id?: string } }).model,
).toEqual(expect.objectContaining({ provider: "anthropic", id: "claude-opus-4-7" }));
});
it("resolves model-only provider refs instead of prefixing the default provider", async () => {
const sessionFile = nextSessionFile();
const cfg = addAnthropicProvider(createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]), [
"claude-sonnet-4-6",
]);
mockSuccessfulEmbeddedAttempt();
await runEmbeddedAgent({
sessionId: "model-only-provider-ref",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
model: "anthropic/claude-sonnet-4-6",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("model-only-provider-ref"),
enqueue: immediateEnqueue,
});
expect(resolveModelAsyncMock).toHaveBeenNthCalledWith(
1,
"anthropic",
"claude-sonnet-4-6",
agentDir,
cfg,
expect.objectContaining({ skipAgentDiscovery: true }),
);
expect(
(firstRunEmbeddedAttemptParams() as { model?: { provider?: string; id?: string } }).model,
).toEqual(expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-6" }));
});
it("skips models.json generation when dynamic model resolution succeeds", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedAgentRunnerOpenAiConfig([]);

View File

@@ -11,8 +11,7 @@ import {
captureCompactionCheckpointSnapshotAsync,
cleanupCompactionCheckpointSnapshot,
persistSessionCompactionCheckpoint,
readSessionLeafStateFromTranscriptAsync,
resolveCompactionCheckpointTranscriptPosition,
readSessionLeafIdFromTranscriptAsync,
resolveSessionCompactionCheckpointReason,
type CapturedCompactionCheckpointSnapshot,
} from "../../gateway/session-compaction-checkpoints.js";
@@ -453,12 +452,10 @@ export async function compactEmbeddedAgentSession(
}
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const transcriptState =
await readSessionLeafStateFromTranscriptAsync(postCompactionSessionFile);
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
preferredLeafId: postCompactionLeafId,
transcriptState,
});
const postLeafId =
postCompactionLeafId ??
(await readSessionLeafIdFromTranscriptAsync(postCompactionSessionFile)) ??
undefined;
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
@@ -472,8 +469,8 @@ export async function compactEmbeddedAgentSession(
tokensBefore: result.result?.tokensBefore,
tokensAfter: result.result?.tokensAfter,
postSessionFile: postCompactionSessionFile,
postLeafId: checkpointPosition.leafId,
postEntryId: checkpointPosition.entryId,
postLeafId,
postEntryId: postLeafId,
});
checkpointSnapshotRetained = storedCheckpoint !== null;
} catch (err) {

View File

@@ -11,8 +11,6 @@ import {
captureCompactionCheckpointSnapshotAsync,
cleanupCompactionCheckpointSnapshot,
persistSessionCompactionCheckpoint,
readSessionLeafStateFromTranscriptAsync,
resolveCompactionCheckpointTranscriptPosition,
resolveSessionCompactionCheckpointReason,
type CapturedCompactionCheckpointSnapshot,
} from "../../gateway/session-compaction-checkpoints.js";
@@ -1506,12 +1504,6 @@ async function compactEmbeddedAgentSessionDirectOnce(
});
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const transcriptState =
await readSessionLeafStateFromTranscriptAsync(activeSessionFile);
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
preferredLeafId: activePostLeafId,
transcriptState,
});
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
@@ -1525,8 +1517,8 @@ async function compactEmbeddedAgentSessionDirectOnce(
tokensBefore: observedTokenCount ?? result.tokensBefore,
tokensAfter,
postSessionFile: activeSessionFile,
postLeafId: checkpointPosition.leafId,
postEntryId: checkpointPosition.entryId,
postLeafId: activePostLeafId,
postEntryId: activePostLeafId,
createdAt: compactStartedAt,
});
checkpointSnapshotRetained = storedCheckpoint !== null;

View File

@@ -132,9 +132,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
runId: "run-cross-provider-fallback-error-context",
config: makeCrossProviderFallbackConfig(),
agentHarnessRuntimeOverride: "openclaw",
provider: "deepseek",
model: "deepseek-chat",
modelFallbacksOverride: ["deepseek/deepseek-chat"],
});
await expectDeepseekFallbackError(promise, getLastFormattedAssistant);
@@ -170,9 +167,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
runId: "run-compaction-fallback-error-context",
config: makeCrossProviderFallbackConfig(),
agentHarnessRuntimeOverride: "openclaw",
provider: "anthropic",
model: "test-model",
modelFallbacksOverride: ["deepseek/deepseek-chat"],
});
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
@@ -209,9 +203,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
runId: "run-stale-session-assistant-timeout",
config: makeCrossProviderFallbackConfig(),
agentHarnessRuntimeOverride: "openclaw",
provider: "deepseek",
model: "deepseek-chat",
modelFallbacksOverride: ["deepseek/deepseek-chat"],
});
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
@@ -245,9 +236,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
runId: "run-stale-session-assistant-non-timeout",
config: makeCrossProviderFallbackConfig(),
agentHarnessRuntimeOverride: "openclaw",
provider: "deepseek",
model: "deepseek-chat",
modelFallbacksOverride: ["deepseek/deepseek-chat"],
});
expect(mockedIsFailoverAssistantError).toHaveBeenCalledWith(undefined);

View File

@@ -100,11 +100,7 @@ import {
resolveAuthProfileOrder,
shouldPreferExplicitConfigApiKeyAuth,
} from "../model-auth.js";
import {
buildModelAliasIndex,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../model-selection.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { resolveThinkingDefault } from "../model-thinking-default.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import {
@@ -532,49 +528,6 @@ function buildHandledReplyPayloads(reply?: ReplyPayload) {
];
}
function resolveInitialEmbeddedRunModel(params: {
config: RunEmbeddedAgentParams["config"];
agentId?: string;
provider?: string;
model?: string;
}): { provider: string; modelId: string } {
const cfg = params.config ?? {};
const configuredDefault = resolveDefaultModelForAgent({
cfg,
agentId: params.agentId,
});
const explicitProvider = normalizeOptionalString(params.provider);
const explicitModel = normalizeOptionalString(params.model);
const defaultProvider = configuredDefault.provider || DEFAULT_PROVIDER;
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, modelId: explicitModel };
}
if (explicitModel) {
const provider = explicitProvider ?? defaultProvider;
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: provider,
});
const resolved = resolveModelRefFromString({
cfg,
raw: explicitModel,
defaultProvider: provider,
aliasIndex,
});
return {
provider: explicitProvider ?? resolved?.ref.provider ?? provider,
modelId: resolved?.ref.model ?? explicitModel,
};
}
return {
provider: explicitProvider ?? defaultProvider,
modelId: configuredDefault.model || DEFAULT_MODEL,
};
}
export function runEmbeddedAgent(
paramsInput: RunEmbeddedAgentParams,
): Promise<EmbeddedAgentRunResult> {
@@ -805,12 +758,17 @@ async function runEmbeddedAgentInternal(
startupStages.mark("runtime-plugins");
notifyExecutionPhase("runtime_plugins");
let { provider, modelId } = resolveInitialEmbeddedRunModel({
config: params.config,
agentId: workspaceResolution.agentId,
provider: params.provider,
model: params.model,
});
const requestedProvider = normalizeOptionalString(params.provider);
const requestedModel = normalizeOptionalString(params.model);
const configuredDefault =
!requestedProvider && !requestedModel
? resolveDefaultModelForAgent({
cfg: params.config ?? {},
agentId: workspaceResolution.agentId,
})
: undefined;
let provider = requestedProvider ?? configuredDefault?.provider ?? DEFAULT_PROVIDER;
let modelId = requestedModel ?? configuredDefault?.model ?? DEFAULT_MODEL;
const agentDir =
params.agentDir ?? resolveAgentDir(params.config ?? {}, workspaceResolution.agentId);
const normalizedSessionKey = params.sessionKey?.trim();

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
/**
* Handles sessions-yield interruption, persistence, and artifact cleanup.
*/
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
import type { AgentMessage } from "../../runtime/index.js";
import { log } from "../logger.js";
import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js";
@@ -181,51 +180,47 @@ export function stripSessionsYieldArtifacts(activeSession: {
const sessionManager = activeSession.sessionManager as
| {
removeTrailingEntries?: (
predicate: (entry: {
type?: string;
message?: {
role?: string;
stopReason?: string;
provider?: string;
model?: string;
};
customType?: string;
}) => boolean,
options?: {
preserveTrailing?: (entry: {
type?: string;
message?: {
role?: string;
provider?: string;
model?: string;
};
}) => boolean;
},
) => number;
fileEntries?: Array<{
type?: string;
id?: string;
parentId?: string | null;
message?: { role?: string; stopReason?: string };
customType?: string;
}>;
byId?: Map<string, { id: string }>;
leafId?: string | null;
rewriteFile?: () => void;
}
| undefined;
if (typeof sessionManager?.removeTrailingEntries !== "function") {
const fileEntries = sessionManager?.fileEntries;
const byId = sessionManager?.byId;
if (!fileEntries || !byId) {
return;
}
sessionManager.removeTrailingEntries(
(entry) => {
const isYieldAbortAssistant =
entry.type === "message" &&
entry.message?.role === "assistant" &&
entry.message?.stopReason === "aborted";
const isYieldInterruptMessage =
entry.type === "custom_message" &&
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
return isYieldAbortAssistant || isYieldInterruptMessage;
},
{
preserveTrailing: (entry) =>
entry.type === "custom" ||
entry.type === "label" ||
entry.type === "session_info" ||
(entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)),
},
);
let changed = false;
while (fileEntries.length > 1) {
const last = fileEntries.at(-1);
if (!last || last.type === "session") {
break;
}
const isYieldAbortAssistant =
last.type === "message" &&
last.message?.role === "assistant" &&
last.message?.stopReason === "aborted";
const isYieldInterruptMessage =
last.type === "custom_message" && last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
if (!isYieldAbortAssistant && !isYieldInterruptMessage) {
break;
}
fileEntries.pop();
if (last.id) {
byId.delete(last.id);
}
sessionManager.leafId = last.parentId ?? null;
changed = true;
}
if (changed) {
sessionManager.rewriteFile?.();
}
}

View File

@@ -59,7 +59,6 @@ type SessionManagerMocks = {
appendCustomEntry: UnknownMock;
flushPendingToolResults: UnknownMock;
clearPendingToolResults: UnknownMock;
removeTrailingEntries: UnknownMock;
};
type AttemptSpawnWorkspaceHoisted = {
spawnSubagentDirectMock: UnknownMock;
@@ -209,7 +208,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
appendCustomEntry: vi.fn(),
flushPendingToolResults: vi.fn(),
clearPendingToolResults: vi.fn(),
removeTrailingEntries: vi.fn(() => 0),
};
return {
spawnSubagentDirectMock,

View File

@@ -21,7 +21,6 @@ import { resolveQuotaSuspensionEntryMaintenance } from "../../../config/sessions
import {
bindOwnedSessionTranscriptWrites,
type OwnedSessionTranscriptCacheSnapshot,
type OwnedSessionTranscriptWriteOptions,
withOwnedSessionTranscriptWrites,
} from "../../../config/sessions/transcript-write-context.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
@@ -67,7 +66,6 @@ import {
import { getPluginToolMeta } from "../../../plugins/tools.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../../sessions/input-provenance.js";
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
import { resolveSkillsPromptForRun } from "../../../skills/loading/workspace.js";
import { resolveEmbeddedRunSkillEntries } from "../../../skills/runtime/embedded-run-entries.js";
import {
@@ -701,27 +699,42 @@ function removeTrailingMidTurnPrecheckAssistantError(params: {
sessionManager: ReturnType<typeof guardSessionManager>;
}): void {
const messages = params.activeSession.agent.state.messages;
const removedActiveError = isMidTurnPrecheckAssistantError(messages.at(-1));
if (removedActiveError) {
if (isMidTurnPrecheckAssistantError(messages.at(-1))) {
params.activeSession.agent.state.messages = messages.slice(0, -1);
}
const removedPersistedError =
params.sessionManager.removeTrailingEntries(
(entry) => entry.type === "message" && isMidTurnPrecheckAssistantError(entry.message),
{
preserveTrailing: (entry) =>
entry.type === "custom" ||
entry.type === "label" ||
entry.type === "session_info" ||
(entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)),
},
) > 0;
if (removedActiveError && !removedPersistedError) {
log.warn(
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but could not locate matching persisted SessionManager entry",
);
const mutableSessionManager = params.sessionManager as unknown as {
fileEntries?: Array<{
type?: string;
id?: string;
parentId?: string | null;
message?: AgentMessage;
}>;
byId?: Map<string, unknown>;
leafId?: string | null;
rewriteFile?: () => void;
};
const lastEntry = mutableSessionManager.fileEntries?.at(-1);
if (lastEntry?.type !== "message" || !isMidTurnPrecheckAssistantError(lastEntry.message)) {
if (isMidTurnPrecheckAssistantError(params.activeSession.agent.state.messages.at(-1))) {
log.warn(
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but could not locate matching persisted SessionManager entry",
);
}
return;
}
if (typeof mutableSessionManager.rewriteFile !== "function") {
log.warn(
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but SessionManager rewrite hook is unavailable",
);
return;
}
mutableSessionManager.fileEntries?.pop();
if (lastEntry.id) {
mutableSessionManager.byId?.delete(lastEntry.id);
}
mutableSessionManager.leafId = lastEntry.parentId ?? null;
mutableSessionManager.rewriteFile();
}
function collectAttemptExplicitToolAllowlistSources(params: {
@@ -2102,25 +2115,12 @@ export async function runEmbeddedAttempt(
timeoutMs: sessionWriteLockOptions.maxHoldMs,
signal: params.abortSignal,
});
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
const sessionLockController = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock,
lockOptions: {
sessionFile: params.sessionFile,
...sessionWriteLockOptions,
},
mergePromptReleasedSessionEntries: (entries) => {
if (!sessionManager) {
throw new Error("session manager unavailable during prompt-released entry merge");
}
return sessionManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true });
},
reloadPromptReleasedSessionFile: () => {
if (!sessionManager) {
throw new Error("session manager unavailable during prompt-released file reload");
}
sessionManager.setSessionFile(params.sessionFile);
},
});
releaseRetainedSessionLock = () => sessionLockController.dispose();
const ownedTranscriptWriteContext = {
@@ -2132,7 +2132,7 @@ export async function runEmbeddedAttempt(
sessionLockController.publishOwnedSessionFileSnapshot(snapshot),
withSessionWriteLock: <T>(
operation: () => Promise<T> | T,
options?: OwnedSessionTranscriptWriteOptions<T>,
options?: { publishOwnedWrite?: boolean },
) => sessionLockController.withSessionWriteLock(operation, options),
};
const withOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T): Promise<T> =>
@@ -2141,6 +2141,7 @@ export async function runEmbeddedAttempt(
);
armExternalAbortSignal();
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
let removeToolResultContextGuard: (() => void) | undefined;
let trajectoryRecorder: ReturnType<typeof createTrajectoryRuntimeRecorder> | null = null;

View File

@@ -71,63 +71,6 @@ describe("prepareSessionManagerForRun", () => {
expect(await fs.readFile(sessionFile, "utf-8")).toBe("");
});
it("clears the append parent when resetting a real user-only manager", async () => {
const sessionFile = await makeTempFile();
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "old-session",
timestamp: "2026-05-27T00:00:00.000Z",
cwd: "/old/cwd",
}),
JSON.stringify({
type: "message",
id: "old-user",
parentId: null,
timestamp: "2026-05-27T00:00:01.000Z",
message: { role: "user", content: "old prompt" },
}),
].join("\n") + "\n",
"utf8",
);
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
await prepareSessionManagerForRun({
sessionManager,
sessionFile,
hadSessionFile: true,
sessionId: "new-session",
cwd: "/tmp/task-repo",
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "response" }],
api: "messages",
provider: "anthropic",
model: "sonnet-4.6",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
});
const entries = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type: string; parentId?: string | null });
expect(entries).toHaveLength(2);
expect(entries[1]).toEqual(expect.objectContaining({ type: "message", parentId: null }));
});
it("rewrites forked transcript headers with copied assistant messages to the runtime cwd", async () => {
// Forked sessions keep copied assistant context but rewrite the session
// header to the child run id and active workspace cwd.
@@ -211,85 +154,6 @@ describe("prepareSessionManagerForRun", () => {
expect(JSON.parse(assistantLine ?? "{}")).toEqual(assistantEntry);
});
it("preserves a forked empty branch and its opaque append cursor", async () => {
const sessionFile = await makeTempFile();
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "forked-session",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/old/cwd",
parentSession: "/sessions/parent.jsonl",
}),
JSON.stringify({
type: "metadata",
id: "plugin-metadata",
parentId: null,
}),
JSON.stringify({
type: "leaf",
id: "empty-leaf",
parentId: "plugin-metadata",
targetId: null,
appendParentId: "plugin-metadata",
}),
].join("\n") + "\n",
"utf8",
);
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
await prepareSessionManagerForRun({
sessionManager,
sessionFile,
hadSessionFile: true,
sessionId: "child-session",
cwd: "/tmp/task-repo",
});
const userId = sessionManager.appendMessage({
role: "user",
content: "continued",
timestamp: Date.now(),
});
sessionManager.appendMessage({
role: "assistant",
content: [],
api: "responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(records[0]).toMatchObject({
type: "session",
id: "child-session",
cwd: "/tmp/task-repo",
parentSession: "/sessions/parent.jsonl",
});
expect(records.some((record) => record.id === "plugin-metadata")).toBe(true);
expect(records.some((record) => record.id === "empty-leaf")).toBe(true);
expect(records.find((record) => record.id === userId)).toMatchObject({
type: "message",
parentId: "plugin-metadata",
});
});
it("does not truncate an existing transcript with a corrupted header", async () => {
// A corrupt header may still be followed by useful transcript entries; fail
// closed instead of truncating unknown persisted user data.

View File

@@ -5,12 +5,7 @@ import fs from "node:fs/promises";
import { serializeJsonlLine, writeJsonlLines } from "../../config/sessions/transcript-jsonl.js";
import { invalidateSessionFileRepairCache } from "../session-file-repair.js";
type SessionHeaderEntry = {
type: "session";
id?: string;
cwd?: string;
parentSession?: string;
};
type SessionHeaderEntry = { type: "session"; id?: string; cwd?: string };
type SessionMessageEntry = { type: "message"; message?: { role?: string } };
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -63,8 +58,6 @@ export async function prepareSessionManagerForRun(params: {
labelsById?: Map<string, unknown>;
leafId?: string | null;
wasRecoveredFromCorruptHeader?: () => boolean;
clearPreservedOpaqueFileEntries?: () => void;
getSerializedFileLinesForRewrite?: () => string[];
syncSnapshotAfterHeaderRewrite?: (expectedContent?: string) => void;
};
@@ -82,18 +75,14 @@ export async function prepareSessionManagerForRun(params: {
}
if (params.hadSessionFile && header && !hasAssistant) {
const preservesForkedBranch =
typeof header.parentSession === "string" && header.parentSession.length > 0;
if (sm.wasRecoveredFromCorruptHeader?.() || preservesForkedBranch) {
// Fork transcripts can intentionally select a user-only or empty branch.
// Keep their copied tree so the first run appends at the preserved cursor.
if (sm.wasRecoveredFromCorruptHeader?.()) {
header.id = params.sessionId;
header.cwd = params.cwd;
sm.sessionId = params.sessionId;
sm.cwd = params.cwd;
const content = await writeJsonlLines(
params.sessionFile,
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
sm.fileEntries.map(serializeJsonlLine),
{
mode: 0o600,
},
@@ -112,7 +101,6 @@ export async function prepareSessionManagerForRun(params: {
sm.sessionId = params.sessionId;
sm.cwd = params.cwd;
sm.fileEntries = [header];
sm.clearPreservedOpaqueFileEntries?.();
sm.byId?.clear?.();
sm.labelsById?.clear?.();
sm.leafId = null;
@@ -132,7 +120,7 @@ export async function prepareSessionManagerForRun(params: {
}
const content = await writeJsonlLines(
params.sessionFile,
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
sm.fileEntries.map(serializeJsonlLine),
{
mode: 0o600,
},

View File

@@ -67,6 +67,7 @@ const DEFAULT_SUFFIX = (truncatedChars: number) =>
formatContextLimitTruncationNotice(truncatedChars);
const COMPACT_RECOVERY_SUFFIX = (truncatedChars: number) =>
`[... ${Math.max(1, Math.floor(truncatedChars))} chars truncated; narrow args]`;
export const MIN_TRUNCATED_TEXT_CHARS = MIN_KEEP_CHARS + DEFAULT_SUFFIX(1).length;
function resolveSuffixFactory(
suffix: ToolResultTruncationOptions["suffix"],

View File

@@ -4,10 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
persistTranscriptStateMutation,
readTranscriptFileState,
} from "./transcript-file-state.js";
import { readTranscriptFileState } from "./transcript-file-state.js";
import { rewriteTranscriptEntriesInState } from "./transcript-rewrite.js";
const roots: string[] = [];
@@ -463,54 +460,6 @@ describe("readTranscriptFileState", () => {
]);
});
it("canonicalizes opaque append parents before a legacy migration rewrite", async () => {
const root = await makeRoot("openclaw-transcript-state-v1-opaque-parent-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 1,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "assistant", content: "legacy active" },
},
{
type: "metadata",
id: "plugin-metadata",
parentId: "missing-before-migration",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const state = await readTranscriptFileState(sessionFile);
const activeLeafId = state.getLeafId();
const appended = state.appendMessage({
role: "user",
content: "continued",
timestamp: Date.now(),
});
await persistTranscriptStateMutation({
sessionFile,
state,
appendedEntries: [appended],
});
expect(state.migrated).toBe(true);
expect(appended.parentId).toBe(activeLeafId);
const reopened = await readTranscriptFileState(sessionFile);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([activeLeafId, appended.id]);
});
it("preserves legacy compaction keep indexes across JSON-valid non-object rows", async () => {
const root = await makeRoot("openclaw-transcript-state-v1-compaction-null-row-");
const sessionFile = path.join(root, "session.jsonl");
@@ -895,55 +844,6 @@ describe("readTranscriptFileState", () => {
expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1"]);
});
it("breaks cycles between canonical and opaque rows", async () => {
const root = await makeRoot("openclaw-transcript-state-canonical-opaque-cycle-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
id: "active-entry",
parentId: "opaque-cycle",
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "user", content: "kept through cycle" },
},
{
type: "metadata",
id: "opaque-cycle",
parentId: "active-entry",
payload: { source: "plugin" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
const state = await readTranscriptFileState(sessionFile);
expect(state.getBranch().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual([
{ id: "active-entry", parentId: null },
]);
expect(state.buildSessionContext().messages).toMatchObject([
{ role: "user", content: "kept through cycle" },
]);
const appended = state.appendMessage({
role: "user",
content: "continued",
timestamp: Date.now(),
});
expect(appended.parentId).toBe("opaque-cycle");
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-entry", appended.id]);
});
it("drops missing parents reached through rejected rows before rewrite replay", async () => {
const root = await makeRoot("openclaw-transcript-state-rejected-missing-parent-");
const sessionFile = path.join(root, "session.jsonl");
@@ -1070,333 +970,6 @@ describe("readTranscriptFileState", () => {
).not.toThrow();
});
it("applies leaf controls to active state and marker-linked descendants", async () => {
const root = await makeRoot("openclaw-transcript-state-leaf-");
const sessionFile = path.join(root, "session.jsonl");
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-05-16T00:00:00.000Z",
cwd: root,
};
const rootEntry = {
type: "message",
id: "root-user",
parentId: null,
timestamp: "2026-05-16T00:00:01.000Z",
message: { role: "user", content: "root question" },
};
const abandonedEntry = {
type: "message",
id: "abandoned-assistant",
parentId: rootEntry.id,
timestamp: "2026-05-16T00:00:02.000Z",
message: { role: "assistant", content: "abandoned answer" },
};
const leafEntry = {
type: "leaf",
id: "leaf-1",
parentId: abandonedEntry.id,
timestamp: "2026-05-16T00:00:03.000Z",
targetId: rootEntry.id,
};
await fs.writeFile(
sessionFile,
[header, rootEntry, abandonedEntry, leafEntry]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const selectedState = await readTranscriptFileState(sessionFile);
expect(selectedState.getLeafId()).toBe(rootEntry.id);
expect(selectedState.getBranch().map((entry) => entry.id)).toEqual([rootEntry.id]);
const replacementEntry = {
type: "message",
id: "replacement-assistant",
parentId: leafEntry.id,
timestamp: "2026-05-16T00:00:04.000Z",
message: { role: "assistant", content: "replacement answer" },
};
await fs.appendFile(sessionFile, `${JSON.stringify(replacementEntry)}\n`, "utf8");
const reopened = await readTranscriptFileState(sessionFile);
expect(reopened.getEntries().find((entry) => entry.id === replacementEntry.id)).toEqual(
expect.objectContaining({ parentId: rootEntry.id }),
);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([
rootEntry.id,
replacementEntry.id,
]);
});
it("keeps parentless canonical ancestry through rewrite replay", async () => {
const root = await makeRoot("openclaw-transcript-state-parentless-leaf-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
id: "user-1",
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "user", content: "question", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
timestamp: "2026-06-15T00:00:02.000Z",
message: { role: "assistant", content: "answer", timestamp: 2 },
},
{
type: "leaf",
id: "active-leaf",
parentId: "assistant-1",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "assistant-1",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const state = await readTranscriptFileState(sessionFile);
expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1", "assistant-1"]);
rewriteTranscriptEntriesInState({
state,
replacements: [
{
entryId: "user-1",
message: { role: "user", content: "rewritten question", timestamp: 3 },
},
],
});
expect(state.buildSessionContext().messages).toMatchObject([
{ role: "user", content: "rewritten question" },
{ role: "assistant", content: "answer" },
]);
});
it("preserves marked side ancestry without capturing the next active append", async () => {
const root = await makeRoot("openclaw-transcript-state-side-append-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "assistant", content: "active" },
},
{
type: "message",
id: "side-one",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
message: { role: "assistant", content: "first side delivery" },
},
{
type: "leaf",
id: "first-leaf",
parentId: "side-one",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "side-one",
appendMode: "side",
},
{
type: "message",
id: "side-two",
parentId: "side-one",
timestamp: "2026-06-15T00:00:04.000Z",
appendMode: "side",
message: { role: "assistant", content: "second side delivery" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const state = await readTranscriptFileState(sessionFile);
expect(state.getBranch("side-two").map((entry) => entry.id)).toEqual([
"active-root",
"side-one",
"side-two",
]);
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root"]);
expect(state.getLeafId()).toBe("active-root");
expect(state.getAppendParentId()).toBe("side-two");
expect(state.getAppendMode()).toBe("side");
const nextUser = state.appendMessage({
role: "user",
content: "next question",
timestamp: Date.now(),
});
expect(state.getBranch(nextUser.id).map((entry) => entry.id)).toEqual([
"active-root",
nextUser.id,
]);
});
it("keeps a terminal leaf control's opaque append parent", async () => {
const root = await makeRoot("openclaw-transcript-state-opaque-append-parent-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "assistant", content: "active" },
},
{
type: "metadata",
id: "plugin-metadata",
parentId: null,
payload: { source: "plugin" },
},
{
type: "message",
id: "side-delivery",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
message: { role: "assistant", content: "side delivery" },
},
{
type: "leaf",
id: "active-leaf",
parentId: "side-delivery",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "plugin-metadata",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const state = await readTranscriptFileState(sessionFile);
const appended = state.appendMessage({
role: "user",
content: "continued",
timestamp: Date.now(),
});
await persistTranscriptStateMutation({
sessionFile,
state,
appendedEntries: [appended],
});
const persisted = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split(/\r?\n/)
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(state.getLeafId()).toBe(appended.id);
expect(appended.parentId).toBe("plugin-metadata");
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
expect(persisted.at(-1)).toMatchObject({ id: appended.id, parentId: "plugin-metadata" });
});
it("ignores leaf controls with dangling target or append references", async () => {
const root = await makeRoot("openclaw-transcript-state-invalid-leaf-");
const sessionFile = path.join(root, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: root,
},
{
type: "message",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "assistant", content: "active" },
},
{
type: "metadata",
id: "plugin-metadata",
parentId: "active-root",
payload: { source: "plugin" },
},
{
type: "leaf",
id: "missing-target",
parentId: "plugin-metadata",
timestamp: "2026-06-15T00:00:02.000Z",
targetId: "missing",
},
{
type: "leaf",
id: "missing-append",
parentId: "missing-target",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "missing",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const state = await readTranscriptFileState(sessionFile);
const appended = state.appendMessage({
role: "user",
content: "continued",
timestamp: Date.now(),
});
await persistTranscriptStateMutation({
sessionFile,
state,
appendedEntries: [appended],
});
expect(appended.parentId).toBe("plugin-metadata");
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
const reopened = await readTranscriptFileState(sessionFile);
expect(reopened.getLeafId()).toBe(appended.id);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
});
it("keeps legacy roots that are missing tree metadata", async () => {
const root = await makeRoot("openclaw-transcript-state-legacy-root-");
const sessionFile = path.join(root, "session.jsonl");

View File

@@ -4,7 +4,6 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js";
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
import { appendRegularFile } from "../../infra/fs-safe.js";
import { privateFileStore } from "../../infra/private-file-store.js";
@@ -30,18 +29,6 @@ type SessionInfoEntry = Extract<SessionEntry, { type: "session_info" }>;
type SessionMessageEntry = Extract<SessionEntry, { type: "message" }>;
type ThinkingLevelChangeEntry = Extract<SessionEntry, { type: "thinking_level_change" }>;
export type TranscriptLeafControlEntry = {
type: "leaf";
id: string;
parentId: string | null;
timestamp: string;
targetId: string | null;
appendParentId?: string | null;
appendMode?: "side";
};
export type TranscriptPersistedEntry = SessionEntry | TranscriptLeafControlEntry;
const sessionEntryTypes = new Set<string>([
"branch_summary",
"compaction",
@@ -289,73 +276,16 @@ function isSessionEntry(entry: FileEntry): entry is SessionEntry {
return false;
}
function parseLeafControlEntry(entry: unknown):
| {
id: string;
parentId: string | null;
targetId: string | null;
appendParentId?: string | null;
appendMode?: "side";
}
| undefined {
if (!isRecord(entry) || entry.type !== "leaf") {
return undefined;
}
const candidate = entry as {
id?: unknown;
parentId?: unknown;
targetId?: unknown;
appendParentId?: unknown;
appendMode?: unknown;
timestamp?: unknown;
};
if (
!isString(candidate.id) ||
(candidate.parentId !== undefined &&
candidate.parentId !== null &&
!isString(candidate.parentId)) ||
(candidate.timestamp !== undefined && !isString(candidate.timestamp)) ||
(candidate.targetId !== null && typeof candidate.targetId !== "string") ||
(candidate.appendParentId !== undefined &&
candidate.appendParentId !== null &&
typeof candidate.appendParentId !== "string") ||
(candidate.appendMode !== undefined && candidate.appendMode !== "side")
) {
return undefined;
}
return {
id: candidate.id,
parentId: candidate.parentId ?? null,
targetId: candidate.targetId,
...(candidate.appendParentId !== undefined ? { appendParentId: candidate.appendParentId } : {}),
...(candidate.appendMode === "side" ? { appendMode: candidate.appendMode } : {}),
};
}
type ReadableSessionState = {
entries: SessionEntry[];
leafId: string | null;
appendParentId: string | null;
appendMode?: "side";
opaqueParentsById: Map<string, string | null>;
logicalParentsById: Map<string, string | null>;
};
// Keep every readable entry while repairing links through rejected rows. This
// preserves usable branches from partially written or migrated transcripts.
function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
function readableSessionEntries(fileEntries: FileEntry[]): SessionEntry[] {
const entries: SessionEntry[] = [];
const acceptedIds = new Set<string>();
const acceptedEntryById = new Map<string, SessionEntry>();
const rejectedIds = new Set<string>();
const rejectedParentById = new Map<string, string | null>();
const logicalParentsById = new Map<string, string | null>();
const invalidLeafIds = new Set<string>();
const firstReadableDescendantByRejectedId = new Map<string, string>();
const rejectedAncestorsByAcceptedId = new Map<string, string[]>();
let effectiveLeafId: string | null = null;
let effectiveAppendParentId: string | null = null;
let effectiveAppendMode: "side" | undefined;
const acceptedPath = (leafId: string | null | undefined): SessionEntry[] => {
const pathLocal: SessionEntry[] = [];
let id = leafId ?? null;
@@ -454,62 +384,13 @@ function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
if (!isRecord(rawEntry)) {
continue;
}
const rawRecord = rawEntry as unknown as Record<string, unknown>;
const entry = rawEntry as FileEntry;
const id = rawRecord.id;
const rawType = rawRecord.type;
const rawParentId = rawRecord.parentId;
const leafEntry = parseLeafControlEntry(rawRecord);
if (leafEntry) {
rejectedIds.add(leafEntry.id);
const targetIsKnown =
leafEntry.targetId === null ||
acceptedIds.has(leafEntry.targetId) ||
(rejectedParentById.has(leafEntry.targetId) && !invalidLeafIds.has(leafEntry.targetId));
const appendParentIsKnown =
leafEntry.appendParentId === undefined ||
leafEntry.appendParentId === null ||
acceptedIds.has(leafEntry.appendParentId) ||
(rejectedParentById.has(leafEntry.appendParentId) &&
!invalidLeafIds.has(leafEntry.appendParentId));
if (!targetIsKnown || !appendParentIsKnown) {
// Ignore corrupt navigation state, but keep the marker transparent so
// descendants can still repair through the serialized raw branch.
invalidLeafIds.add(leafEntry.id);
rejectedParentById.set(leafEntry.id, leafEntry.parentId);
continue;
}
rejectedParentById.set(leafEntry.id, leafEntry.targetId);
const resolvedTargetId = resolveRejectedParent(leafEntry.targetId);
effectiveLeafId =
resolvedTargetId !== null && acceptedIds.has(resolvedTargetId) ? resolvedTargetId : null;
effectiveAppendParentId =
leafEntry.appendParentId === undefined ? effectiveLeafId : leafEntry.appendParentId;
effectiveAppendMode = leafEntry.appendMode;
continue;
}
if (rawType === "leaf") {
if (isString(id)) {
rejectedIds.add(id);
invalidLeafIds.add(id);
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
}
continue;
}
const id = rawEntry.id;
if (!isSessionEntry(entry)) {
if (isString(id)) {
rejectedIds.add(id);
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
const isParentLinkedOpaque =
typeof rawType === "string" &&
rawType !== "session" &&
!id.startsWith("__openclaw_invalid_jsonl_slot_") &&
!sessionEntryTypes.has(rawType) &&
Object.hasOwn(rawRecord, "parentId") &&
(rawParentId === null || isString(rawParentId));
if (isParentLinkedOpaque) {
effectiveAppendParentId = id;
}
const parentId = rawEntry.parentId;
rejectedParentById.set(id, isString(parentId) ? parentId : null);
}
continue;
}
@@ -521,35 +402,12 @@ function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
if (acceptedIds.has(entry.id)) {
continue;
}
const hasSerializedParent = Object.hasOwn(rawRecord, "parentId");
if (
!hasSerializedParent ||
(!isSessionTranscriptSideAppendEntry(rawRecord) &&
entry.parentId === effectiveAppendParentId &&
effectiveLeafId !== effectiveAppendParentId)
) {
logicalParentsById.set(entry.id, effectiveLeafId);
}
const repaired = repairEntryLinks(entry);
entries.push(repaired);
acceptedIds.add(repaired.id);
acceptedEntryById.set(repaired.id, repaired);
effectiveAppendParentId = repaired.id;
if (isSessionTranscriptSideAppendEntry(rawRecord)) {
effectiveAppendMode = "side";
} else {
effectiveLeafId = repaired.id;
effectiveAppendMode = undefined;
}
}
return {
entries,
leafId: effectiveLeafId,
appendParentId: effectiveAppendParentId,
...(effectiveAppendMode ? { appendMode: effectiveAppendMode } : {}),
opaqueParentsById: rejectedParentById,
logicalParentsById,
};
return entries;
}
function sessionHeaderVersion(header: SessionHeader | null): number {
@@ -566,7 +424,7 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
return randomUUID();
}
function serializeTranscriptFileEntries(entries: readonly unknown[]): string {
function serializeTranscriptFileEntries(entries: FileEntry[]): string {
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
}
@@ -590,58 +448,27 @@ export class TranscriptFileState {
private readonly byId = new Map<string, SessionEntry>();
private readonly labelsById = new Map<string, string>();
private readonly labelTimestampsById = new Map<string, string>();
private readonly opaqueParentsById = new Map<string, string | null>();
private readonly logicalParentsById = new Map<string, string | null>();
private leafId: string | null = null;
private appendParentId: string | null = null;
private appendMode: "side" | undefined;
constructor(params: {
header: SessionHeader | null;
entries: SessionEntry[];
leafId?: string | null;
appendParentId?: string | null;
appendMode?: "side";
opaqueParentsById?: ReadonlyMap<string, string | null>;
logicalParentsById?: ReadonlyMap<string, string | null>;
migrated?: boolean;
}) {
this.header = params.header;
this.entries = [...params.entries];
this.migrated = params.migrated === true;
for (const [id, parentId] of params.opaqueParentsById ?? []) {
this.opaqueParentsById.set(id, parentId);
}
for (const [id, parentId] of params.logicalParentsById ?? []) {
this.logicalParentsById.set(id, parentId);
}
this.rebuildIndex(params.leafId, params.appendParentId);
this.appendMode = params.appendMode;
this.rebuildIndex();
}
private resolveCanonicalParentId(parentId: string | null): string | null {
const seen = new Set<string>();
let currentId = parentId;
while (currentId !== null && this.opaqueParentsById.has(currentId)) {
if (seen.has(currentId)) {
return null;
}
seen.add(currentId);
currentId = this.opaqueParentsById.get(currentId) ?? null;
}
return currentId;
}
private rebuildIndex(leafId?: string | null, appendParentId?: string | null): void {
private rebuildIndex(): void {
this.byId.clear();
this.labelsById.clear();
this.labelTimestampsById.clear();
this.leafId = null;
this.appendParentId = null;
for (const entry of this.entries) {
this.byId.set(entry.id, entry);
this.leafId = entry.id;
this.appendParentId = entry.id;
if (entry.type === "label") {
if (entry.label) {
this.labelsById.set(entry.targetId, entry.label);
@@ -652,14 +479,6 @@ export class TranscriptFileState {
}
}
}
if (leafId !== undefined) {
this.leafId = leafId;
}
if (appendParentId !== undefined) {
this.appendParentId = appendParentId;
} else if (leafId !== undefined) {
this.appendParentId = leafId;
}
}
getCwd(): string {
@@ -678,14 +497,6 @@ export class TranscriptFileState {
return this.leafId;
}
getAppendParentId(): string | null {
return this.appendParentId;
}
getAppendMode(): "side" | undefined {
return this.appendMode;
}
getLeafEntry(): SessionEntry | undefined {
return this.leafId ? this.byId.get(this.leafId) : undefined;
}
@@ -696,34 +507,17 @@ export class TranscriptFileState {
getBranch(fromId?: string): SessionEntry[] {
const branch: SessionEntry[] = [];
const seen = new Set<string>();
let currentId = fromId ?? this.leafId;
while (currentId && !seen.has(currentId)) {
const current = this.byId.get(currentId);
if (!current) {
break;
}
seen.add(current.id);
const resolvedParentId = this.logicalParentsById.has(current.id)
? (this.logicalParentsById.get(current.id) ?? null)
: this.resolveCanonicalParentId(current.parentId);
const parentId =
resolvedParentId === current.id || (resolvedParentId && seen.has(resolvedParentId))
? null
: resolvedParentId;
branch.push(
parentId === current.parentId ? current : ({ ...current, parentId } as SessionEntry),
);
currentId = parentId;
let current = (fromId ?? this.leafId) ? this.byId.get((fromId ?? this.leafId)!) : undefined;
while (current) {
branch.push(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
branch.reverse();
return branch;
}
buildSessionContext(): SessionContext {
const entries = this.getBranch();
const leafId = entries.at(-1)?.id ?? null;
return buildSessionContext(entries, leafId, new Map(entries.map((entry) => [entry.id, entry])));
return buildSessionContext(this.entries, this.leafId, this.byId);
}
/** Move the active leaf to an existing entry without appending a row. */
@@ -732,22 +526,18 @@ export class TranscriptFileState {
throw new Error(`Entry ${branchFromId} not found`);
}
this.leafId = branchFromId;
this.appendParentId = branchFromId;
this.appendMode = undefined;
}
/** Clear the active leaf so the next append starts a root branch. */
resetLeaf(): void {
this.leafId = null;
this.appendParentId = null;
this.appendMode = undefined;
}
appendMessage(message: SessionMessageEntry["message"]): SessionMessageEntry {
return this.appendEntry({
type: "message",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
message,
});
@@ -757,7 +547,7 @@ export class TranscriptFileState {
return this.appendEntry({
type: "thinking_level_change",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
thinkingLevel,
});
@@ -767,7 +557,7 @@ export class TranscriptFileState {
return this.appendEntry({
type: "model_change",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
provider,
modelId,
@@ -784,7 +574,7 @@ export class TranscriptFileState {
return this.appendEntry({
type: "compaction",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
@@ -800,7 +590,7 @@ export class TranscriptFileState {
customType,
data,
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
});
}
@@ -809,7 +599,7 @@ export class TranscriptFileState {
return this.appendEntry({
type: "session_info",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
name: name.trim(),
});
@@ -828,7 +618,7 @@ export class TranscriptFileState {
display,
details,
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
});
}
@@ -840,7 +630,7 @@ export class TranscriptFileState {
return this.appendEntry({
type: "label",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
parentId: this.leafId,
timestamp: new Date().toISOString(),
targetId,
label,
@@ -857,7 +647,6 @@ export class TranscriptFileState {
throw new Error(`Entry ${branchFromId} not found`);
}
this.leafId = branchFromId;
this.appendParentId = branchFromId;
return this.appendEntry({
type: "branch_summary",
id: generateEntryId(this.byId),
@@ -870,58 +659,10 @@ export class TranscriptFileState {
});
}
appendLeafControl(params: {
targetId: string | null;
appendParentId: string | null;
appendMode?: "side";
}): TranscriptLeafControlEntry {
if (params.targetId !== null && !this.byId.has(params.targetId)) {
throw new Error(`Entry ${params.targetId} not found`);
}
if (
params.appendParentId !== null &&
!this.byId.has(params.appendParentId) &&
!this.opaqueParentsById.has(params.appendParentId)
) {
throw new Error(`Entry ${params.appendParentId} not found`);
}
const entry: TranscriptLeafControlEntry = {
type: "leaf",
id: generateEntryId({
has: (id) => this.byId.has(id) || this.opaqueParentsById.has(id),
}),
parentId: this.appendParentId,
timestamp: new Date().toISOString(),
targetId: params.targetId,
...(params.appendParentId !== params.targetId
? { appendParentId: params.appendParentId }
: {}),
...(params.appendMode ? { appendMode: params.appendMode } : {}),
};
this.opaqueParentsById.set(entry.id, params.targetId);
this.leafId = params.targetId;
this.appendParentId = params.appendParentId;
this.appendMode = params.appendMode;
return entry;
}
private appendEntry<T extends SessionEntry>(entry: T): T {
if (
!isSessionTranscriptSideAppendEntry(entry) &&
entry.parentId === this.appendParentId &&
this.leafId !== this.appendParentId
) {
this.logicalParentsById.set(entry.id, this.leafId);
}
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.appendParentId = entry.id;
if (isSessionTranscriptSideAppendEntry(entry)) {
this.appendMode = "side";
} else {
this.leafId = entry.id;
this.appendMode = undefined;
}
this.leafId = entry.id;
if (entry.type === "label") {
if (entry.label) {
this.labelsById.set(entry.targetId, entry.label);
@@ -946,23 +687,14 @@ export async function readTranscriptFileState(sessionFile: string): Promise<Tran
migrateSessionEntries(fileEntries);
const header =
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
const readable = readableSessionState(fileEntries);
return new TranscriptFileState({
header,
entries: readable.entries,
leafId: readable.leafId,
appendParentId: migrated ? readable.leafId : readable.appendParentId,
...(!migrated && readable.appendMode ? { appendMode: readable.appendMode } : {}),
opaqueParentsById: readable.opaqueParentsById,
logicalParentsById: readable.logicalParentsById,
migrated,
});
const entries = readableSessionEntries(fileEntries);
return new TranscriptFileState({ header, entries, migrated });
}
/** Rewrite the full transcript through the private-file store. */
export async function writeTranscriptFileAtomic(
filePath: string,
entries: Array<SessionHeader | TranscriptPersistedEntry>,
entries: Array<SessionHeader | SessionEntry>,
): Promise<void> {
await privateFileStore(path.dirname(filePath)).writeText(
path.basename(filePath),
@@ -974,19 +706,15 @@ export async function writeTranscriptFileAtomic(
export async function persistTranscriptStateMutation(params: {
sessionFile: string;
state: TranscriptFileState;
appendedEntries: TranscriptPersistedEntry[];
appendedEntries: SessionEntry[];
}): Promise<void> {
if (params.appendedEntries.length === 0 && !params.state.migrated) {
return;
}
if (params.state.migrated) {
const appendedLeafControls = params.appendedEntries.filter(
(entry): entry is TranscriptLeafControlEntry => entry.type === "leaf",
);
await writeTranscriptFileAtomic(params.sessionFile, [
...(params.state.header ? [params.state.header] : []),
...params.state.entries,
...appendedLeafControls,
]);
return;
}

View File

@@ -397,185 +397,6 @@ describe("rewriteTranscriptEntriesInSessionFile", () => {
}
});
it("rewrites a guarded side branch and restores the active navigation state", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-side-"));
const sessionFile = path.join(dir, "session.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "session",
version: 3,
id: "session-side-rewrite",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: dir,
},
{
type: "message",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "user", content: "active root", timestamp: 1 },
},
{
type: "message",
id: "side-mirror",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
message: {
role: "assistant",
content: createTextContent("source reply before rewrite"),
timestamp: 2,
},
},
{
type: "leaf",
id: "active-leaf",
parentId: "side-mirror",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "side-mirror",
appendMode: "side",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
const result = await rewriteTranscriptEntriesInSessionFile({
sessionFile,
sessionKey: "agent:main:test",
request: {
allowedRewriteSuffixEntryIds: ["side-mirror"],
replacements: [
{
entryId: "side-mirror",
message: asAppendMessage({
role: "assistant",
content: createTextContent("source reply after rewrite"),
timestamp: 2,
}) as AgentMessage,
},
],
},
});
expect(result).toMatchObject({ changed: true, rewrittenEntries: 1 });
const records = (await fs.readFile(sessionFile, "utf-8"))
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
type?: string;
id?: string;
parentId?: string | null;
targetId?: string | null;
appendParentId?: string | null;
appendMode?: "side";
message?: AgentMessage;
},
);
const rewrittenSideEntry = records.findLast(
(entry) =>
entry.type === "message" &&
JSON.stringify(entry.message).includes("source reply after rewrite"),
);
expect(rewrittenSideEntry).toMatchObject({ parentId: "active-root" });
expect(records.at(-1)).toMatchObject({
type: "leaf",
parentId: rewrittenSideEntry?.id,
targetId: "active-root",
appendParentId: "side-mirror",
appendMode: "side",
});
const reopened = SessionManager.open(sessionFile, dir, dir);
expect(getBranchMessages(reopened).map(getMessageContent)).toEqual(["active root"]);
const nextId = reopened.appendMessage(
asAppendMessage({ role: "user", content: "active continuation", timestamp: 3 }),
);
expect(reopened.getEntry(nextId)).toMatchObject({ parentId: "active-root" });
expect(reopened.getEntry(nextId)).not.toHaveProperty("appendMode");
});
it("rejects a rewrite batch split across active and side branches", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-mixed-"));
const sessionFile = path.join(dir, "session.jsonl");
const records = [
{
type: "session",
version: 3,
id: "session-mixed-rewrite",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: dir,
},
{
type: "message",
id: "root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
message: { role: "user", content: "root", timestamp: 1 },
},
{
type: "message",
id: "active-mirror",
parentId: "root",
timestamp: "2026-06-15T00:00:02.000Z",
message: { role: "assistant", content: createTextContent("active"), timestamp: 2 },
},
{
type: "message",
id: "side-mirror",
parentId: "root",
timestamp: "2026-06-15T00:00:03.000Z",
message: { role: "assistant", content: createTextContent("side"), timestamp: 3 },
},
{
type: "leaf",
id: "active-leaf",
parentId: "side-mirror",
timestamp: "2026-06-15T00:00:04.000Z",
targetId: "active-mirror",
},
];
const original = records.map((entry) => JSON.stringify(entry)).join("\n") + "\n";
await fs.writeFile(sessionFile, original, "utf-8");
const result = await rewriteTranscriptEntriesInSessionFile({
sessionFile,
sessionKey: "agent:main:test",
request: {
allowedRewriteSuffixEntryIds: ["active-mirror", "side-mirror"],
replacements: [
{
entryId: "active-mirror",
message: asAppendMessage({
role: "assistant",
content: createTextContent("active rewritten"),
timestamp: 2,
}) as AgentMessage,
},
{
entryId: "side-mirror",
message: asAppendMessage({
role: "assistant",
content: createTextContent("side rewritten"),
timestamp: 3,
}) as AgentMessage,
},
],
},
});
expect(result).toMatchObject({
changed: false,
reason: "rewrite targets span multiple branches",
});
expect(await fs.readFile(sessionFile, "utf-8")).toBe(original);
});
it("emits transcript updates when the active branch changes without opening a manager", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-"));
const sessionManager = SessionManager.create(dir, dir);

View File

@@ -21,7 +21,6 @@ import {
persistTranscriptStateMutation,
readTranscriptFileState,
type TranscriptFileState,
type TranscriptPersistedEntry,
} from "./transcript-file-state.js";
import {
persistRuntimeTranscriptStateMutation,
@@ -262,7 +261,7 @@ export function rewriteTranscriptEntriesInState(params: {
state: TranscriptFileState;
replacements: TranscriptRewriteReplacement[];
allowedRewriteSuffixEntryIds?: string[];
}): TranscriptRewriteResult & { appendedEntries: TranscriptPersistedEntry[] } {
}): TranscriptRewriteResult & { appendedEntries: SessionBranchEntry[] } {
const replacementsById = new Map(
params.replacements
.filter((replacement) => replacement.entryId.trim().length > 0)
@@ -278,58 +277,7 @@ export function rewriteTranscriptEntriesInState(params: {
};
}
const originalLeafId = params.state.getLeafId();
const originalAppendParentId = params.state.getAppendParentId();
const originalAppendMode = params.state.getAppendMode();
const activeBranch = params.state.getBranch();
const allEntries = params.state.getEntries();
let branch = activeBranch;
let restoreOriginalNavigation = false;
const replacementIdsOnBranch = (candidate: readonly SessionBranchEntry[]): Set<string> =>
new Set(
candidate
.filter((entry) => entry.type === "message" && replacementsById.has(entry.id))
.map((entry) => entry.id),
);
const activeReplacementIds = replacementIdsOnBranch(activeBranch);
if (activeReplacementIds.size > 0 && activeReplacementIds.size < replacementsById.size) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "rewrite targets span multiple branches",
appendedEntries: [],
};
}
const activeBranchHasEveryReplacement = activeReplacementIds.size === replacementsById.size;
if (!activeBranchHasEveryReplacement && params.allowedRewriteSuffixEntryIds) {
const allowedIds = new Set(params.allowedRewriteSuffixEntryIds);
const sideBranch = allEntries
.toReversed()
.filter((entry) => allowedIds.has(entry.id))
.map((entry) => params.state.getBranch(entry.id))
.find((candidate) => replacementIdsOnBranch(candidate).size === replacementsById.size);
if (sideBranch) {
branch = sideBranch;
restoreOriginalNavigation = true;
}
}
if (
!activeBranchHasEveryReplacement &&
!restoreOriginalNavigation &&
activeReplacementIds.size === 0 &&
params.replacements.some((replacement) =>
allEntries.some((entry) => entry.id === replacement.entryId),
)
) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "rewrite targets span multiple branches",
appendedEntries: [],
};
}
const branch = params.state.getBranch();
if (branch.length === 0) {
return {
changed: false,
@@ -403,7 +351,7 @@ export function rewriteTranscriptEntriesInState(params: {
params.state.branch(firstMatchedEntry.parentId);
}
const appendedEntries: TranscriptPersistedEntry[] = [];
const appendedEntries: SessionBranchEntry[] = [];
const rewrittenEntryIds = new Map<string, string>();
for (let index = matchedIndices[0]; index < branch.length; index++) {
const entry = branch[index];
@@ -419,15 +367,6 @@ export function rewriteTranscriptEntriesInState(params: {
rewrittenEntryIds.set(entry.id, newEntry.id);
appendedEntries.push(newEntry);
}
if (restoreOriginalNavigation) {
appendedEntries.push(
params.state.appendLeafControl({
targetId: originalLeafId,
appendParentId: originalAppendParentId,
...(originalAppendMode ? { appendMode: originalAppendMode } : {}),
}),
);
}
return {
changed: true,

View File

@@ -12,7 +12,6 @@ import {
persistTranscriptStateMutation,
readTranscriptFileState,
type TranscriptFileState,
type TranscriptPersistedEntry,
writeTranscriptFileAtomic,
} from "./transcript-file-state.js";
@@ -61,7 +60,7 @@ export async function readRuntimeTranscriptState(
* Persists an append or migration rewrite for a resolved runtime transcript.
*/
export async function persistRuntimeTranscriptStateMutation(params: {
appendedEntries: TranscriptPersistedEntry[];
appendedEntries: SessionEntry[];
state: TranscriptFileState;
target: RuntimeTranscriptTarget;
}): Promise<void> {

View File

@@ -1,164 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { getMatchingMessagingToolReplyTargets } from "../auto-reply/reply/reply-payloads-dedupe.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
extractMessagingToolSend,
extractMessagingToolSendResult,
} from "./embedded-agent-subscribe.tools.js";
const PARTIAL_RESULT_PROVIDER = "partialthreadprovider";
function createPartialResultPlugin(): unknown {
return {
...createChannelTestPluginBase({ id: PARTIAL_RESULT_PROVIDER }),
actions: {
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
args.action === "send" && typeof args.to === "string"
? { to: args.to, threadImplicit: true }
: null,
extractToolSendResult: ({ result }: { result: unknown }) => {
const toolSend = (result as { details?: { toolSend?: Record<string, unknown> } })?.details
?.toolSend;
const to = typeof toolSend?.to === "string" ? toolSend.to : undefined;
if (!to) {
return null;
}
const threadId = typeof toolSend?.threadId === "string" ? toolSend.threadId : undefined;
return {
to,
...(threadId ? { threadId } : {}),
...(toolSend?.threadImplicit === true ? { threadImplicit: true } : {}),
...(toolSend?.threadSuppressed === true ? { threadSuppressed: true } : {}),
};
},
},
threading: {
resolveAutoThreadId: ({ toolContext }: { toolContext?: { currentThreadTs?: string } }) =>
toolContext?.currentThreadTs,
},
};
}
function registerPartialResultProvider(): void {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: PARTIAL_RESULT_PROVIDER, source: "test", plugin: createPartialResultPlugin() },
]),
);
}
describe("extractMessagingToolSendResult thread evidence", () => {
afterEach(() => {
setActivePluginRegistry(createTestRegistry());
});
it("preserves implicit thread evidence when the provider result omits it", () => {
registerPartialResultProvider();
const pending = extractMessagingToolSend(
"message",
{ action: "send", provider: PARTIAL_RESULT_PROVIDER, to: "channel:abc", message: "answer" },
{
currentChannelId: "channel:abc",
currentMessagingTarget: "channel:abc",
currentThreadId: "root-1",
replyToMode: "all",
},
);
expect(pending?.threadImplicit).toBe(true);
expect(pending?.threadId).toBe("root-1");
const confirmed = extractMessagingToolSendResult(pending!, {
details: { toolSend: { to: "channel:abc" } },
});
expect(confirmed.threadImplicit).toBe(true);
expect(confirmed.threadId).toBe("root-1");
const matches = getMatchingMessagingToolReplyTargets({
messageProvider: PARTIAL_RESULT_PROVIDER,
originatingTo: "channel:abc",
originatingThreadId: "root-1",
messagingToolSentTargets: [confirmed],
});
expect(matches).toHaveLength(1);
});
it("lets an explicit provider-reported thread override pending implicit evidence", () => {
registerPartialResultProvider();
const confirmed = extractMessagingToolSendResult(
{
tool: "message",
provider: PARTIAL_RESULT_PROVIDER,
to: "channel:abc",
threadImplicit: true,
},
{ details: { toolSend: { to: "channel:abc", threadId: "root-9" } } },
);
expect(confirmed.threadId).toBe("root-9");
expect(confirmed.threadImplicit).toBeUndefined();
});
it.each([
{
name: "provider suppression replaces pending implicit evidence",
pending: {
threadId: "root-1",
threadImplicit: true,
},
result: {
threadSuppressed: true,
},
expected: {
threadId: undefined,
threadImplicit: undefined,
threadSuppressed: true,
},
},
{
name: "provider implicit evidence replaces pending suppression",
pending: {
threadSuppressed: true,
},
result: {
threadImplicit: true,
},
expected: {
threadId: undefined,
threadImplicit: true,
threadSuppressed: undefined,
},
},
{
name: "a partial result preserves pending suppression",
pending: {
threadSuppressed: true,
},
result: {},
expected: {
threadId: undefined,
threadImplicit: undefined,
threadSuppressed: true,
},
},
])("$name", ({ pending, result, expected }) => {
registerPartialResultProvider();
const confirmed = extractMessagingToolSendResult(
{
tool: "message",
provider: PARTIAL_RESULT_PROVIDER,
to: "channel:abc",
...pending,
},
{ details: { toolSend: { to: "channel:abc", ...result } } },
);
expect({
threadId: confirmed.threadId,
threadImplicit: confirmed.threadImplicit,
threadSuppressed: confirmed.threadSuppressed,
}).toEqual(expected);
});
});

View File

@@ -972,21 +972,13 @@ export function extractMessagingToolSendResult(
if (!extracted?.to) {
return pending;
}
const extractedThreadId = normalizeOptionalString(extracted.threadId);
const providerReportedThread =
extractedThreadId != null ||
extracted.threadImplicit === true ||
extracted.threadSuppressed === true;
// Thread route fields are one state. Mixing provider and pending values can
// create contradictory implicit and suppressed evidence.
const threadEvidence = providerReportedThread ? extracted : pending;
return {
...pending,
...extracted,
accountId: normalizeOptionalString(extracted.accountId) ?? pending.accountId,
to: normalizeTargetForProvider(providerId ?? pending.provider, extracted.to),
threadId: normalizeOptionalString(threadEvidence.threadId),
threadImplicit: threadEvidence.threadImplicit === true ? true : undefined,
threadSuppressed: threadEvidence.threadSuppressed === true ? true : undefined,
threadId: normalizeOptionalString(extracted.threadId),
threadImplicit: extracted.threadImplicit === true ? true : undefined,
threadSuppressed: extracted.threadSuppressed === true ? true : undefined,
};
}

View File

@@ -17,7 +17,10 @@ export type AgentGeneratedAttachment = {
name?: string;
};
function generatedAttachmentReference(attachment: AgentGeneratedAttachment): string | undefined {
/** Resolve the first usable path or URL reference for a generated attachment. */
export function generatedAttachmentReference(
attachment: AgentGeneratedAttachment,
): string | undefined {
return normalizeOptionalString(
attachment.path ?? attachment.url ?? attachment.mediaUrl ?? attachment.filePath,
);
@@ -32,7 +35,10 @@ export function mediaUrlsFromGeneratedAttachments(
);
}
function nameFromGeneratedAttachment(attachment: AgentGeneratedAttachment): string | undefined {
/** Resolve a display name from attachment metadata or path basename. */
export function nameFromGeneratedAttachment(
attachment: AgentGeneratedAttachment,
): string | undefined {
return (
normalizeOptionalString(attachment.name) ??
basenameFromAnyPath(generatedAttachmentReference(attachment) ?? "")

View File

@@ -173,6 +173,11 @@ export function collectAnthropicApiKeys(): string[] {
return collectProviderApiKeys("anthropic");
}
/** Collect Gemini API keys for live cache/model tests. */
export function collectGeminiApiKeys(): string[] {
return collectProviderApiKeys("google");
}
/** Return whether a provider error message indicates API-key rate limiting. */
export function isApiKeyRateLimitError(message: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(message);
@@ -197,6 +202,11 @@ export function isApiKeyRateLimitError(message: string): boolean {
return false;
}
/** Return whether an Anthropic error message indicates rate limiting. */
export function isAnthropicRateLimitError(message: string): boolean {
return isApiKeyRateLimitError(message);
}
/** Return whether an Anthropic error message indicates billing exhaustion. */
export function isAnthropicBillingError(message: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(message);

View File

@@ -16,7 +16,7 @@ import { loadUndiciRuntimeDeps } from "../infra/net/undici-runtime.js";
export type { FetchLike };
/** Default MCP HTTP fetch backed by lazy-loaded undici runtime deps. */
const fetchWithUndici: FetchLike = async (url, init) =>
export const fetchWithUndici: FetchLike = async (url, init) =>
(await loadUndiciRuntimeDeps().fetch(
url,
init as Parameters<ReturnType<typeof loadUndiciRuntimeDeps>["fetch"]>[1],

View File

@@ -47,6 +47,15 @@ export function listProviderEnvAuthLookupKeys(params: {
).toSorted((a, b) => a.localeCompare(b));
}
/** Resolves provider auth lookup maps and returns their sorted provider keys. */
export function resolveProviderEnvAuthLookupKeys(params?: ProviderEnvVarLookupParams): string[] {
const lookupMaps = resolveProviderEnvAuthLookupMaps(params);
return listProviderEnvAuthLookupKeys({
envCandidateMap: lookupMaps.envCandidateMap,
authEvidenceMap: lookupMaps.authEvidenceMap,
});
}
/** Lists known provider API-key env var names for redaction and marker matching. */
export function listKnownProviderEnvApiKeyNames(): string[] {
return listKnownProviderAuthEnvVarNames();

View File

@@ -784,120 +784,6 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
expect(ids).toEqual(expectedIds);
});
it("keeps finalized OpenAI Responses calls and drops partialJson streaming artifacts", () => {
const input = castAgentMessages([
{
role: "assistant",
stopReason: "toolUse",
content: [
// complete tool call — kept as-is
{ type: "toolCall", id: "call_ok", name: "read", arguments: { path: "/a" } },
// Legacy generic Responses transport persisted finalized toolUse
// turns with partialJson; repair strips the scratch field.
{
type: "toolCall",
id: "call_partial|fc_123",
name: "Bash",
arguments: { command: "ls" },
partialJson: '{"command": "ls"}',
},
{
type: "toolCall",
id: "call_empty|fc_789",
name: "session_status",
arguments: {},
partialJson: "",
},
// Anthropic can persist initialized tool calls with arguments: {}
// plus partialJson if the stream aborts before content_block_stop.
// Those incomplete artifacts must be dropped.
{
type: "toolCall",
id: "toolu_123",
name: "Bash",
arguments: {},
partialJson: '{"command":',
},
// An OpenAI-shaped id and parsed partial arguments do not prove that
// response.output_item.done arrived.
{
type: "toolCall",
id: "call_truncated|fc_456",
name: "Bash",
arguments: { command: "ls" },
partialJson: '{"command":"ls"',
},
// Missing required input is also an interrupted artifact and should drop.
{
type: "toolUse",
id: "call_partial2",
name: "read",
input: null,
partialJson: '{"path":',
},
],
},
{ role: "user", content: "retry" },
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
const ids = toolCalls.map((t) => (t as { id?: unknown }).id);
expect(ids).toEqual(["call_ok", "call_partial|fc_123", "call_empty|fc_789"]);
expect(toolCalls[1]).not.toHaveProperty("partialJson");
expect(toolCalls[2]).not.toHaveProperty("partialJson");
});
it("strips finalized partialJson without rewriting sessions_spawn arguments", () => {
const input = castAgentMessages([
{
role: "assistant",
stopReason: "toolUse",
content: [
{
type: "toolCall",
id: "call_spawn|fc_456",
name: "sessions_spawn",
arguments: { attachments: [{ content: "secret data" }] },
partialJson: '{"attachments":[{"content":"secret data"}]}',
},
],
},
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0]).not.toHaveProperty("partialJson");
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({
attachments: [{ content: "secret data" }],
});
});
it.each(["stop", "aborted", "error", "length"] as const)(
"drops OpenAI Responses partialJson blocks on %s assistant turns",
(stopReason) => {
const input = castAgentMessages([
{
role: "assistant",
stopReason,
content: [
{
type: "toolCall",
id: "call_partial|fc_123",
name: "Bash",
arguments: { command: "ls" },
partialJson: '{"command":"ls"}',
},
],
},
{ role: "user", content: "retry" },
]);
const out = sanitizeToolCallInputs(input);
expect(getAssistantToolCallBlocks(out)).toHaveLength(0);
},
);
it("keeps valid tool calls and preserves text blocks", () => {
const input = castAgentMessages([
{
@@ -949,36 +835,6 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
expect(out).toStrictEqual([]);
});
it("drops signed-thinking assistant turns with partialJson tool calls", () => {
const input = castAgentMessages([
{
role: "assistant",
stopReason: "toolUse",
content: [
{
type: "thinking",
thinking: "Let me run a command.",
thinkingSignature: "sig_partial",
},
{
type: "toolCall",
id: "call_partial|fc_123",
name: "exec",
arguments: {},
partialJson: '{"command":"ls"}',
},
],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["exec"],
allowProviderOwnedThinkingReplay: true,
});
expect(out).toStrictEqual([]);
});
it("drops signed-thinking assistant turns when sibling tool calls reuse an id", () => {
const input = castAgentMessages([
{

View File

@@ -5,7 +5,6 @@
*/
import {
hasNonEmptyString as hasNonEmptyStringField,
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
@@ -28,7 +27,6 @@ type RawToolCallBlock = {
name?: unknown;
input?: unknown;
arguments?: unknown;
partialJson?: unknown;
};
const RAW_TOOL_CALL_BLOCK_TYPES = new Set([
@@ -74,45 +72,6 @@ function hasToolCallId(block: RawToolCallBlock): boolean {
);
}
function hasPartialJson(
block: RawToolCallBlock,
): block is RawToolCallBlock & { partialJson: string } {
return typeof block.partialJson === "string";
}
function isCompleteJsonObject(value: string): boolean {
try {
const parsed: unknown = JSON.parse(value);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
function isFinalizedOpenAIResponsesToolCall(
message: AgentMessage,
block: RawToolCallBlock,
): boolean {
if (
message.role !== "assistant" ||
!("stopReason" in message) ||
message.stopReason !== "toolUse" ||
!hasPartialJson(block) ||
typeof block.id !== "string" ||
"input" in block ||
!block.arguments ||
typeof block.arguments !== "object" ||
Array.isArray(block.arguments) ||
(!isCompleteJsonObject(block.partialJson) &&
(block.partialJson.trim() !== "" || Object.keys(block.arguments).length > 0))
) {
return false;
}
const separator = block.id.indexOf("|");
return separator > 0 && separator < block.id.length - 1;
}
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
// This repair path normalizes replay shape only. Tool payloads are local
// trusted-operator transcript state per SECURITY.md, so do not redact or
@@ -157,7 +116,6 @@ function isReplaySafeThinkingAssistantTurn(
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
if (
!hasToolCallInput(block) ||
hasPartialJson(block) ||
!toolCallId ||
seenToolCallIds.has(toolCallId) ||
!isAllowedToolCallName(block.name, allowedToolNames)
@@ -424,72 +382,31 @@ function repairToolCallInputs(
let messageChanged = false;
for (const block of msg.content) {
if (isRawToolCallBlock(block)) {
// Drop genuinely incomplete streaming artifacts (missing required fields).
if (
!hasToolCallInput(block) ||
if (
isRawToolCallBlock(block) &&
(!hasToolCallInput(block) ||
!hasToolCallId(block) ||
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames)
) {
droppedToolCalls += 1;
droppedInMessage += 1;
changed = true;
messageChanged = true;
continue;
}
}
let workBlock = block;
if (isRawToolCallBlock(block) && hasPartialJson(block)) {
if (!isFinalizedOpenAIResponsesToolCall(msg, block)) {
droppedToolCalls += 1;
droppedInMessage += 1;
changed = true;
messageChanged = true;
continue;
}
// Legacy generic Responses transport persisted successful toolUse turns
// with the scratch buffer intact. Strip it only when terminal state and
// the provider-specific finalized shape both prove completion.
const stripped = { ...block };
delete (stripped as RawToolCallBlock & { partialJson?: unknown }).partialJson;
workBlock = stripped;
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames))
) {
droppedToolCalls += 1;
droppedInMessage += 1;
changed = true;
messageChanged = true;
continue;
}
if (isRawToolCallBlock(workBlock)) {
if (RAW_TOOL_CALL_BLOCK_TYPES.has((workBlock as { type?: string }).type ?? "")) {
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
const blockName =
typeof (workBlock as { name?: unknown }).name === "string"
? (workBlock as { name: string }).name.trim()
: undefined;
if (normalizeLowercaseStringOrEmpty(blockName) === "sessions_spawn") {
const sanitized = sanitizeToolCallBlock(workBlock);
if (sanitized !== workBlock) {
changed = true;
messageChanged = true;
}
nextContent.push(sanitized as typeof block);
} else if (typeof (workBlock as { name?: unknown }).name === "string") {
const rawName = (workBlock as { name: string }).name;
const trimmedName = rawName.trim();
if (rawName !== trimmedName && trimmedName) {
const renamed = { ...(workBlock as object), name: trimmedName } as typeof block;
nextContent.push(renamed);
changed = true;
messageChanged = true;
} else {
nextContent.push(workBlock);
}
} else {
nextContent.push(workBlock);
if (isRawToolCallBlock(block)) {
if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) {
const sanitized = sanitizeToolCallBlock(block);
if (sanitized !== block) {
changed = true;
messageChanged = true;
}
nextContent.push(sanitized as typeof block);
continue;
}
} else {
nextContent.push(block);
}
nextContent.push(workBlock);
}
if (droppedInMessage > 0) {

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