Compare commits

..

2 Commits

Author SHA1 Message Date
Vincent Koc
c1c0e492b5 fix(agents): preserve explicit read limits 2026-05-31 19:32:41 +02:00
Vincent Koc
aa170419e7 fix(agents): scale read output for small contexts 2026-05-31 19:32:41 +02:00
1783 changed files with 15676 additions and 48179 deletions

View File

@@ -187,13 +187,11 @@ gh pr view <number> --json additions,deletions,changedFiles \
## Read beyond the diff
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
- Before any verdict, read enough code to fill this map: changed surface, runtime entry point, owner boundary, one caller, one callee, sibling implementations sharing the invariant, adjacent tests, current `main` behavior, and shipped/dependency/Codex contracts when relevant.
- For large-codebase PRs, sample enough related files to understand the runtime boundary before deciding. Default to more code reading when the change touches agents, gateway, plugins, auth, sessions, process, config, or provider/runtime seams.
- Compare the PR against current `origin/main` behavior. Check whether recent main already changed the same surface.
- Dependency-backed behavior: MUST read upstream docs/source/types before judging API use, defaults, output shapes, errors, timeouts, memory behavior, or compatibility. Do not assume dependency contracts from memory or PR text.
- Judge solution quality, not only correctness. Ask whether the PR is the clean owner-boundary fix or a wart/workaround that should be replaced by a small refactor, moved seam, contract change, or deletion of duplicate logic.
- Mention the main files read when the verdict depends on code-path evidence.
- If the user challenges the verdict or asks whether the idea is really good, resume code reading first. Do not defend, soften, or reverse the verdict until the missing caller/callee/sibling/dependency path is checked.
## Best-fix review loop
@@ -208,7 +206,6 @@ Before verdict:
5. Compare against current `origin/main` and shipped behavior when regression/compat matters.
6. Inspect upstream dependency/Codex source or docs for dependency-backed behavior.
7. Identify at least one alternative fix location or shape, then reject it with evidence.
8. If any required path above is uninspected, keep reading or mark `Remaining uncertainty`; do not call the PR best, blocked, proof-sufficient, or merge-ready.
Review output must include:

View File

@@ -2,7 +2,7 @@
// Secret scanning alert handler for OpenClaw maintainers.
// Usage: node secret-scanning.mjs <command> [options]
import { spawnSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
@@ -39,9 +39,7 @@ function gh(args, { json = true, allowFailure = false } = {}) {
stderr: proc.stderr,
};
}
if (!json) {
return proc.stdout;
}
if (!json) return proc.stdout;
try {
return JSON.parse(proc.stdout);
} catch {
@@ -72,9 +70,7 @@ export function loadBodyRedactionResult(locationType, resultFile) {
if (!resultFile) {
fail("Body notifications require a redaction result file from redact-body-if-needed");
}
if (!fs.existsSync(resultFile)) {
fail(`File not found: ${resultFile}`);
}
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
if (typeof result.notify_required !== "boolean") {
@@ -186,11 +182,10 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
const discussion = gql?.data?.repository?.discussion;
if (!discussion) {
if (!discussion)
fail(
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
);
}
discussionId = discussion.id;
@@ -210,18 +205,15 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
const replies = replyPage?.data?.node?.replies;
if (!replies) {
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
}
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
replyCursor = replies.pageInfo.endCursor;
}
if (reply) {
return { discussionId, comment: reply };
}
if (reply) return { discussionId, comment: reply };
}
hasNextPage = discussion.comments.pageInfo.hasNextPage;
@@ -249,9 +241,7 @@ function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
* Fetch alert metadata + locations. Never exposes .secret.
*/
function cmdFetchAlert(alertNumber) {
if (!alertNumber) {
fail("Usage: fetch-alert <number>");
}
if (!alertNumber) fail("Usage: fetch-alert <number>");
const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);
@@ -290,23 +280,17 @@ function cmdFetchAlert(alertNumber) {
* Saves full body to a temp file. Prints metadata + file path to stdout.
*/
function cmdFetchContent(locationJson) {
if (!locationJson) {
fail("Usage: fetch-content '<location-json>'");
}
if (!locationJson) fail("Usage: fetch-content '<location-json>'");
const location = JSON.parse(locationJson);
const type = location.type;
const details = location.details;
if (type === "discussion_comment") {
const commentUrl = details.discussion_comment_url;
if (!commentUrl) {
fail("No discussion_comment_url in location details");
}
if (!commentUrl) fail("No discussion_comment_url in location details");
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
if (!urlMatch) {
fail(`Cannot parse discussion comment URL: ${commentUrl}`);
}
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
@@ -314,11 +298,10 @@ function cmdFetchContent(locationJson) {
discussionNumber,
discussionCommentDbId,
);
if (!comment) {
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
);
}
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, comment.body || "");
@@ -351,9 +334,7 @@ function cmdFetchContent(locationJson) {
details.issue_comment_url ||
details.pull_request_comment_url ||
details.pull_request_review_comment_url;
if (!commentUrl) {
fail(`No comment URL in location details`);
}
if (!commentUrl) fail(`No comment URL in location details`);
const comment = gh(["api", commentUrl]);
const bodyFile = tmpFile("body.md");
@@ -397,9 +378,7 @@ function cmdFetchContent(locationJson) {
);
} else if (type === "issue_body") {
const issueUrl = details.issue_body_url || details.issue_url;
if (!issueUrl) {
fail("No issue URL in location details");
}
if (!issueUrl) fail("No issue URL in location details");
const issue = gh(["api", issueUrl]);
const bodyFile = tmpFile("body.md");
@@ -435,9 +414,7 @@ function cmdFetchContent(locationJson) {
);
} else if (type === "pull_request_body") {
const prUrl = details.pull_request_body_url || details.pull_request_url;
if (!prUrl) {
fail("No PR URL in location details");
}
if (!prUrl) fail("No PR URL in location details");
const pr = gh(["api", prUrl]);
const bodyFile = tmpFile("body.md");
@@ -513,9 +490,7 @@ function cmdRedactBody(kind, number, bodyFile) {
if (!kind || !number || !bodyFile) {
fail("Usage: redact-body <issue|pr> <number> <redacted-body-file>");
}
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const endpoint =
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
@@ -534,12 +509,8 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
);
}
if (!fs.existsSync(currentBodyFile)) {
fail(`File not found: ${currentBodyFile}`);
}
if (!fs.existsSync(redactedBodyFile)) {
fail(`File not found: ${redactedBodyFile}`);
}
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
@@ -570,9 +541,7 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
* Delete a comment (and all its edit history).
*/
function cmdDeleteComment(commentId) {
if (!commentId) {
fail("Usage: delete-comment <comment-id>");
}
if (!commentId) fail("Usage: delete-comment <comment-id>");
gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false });
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
}
@@ -582,9 +551,7 @@ function cmdDeleteComment(commentId) {
* Delete a discussion comment via GraphQL (and all its edit history).
*/
function cmdDeleteDiscussionComment(nodeId) {
if (!nodeId) {
fail("Usage: delete-discussion-comment <node-id>");
}
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
const result = ghGraphQL(
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
);
@@ -599,12 +566,9 @@ function cmdDeleteDiscussionComment(nodeId) {
* Create a new discussion comment via GraphQL.
*/
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
if (!discussionNodeId || !bodyFile) {
if (!discussionNodeId || !bodyFile)
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
}
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const body = fs.readFileSync(bodyFile, "utf8");
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
@@ -622,12 +586,8 @@ function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId)
* Create a new comment from a file.
*/
function cmdRecreateComment(issueNumber, bodyFile) {
if (!issueNumber || !bodyFile) {
fail("Usage: recreate-comment <issue-number> <body-file>");
}
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
if (!issueNumber || !bodyFile) fail("Usage: recreate-comment <issue-number> <body-file>");
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
const result = gh([
"api",
@@ -755,9 +715,7 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
* Close a secret scanning alert.
*/
function cmdResolve(alertNumber, resolution, comment) {
if (!alertNumber) {
fail("Usage: resolve <alert-number> [resolution] [comment]");
}
if (!alertNumber) fail("Usage: resolve <alert-number> [resolution] [comment]");
const res = resolution || "revoked";
const resComment = comment || "Content redacted and author notified to rotate credentials.";
@@ -815,12 +773,8 @@ function cmdListOpen() {
* Print a formatted summary table from a JSON results file.
*/
function cmdSummary(jsonFile) {
if (!jsonFile) {
fail("Usage: summary <json-file>");
}
if (!fs.existsSync(jsonFile)) {
fail(`File not found: ${jsonFile}`);
}
if (!jsonFile) fail("Usage: summary <json-file>");
if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);
const results = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
const lines = [];

View File

@@ -4,11 +4,11 @@ profile: openclaw-check
provider: azure
class: standard
capacity:
market: on-demand
market: spot
strategy: most-available
# The Azure-backed billing account carries the OpenClaw runner credits; use
# explicit on-demand capacity instead of low-priority spot, whose regional
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
hints: true
actions:
workflow: .github/workflows/crabbox-hydrate.yml
@@ -48,10 +48,6 @@ aws:
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
azure:
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
# same SKUs and can stall provisioning.
location: eastus2
sync:
delete: true
checksum: false
@@ -71,16 +67,13 @@ env:
- OPENCLAW_*
ssh:
user: crabbox
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
# from 2222, but `crabbox job run` hydrates via the configured port directly.
port: "22"
port: "2222"
jobs:
prewarm:
provider: azure
target: linux
class: standard
type: Standard_D4ads_v6
market: on-demand
market: spot
idleTimeout: 90m
hydrate:
actions: true
@@ -97,8 +90,7 @@ jobs:
provider: azure
target: linux
class: standard
type: Standard_D4ads_v6
market: on-demand
market: spot
idleTimeout: 90m
hydrate:
actions: true
@@ -107,18 +99,7 @@ jobs:
workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate
ref: main
shell: true
command: |
set -euo pipefail
if ! git status --short >/dev/null 2>&1; then
rm -rf .git
git init -q
git add -A
if ! git diff --cached --quiet; then
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
fi
fi
env CI=1 corepack pnpm check --timed
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
stop: always
testbox-changed:
provider: blacksmith-testbox

View File

@@ -139,139 +139,3 @@ jobs:
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
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 \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
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"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -1202,9 +1202,6 @@ jobs:
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-shrinkwrap
task: shrinkwrap
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1280,6 +1277,7 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:shrinkwrap:check
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
@@ -1288,9 +1286,6 @@ jobs:
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
;;
shrinkwrap)
pnpm deps:shrinkwrap:check
;;
prod-types)
pnpm tsgo:prod
;;

View File

@@ -257,8 +257,7 @@ jobs:
return -1;
}
for (const match of input.matchAll(/\[/g)) {
const start = match.index;
for (let start = input.indexOf("["); start !== -1; start = input.indexOf("[", start + 1)) {
const end = arrayEndFrom(start);
if (end === -1) {
continue;

View File

@@ -43,4 +43,4 @@ jobs:
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
telegram_mode: none
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.
secrets: inherit

View File

@@ -61,14 +61,12 @@ jobs:
submodules: false
- name: Probe native Windows
env:
TARGET_REF: ${{ inputs.target_ref || github.ref }}
run: |
$ErrorActionPreference = "Stop"
Write-Host "runner=$env:RUNNER_NAME"
Write-Host "machine=$env:COMPUTERNAME"
Write-Host "workspace=$env:GITHUB_WORKSPACE"
Write-Host "target_ref=$env:TARGET_REF"
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())

View File

@@ -84,65 +84,6 @@ jobs:
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Prepare trusted workflow audit configs
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
> "$trusted_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
> "$trusted_zizmor_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
zizmor_config_path = sys.argv[2]
text = config_path.read_text()
if ".github/zizmor.yml" not in text:
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
PY
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Install pre-commit
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
- name: Install actionlint
shell: bash
run: |
@@ -162,15 +103,6 @@ jobs:
- name: Lint workflows
run: actionlint
- name: Audit all workflows with zizmor
shell: bash
run: |
set -euo pipefail
mapfile -t workflow_files < <(
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
)
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

View File

@@ -22,12 +22,11 @@
"eslint/no-object-constructor": "error",
"eslint/no-param-reassign": "error",
"eslint/no-proto": "error",
"eslint/no-promise-executor-return": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-return-assign": "error",
"eslint/no-sequences": "error",
"eslint/no-self-compare": "error",
"eslint/no-shadow": "error",
"eslint/no-shadow": "off",
"eslint/no-implicit-coercion": "error",
"eslint/no-var": "error",
"eslint/no-useless-call": "error",
@@ -36,8 +35,7 @@
"eslint/no-useless-constructor": "error",
"eslint/no-useless-rename": "error",
"eslint/no-useless-return": "error",
"eslint/no-useless-assignment": "error",
"eslint/no-unused-vars": "error",
"eslint/no-unused-vars": "off",
"eslint/no-warning-comments": "error",
"eslint/no-unmodified-loop-condition": "error",
"eslint/no-new-wrappers": "error",
@@ -80,7 +78,6 @@
"typescript/no-extraneous-class": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",
@@ -232,7 +229,15 @@
"**/*test-support.ts"
],
"rules": {
"typescript/no-explicit-any": "off"
"typescript/no-explicit-any": "off",
"typescript/unbound-method": "off",
"eslint/no-unsafe-optional-chaining": "off"
}
},
{
"files": ["src/agents/embedded-agent-runner/run/attempt.ts"],
"rules": {
"eslint/no-shadow": "error"
}
}
]

View File

@@ -10,10 +10,9 @@ Skills own workflows; root owns hard policy and routing.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work has a hard gate: the acting agent must personally inspect sibling `../codex` source for the exact protocol/runtime behavior before any verdict, comment, approval, merge recommendation, code change, or `proof sufficient` claim. If missing, clone `https://github.com/openai/codex.git` there first. Subagent reports, PR text, OpenClaw wrappers, generated schemas, memory, and prior bot reviews do not satisfy this gate. No direct `../codex` check means no Codex verdict. Cite Codex files/lines checked in final/review/comment.
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work: before any verdict, comment, approval, merge recommendation, or `proof sufficient` claim, inspect sibling `../codex` source for the exact protocol/runtime behavior involved; if missing, clone `https://github.com/openai/codex.git` there first. Do not rely on PR text, OpenClaw wrappers, generated schemas, memory, or prior bot reviews as a substitute. Cite Codex files/lines checked in final/review/comment.
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
- External API work: live test required. Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
- External API work: Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
- Live-verify when feasible. Never print secrets.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
@@ -31,7 +30,6 @@ Skills own workflows; root owns hard policy and routing.
- For PRs that add, remove, or change config/default surfaces with possible compatibility, upgrade, provider/plugin, operator, setup, startup, or fallback impact, ClawSweeper review should emit a `reviewMetrics` entry when practical. The metric should name the count and direction of the changes, such as added, changed, or removed config/default surfaces, and explain why the metric matters before merge. When the metric indicates concrete merge risk, also surface the concern in `risks`, use `mergeRiskLabels` when the risk matches the label rubric, make `bestSolution` name the desired pre-merge state, and ensure `labelJustifications` explain the specific reason rather than restating the label.
- Review whole decision surfaces, not only the touched runtime, provider, channel, harness, plugin seam, or context path. Check sibling Codex/Pi-style runtimes, provider/model routing, channel delivery, gateway/protocol, plugin SDK, and context-management paths when relevant.
- Every PR review must explicitly ask whether the PR is the best fix, not merely a plausible fix. Verdicts need a best-fix judgment backed by enough code reading to compare owner boundaries, callers, siblings, tests, docs, current `main`, shipped behavior when relevant, and dependency/Codex contracts when involved.
- Before a PR verdict, build a small evidence map: changed surface, entry point, owner boundary, at least one caller and callee, sibling surfaces that share the invariant, existing tests, and current `main` behavior. If any cell is missing, say the gap instead of concluding.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
@@ -63,7 +61,6 @@ Skills own workflows; root owns hard policy and routing.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
- OpenAI Codex is folded into `openai`. No new/live `openai-codex` provider/plugin/auth/model routes; treat them as legacy input only. Runtime/setup/auth/catalog use `openai` + `openai/*`; doctor/migrations repair stale `openai-codex/*` profiles/metadata.
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
@@ -74,7 +71,6 @@ Skills own workflows; root owns hard policy and routing.
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
@@ -128,7 +124,6 @@ Skills own workflows; root owns hard policy and routing.
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
@@ -150,7 +145,6 @@ Skills own workflows; root owns hard policy and routing.
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
- Issue/PR work: search strong related issues/PRs before final; close proven dupes/fixed siblings. If none close, suggest one next related follow-up.
- PR superseded by `main`: if code proof shows `main` already has same-or-better behavior, comment canonical commit/PR + focused proof, then close. Bar high: inspect PR diff, current code/tests, linked issue, caller/sibling path. If unsure, leave open.
- Issue/PR numbers need a short summary every time; assume the reader has not opened or read them.
- Before presenting a batch of issues/PRs, use smart subagents to verify live state and current `main`; omit closed/fixed items, and comment+close items already fixed on `main` when maintainer action is authorized.
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
@@ -265,7 +259,6 @@ Skills own workflows; root owns hard policy and routing.
- Version bump surfaces live in `$release-openclaw-maintainer`.
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.

View File

@@ -10,7 +10,6 @@ Docs: https://docs.openclaw.ai
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
@@ -36,12 +35,8 @@ Docs: https://docs.openclaw.ai
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
@@ -57,7 +52,7 @@ Docs: https://docs.openclaw.ai
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.

View File

@@ -9,9 +9,9 @@
# Build stages use full bookworm; the runtime image is always bookworm-slim.
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
# Keep in sync with .github/actions/setup-node-env/action.yml bun-version.
# To update: docker buildx imagetools inspect oven/bun:<version> and use the manifest-list digest.
ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"

View File

@@ -5528,7 +5528,6 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
public let createdat: String
public let updatedat: String
public let createdby: AnyCodable
public let origin: [String: AnyCodable]?
public let proposedversion: String
public let draftfile: String
public let drafthash: String
@@ -5553,7 +5552,6 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
createdat: String,
updatedat: String,
createdby: AnyCodable,
origin: [String: AnyCodable]?,
proposedversion: String,
draftfile: String,
drafthash: String,
@@ -5577,7 +5575,6 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
self.createdat = createdat
self.updatedat = updatedat
self.createdby = createdby
self.origin = origin
self.proposedversion = proposedversion
self.draftfile = draftfile
self.drafthash = drafthash
@@ -5603,7 +5600,6 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
case createdat = "createdAt"
case updatedat = "updatedAt"
case createdby = "createdBy"
case origin
case proposedversion = "proposedVersion"
case draftfile = "draftFile"
case drafthash = "draftHash"

View File

@@ -1,2 +1,2 @@
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
f1da4b7930475e4be33cb05b8c239f728c7338eb1e8df9b7905bbae94d62da9e plugin-sdk-api-baseline.json
6fd007eede80893680d65c6f245eafb9e6301a1e4306530b0134fd5b3da0cddb plugin-sdk-api-baseline.jsonl

View File

@@ -248,7 +248,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On
There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover.
The reply cache lives in SQLite plugin state. `openclaw doctor --fix` imports and archives the old `imessage/reply-cache.jsonl` sidecar when present.
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
## Related

View File

@@ -533,7 +533,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
</Accordion>
<Accordion title="Message IDs">
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent SQLite-backed reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
</Accordion>
@@ -714,7 +714,7 @@ Each replayed row is fed through the live dispatch path (`evaluateIMessageInboun
### Cursor and retry semantics
Catchup keeps a per-account cursor in SQLite plugin state:
Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<account>__<hash>.json` (the OpenClaw state dir defaults to `~/.openclaw`, overridable with `OPENCLAW_STATE_DIR`):
```json
{
@@ -729,7 +729,6 @@ Catchup keeps a per-account cursor in SQLite plugin state:
- After the startup catchup query succeeds, later live-handled rows also advance the same cursor so a gateway restart does not replay messages that were already handled live. Live cursor writes do not jump past catchup failures that are still below `maxFailureRetries`.
- After `maxFailureRetries` consecutive throws against the same `guid`, catchup logs a `warn` and force-advances the cursor past the wedged message so subsequent startups can make progress.
- Already-given-up guids are skipped on sight (no dispatch attempt) on later runs and counted under `skippedGivenUp` in the run summary.
- `openclaw doctor --fix` imports legacy `<openclawStateDir>/imessage/catchup/*.json` cursor files into SQLite plugin state and archives the old files.
### Operator-visible signals

View File

@@ -15,7 +15,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
@@ -80,7 +80,6 @@ apply to that PR.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.

View File

@@ -45,8 +45,6 @@ openclaw doctor --deep
openclaw doctor --fix
openclaw doctor --fix --non-interactive
openclaw doctor --generate-gateway-token
openclaw doctor --post-upgrade
openclaw doctor --post-upgrade --json
```
For channel-specific permissions, use the channel probes instead of `doctor`:
@@ -70,8 +68,7 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--allow-exec`: allow doctor to execute configured exec SecretRefs while verifying secrets
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
- `--json`: with `--lint`, emit JSON findings instead of human output
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -191,16 +188,6 @@ id is not registered, no check runs for that id; use the command's `checksRun`
and `checksSkipped` fields to verify a focused gate is selecting the checks you
expect.
## Post-upgrade mode
`openclaw doctor --post-upgrade` runs plugin compatibility probes intended to be
chained after a build or upgrade. Findings are emitted to stdout; the command
exits with code 1 if any finding has `level: "error"`. Add `--json` to receive a
machine-readable envelope (`{ probesRun, findings }`) suitable for CI, the
community `fork-upgrade` skill, and other post-upgrade smoke tooling. If the
installed plugin index is missing or malformed, JSON mode still emits that
envelope with a `plugin.index_unavailable` error finding.
Notes:
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).

View File

@@ -35,7 +35,6 @@ openclaw uninstall --dry-run
Notes:
- Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces.
- `--state` preserves configured workspace directories unless `--workspace` is also selected.
- `--all` is shorthand for removing service, state, workspace, and app together.
- `--non-interactive` requires `--yes`.

View File

@@ -91,7 +91,7 @@ For the bundled non-ACP Codex harness, OpenClaw applies the same lifecycle by pr
OpenClaw calls two optional subagent lifecycle hooks:
<ParamField path="prepareSubagentSpawn" type="method">
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds. Native subagent spawns that request `lightContext` and resolve to `contextMode="isolated"` intentionally skip this hook so the child starts from the lightweight bootstrap context without context-engine-managed pre-spawn state.
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds.
</ParamField>
<ParamField path="onSubagentEnded" type="method">
Clean up when a subagent session completes or is swept.

View File

@@ -67,27 +67,6 @@ OpenClaw separates the selected provider/model from why it was selected. That so
The auto fallback primary-probe interval is five minutes and is not configurable. OpenClaw remembers recent probes per session and primary model so a failing primary is not retried on every turn. OpenClaw sends a visible notice when a session moves onto fallback and another notice when it returns to the selected primary; it does not repeat the notice on every sticky fallback turn.
## Auth failure skip cache
By default, every new turn keeps the existing fallback retry behavior: OpenClaw
will try each configured fallback candidate again, including non-primary
candidates that recently failed with `auth` or `auth_permanent`.
Operators who prefer to suppress those repeat auth failures can opt in with:
```bash
OPENCLAW_FALLBACK_SKIP_TTL_MS=60000
```
When enabled, OpenClaw records an in-memory, session-scoped skip marker for a
non-primary fallback candidate after an auth-class failure. The marker is keyed
by session id, provider, and model. Primary candidates are never skipped, so an
explicit user model selection still surfaces the real auth error. The cache is
process-local and clears on Gateway restart.
The value is a TTL in milliseconds. `0` or an unset value disables the cache.
Positive values are clamped between 1 second and 10 minutes.
## User-visible fallback notices
When a session moves onto an auto-selected fallback, OpenClaw sends a status notice in the same reply surface:

View File

@@ -120,19 +120,6 @@ sparse token/cache counters from the latest transcript usage entry, and
the caller's current session; visible client labels such as `openclaw-tui` are
not session keys.
When route metadata is available, `session_status` also includes a visible
`Route context` JSON block and matching structured `details` fields. These
fields disambiguate the session key from the route that is currently handling
the live run:
- `origin` is where the session was created, or the provider inferred from a
deliverable session-key prefix when older state lacks stored origin metadata.
- `active` is the current live-run route. It is only reported for the live or
current session being handled now.
- `deliveryContext` is the persisted delivery route stored on the session,
which OpenClaw can reuse for later delivery even when the active surface
differs.
`sessions_yield` intentionally ends the current turn so the next message can be
the follow-up event you are waiting for. Use it after spawning sub-agents when
you want completion results to arrive as the next message instead of building

View File

@@ -206,17 +206,6 @@ openclaw models auth login --provider openai --profile-id openai:lain
This is the easiest way to keep multiple OAuth logins for the same provider
separate inside one agent.
Use `--force` when a saved provider profile is stuck, expired, or tied to the
wrong account and the normal login command keeps reusing it. `--force` deletes
the saved auth profiles for that provider in the selected agent directory, then
runs the same provider auth flow again. It does not revoke credentials at the
provider; rotate or revoke them in the provider dashboard when you need
provider-side invalidation.
```bash
openclaw models auth login --provider anthropic --force
```
### Per-session (chat command)
Use `/model <alias-or-id>@<profileId>` to pin a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`).

View File

@@ -76,37 +76,6 @@ Server globs use the provider-safe MCP server prefix, not necessarily the raw `m
Without that sandbox-layer entry, the MCP server can still load successfully while its tools are filtered before the provider request. Use `openclaw doctor` to catch this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
### `tools.codeMode`
`tools.codeMode` enables the generic OpenClaw code-mode surface. When enabled
for a run with tools, the model sees only `exec` and `wait`; normal OpenClaw
tools move behind the in-sandbox `tools.*` catalog bridge, and MCP tools are
available through the generated `MCP` namespace.
```json5
{
tools: {
codeMode: {
enabled: true,
},
},
}
```
The shorthand is also accepted:
```json5
{
tools: { codeMode: true },
}
```
MCP declarations are exposed through the read-only virtual API file surface in
code mode. Guest code can call `API.list("mcp")` and
`API.read("mcp/<server>.d.ts")` to inspect TypeScript-style signatures before
calling `MCP.<server>.<tool>()`. See [Code mode](/reference/code-mode) for the
runtime contract, limits, and debugging steps.
### `tools.allow` / `tools.deny`
Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is off.

View File

@@ -580,11 +580,6 @@ See [Inferred commitments](/concepts/commitments).
value, so repeated failures from one localhost origin do not automatically
lock out a different origin.
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
- `tailscale.serviceName`: optional Tailscale Service name for Serve mode, such
as `svc:openclaw`. When set, OpenClaw passes it to `tailscale serve
--service` so the Control UI can be exposed through a named Service instead
of the device hostname. The value must use Tailscale's `svc:<dns-label>`
Service name format; startup reports the derived Service URL.
- `tailscale.preserveFunnel`: when `true` and `tailscale.mode = "serve"`, OpenClaw
checks `tailscale funnel status` before re-applying Serve at startup and skips
it if an externally configured Funnel route already covers the gateway port.

View File

@@ -67,25 +67,6 @@ and use `gateway.auth.mode: "token"` or `"password"`.
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
To expose the Control UI through a named Tailscale Service instead of the
device hostname, set `gateway.tailscale.serviceName` to the Service name:
```json5
{
gateway: {
bind: "loopback",
tailscale: { mode: "serve", serviceName: "svc:openclaw" },
},
}
```
With the example above, startup reports the Service URL as
`https://openclaw.<tailnet-name>.ts.net/` instead of the device hostname.
Tailscale Services require the host to be an approved tagged node in your
tailnet. Configure the tag and approve the Service in Tailscale before enabling
this option, otherwise `tailscale serve --service=...` will fail during gateway
startup.
### Tailnet-only (bind to Tailnet IP)
Use this when you want the Gateway to listen directly on the Tailnet IP (no Serve/Funnel).
@@ -133,11 +114,6 @@ openclaw gateway --tailscale funnel --auth password
- Tailscale Serve/Funnel requires the `tailscale` CLI to be installed and logged in.
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
- `gateway.tailscale.serviceName` applies only to Serve mode and is passed to
`tailscale serve --service=<name>`. The value must use Tailscale's
`svc:<dns-label>` Service name format, for example `svc:openclaw`.
Tailscale requires Service hosts to be tagged nodes, and the Service may need
approval in the admin console before Serve can publish it.
- Set `gateway.tailscale.resetOnExit` if you want OpenClaw to undo `tailscale serve`
or `tailscale funnel` configuration on shutdown.
- Set `gateway.tailscale.preserveFunnel: true` to keep an externally configured

View File

@@ -1775,7 +1775,7 @@ lives on the [Models FAQ](/help/faq-models).
- The target channel supports outbound media and isn't blocked by allowlists.
- The file is within the provider's size limits (images are resized to max 2048px).
- `tools.fs.workspaceOnly=true` keeps local-path sends limited to workspace, temp/media-store, and sandbox-validated files.
- `tools.fs.workspaceOnly=false` lets structured local media sends use host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, Office docs, and validated text documents such as Markdown/MD, TXT, JSON, YAML, and YML). This is not a secret scanner: an agent-readable `secret.txt` or `config.json` can be attached when the extension and content validation match. Keep sensitive files outside agent-readable paths, or keep `tools.fs.workspaceOnly=true` for stricter local-path sends.
- `tools.fs.workspaceOnly=false` lets structured local media sends use host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, and Office docs). Plain text and secret-like files are still blocked.
See [Images](/nodes/images).

View File

@@ -19,8 +19,6 @@ Recommended: use the built-in uninstaller:
openclaw uninstall
```
When using the CLI, state removal preserves configured workspace directories unless you also select `--workspace`.
Non-interactive (automation / npx):
```bash
@@ -49,7 +47,6 @@ rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
```
If you set `OPENCLAW_CONFIG_PATH` to a custom location outside the state dir, delete that file too.
If you want to keep a workspace inside the state dir, such as `~/.openclaw/workspace`, move it aside before running `rm -rf` or delete state contents selectively.
4. Delete your workspace (optional, removes agent files):

View File

@@ -52,14 +52,8 @@ type MessagePresentationBlock =
| { type: "buttons"; buttons: MessagePresentationButton[] }
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] };
type MessagePresentationAction =
| { type: "command"; command: string }
| { type: "callback"; value: string };
type MessagePresentationButton = {
label: string;
action?: MessagePresentationAction;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
url?: string;
webApp?: { url: string };
@@ -73,9 +67,7 @@ type MessagePresentationButton = {
type MessagePresentationOption = {
label: string;
action?: MessagePresentationAction;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
value: string;
};
type ReplyPayloadDelivery = {
@@ -91,13 +83,8 @@ type ReplyPayloadDelivery = {
Button semantics:
- `action.type: "command"` runs a native slash command through core's command
path. Use this for built-in command buttons and menus.
- `action.type: "callback"` carries opaque plugin data through the channel's
interaction path. Channel plugins must not reinterpret callback data as slash
commands.
- `value` is the legacy opaque callback value. New controls should use `action`
so channel plugins can map commands and callbacks without guessing from text.
- `value` is an application action value routed back through the channel's
existing interaction path when the channel supports clickable controls.
- `url` is a link button. It can exist without `value`.
- `webApp` describes a channel-native web app button. Telegram renders this
as `web_app` and only supports it in private chats. `web_app` is still
@@ -119,8 +106,7 @@ Button semantics:
Select semantics:
- `options[].action` has the same command/callback meaning as button `action`.
- `options[].value` is the legacy selected application value.
- `options[].value` is the selected application value.
- `placeholder` is advisory and may be ignored by channels without native
select support.
- If a channel does not support selects, fallback text lists the labels.

View File

@@ -311,8 +311,7 @@ The branch already has a real shared SQLite base:
`delivery_queue_entries`, `model_capability_cache`,
`workspace_setup_state`, `native_hook_relay_bridges`,
`current_conversation_bindings`, `plugin_binding_approvals`,
`tui_last_sessions`, `acp_sessions`, `acp_replay_sessions`,
`acp_replay_events`, `task_runs`, `task_delivery_state`, `flow_runs`,
`tui_last_sessions`, `task_runs`, `task_delivery_state`, `flow_runs`,
`subagent_runs`, `migration_runs`, and `backup_runs`.
- Arbitrary plugin-owned state does not get host-owned typed tables. Installed
plugins use `plugin_state_entries` for versioned JSON payloads and
@@ -1670,8 +1669,6 @@ Move these into agent databases:
- ACP replay ledger sessions. Done for runtime writes via
`acp_replay_sessions` and `acp_replay_events`; legacy `acp/event-ledger.json`
remains only as doctor input.
- ACP session metadata. Done for runtime writes via `acp_sessions`; legacy
`entry.acp` blocks in `sessions.json` are doctor migration input only.
- Trajectory sidecars when they are not explicit export files. Done for runtime
writes: trajectory capture writes agent-database `trajectory_runtime_events`
rows and mirrors run-scoped artifacts into SQLite. Legacy sidecars are doctor

View File

@@ -100,11 +100,6 @@ The shorthand is also accepted:
Code mode remains off when `tools.codeMode` is omitted, `false`, or an object
without `enabled: true`.
When you use sandboxed agents with configured MCP servers, also make sure the
sandbox tool policy allows the bundled MCP plugin, for example with
`tools.sandbox.tools.alsoAllow: ["bundle-mcp"]`. See
[Configuration - tools and custom providers](/gateway/config-tools#mcp-and-plugin-tools-inside-sandbox-tool-policy).
Use explicit limits when you want tighter bounds:
```json5
@@ -446,13 +441,12 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
MCP catalog entries are not callable through `tools.call(...)` or convenience
functions in code mode. They are exposed only through the generated `MCP`
namespace. TypeScript-style declaration files are available through the
read-only `API` virtual file surface, so agents can inspect MCP signatures
without adding MCP schemas to the prompt:
namespace, which includes TypeScript-style API headers for discovery:
```typescript
const files = await API.list("mcp");
const githubApi = await API.read("mcp/github.d.ts");
const servers = await MCP.$api();
const githubApi = await MCP.github.$api();
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
const issue = await MCP.github.createIssue({
owner: "openclaw",
@@ -468,8 +462,7 @@ const prompt = await MCP.docs.prompts.get({
});
```
`API.read("mcp/<server>.d.ts")` returns compact declarations inferred from MCP
tool metadata:
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
```typescript
type McpToolResult = {
@@ -498,20 +491,6 @@ declare namespace MCP.github {
}
```
The declaration files are virtual, not files written under the workspace or
state directory. For each code-mode `exec` call, OpenClaw builds the run-scoped
tool catalog, keeps the visible MCP entries, renders `mcp/index.d.ts` plus one
`mcp/<server>.d.ts` declaration per visible server, and injects that small
read-only table into the QuickJS worker. Guest code sees only the `API` object:
`API.list(prefix?)` returns file metadata and `API.read(path)` returns the
selected declaration content. Unknown paths and `.` / `..` segments are rejected.
This keeps large MCP schemas out of the model prompt. The agent learns that the
virtual API exists from the `exec` tool description, reads only the needed
declaration file, and then calls `MCP.<server>.<tool>()` with one object argument.
`MCP.<server>.$api()` remains available as an inline fallback when the agent
needs a single-tool schema response inside the program.
The guest runtime must not expose host objects directly. Inputs and outputs cross
the bridge as JSON-compatible values with explicit size caps.
@@ -1002,9 +981,8 @@ Code mode coverage should prove:
- all effective non-MCP tools appear in `ALL_TOOLS`
- denied tools do not appear in `ALL_TOOLS`
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
MCP declarations without a bridge/tool call
- MCP namespace `$api()` remains available as an inline fallback for schemas
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
schemas
- MCP namespace calls work for visible MCP tools with one object input, while
direct MCP catalog entries are absent from `tools.*`
- Tool Search control tools are hidden from both the model surface and the hidden
@@ -1036,8 +1014,8 @@ Run these as integration or end-to-end tests when changing the runtime:
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
`tools.describe`, and `tools.call`.
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
assert the declaration files describe visible MCP tools.
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
describe visible MCP tools.
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
11. Assert denied tools are absent and cannot be called by guessed id.

View File

@@ -212,9 +212,9 @@ Local-path behavior follows the same file-read trust model as the agent:
- If `tools.fs.workspaceOnly` is `true`, outbound local media paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files.
- If `tools.fs.workspaceOnly` is `false`, outbound local media can use host-local files the agent is already allowed to read.
- Local paths can be absolute, workspace-relative, or home-relative with `~/`.
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, Office documents, and validated text documents such as Markdown/MD, TXT, JSON, YAML, and YML). This is an extension of the existing host-read trust boundary, not a secret scanner: if the agent can read a host-local `secret.txt` or `config.json`, it can attach that file when the extension and content validation match.
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, and Office documents). Plain text and secret-like files are not treated as sendable media.
That means generated images/files outside the workspace can now send when your fs policy already allows those reads, while arbitrary host-local text extensions remain blocked. Keep sensitive files outside the agent-readable filesystem, or keep `tools.fs.workspaceOnly=true` for stricter local-path sends.
That means generated images/files outside the workspace can now send when your fs policy already allows those reads, without reopening arbitrary host-text attachment exfiltration.
## Operations checklist

View File

@@ -37,8 +37,8 @@ async function startRealService(state: DeferredServiceState): Promise<AcpRuntime
throw new Error("ACPX runtime service is not started");
}
state.startPromise ??= (async () => {
const { createAcpxRuntimeService: createAcpxRuntimeServiceLocal } = await loadServiceModule();
const service = createAcpxRuntimeServiceLocal(state.params);
const { createAcpxRuntimeService } = await loadServiceModule();
const service = createAcpxRuntimeService(state.params);
state.realService = service;
await service.start(state.ctx as OpenClawPluginServiceContext);
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);

View File

@@ -262,12 +262,7 @@ async function terminatePids(
deps: AcpxProcessCleanupDeps | undefined,
): Promise<number[]> {
const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
const sleep =
deps?.sleep ??
((ms) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
}));
const sleep = deps?.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const terminated: number[] = [];
for (const pid of pids) {
@@ -307,7 +302,7 @@ export async function cleanupOpenClawOwnedAcpxProcessTree(params: {
return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
}
let processes: AcpxProcessInfo[];
let processes: AcpxProcessInfo[] = [];
try {
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
} catch {

View File

@@ -335,7 +335,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
await expect(async () => {
for await (const ignoredEventValue of runtime.runTurn({
for await (const eventValue of runtime.runTurn({
handle: {
sessionKey: "agent:codex:acp:test",
backend: "acpx",
@@ -346,7 +346,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
mode: "prompt",
requestId: "turn-1",
})) {
void ignoredEventValue;
// no-op
}
}).rejects.toMatchObject({
@@ -569,7 +568,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
}),
);
for await (const ignoredEventValue of runtime.runTurn({
for await (const eventValue of runtime.runTurn({
handle: {
sessionKey: "agent:codex:acp:test",
backend: "acpx",
@@ -580,7 +579,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
mode: "prompt",
requestId: "turn-1",
})) {
void ignoredEventValue;
// no-op
}
@@ -601,8 +599,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
mode: "prompt",
requestId: "turn-2",
});
for await (const ignoredEventValue of turn.events) {
void ignoredEventValue;
for await (const eventValue of turn.events) {
// no-op
}
await turn.result;
@@ -950,16 +947,16 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
expect(baseStore.load).toHaveBeenCalledTimes(1);
await runtime.prepareFreshSession({
sessionKey: "agent:codex:acp:binding:test",
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
expect(baseStore.load).toHaveBeenCalledTimes(1);
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
expect(baseStore.load).toHaveBeenCalledTimes(1);
await wrappedStore.save({
acpxRecordId: "fresh-record",
@@ -969,7 +966,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore["load"]).toHaveBeenCalledTimes(2);
expect(baseStore.load).toHaveBeenCalledTimes(2);
});
it("marks the session fresh after discardPersistentState close", async () => {
@@ -1001,7 +998,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
discardPersistentState: true,
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore["load"]).toHaveBeenCalledOnce();
expect(baseStore.load).toHaveBeenCalledOnce();
});
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {

View File

@@ -1196,7 +1196,7 @@ export class AcpxRuntime implements AcpRuntime {
const record = await this.sessionStore.load(
input.handle.acpxRecordId ?? input.handle.sessionKey,
);
let closeSucceeded;
let closeSucceeded = false;
try {
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
handle: input.handle,

View File

@@ -2958,9 +2958,7 @@ describe("active-memory plugin", () => {
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
await new Promise((resolve) => {
setTimeout(resolve, (params.timeoutMs ?? 0) + 5);
});
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 5));
return {
payloads: [{ text: "late timeout payload that should never become memory context" }],
meta: { aborted: true },
@@ -3003,9 +3001,7 @@ describe("active-memory plugin", () => {
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(async () => {
await new Promise((resolve) => {
setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5);
});
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5));
return { payloads: [{ text: "remember the ramen place" }] };
});
@@ -3135,9 +3131,7 @@ describe("active-memory plugin", () => {
},
},
]);
await new Promise((resolve) => {
setTimeout(resolve, 35);
});
await new Promise((resolve) => setTimeout(resolve, 35));
return { payloads: [{ text: "User usually orders ramen." }] };
});
@@ -3227,9 +3221,7 @@ describe("active-memory plugin", () => {
},
},
]);
await new Promise((resolve) => {
setTimeout(resolve, 35);
});
await new Promise((resolve) => setTimeout(resolve, 35));
return { payloads: [{ text: "User usually orders ramen after late flights." }] };
});

View File

@@ -605,25 +605,25 @@ function resolveRecallRunChannelContext(params: {
(!explicitProvider || explicitProvider === "webchat")
? runnableExplicitChannel
: undefined;
const resolveReturnValue = (paramsLocal: {
const resolveReturnValue = (params: {
resolvedChannel?: string;
resolvedChannelStrength?: "strong" | "weak";
}) => {
const trustedResolvedChannel =
paramsLocal.resolvedChannelStrength === "strong" ? paramsLocal.resolvedChannel : undefined;
params.resolvedChannelStrength === "strong" ? params.resolvedChannel : undefined;
return {
messageChannel:
trustedExplicitChannel ??
trustedResolvedChannel ??
explicitProvider ??
runnableExplicitChannel ??
paramsLocal.resolvedChannel,
params.resolvedChannel,
messageProvider:
trustedExplicitChannel ??
trustedResolvedChannel ??
explicitProvider ??
runnableExplicitChannel ??
paramsLocal.resolvedChannel,
params.resolvedChannel,
};
};
const resolvedSessionKey =
@@ -1793,9 +1793,7 @@ function watchTerminalMemorySearchResult(params: {
if (stopped) {
return;
}
timeoutId = setTimeout(() => {
void tick();
}, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId = setTimeout(tick, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId.unref?.();
};
const tick = async () => {

View File

@@ -1,11 +0,0 @@
# OpenClaw Amazon Bedrock Mantle Provider
Official OpenClaw provider plugin for routing Amazon Bedrock Mantle models through OpenAI-compatible provider flows.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/amazon-bedrock-mantle-provider
```
Use this plugin when your Bedrock deployment exposes Mantle-compatible model routing and you want OpenClaw agents to address those models through the Bedrock Mantle provider.

View File

@@ -1,4 +1,4 @@
import type { Model } from "openclaw/plugin-sdk/llm";
import type { Api, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import {
createMantleAnthropicStreamFn,

View File

@@ -1,11 +0,0 @@
# OpenClaw Amazon Bedrock Provider
Official OpenClaw provider plugin for Amazon Bedrock. It adds Bedrock model discovery, text generation, embeddings, and guardrail-aware provider routing for agents that use AWS-hosted models.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/amazon-bedrock-provider
```
Configure AWS credentials and region through your normal OpenClaw credential/profile setup, then select Bedrock models with the `amazon-bedrock/...` provider prefix.

View File

@@ -365,29 +365,29 @@ export async function createBedrockEmbeddingProvider(
const embedQuery = async (
text: string,
optionsValue?: { signal?: AbortSignal },
options?: { signal?: AbortSignal },
): Promise<number[]> => {
if (!text.trim()) {
return [];
}
if (isCohere) {
return (await embedCohere([text], "search_query", optionsValue?.signal))[0] ?? [];
return (await embedCohere([text], "search_query", options?.signal))[0] ?? [];
}
return embedSingle(text, optionsValue?.signal);
return embedSingle(text, options?.signal);
};
const embedBatch = async (
texts: string[],
optionsLocal?: { signal?: AbortSignal },
options?: { signal?: AbortSignal },
): Promise<number[][]> => {
if (texts.length === 0) {
return [];
}
if (isCohere) {
return embedCohere(texts, "search_document", optionsLocal?.signal);
return embedCohere(texts, "search_document", options?.signal);
}
return Promise.all(
texts.map((t) => (t.trim() ? embedSingle(t, optionsLocal?.signal) : Promise.resolve([]))),
texts.map((t) => (t.trim() ? embedSingle(t, options?.signal) : Promise.resolve([]))),
);
};

View File

@@ -272,7 +272,7 @@ describe("amazon-bedrock provider plugin", () => {
setBedrockAppProfileControlPlaneForTest((region) => ({
async getInferenceProfile(input) {
class GetInferenceProfileCommand {
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
constructor(readonly input: Record<string, unknown> = {}) {}
}
bedrockClientConfigs.push(region ? { region } : {});
return await sendBedrockCommand(new GetInferenceProfileCommand(input));

View File

@@ -1,11 +0,0 @@
# OpenClaw Anthropic Vertex Provider
Official OpenClaw provider plugin for Claude models hosted through Google Vertex AI.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/anthropic-vertex-provider
```
Configure Google Cloud credentials and the target Vertex project/region in OpenClaw, then select Claude models with the Anthropic Vertex provider.

View File

@@ -127,11 +127,11 @@ export default definePluginEntry({
: undefined;
},
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
normalizeTransport: ({ api, baseUrl }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
api,
baseUrl: normalizedBaseUrl,
}
: undefined;

View File

@@ -495,10 +495,7 @@ export async function startGatewayBonjourAdvertiser(
return { responder, services };
}
async function stopCycle(
cycle: BonjourCycle | null,
optsValue?: { shutdownResponder?: boolean },
) {
async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) {
if (!cycle) {
return;
}
@@ -510,7 +507,7 @@ export async function startGatewayBonjourAdvertiser(
}
}
try {
if (optsValue?.shutdownResponder) {
if (opts?.shutdownResponder) {
await cycle.responder.shutdown();
}
} catch {
@@ -618,7 +615,7 @@ export async function startGatewayBonjourAdvertiser(
}
};
const recreateAdvertiser = async (reason: string, optsLocal?: { stuckState?: boolean }) => {
const recreateAdvertiser = async (reason: string, opts?: { stuckState?: boolean }) => {
if (stopped || disabled) {
return;
}
@@ -627,9 +624,7 @@ export async function startGatewayBonjourAdvertiser(
}
recreatePromise = (async () => {
consecutiveRestarts += 1;
consecutiveStuckStateRestarts = optsLocal?.stuckState
? consecutiveStuckStateRestarts + 1
: 0;
consecutiveStuckStateRestarts = opts?.stuckState ? consecutiveStuckStateRestarts + 1 : 0;
const now = Date.now();
while (
restartTimestamps.length > 0 &&

View File

@@ -530,7 +530,7 @@ export function createBrowserTool(opts?: {
});
const proxyRequest = nodeTarget
? async (optsLocal: {
? async (opts: {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
@@ -540,12 +540,12 @@ export function createBrowserTool(opts?: {
}) => {
const proxy = await callBrowserProxy({
nodeId: nodeTarget.nodeId,
method: optsLocal.method,
path: optsLocal.path,
query: optsLocal.query,
body: optsLocal.body,
timeoutMs: optsLocal.timeoutMs,
profile: optsLocal.profile,
method: opts.method,
path: opts.path,
query: opts.query,
body: opts.body,
timeoutMs: opts.timeoutMs,
profile: opts.profile,
});
const mapping = await persistProxyFiles(proxy.files);
applyProxyPaths(proxy.result, mapping);

View File

@@ -232,13 +232,12 @@ describe("cdp-proxy-bypass", () => {
describe("withNoProxyForLocalhost concurrency", () => {
it("does not leak NO_PROXY when called concurrently", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost: withNoProxyForLocalhostScoped } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
const releaseA = createDeferred();
const enteredA = createDeferred();
const callA = withNoProxyForLocalhostScoped(async () => {
const callA = withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toContain("localhost");
expect(process.env.NO_PROXY).toContain("[::1]");
enteredA.resolve();
@@ -248,7 +247,7 @@ describe("withNoProxyForLocalhost concurrency", () => {
await enteredA.promise;
const callB = withNoProxyForLocalhostScoped(async () => {
const callB = withNoProxyForLocalhost(async () => {
return "b";
});
@@ -265,22 +264,21 @@ describe("withNoProxyForLocalhost concurrency", () => {
describe("withNoProxyForLocalhost reverse exit order", () => {
it("restores NO_PROXY when first caller exits before second", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost: withNoProxyForLocalhostItem } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
const enteredA = createDeferred();
const enteredB = createDeferred();
const releaseA = createDeferred();
const releaseB = createDeferred();
const callA = withNoProxyForLocalhostItem(async () => {
const callA = withNoProxyForLocalhost(async () => {
enteredA.resolve();
await releaseA.promise;
return "a";
});
await enteredA.promise;
const callB = withNoProxyForLocalhostItem(async () => {
const callB = withNoProxyForLocalhost(async () => {
enteredB.resolve();
await releaseB.promise;
return "b";
@@ -308,10 +306,9 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost: withNoProxyForLocalhostCandidate } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhostCandidate(async () => {
await withNoProxyForLocalhost(async () => {
// Should not modify since loopback is already covered
expect(process.env.NO_PROXY).toBe(userNoProxy);
return "ok";
@@ -335,10 +332,9 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost: withNoProxyForLocalhostEntry } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhostEntry(async () => {
await withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toBe(`${coveredNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${staleLowerNoProxy},localhost,127.0.0.1,[::1]`);
});
@@ -359,10 +355,9 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost: withNoProxyForLocalhostResult } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhostResult(async () => {
await withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
});
@@ -383,10 +378,9 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost: withNoProxyForLocalhostValue } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhostValue(async () => {
await withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
delete process.env.no_proxy;
@@ -408,10 +402,9 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost: withNoProxyForLocalhostLocal } =
await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
await withNoProxyForLocalhostLocal(async () => {
await withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
});

View File

@@ -42,9 +42,7 @@ import { BrowserCdpEndpointBlockedError } from "./errors.js";
async function startWsServer() {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
}
@@ -57,9 +55,7 @@ describe("cdp.helpers internal", () => {
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
if (wss) {
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
});
@@ -311,9 +307,7 @@ describe("cdp.helpers internal", () => {
cb(true);
},
});
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
let callbackCount = 0;
wss.on("connection", (socket) => {
@@ -347,9 +341,7 @@ describe("cdp.helpers internal", () => {
cb(false, 429, "too many requests");
},
});
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
await expect(

View File

@@ -397,9 +397,7 @@ type CdpSocketOptions = {
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeRetryCount(value: number | undefined, fallback: number): number {

View File

@@ -79,9 +79,7 @@ function replyToViewportCommandOrScreenshot(
async function startMockWsServer(handle: CdpReplyHandler) {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
@@ -115,9 +113,7 @@ describe("cdp internal", () => {
afterEach(async () => {
if (wss) {
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
});
@@ -1076,9 +1072,7 @@ describe("cdp internal", () => {
// in createTargetViaCdp — the bare-ws root triggers discovery.
const http = await import("node:http");
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wsServer.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wsServer.once("listening", () => resolve()));
const wsPort = (wsServer.address() as { port: number }).port;
wsServer.on("connection", (socket) => {
socket.on("message", (raw) => {
@@ -1116,9 +1110,7 @@ describe("cdp internal", () => {
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => {
httpServer.listen(0, "127.0.0.1", () => resolve());
});
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
const httpPort = (httpServer.address() as { port: number }).port;
try {
const out = await createTargetViaCdp({
@@ -1127,12 +1119,8 @@ describe("cdp internal", () => {
});
expect(out.targetId).toBe("T_BARE_WS");
} finally {
await new Promise<void>((resolve) => {
wsServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
await new Promise<void>((resolve) => wsServer.close(() => resolve()));
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
});

View File

@@ -27,9 +27,7 @@ describe("cdp", () => {
const startWsServer = async () => {
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wsServer?.once("listening", resolve);
});
await new Promise<void>((resolve) => wsServer?.once("listening", resolve));
return (wsServer.address() as { port: number }).port;
};
@@ -79,9 +77,7 @@ describe("cdp", () => {
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
return (httpServer.address() as { port: number }).port;
};
@@ -89,16 +85,14 @@ describe("cdp", () => {
vi.unstubAllEnvs();
await new Promise<void>((resolve) => {
if (!httpServer) {
resolve();
return;
return resolve();
}
httpServer.close(() => resolve());
httpServer = null;
});
await new Promise<void>((resolve) => {
if (!wsServer) {
resolve();
return;
return resolve();
}
wsServer.close(() => resolve());
wsServer = null;
@@ -196,9 +190,7 @@ describe("cdp", () => {
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const httpPort = (httpServer.address() as AddressInfo).port;
await expect(
@@ -218,9 +210,7 @@ describe("cdp", () => {
heldSockets.push(socket);
// Hold the TCP connection open without completing the WebSocket handshake.
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const port = (httpServer.address() as AddressInfo).port;
try {
@@ -517,9 +507,7 @@ describe("cdp", () => {
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const addr = server.address() as AddressInfo;
const created = await createTargetViaCdp({
@@ -528,12 +516,8 @@ describe("cdp", () => {
});
expect(created.targetId).toBe("ROOT_FALLBACK");
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

View File

@@ -490,11 +490,11 @@ describe("chrome MCP page parsing", () => {
url: "about:blank",
type: "page",
});
expect(session.client["callTool"]).toHaveBeenCalledWith({
expect(session.client.callTool).toHaveBeenCalledWith({
name: "new_page",
arguments: { url: "about:blank", timeout: 5000 },
});
const callToolMock = session.client["callTool"] as unknown as ToolCallMock;
const callToolMock = session.client.callTool as unknown as ToolCallMock;
const callNames = callToolMock.mock.calls.map(([call]) => call.name);
expect(callNames).not.toContain("navigate_page");
});
@@ -917,7 +917,7 @@ describe("chrome MCP page parsing", () => {
// intentionally no timeoutMs
});
const callToolMock = session.client["callTool"] as unknown as ToolCallMock;
const callToolMock = session.client.callTool as unknown as ToolCallMock;
const navigateCall = callToolMock.mock.calls.find(
([call]) => call.name === "navigate_page",
)?.[0];

View File

@@ -137,7 +137,7 @@ async function diagnoseCdpHealthCommand(
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null;
let parsed: { id?: unknown; result?: unknown } | null = null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {

View File

@@ -194,12 +194,8 @@ async function withMockChromeCdpServer(params: {
const addr = server.address() as AddressInfo;
await params.run(`http://127.0.0.1:${addr.port}`);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
@@ -956,13 +952,9 @@ describe("chrome.ts internal", () => {
it("resolves false when the direct-ws probe cannot connect", async () => {
// Bind a ws server and then close it, so connecting to it fails.
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
await expect(
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/GONE`, 50),
).resolves.toBe(false);
@@ -970,9 +962,7 @@ describe("chrome.ts internal", () => {
it("resolves true when the direct-ws handshake succeeds", async () => {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
try {
// Direct /devtools/ WS URL — isChromeReachable goes through
@@ -982,9 +972,7 @@ describe("chrome.ts internal", () => {
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/OK`, 500),
).resolves.toBe(true);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
}
});
});
@@ -1006,13 +994,9 @@ describe("chrome.ts internal", () => {
// accepting ws upgrades — the canRunCdpHealthCommand probe will
// fire its 'error' handler during handshake.
const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
dead.once("listening", () => resolve());
});
await new Promise<void>((resolve) => dead.once("listening", () => resolve()));
const deadPort = (dead.address() as { port: number }).port;
await new Promise<void>((resolve) => {
dead.close(() => resolve());
});
await new Promise<void>((resolve) => dead.close(() => resolve()));
const server = createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
@@ -1025,18 +1009,14 @@ describe("chrome.ts internal", () => {
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
try {
const addr = server.address() as AddressInfo;
await expect(isChromeCdpReady(`http://127.0.0.1:${addr.port}`, 50, 10)).resolves.toBe(
false,
);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

View File

@@ -42,12 +42,14 @@ async function startLoopbackCdpServer(): Promise<RunningServer> {
afterEach(async () => {
await Promise.all(
runningServers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
),
runningServers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
),
),
);
});

View File

@@ -108,12 +108,8 @@ async function withMockChromeCdpServer(params: {
const addr = server.address() as AddressInfo;
await params.run(`http://127.0.0.1:${addr.port}`);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
@@ -553,9 +549,7 @@ describe("browser chrome helpers", () => {
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -565,7 +559,7 @@ describe("browser chrome helpers", () => {
onConnection: (wss) => {
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
let message: { id?: unknown; method?: unknown } | null;
let message: { id?: unknown; method?: unknown } | null = null;
try {
const text =
typeof raw === "string"
@@ -761,12 +755,8 @@ describe("browser chrome helpers", () => {
expect(diagnostic.wsUrl).toBe(wsOnlyBase);
expect(diagnostic.browser).toBe("Browserless/Mock");
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -795,16 +785,12 @@ describe("browser chrome helpers", () => {
);
// A real WS server accepts the handshake.
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as AddressInfo).port;
try {
await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
}
});
@@ -825,9 +811,7 @@ describe("browser chrome helpers", () => {
}
});
});
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as AddressInfo).port;
try {
await expect(isChromeCdpReady(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toBe(true);
@@ -836,9 +820,7 @@ describe("browser chrome helpers", () => {
);
expect(diagnostic.wsUrl).toBe(`ws://127.0.0.1:${port}`);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
}
});

View File

@@ -519,9 +519,7 @@ export async function launchOpenClawChrome(
if (exists(localStatePath) && exists(preferencesPath)) {
break;
}
await new Promise((r) => {
setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS);
});
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS));
}
try {
bootstrap.kill("SIGTERM");
@@ -533,9 +531,7 @@ export async function launchOpenClawChrome(
if (bootstrap.exitCode != null) {
break;
}
await new Promise((r) => {
setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS);
});
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS));
}
}
@@ -581,9 +577,7 @@ export async function launchOpenClawChrome(
launchHttpReachable = true;
break;
}
await new Promise((r) => {
setTimeout(r, CHROME_LAUNCH_READY_POLL_MS);
});
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
}
if (!launchHttpReachable) {
@@ -688,9 +682,7 @@ export async function stopOpenClawChrome(
return;
}
const remainingMs = timeoutMs - (Date.now() - start);
await new Promise((r) => {
setTimeout(r, Math.max(1, Math.min(100, remainingMs)));
});
await new Promise((r) => setTimeout(r, Math.max(1, Math.min(100, remainingMs))));
}
try {

View File

@@ -37,9 +37,7 @@ describe("browser client fetch attachOnly diagnostics", () => {
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json");
await fs.writeFile(
@@ -80,9 +78,7 @@ describe("browser client fetch attachOnly diagnostics", () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

View File

@@ -469,7 +469,7 @@ export function resolveProfile(
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl;
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
const headlessSource =

View File

@@ -212,9 +212,7 @@ describe("pw-session ensurePageState", () => {
try {
handlers.get("download")?.[0]?.(download);
await new Promise((resolve) => {
setImmediate(resolve);
});
await new Promise((resolve) => setImmediate(resolve));
expect(unhandled).toStrictEqual([]);
await expect(download.path?.()).rejects.toThrow("save failed");

View File

@@ -947,9 +947,7 @@ async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<
break;
}
const delay = resolveCdpConnectRetryDelayMs(attempt);
await new Promise((r) => {
setTimeout(r, delay);
});
await new Promise((r) => setTimeout(r, delay));
}
}
if (lastErr instanceof Error) {
@@ -1068,7 +1066,7 @@ async function findPageByTargetId(
const pages = await getAllPages(browser);
let resolvedViaCdp = false;
for (const page of pages) {
let tid: string | null;
let tid: string | null = null;
try {
tid = await pageTargetId(page);
resolvedViaCdp = true;
@@ -1172,7 +1170,7 @@ export async function getPageForTargetId(opts: {
}
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
let sameMainFrame;
let sameMainFrame = false;
try {
sameMainFrame = request.frame() === page.mainFrame();
} catch {
@@ -1199,7 +1197,7 @@ function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
}
function isSubframeDocumentNavigationRequest(page: Page, request: Request): boolean {
let sameMainFrame;
let sameMainFrame = false;
try {
sameMainFrame = request.frame() === page.mainFrame();
} catch {

View File

@@ -581,9 +581,7 @@ export async function clickViaPlaywright(opts: {
abortPromise,
reconcileRemoteDialog,
);
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (opts.doubleClick) {
await awaitActionWithAbort(

View File

@@ -107,7 +107,7 @@ describe("pw-tools-core", () => {
await fs.writeFile(uploadPath, "fixture", "utf8");
const canonicalUploadPath = await fs.realpath(uploadPath);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi.fn(async (_eventValue: string, _opts: unknown) => fileChooser);
const waitForEvent = vi.fn(async (eventValue: string, _opts: unknown) => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },

View File

@@ -45,9 +45,7 @@ import type { BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
return new Promise((resolve) => setTimeout(resolve, ms));
}
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;

View File

@@ -86,10 +86,10 @@ export function registerBrowserAgentDebugRoutes(
ctx,
targetId,
feature: "page errors",
collect: async ({ cdpUrl, targetId: targetIdValue, pw }) =>
collect: async ({ cdpUrl, targetId, pw }) =>
await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: targetIdValue,
targetId,
clear,
}),
});
@@ -109,10 +109,10 @@ export function registerBrowserAgentDebugRoutes(
ctx,
targetId,
feature: "network requests",
collect: async ({ cdpUrl, targetId: targetIdLocal, pw }) =>
collect: async ({ cdpUrl, targetId, pw }) =>
await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: targetIdLocal,
targetId,
filter: normalizeOptionalString(filter),
clear,
}),

View File

@@ -34,9 +34,7 @@ export async function resolveTargetIdAfterNavigate(opts: {
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => {
setTimeout(r, opts.retryDelayMs ?? 800);
});
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;

View File

@@ -25,14 +25,14 @@ const {
stopKnownBrowserProfilesMock,
trackedTabCleanupMock,
} = vi.hoisted(() => {
const trackedTabCleanupMockLocal = vi.fn();
const trackedTabCleanupMock = vi.fn();
return {
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
getPwAiModuleMock: vi.fn(),
isPwAiLoadedMock: vi.fn(() => false),
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMock),
stopKnownBrowserProfilesMock: vi.fn(async () => {}),
trackedTabCleanupMock: trackedTabCleanupMockLocal,
trackedTabCleanupMock,
};
});

View File

@@ -286,9 +286,7 @@ export function createProfileAvailability({
if (await isReachable(attemptTimeoutMs)) {
return;
}
await new Promise((r) => {
setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS);
});
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
}
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure(
@@ -308,9 +306,7 @@ export function createProfileAvailability({
} catch (err) {
lastError = err;
}
await new Promise((r) => {
setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS);
});
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
}
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
};

View File

@@ -350,9 +350,7 @@ export function createProfileTabOps({
triggerManagedTabLimit(found.targetId);
return assignTabAlias({ profileState, tab: found, label: opts?.label });
}
await new Promise((r) => {
setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS);
});
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
}
triggerManagedTabLimit(createdViaCdp);
return assignTabAlias({

View File

@@ -264,7 +264,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
isTransportAvailable: (timeoutMs) => getDefaultContext().isTransportAvailable(timeoutMs),
isReachable: (timeoutMs, options) => getDefaultContext().isReachable(timeoutMs, options),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url, optsLocal) => getDefaultContext().openTab(url, optsLocal),
openTab: (url, opts) => getDefaultContext().openTab(url, opts),
labelTab: (targetId, label) => getDefaultContext().labelTab(targetId, label),
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
closeTab: (targetId) => getDefaultContext().closeTab(targetId),

View File

@@ -21,9 +21,7 @@ function isTransientStartupFetchError(error: unknown): boolean {
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postStartWithRetry(params: {

View File

@@ -41,9 +41,7 @@ describe("browser control HTTP auth", () => {
if (!current) {
return;
}
await new Promise<void>((resolve) => {
current.close(() => resolve());
});
await new Promise<void>((resolve) => current.close(() => resolve()));
});
it("requires bearer auth for standalone browser HTTP routes", async () => {

View File

@@ -11,14 +11,14 @@ import {
type DescribeFn = ReturnType<typeof vi.fn>;
function makeDeps(
describeCandidate: DescribeFn,
describe: DescribeFn,
overrides?: {
normalizeBrowserScreenshot?: ReturnType<typeof vi.fn>;
saveMediaBuffer?: ReturnType<typeof vi.fn>;
},
) {
return {
describeImageFile: describeCandidate as never,
describeImageFile: describe as never,
normalizeBrowserScreenshot:
(overrides?.normalizeBrowserScreenshot as never) ??
(vi.fn(async (buffer: Buffer) => ({ buffer })) as never),
@@ -41,7 +41,7 @@ async function withTempImage<T>(fn: (filePath: string) => Promise<T>): Promise<T
describe("describeBrowserScreenshot", () => {
it("uses existing image understanding config with a browser screenshot prompt", async () => {
const describeEntry = vi.fn().mockResolvedValue({
const describe = vi.fn().mockResolvedValue({
text: "A login screen.",
provider: "openai",
model: "gpt-vision",
@@ -62,7 +62,7 @@ describe("describeBrowserScreenshot", () => {
activeModel: { provider: "anthropic", model: "claude-sonnet-4.6" },
mediaScope: { sessionKey: "agent:main:telegram:dm:123", channel: "telegram" },
},
makeDeps(describeEntry),
makeDeps(describe),
);
expect(result).toEqual({
@@ -71,7 +71,7 @@ describe("describeBrowserScreenshot", () => {
model: "gpt-vision",
decision: { outcome: "success" },
});
expect(describeEntry).toHaveBeenCalledWith({
expect(describe).toHaveBeenCalledWith({
filePath,
cfg: {
tools: {
@@ -92,7 +92,7 @@ describe("describeBrowserScreenshot", () => {
});
it("resizes screenshots before image understanding when image sanitization is configured", async () => {
const describeResult = vi.fn().mockResolvedValue({ text: "Small screenshot." });
const describe = vi.fn().mockResolvedValue({ text: "Small screenshot." });
const normalizeBrowserScreenshot = vi.fn(async () => ({
buffer: Buffer.from("small"),
contentType: "image/jpeg" as const,
@@ -106,7 +106,7 @@ describe("describeBrowserScreenshot", () => {
filePath,
imageSanitization: { maxDimensionPx: 800 },
},
makeDeps(describeResult, { normalizeBrowserScreenshot, saveMediaBuffer }),
makeDeps(describe, { normalizeBrowserScreenshot, saveMediaBuffer }),
);
});
@@ -114,11 +114,11 @@ describe("describeBrowserScreenshot", () => {
maxSide: 800,
});
expect(saveMediaBuffer).toHaveBeenCalledWith(Buffer.from("small"), "image/jpeg", "browser");
expect(describeResult.mock.calls[0][0].filePath).toBe("/tmp/resized.jpg");
expect(describe.mock.calls[0][0].filePath).toBe("/tmp/resized.jpg");
});
it("returns null when image understanding is skipped or not configured", async () => {
const describeValue = vi.fn().mockResolvedValue({
const describe = vi.fn().mockResolvedValue({
text: undefined,
decision: { outcome: "skipped" },
});
@@ -126,13 +126,13 @@ describe("describeBrowserScreenshot", () => {
await expect(
describeBrowserScreenshot(
{ cfg: { browser: {} }, filePath: "/tmp/screenshot.png" },
makeDeps(describeValue),
makeDeps(describe),
),
).resolves.toBeNull();
});
it("does not pass an incomplete active model to media understanding", async () => {
const describeLocal = vi.fn().mockResolvedValue({ text: "ok" });
const describe = vi.fn().mockResolvedValue({ text: "ok" });
await describeBrowserScreenshot(
{
@@ -144,10 +144,10 @@ describe("describeBrowserScreenshot", () => {
filePath: "/tmp/screenshot.png",
activeModel: { model: "missing-provider" },
},
makeDeps(describeLocal),
makeDeps(describe),
);
expect(describeLocal.mock.calls[0][0].activeModel).toBeUndefined();
expect(describe.mock.calls[0][0].activeModel).toBeUndefined();
});
});

View File

@@ -150,7 +150,7 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
const checks: BrowserDoctorCheck[] = [];
let status: BrowserStatus | null;
let status: BrowserStatus | null = null;
try {
status = await fetchBrowserStatus(parent, profile);
@@ -495,10 +495,8 @@ export function registerBrowserManageCommands(
if (printJsonResult(parent, result)) {
return;
}
const tabValue = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(
`labeled tab ${tabValue?.tabId ?? targetId} as ${tabValue?.label ?? label}`,
);
const tab = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(`labeled tab ${tab?.tabId ?? targetId} as ${tab?.label ?? label}`);
});
});
@@ -557,7 +555,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const tabLocal = await callBrowserRequest<BrowserTab>(
const tab = await callBrowserRequest<BrowserTab>(
parent,
{
method: "POST",
@@ -567,11 +565,11 @@ export function registerBrowserManageCommands(
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, tabLocal)) {
if (printJsonResult(parent, tab)) {
return;
}
defaultRuntime.log(
`opened: ${tabLocal.url}\n${tabLocal.tabId ? `tab: ${tabLocal.tabId}\n` : ""}${tabLocal.label ? `label: ${tabLocal.label}\n` : ""}id: ${tabLocal.targetId}`,
`opened: ${tab.url}\n${tab.tabId ? `tab: ${tab.tabId}\n` : ""}${tab.label ? `label: ${tab.label}\n` : ""}id: ${tab.targetId}`,
);
});
});

View File

@@ -171,7 +171,7 @@ export async function handleBrowserGatewayRequest({
}
const cfg = getRuntimeConfig();
let nodeTarget: NodeSession | null;
let nodeTarget: NodeSession | null = null;
try {
nodeTarget = resolveBrowserNodeTarget({
cfg,

View File

@@ -388,6 +388,7 @@ describe("canvas host", () => {
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
try {
await fs.stat(bundlePath);
@@ -397,6 +398,7 @@ describe("canvas host", () => {
}
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
createdLink = true;
try {
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
@@ -419,7 +421,9 @@ describe("canvas host", () => {
expect(symlinkRes.status).toBe(404);
expect(symlinkRes.body).toBe("not found");
} finally {
await fs.rm(linkPath, { force: true });
if (createdLink) {
await fs.rm(linkPath, { force: true });
}
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}

View File

@@ -443,9 +443,7 @@ export async function createCanvasHostHandler(
}
}
if (wss) {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => wss.close(() => resolve()));
}
},
};
@@ -530,9 +528,9 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
if (ownsHandler) {
await handler.close();
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
},
};
}

View File

@@ -121,9 +121,7 @@ describe("ClickClack gateway", () => {
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit("message", Buffer.from("{not json"));
await new Promise((resolve) => {
setImmediate(resolve);
});
await new Promise((resolve) => setImmediate(resolve));
expect(runError).toBeUndefined();
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] skipped malformed ClickClack websocket event",

View File

@@ -190,9 +190,7 @@ export async function startClickClackGatewayAccount(
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {
setTimeout(resolve, account.reconnectMs);
});
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
}
}
ctx.setStatus({ accountId: account.accountId, running: false });

View File

@@ -790,9 +790,7 @@ async function waitForFile(filePath: string): Promise<string> {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
throw new Error(`timed out waiting for ${filePath}`);
@@ -840,14 +838,10 @@ describe("connectCodexAppServerEndpoint", () => {
await expect(
Promise.race([
probe,
new Promise((_, reject) => {
setTimeout(() => reject(new Error("probe timed out")), 500);
}),
new Promise((_, reject) => setTimeout(() => reject(new Error("probe timed out")), 500)),
]),
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("rejects malformed stdio frames instead of throwing out of band", async () => {
@@ -936,9 +930,7 @@ describe("connectCodexAppServerEndpoint", () => {
);
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]);
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await new Promise((resolve) => setTimeout(resolve, 50));
await expect(supervisor.probeEndpoints()).resolves.toMatchObject([
{
endpointId: "exits",

View File

@@ -1,11 +0,0 @@
# OpenClaw Codex
Official OpenClaw provider and harness plugin for OpenAI Codex app-server integration. It exposes the Codex-managed GPT model catalog and the Codex runtime surfaces used by OpenClaw agents.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/codex
```
Use this plugin when you want OpenClaw to run Codex-backed model turns, media understanding, and prompt overlays through the Codex app-server harness.

View File

@@ -216,7 +216,7 @@ describe("codex plugin", () => {
it("enables the native hook relay for public Codex side questions", async () => {
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const runSideQuestion = harness["runSideQuestion"];
const runSideQuestion = harness.runSideQuestion;
const result = { text: "ok" };
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);

View File

@@ -266,7 +266,7 @@ describe("codex provider", () => {
listModels: listTestCodexAppServerModels,
});
expect(client["close"]).toHaveBeenCalledTimes(1);
expect(client.close).toHaveBeenCalledTimes(1);
});
it("does not close an active shared app-server client during live discovery", async () => {
@@ -293,8 +293,8 @@ describe("codex provider", () => {
listModels: listTestCodexAppServerModels,
});
expect(activeClient["close"]).not.toHaveBeenCalled();
expect(discoveryClient["close"]).toHaveBeenCalledTimes(1);
expect(activeClient.close).not.toHaveBeenCalled();
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
});
it("resolves arbitrary Codex app-server model ids as text-only until discovered", () => {

View File

@@ -337,6 +337,7 @@ export async function startCodexAttemptThread(params: {
if (startupClientForAbandonedRequestCleanup === failedClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
attemptedClient = undefined;
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
embeddedAgentLog.warn(
"codex app-server connection closed during startup; retries exhausted",

View File

@@ -147,7 +147,7 @@ describe("Codex app-server attempt timeouts", () => {
}, 5);
});
},
operation: async () => new Promise<never>(() => {}),
operation: async () => new Promise<never>(() => undefined),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
@@ -164,7 +164,7 @@ describe("Codex app-server attempt timeouts", () => {
const run = withCodexStartupTimeout({
timeoutMs: 1_000,
signal: controller.signal,
operation: async () => new Promise<never>(() => {}),
operation: async () => new Promise<never>(() => undefined),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");

View File

@@ -486,7 +486,7 @@ describe("CodexAppServerClient", () => {
clients.push(harness.client);
harness.client.addRequestHandler((request) => {
if (request.method === "item/tool/call") {
return new Promise<never>(() => {});
return new Promise<never>(() => undefined);
}
return undefined;
});

View File

@@ -114,7 +114,7 @@ describe("maybeCompactCodexAppServerSession", () => {
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(fake.client["addNotificationHandler"]).not.toHaveBeenCalled();
expect(fake.client.addNotificationHandler).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.result?.tokensBefore).toBe(123);

View File

@@ -179,32 +179,6 @@ describe("Codex app-server dynamic tool build", () => {
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
});
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
const messageTool = createRuntimeDynamicTool("message");
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
get(target, property, receiver) {
if (property === "0") {
throw new Error("fuzzplugin tool entry getter exploded");
}
if (property === "1") {
return messageTool;
}
if (property === "length") {
return 2;
}
return Reflect.get(target, property, receiver);
},
});
setOpenClawCodingToolsFactoryForTests(() => sourceTools);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
});
it("limits Codex memory flush runs to managed read and write tools", async () => {
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {

View File

@@ -2,7 +2,6 @@ import {
buildAgentHookContextChannelFields,
buildEmbeddedAttemptToolRunContext,
embeddedAgentLog,
filterProviderNormalizableTools,
isSubagentSessionKey,
normalizeAgentRuntimeTools,
resolveAttemptSpawnWorkspaceDir,
@@ -10,7 +9,6 @@ import {
resolveSandboxContext,
supportsModelTools,
type EmbeddedRunAttemptParams,
type RuntimeToolSchemaDiagnostic,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
@@ -267,19 +265,15 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
},
});
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const readableAllTools = [...readableAllToolProjection.tools];
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
? filterCodexMemoryFlushDynamicTools(readableAllTools)
: filterCodexDynamicTools(readableAllTools, input.pluginConfig),
readableAllTools,
? filterCodexMemoryFlushDynamicTools(allTools)
: filterCodexDynamicTools(allTools, input.pluginConfig),
allTools,
input,
),
readableAllTools,
allTools,
input,
);
toolBuildStages.mark("codex-filtering");
@@ -301,25 +295,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
toolBuildStages.mark("runtime-normalization");
if (preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
{
runId: params.runId,
sessionId: params.sessionId,
diagnostics: preNormalizationDiagnostics.map((diagnostic) => ({
index: diagnostic.toolIndex,
tool: diagnostic.toolName,
violations: diagnostic.violations.slice(0, 12),
violationCount: diagnostic.violations.length,
})),
},
);
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
@@ -331,7 +308,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
phase,
totalMs: summary.totalMs,
stages: summary.stages,
allToolCount: readableAllTools.length,
allToolCount: allTools.length,
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,

View File

@@ -194,7 +194,7 @@ describe("dynamic tool execution helpers", () => {
toolBridge: {
handleToolCall: vi.fn((_call, options) => {
capturedSignal = options?.signal;
return new Promise<never>(() => {});
return new Promise<never>(() => undefined);
}),
},
signal: new AbortController().signal,
@@ -230,7 +230,7 @@ describe("dynamic tool execution helpers", () => {
arguments: { action: "poll", sessionId: "process-session", timeout: 30_000 },
},
toolBridge: {
handleToolCall: vi.fn(() => new Promise<never>(() => {})),
handleToolCall: vi.fn(() => new Promise<never>(() => undefined)),
},
signal: new AbortController().signal,
timeoutMs: 1,

View File

@@ -1,15 +1,15 @@
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
onInternalDiagnosticEvent,
waitForDiagnosticEventsDrained,
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
embeddedAgentLog,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
onInternalDiagnosticEvent,
waitForDiagnosticEventsDrained,
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -85,6 +85,12 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): Array<unknown> {
expect(Array.isArray(value), label).toBe(true);
return value as Array<unknown>;
}
function callArg(
mock: { mock: { calls: Array<Array<unknown>> } },
callIndex: number,
@@ -340,7 +346,9 @@ describe("createCodexDynamicToolBridge", () => {
}),
);
const blockedEvents = diagnosticEvents.filter(
(event): event is Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }> =>
(
event,
): event is Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }> =>
event.type === "tool.execution.blocked",
);
expect(blockedEvents).toContainEqual(
@@ -881,7 +889,7 @@ describe("createCodexDynamicToolBridge", () => {
it("passes raw tool failure state into agent tool result middleware", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async (_eventValue: { isError?: boolean }) => undefined);
const handler = vi.fn(async (eventValue: { isError?: boolean }) => undefined);
registry.agentToolResultMiddlewares.push({
pluginId: "tokenjuice",
pluginName: "Tokenjuice",

View File

@@ -35,9 +35,7 @@ const tinyPngBase64 =
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
function flushDiagnosticEvents() {
return new Promise<void>((resolve) => {
setImmediate(resolve);
});
return new Promise<void>((resolve) => setImmediate(resolve));
}
function assistantMessage(text: string, timestamp: number) {
@@ -318,29 +316,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.replayMetadata.replaySafe).toBe(true);
});
it("streams final-answer assistant deltas into partial replies", async () => {
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
await projector.handleNotification(
forCurrentTurn("item/started", {
item: {
type: "agentMessage",
id: "msg-final",
phase: "final_answer",
text: "",
},
}),
);
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
expect(onPartialReply).toHaveBeenCalledTimes(2);
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
{ text: "hel", delta: "hel" },
{ text: "hello", delta: "lo" },
]);
});
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
const params = await createParams();
const projector = await createProjector({

View File

@@ -447,11 +447,6 @@ export class CodexAppServerEventProjector {
if (!delta) {
return;
}
this.rememberAssistantPhase(readItem(params.item));
const phase = readString(params, "phase");
if (phase) {
this.assistantPhaseByItem.set(itemId, phase);
}
if (!this.assistantStarted) {
this.assistantStarted = true;
await this.params.onAssistantMessageStart?.();
@@ -461,13 +456,10 @@ export class CodexAppServerEventProjector {
this.assistantTextByItem.set(itemId, text);
if (this.isCommentaryAssistantItem(itemId)) {
this.emitCommentaryProgress({ itemId, text });
} else if (this.shouldStreamAssistantPartial(itemId)) {
await this.params.onPartialReply?.({ text, delta });
}
// Codex app-server can emit multiple agentMessage items per turn, including
// intermediate coordination/progress prose. Keep those deltas internal until
// their phase identifies terminal answer text or turn completion chooses the
// last assistant item as the user-visible reply.
// turn completion chooses the last assistant item as the user-visible reply.
}
private async handleReasoningDelta(
@@ -978,10 +970,6 @@ export class CodexAppServerEventProjector {
return this.assistantPhaseByItem.get(itemId) === "commentary";
}
private shouldStreamAssistantPartial(itemId: string): boolean {
return this.assistantPhaseByItem.get(itemId) === "final_answer";
}
private emitCommentaryProgress(params: { itemId: string; text: string }): void {
const progressText = params.text.replace(/\s+/g, " ").trim();
if (

View File

@@ -52,30 +52,8 @@ function createRuntime() {
error?: string;
}>;
};
const createRunningTaskRun = vi.fn(
(params): AgentHarnessTaskRecord => ({
taskId: params.sourceId ?? params.runId,
runtime: "subagent",
sourceId: params.sourceId,
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
agentId: params.agentId,
runId: params.runId,
label: params.label,
task: params.task,
status: "running",
deliveryStatus: params.deliveryStatus ?? "not_applicable",
notifyPolicy: params.notifyPolicy ?? "silent",
createdAt: params.startedAt ?? Date.now(),
startedAt: params.startedAt,
lastEventAt: params.lastEventAt,
progressSummary: params.progressSummary,
}),
);
const taskRuntime = {
createRunningTaskRun,
tryCreateRunningTaskRun: vi.fn((params) => createRunningTaskRun(params)),
createRunningTaskRun: vi.fn(),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
listTaskRecords: vi.fn((): AgentHarnessTaskRecord[] => []),

View File

@@ -7,7 +7,7 @@ import {
function createRuntime() {
return {
tryCreateRunningTaskRun: vi.fn((params) => ({ taskId: "task-native-subagent", ...params })),
createRunningTaskRun: vi.fn(),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} as unknown as TaskLifecycleRuntime;
@@ -49,7 +49,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
sourceId: "codex-thread:child-thread",
agentId: "main",
runId: "codex-thread:child-thread",
@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 20_000,
progressSummary: "Codex native subagent started.",
});
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
@@ -99,7 +99,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.tryCreateRunningTaskRun).not.toHaveBeenCalled();
expect(runtime.createRunningTaskRun).not.toHaveBeenCalled();
expect(runtime.recordTaskRunProgressByRunId).not.toHaveBeenCalled();
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
});
@@ -133,7 +133,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
mirror.handleNotification(notification);
mirror.handleNotification(notification);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(1);
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
});
it("maps Codex thread status changes onto the mirrored task run", () => {
@@ -228,7 +228,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
sourceId: "codex-thread:child-thread",
runId: "codex-thread:child-thread",
label: "Codex subagent",
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 40_000,
progressSummary: "Codex native subagent spawned.",
});
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
@@ -282,7 +282,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
task: "inspect one thing",
@@ -319,7 +319,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
task: "inspect one thing",

View File

@@ -15,7 +15,7 @@ import { isJsonObject } from "./protocol.js";
export type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
"createRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export type CodexNativeSubagentTaskMirrorParams = {
@@ -27,7 +27,6 @@ export type CodexNativeSubagentTaskMirrorParams = {
export class CodexNativeSubagentTaskMirror {
private readonly mirroredThreadIds = new Set<string>();
private readonly failedMirrorThreadIds = new Set<string>();
private readonly terminalRunIds = new Set<string>();
private readonly now: () => number;
@@ -82,7 +81,7 @@ export class CodexNativeSubagentTaskMirror {
trimOptional(thread.preview) ??
`Codex native subagent${label === "Codex subagent" ? "" : ` ${label}`}`;
const createdAt = secondsToMillis(thread.createdAt) ?? this.now();
const taskRecord = this.runtime.tryCreateRunningTaskRun({
this.runtime.createRunningTaskRun({
sourceId: runId,
agentId: this.params.agentId,
runId,
@@ -95,13 +94,6 @@ export class CodexNativeSubagentTaskMirror {
lastEventAt: this.now(),
progressSummary: "Codex native subagent started.",
});
if (!taskRecord) {
this.mirroredThreadIds.delete(threadId);
this.failedMirrorThreadIds.add(threadId);
return;
}
this.failedMirrorThreadIds.delete(threadId);
this.terminalRunIds.delete(runId);
this.applyStatus(threadId, thread.status);
}
@@ -114,9 +106,6 @@ export class CodexNativeSubagentTaskMirror {
}
private applyStatus(threadId: string, status: CodexThreadStatus | null | undefined): void {
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
return;
}
const statusType = status?.type;
if (!statusType) {
return;
@@ -230,7 +219,7 @@ export class CodexNativeSubagentTaskMirror {
const prompt = trimOptional(readString(item, "prompt"));
const runId = codexNativeSubagentRunId(normalizedThreadId);
const createdAt = this.now();
const taskRecord = this.runtime.tryCreateRunningTaskRun({
this.runtime.createRunningTaskRun({
sourceId: runId,
agentId: this.params.agentId,
runId,
@@ -243,13 +232,6 @@ export class CodexNativeSubagentTaskMirror {
lastEventAt: createdAt,
progressSummary: "Codex native subagent spawned.",
});
if (!taskRecord) {
this.mirroredThreadIds.delete(normalizedThreadId);
this.failedMirrorThreadIds.add(normalizedThreadId);
return;
}
this.failedMirrorThreadIds.delete(normalizedThreadId);
this.terminalRunIds.delete(runId);
}
private applyCollabAgentStatus(
@@ -257,9 +239,6 @@ export class CodexNativeSubagentTaskMirror {
status: string | undefined,
message: string | null | undefined,
): void {
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
return;
}
const normalizedStatus = normalizeAgentStateStatus(status);
if (!normalizedStatus) {
return;

View File

@@ -85,9 +85,7 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
}
await Promise.race([
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
new Promise<void>((resolve) => {
setTimeout(resolve, 5_000);
}),
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
]);
}
@@ -314,7 +312,7 @@ export function createAppServerHarness(
return {
request,
requests,
waitForMethod: async (method: string, timeoutMs: number = appServerHarnessWait.timeout) => {
async waitForMethod(method: string, timeoutMs: number = appServerHarnessWait.timeout) {
await vi.waitFor(
() => {
if (!requests.some((entry) => entry.method === method)) {
@@ -332,15 +330,15 @@ export function createAppServerHarness(
{ interval: 1, timeout: timeoutMs },
);
},
notify: async (notification: CodexServerNotification) => {
async notify(notification: CodexServerNotification) {
await sendNotification(notification);
},
waitForServerRequestHandler,
handleServerRequest: async (requestLocal: Parameters<AppServerRequestHandler>[0]) => {
async handleServerRequest(request: Parameters<AppServerRequestHandler>[0]) {
const handler = await waitForServerRequestHandler();
return handler(requestLocal);
return handler(request);
},
completeTurn: async (params: { threadId: string; turnId: string }) => {
async completeTurn(params: { threadId: string; turnId: string }) {
await sendNotification({
method: "turn/completed",
params: {
@@ -350,7 +348,7 @@ export function createAppServerHarness(
},
});
},
close: () => {
close() {
for (const handler of closeHandlers) {
handler();
}

View File

@@ -334,17 +334,16 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
if (!contextEngine.bootstrap) {
throw new Error("expected bootstrap hook");
}
expect(contextEngine["bootstrap"]).toHaveBeenCalledTimes(1);
const bootstrapParams = requireFirstCallArg(
contextEngine["bootstrap"],
"bootstrap",
) as Parameters<NonNullable<ContextEngine["bootstrap"]>>[0];
expect(contextEngine.bootstrap).toHaveBeenCalledTimes(1);
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
NonNullable<ContextEngine["bootstrap"]>
>[0];
expect(bootstrapParams.sessionId).toBe("session-1");
expect(bootstrapParams.sessionKey).toBe("agent:main:session-1");
expect(bootstrapParams.sessionFile).toBe(sessionFile);
expect(contextEngine["assemble"]).toHaveBeenCalledTimes(1);
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
expect(contextEngine.assemble).toHaveBeenCalledTimes(1);
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.sessionId).toBe("session-1");
@@ -386,13 +385,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
if (!contextEngine.bootstrap) {
throw new Error("expected bootstrap hook");
}
const bootstrapParams = requireFirstCallArg(
contextEngine["bootstrap"],
"bootstrap",
) as Parameters<NonNullable<ContextEngine["bootstrap"]>>[0];
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
NonNullable<ContextEngine["bootstrap"]>
>[0];
expect(bootstrapParams.sessionKey).toBe("agent:main:main");
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.sessionKey).toBe("agent:main:main");
@@ -1642,7 +1640,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await harness.completeTurn();
await run;
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.messages.map((message) => message.role)).toEqual([

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