Compare commits

..

55 Commits

Author SHA1 Message Date
Vincent Koc
54e5105c12 test(plugin-sdk): refresh callable export budget expectation 2026-06-22 19:00:17 +08:00
ly-wang19
b9a7bf83a4 fix(device-pairing): guard role normalization against non-string entries (#93504)
normalizeRoleList in src/shared/device-pairing-access.ts called .trim() on every roles[] entry and the singular role without a typeof === "string" guard, so a malformed/legacy on-disk pairing record (roles/role loaded via blind-cast JSON in coercePairingStateRecord) threw "TypeError: role.trim is not a function" and crashed resolvePendingDeviceApprovalState -- and thus `openclaw devices list`, which calls it per pending request with no try/catch.

Route each item through the shared non-string-safe normalizer normalizeUniqueSingleOrTrimmedStringList, mirroring the #90654/#92178 fix that already guarded the sibling mergeRoles/mergeScopes (src/infra/device-pairing.ts) and the in-file scopes path (normalizeDeviceAuthScopes). Non-string entries are dropped; valid roles are still trimmed, deduped, and sorted. Net -10 LOC.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:56:01 +08:00
Vincent Koc
5c5a8a49d7 fix(matrix): handle missing secret storage facade 2026-06-22 12:53:19 +02:00
Vincent Koc
35be382e56 refactor(msteams): share response release wrapper 2026-06-22 18:50:59 +08:00
Vincent Koc
bdf75474b9 refactor(oc-path): share JSONL line selection 2026-06-22 18:47:24 +08:00
Vincent Koc
f13a10c798 fix(scripts): run gh without terminal formatting 2026-06-22 18:44:21 +08:00
Vincent Koc
2ba9d6eabe refactor(providers): share Qwen chat-template thinking patch 2026-06-22 18:42:40 +08:00
clawsweeper[bot]
6f17c4cc6d fix(doctor): stop promising --fix for working isolated shell-prompt cron jobs (#94655) (#94784)
Summary:
- Merged fix(doctor): stop promising --fix for working isolated shell-prompt cron jobs (#94655) after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(doctor): stop promising --fix for working isolated shell-prompt c…

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

Prepared head SHA: 0d71970a16
Review: https://github.com/openclaw/openclaw/pull/94784#issuecomment-4767423033

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: Altay <altay@hey.com>
Approved-by: altaywtf
2026-06-22 10:42:21 +00:00
chenyangjun-xy
e6f3912347 fix(agents): count message-tool source reply as user-facing reply for tool error warnings (#94072)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-22 10:39:23 +00:00
Vincent Koc
c6d9977902 test(sdk): resolve npm runner in package e2e 2026-06-22 18:28:27 +08:00
Vincent Koc
c67bb1c5aa fix(vercel-ai-gateway): resolve dynamic model selections (#95710)
Merged via squash.

Prepared head SHA: 0f136acbb3
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:26:10 +08:00
Vincent Koc
b8b2f5d98f refactor(gateway): share session event field projection 2026-06-22 18:17:55 +08:00
Vincent Koc
6456790287 refactor(infra): share dotenv file parsing 2026-06-22 18:11:30 +08:00
Vincent Koc
f247ef320a fix(ui): bump dompurify to patched release (#95691)
Merged via squash.

Prepared head SHA: 9658e3a802
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:09:10 +08:00
Marko Milosevic
b08555ef55 fix(gateway): report draining state in readiness (#94915)
Merged via squash.

Prepared head SHA: 0e8d1890c1
Co-authored-by: markoub <2418548+markoub@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:05:58 +08:00
teamclaw
7fe287b0d3 fix(agent-core): stop loop after aborted tool run (#94412)
Merged via squash.

Prepared head SHA: e11d9718e3
Co-authored-by: szsip239 <88223778+szsip239@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:04:50 +08:00
Vincent Koc
f77a74dec7 refactor(channels): share plugin config persistence 2026-06-22 18:03:12 +08:00
Sash Zats
0c1f963532 test: save ~79 CI hours/mo in gateway session utils (#95602)
Merged via squash.

Prepared head SHA: 53574bd4d1
Co-authored-by: zats <2688806+zats@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:03:10 +08:00
Vincent Koc
530658dc29 fix(active-memory): exclude dreaming-narrative session keys from eligibility gate (#95721)
Merged via squash.

Prepared head SHA: fc8717e8f4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 18:01:31 +08:00
Shakker
8cc5b371f1 fix: route config cli env setup 2026-06-22 10:51:51 +01:00
Vincent Koc
afa97a4b10 fix(cli): sync capability inspect metadata flags with registered options (#95719)
Merged via squash.

Prepared head SHA: ef0bf06ee0
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 17:50:58 +08:00
Vincent Koc
d9482063a9 refactor(channels): share supplemental context facts type 2026-06-22 17:49:07 +08:00
Vincent Koc
89eb493d1d fix(whatsapp): remove dead watchdog timeout clamp (#95706)
Merged via squash.

Prepared head SHA: 0b17b1f051
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 17:47:10 +08:00
Vincent Koc
387b5337ec fix(synology-chat): remove duplicate local deliver timeout (#95707)
Merged via squash.

Prepared head SHA: a9860099c9
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 17:45:59 +08:00
Vincent Koc
03a71f3b46 fix(matrix): prevent double bootstrapCrossSigning reset in forced reset (#95720)
Merged via squash.

Prepared head SHA: afa7684e4b
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 02:45:56 -07:00
Jason O'Neal
2220f43f69 fix(ci): increase timeouts in flaky process-group signal test (#95466)
Merged via squash.

Prepared head SHA: 5ebe334a96
Co-authored-by: jason-allen-oneal <8335428+jason-allen-oneal@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 02:44:49 -07:00
wood fish
9ce4c92736 fix(gateway): honor remote status probe timeout (#89859)
Merged via squash.

Prepared head SHA: 056707dbc7
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 02:44:16 -07:00
zhang-guiping
8625b8a92b fix(doctor): prevent non-interactive --fix from auto-restarting gateway (#94148)
Merged via squash.

Prepared head SHA: 60d9d1c242
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 02:43:41 -07:00
Vincent Koc
5571c786d3 refactor(gateway): reuse shared session change broadcaster 2026-06-22 17:40:23 +08:00
jianxing zhang
b9d254f2b0 fix(googlechat): support spaceType field for DM vs Space detection (#58993)
Merged via squash.

Prepared head SHA: 467d289c32
Co-authored-by: Starhappysh <221244539+Starhappysh@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 17:39:25 +08:00
Alberto Gonzalez Trastoy
9f675920bf fix(codex): stream non-final-answer assistant deltas as partials (#95404)
Merged via squash.

Prepared head SHA: 6ab4d9dcf8
Co-authored-by: agonza1 <16296681+agonza1@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 17:38:57 +08:00
Shakker
78b33b86d3 test: route onboard gateway env setup 2026-06-22 10:35:38 +01:00
Vincent Koc
3ca3b97a21 refactor(config): share MCP mutation pipeline 2026-06-22 17:31:38 +08:00
Vincent Koc
ceb69221ec test(scripts): run kitchen sink bash tests on Windows 2026-06-22 17:29:51 +08:00
Shakker
729de383bc fix: track onboard auth state env 2026-06-22 10:28:26 +01:00
Vincent Koc
d1026a3a1a fix(agents): trust load-path harness owners 2026-06-22 17:26:27 +08:00
Vincent Koc
2fbce1c036 fix(agents): canonicalize harness plugin routing 2026-06-22 17:26:27 +08:00
Vincent Koc
741bac9fdf fix(agents): load manifest-owned harnesses 2026-06-22 17:26:27 +08:00
Vincent Koc
8cf0d7dd33 chore(plugin-sdk): refresh API baseline hash 2026-06-22 11:23:02 +02:00
Peter Steinberger
25e2017062 test(config): isolate auto-enable discovery cache case 2026-06-22 05:20:39 -04:00
Vincent Koc
cb301cd16f fix(ci): skip stable closeout without rollback vars 2026-06-22 17:17:36 +08:00
Vincent Koc
d8e6ee04d0 refactor(cli): share root option prefix scanning 2026-06-22 17:17:11 +08:00
Vincent Koc
493e418fa5 test(canvas): isolate A2UI host fixtures 2026-06-22 17:15:40 +08:00
Vincent Koc
4bde68ed38 refactor(transcripts): share rewrite match accounting 2026-06-22 17:09:13 +08:00
Vincent Koc
a289146344 fix(ci): accept matrix node shard timeout 2026-06-22 11:05:34 +02:00
Vincent Koc
95093303c8 chore(deadcode): remove test-only channel helpers 2026-06-22 16:57:03 +08:00
Vincent Koc
607b2e9663 fix(ci): debounce canonical main runner admission (#95681)
Compacts canonical pull request CI to 18 bounded Node jobs, preserves isolated subprocess execution, and delays canonical main runner admission to smooth GitHub runner-registration bursts.

Verification: focused CI planner/workflow tests passed; fresh autoreview clean. Hosted CI had two pre-existing runtime-config failures on the current main baseline; merged with explicit maintainer override.
2026-06-22 16:55:56 +08:00
Vincent Koc
aed6f0a14e test(doctor): mock external channel env vars 2026-06-22 16:50:58 +08:00
Vincent Koc
e20edd753b fix(canvas): guard A2UI asset copy roots 2026-06-22 16:43:08 +08:00
Vincent Koc
a89e65c167 fix(canvas): remove stale A2UI compatibility assets 2026-06-22 16:38:54 +08:00
Vincent Koc
f0afbd7e32 fix(crabbox): preflight macOS Swift toolchain 2026-06-22 16:34:57 +08:00
Vincent Koc
d9a38130b1 chore(deadcode): remove test-only task mutation wrappers 2026-06-22 16:24:57 +08:00
Vincent Koc
f2eca94391 feat(plugins): externalize additional official plugins (#95683) 2026-06-22 16:12:51 +08:00
Vincent Koc
4e9dc6b5d5 fix(skills): harden ClawHub update policy
Pass runtime config into CLI ClawHub skill updates so install policy sees configured safety rules, and update the bundled ClawHub skill docs to prefer openclaw skills for normal skill management. Keeps update-all limited to tracked ClawHub installs and intentionally leaves bundled-skill deprecation, legacy bootstrap, and Sherpa packaging for separate follow-up. Proof: focused ClawHub/CLI tests passed, autoreview clean, remote check:changed passed on Blacksmith Testbox tbx_01kvq0ywztsvw9vdc8zz1xktea; wrapper install/build/check passed, with full local pnpm test failing in unrelated baseline areas already reproduced on latest origin/main.
2026-06-22 16:03:19 +08:00
Vincent Koc
1711d0123c chore(deadcode): remove task registry test-only queries 2026-06-22 15:56:48 +08:00
115 changed files with 2984 additions and 980 deletions

View File

@@ -4,6 +4,14 @@ set -euo pipefail
repo="openclaw/openclaw"
months="12"
include_global="0"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$repo_root" ]; then
repo_root="$(cd "$script_dir/../../../.." && pwd)"
fi
# shellcheck disable=SC1091
source "$repo_root/scripts/lib/plain-gh.sh"
usage() {
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
@@ -18,6 +26,10 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
gh() {
gh_plain "$@"
}
date_utc_relative_months() {
local count="$1"
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
@@ -131,7 +143,8 @@ done
exit 2
}
need gh
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
export OPENCLAW_GH_BIN
need jq
since_ts=$(date_utc_relative_months "$months")

View File

@@ -4,12 +4,12 @@
* Usage: node secret-scanning.mjs <command> [options]
*/
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
}

View File

@@ -5,6 +5,7 @@
*/
import { execFileSync } from "node:child_process";
import process from "node:process";
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
@@ -15,8 +16,9 @@ if (!runId) {
}
function gh(args) {
return execFileSync("gh", args, {
return execFileSync(resolvePlainGhBin(), args, {
encoding: "utf8",
env: plainGhEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
@@ -32,14 +34,15 @@ function githubRestJson(pathSuffix) {
"-lc",
[
"set -euo pipefail",
'token="$(gh auth token)"',
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
].join("\n"),
],
{
encoding: "utf8",
env: {
...process.env,
...plainGhEnv(),
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
},
maxBuffer: 16 * 1024 * 1024,

View File

@@ -244,6 +244,11 @@ jobs:
exit 1
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
exit 1
fi

View File

@@ -1,2 +1,2 @@
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl

View File

@@ -389,7 +389,7 @@ If the homeserver requires UIA to upload cross-signing keys, OpenClaw tries no-a
Useful flags:
- `--recovery-key-stdin` (pair with `printf '%s\n' "$MATRIX_RECOVERY_KEY" | …`) or `--recovery-key <key>`
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only)
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only; requires the active recovery key to be stored or supplied with `--recovery-key-stdin`)
### Room-key backup

View File

@@ -515,7 +515,7 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, and `createPlainTextToolCallCompatWrapper`.
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, `createPlainTextToolCallCompatWrapper`, and `setQwenChatTemplateThinking(...)`.
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with

View File

@@ -164,7 +164,7 @@ and pairing-path families.
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |

View File

@@ -191,10 +191,11 @@ release state.
closeout requires both assets and a matching checksum. A partial manifest
replays its recorded `main` SHA and rollback drill to regenerate identical
bytes, then attaches the missing checksum; an invalid pair, or a checksum
without a manifest, stays blocking. A missing or more-than-90-day-old drill
record blocks a new evidence-backed closeout; private recovery commands
remain in the maintainer-only runbook. Use manual dispatch only to repair or
replay an evidence-backed stable closeout.
without a manifest, stays blocking. A push-triggered run without rollback
drill repository variables skips without completing closeout; a missing or
more-than-90-day-old drill record still blocks manual evidence-backed
closeout. Private recovery commands remain in the maintainer-only runbook.
Use manual dispatch only to repair or replay an evidence-backed stable closeout.
A legacy fallback correction tag may reuse base-package evidence only when
the correction tag resolves to the same source commit as the base stable tag.
A correction with different source must publish and verify its own package

View File

@@ -844,6 +844,79 @@ describe("active-memory plugin", () => {
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("does not run for dreaming-narrative cron session keys", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:dreaming-narrative-light-abc123",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("does not run when a session id resolves to a dreaming-narrative cron session key", async () => {
hoisted.sessionStore["agent:main:dreaming-narrative-light-abc123"] = {
sessionId: "dreaming-session",
updatedAt: 1,
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "dreaming-session",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("allows non-canonical session keys that merely contain the dreaming-narrative substring", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:webchat:dreaming-narrative-room",
messageProvider: "webchat",
},
);
// Real session keys that happen to contain "dreaming-narrative" in a
// non-canonical way (not {light|rem|deep} phase suffix) must remain eligible.
// The session key "agent:main:webchat:dreaming-narrative-room" is a real chat
// whose topic happens to contain the string — not a dreaming cron key.
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
expect(result).not.toBeUndefined();
});
it("allows real webchat session keys whose peer id starts with a phased dreaming-narrative prefix", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:webchat:dreaming-narrative-light-room",
messageProvider: "webchat",
},
);
// A real webchat session key whose peer id begins with a phased dreaming-narrative
// phrase must not be excluded. Only the canonical bare or agent-prefixed key
// shape (dreaming-narrative-<phase>-<hash> directly after agentId or bare) should
// be rejected — not the same phrase appearing deeper in the key as a peer id.
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
expect(result).not.toBeUndefined();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },

View File

@@ -1155,6 +1155,19 @@ function isEligibleInteractiveSession(ctx: {
if (ctx.trigger !== "user") {
return false;
}
// Exclude only canonical dreaming-narrative session keys (bare or agent-prefixed).
// Canonical forms: "dreaming-narrative-<phase>-<hash>" or
// "agent:<agentId>:dreaming-narrative-<phase>-<hash>".
// A colon-delimited match would also exclude real chat session ids whose peer id
// begins with a phased dreaming-narrative phrase (e.g.
// "agent:main:feishu:group:dreaming-narrative-light-room").
const sessionKey = ctx.sessionKey ?? "";
if (
/^dreaming-narrative-(light|rem|deep)-/i.test(sessionKey) ||
/^agent:[^:]+:dreaming-narrative-(light|rem|deep)-/i.test(sessionKey)
) {
return false;
}
if (!ctx.sessionKey && !ctx.sessionId) {
return false;
}
@@ -3617,7 +3630,12 @@ export default definePluginEntry({
});
return undefined;
}
if (!isEligibleInteractiveSession(ctx)) {
if (
!isEligibleInteractiveSession({
...ctx,
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
})
) {
await persistPluginStatusLines({
api,
agentId: effectiveAgentId,

View File

@@ -278,6 +278,11 @@ describe("CodexAppServerEventProjector", () => {
const { onAssistantMessageStart, onPartialReply, projector } =
await createProjectorWithAssistantHooks();
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-1", phase: "final_answer", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("hel"));
await projector.handleNotification(agentMessageDelta("lo"));
await projector.handleNotification(
@@ -305,7 +310,10 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
expect(onPartialReply).not.toHaveBeenCalled();
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
{ text: "hel", delta: "hel" },
{ text: "hello", delta: "lo" },
]);
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
@@ -321,7 +329,13 @@ describe("CodexAppServerEventProjector", () => {
});
it("streams final-answer assistant deltas into partial replies", async () => {
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
@@ -341,6 +355,79 @@ describe("CodexAppServerEventProjector", () => {
{ text: "hel", delta: "hel" },
{ text: "hello", delta: "lo" },
]);
expect(
onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "assistant"),
).toEqual([
{ stream: "assistant", data: { text: "hel", delta: "hel" } },
{ stream: "assistant", data: { text: "hello", delta: "lo" } },
]);
});
it("streams assistant deltas when the app-server omits the item phase", async () => {
// Newer Codex app-servers (>= 0.139) stream agentMessage deltas without a
// "final_answer" phase. These surface on the replaceable agent-event path;
// legacy append-oriented partial callbacks stay quiet.
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const params = await createParams();
const projector = await createProjector({
...params,
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
expect(onPartialReply).not.toHaveBeenCalled();
expect(onAgentEvent.mock.calls.map((call) => call[0])).toEqual([
{ stream: "assistant", data: { text: "hel", delta: "hel", replaceable: true } },
{ stream: "assistant", data: { text: "hello", delta: "lo", replaceable: true } },
]);
});
it("marks partial replacement when an unphased intermediate item is superseded by a final item", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const params = await createParams();
const projector = await createProjector({
...params,
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(agentMessageDelta("coordination ", "msg-intermediate"));
await projector.handleNotification(agentMessageDelta("draft", "msg-intermediate"));
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-final", phase: "final_answer", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("final ", "msg-final"));
await projector.handleNotification(agentMessageDelta("answer", "msg-final"));
expect(onPartialReply).not.toHaveBeenCalled();
expect(
onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "assistant"),
).toEqual([
{
stream: "assistant",
data: { text: "coordination ", delta: "coordination ", replaceable: true },
},
{
stream: "assistant",
data: { text: "coordination draft", delta: "draft", replaceable: true },
},
{
stream: "assistant",
data: { text: "final ", delta: "", replace: true, replaceable: true },
},
{ stream: "assistant", data: { text: "final answer", delta: "answer", replaceable: true } },
]);
});
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
@@ -1041,6 +1128,8 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
// Phase-less snapshots stay on the replaceable agent-event path so legacy
// append-only channel previews do not render superseded coordination text.
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual([
"release fixes first. please drop affected PRs, failing checks, and blockers here.",

View File

@@ -196,6 +196,8 @@ export class CodexAppServerEventProjector {
private assistantStarted = false;
private reasoningStarted = false;
private reasoningEnded = false;
private streamedPartialAssistantItemId: string | undefined;
private streamedPartialAssistantItemReplaceable = false;
private completedTurn: CodexTurn | undefined;
private promptError: unknown;
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
@@ -521,13 +523,46 @@ 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 });
} else {
const knownFinalAnswer = this.shouldStreamAssistantPartial(itemId);
const replace =
this.streamedPartialAssistantItemId !== undefined &&
this.streamedPartialAssistantItemId !== itemId;
// Codex defines final_answer as terminal text. Replacement mode is for
// phase-unknown/provisional items; append-only consumers cannot retract
// bytes after a known terminal answer has started.
if (replace && (!knownFinalAnswer || this.streamedPartialAssistantItemReplaceable)) {
this.streamedPartialAssistantItemReplaceable = true;
} else if (this.streamedPartialAssistantItemId === undefined) {
this.streamedPartialAssistantItemReplaceable = !knownFinalAnswer;
}
this.streamedPartialAssistantItemId = itemId;
const replaceable = this.streamedPartialAssistantItemReplaceable;
const replacement = replace && replaceable;
const streamPayload = {
text,
delta: replacement ? "" : delta,
...(replacement ? { replace: true as const } : {}),
};
this.emitAgentEvent({
stream: "assistant",
data: {
...streamPayload,
...(replaceable ? { replaceable: true as const } : {}),
},
});
// Legacy channel preview callbacks are append-oriented and do not all
// understand replacement snapshots. Keep them on the known final-answer
// path; replaceable snapshots stay on the typed agent-event path.
if (knownFinalAnswer && !replaceable) {
await this.params.onPartialReply?.(streamPayload);
}
}
// 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.
// Stream non-commentary assistant deltas as partial replies and assistant
// agent events so live surfaces (TUI, WebChat) render incremental answer
// text via gateway emitChatDelta. When Codex switches to a new non-commentary
// item, mark replace:true with an empty delta so live merge and append-oriented
// partial consumers reset to the new cumulative text instead of concatenating.
}
private async handleReasoningDelta(

View File

@@ -185,8 +185,14 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
(event) => event.stream === "lifecycle" && event.data.phase === "start",
);
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
expect(assistantEvent?.data).toEqual({ text: "hello back" });
const assistantEvents = agentEvents.filter((event) => event.stream === "assistant");
expect(assistantEvents).toHaveLength(2);
expect(assistantEvents[0]?.data).toEqual({
text: "hello back",
delta: "hello back",
replaceable: true,
});
expect(assistantEvents[1]?.data).toEqual({ text: "hello back" });
const lifecycleEnd = agentEvents.find(
(event) => event.stream === "lifecycle" && event.data.phase === "end",
);
@@ -202,10 +208,16 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(assistantIndex).toBeGreaterThan(startIndex);
expect(endIndex).toBeGreaterThan(assistantIndex);
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");
expect(globalAssistantEvent?.runId).toBe("run-1");
expect(globalAssistantEvent?.sessionKey).toBe("agent:main:session-1");
expect(globalAssistantEvent?.data).toEqual({ text: "hello back" });
const globalAssistantEvents = globalAgentEvents.filter((event) => event.stream === "assistant");
expect(globalAssistantEvents).toHaveLength(2);
expect(globalAssistantEvents[0]?.runId).toBe("run-1");
expect(globalAssistantEvents[0]?.sessionKey).toBe("agent:main:session-1");
expect(globalAssistantEvents[0]?.data).toEqual({
text: "hello back",
delta: "hello back",
replaceable: true,
});
expect(globalAssistantEvents[1]?.data).toEqual({ text: "hello back" });
const globalEndEvent = globalAgentEvents.find(
(event) => event.stream === "lifecycle" && event.data.phase === "end",
);

View File

@@ -2720,6 +2720,8 @@ export async function runCodexAppServerAttempt(
}
turnIdRef.current = turn.turn.id;
const activeTurnId = turn.turn.id;
let assistantStreamEventEmitted = false;
let assistantStreamNeedsTerminalSnapshot = false;
emitExecutionPhaseOnce("turn_accepted", { phase: "turn_accepted" });
userInputBridgeRef.current = createCodexUserInputBridge({
paramsForRun: params,
@@ -2734,7 +2736,16 @@ export async function runCodexAppServerAttempt(
imagesCount: params.images?.length ?? 0,
});
projectorRef.current = new CodexAppServerEventProjector(
dynamicToolParams,
{
...dynamicToolParams,
onAgentEvent: (event) => {
if (event.stream === "assistant" && typeof event.data.delta === "string") {
assistantStreamEventEmitted = true;
assistantStreamNeedsTerminalSnapshot ||= event.data.replaceable === true;
}
return dynamicToolParams.onAgentEvent?.(event);
},
},
thread.threadId,
activeTurnId,
{
@@ -3002,7 +3013,12 @@ export async function runCodexAppServerAttempt(
turnId: activeTurnId,
});
const terminalAssistantText = collectTerminalAssistantText(result);
if (terminalAssistantText && !finalAborted && !finalPromptError) {
if (
terminalAssistantText &&
(!assistantStreamEventEmitted || assistantStreamNeedsTerminalSnapshot) &&
!finalAborted &&
!finalPromptError
) {
void emitCodexAppServerEvent(params, {
stream: "assistant",
data: { text: terminalAssistantText },

View File

@@ -30,6 +30,34 @@ beforeEach(() => {
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
});
function createInboundClassificationHarness() {
const resolveAgentRoute = vi.fn(() => ({
agentId: "agent-1",
accountId: "work",
sessionKey: "session-1",
}));
const buildContext = vi.fn((payload: unknown) => payload);
const runTurn = vi.fn();
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
routing: { resolveAgentRoute },
session: {
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
readSessionUpdatedAt: () => undefined,
recordInboundSession: vi.fn(),
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
inbound: { buildContext, run: runTurn },
},
} as unknown as GoogleChatCoreRuntime;
return { buildContext, core, resolveAgentRoute, runTurn };
}
describe("googlechat monitor bot loop protection", () => {
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
expect(
@@ -159,6 +187,74 @@ describe("googlechat monitor bot loop protection", () => {
});
});
describe("googlechat monitor inbound space classification", () => {
const cases = [
{ name: "legacy DM", space: { type: "DM" }, peerKind: "direct" },
{ name: "modern direct message", space: { spaceType: "DIRECT_MESSAGE" }, peerKind: "direct" },
{ name: "single-user bot DM", space: { singleUserBotDm: true }, peerKind: "direct" },
{ name: "modern space", space: { spaceType: "SPACE" }, peerKind: "group" },
{ name: "modern group chat", space: { spaceType: "GROUP_CHAT" }, peerKind: "group" },
{
name: "modern space over legacy DM",
space: { type: "DM", spaceType: "SPACE" },
peerKind: "group",
},
] as const;
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
const account = {
accountId: "work",
config: {},
credentialSource: "inline",
} as ResolvedGoogleChatAccount;
const event = {
type: "MESSAGE",
space: { name: "spaces/CLASSIFY", ...space },
message: {
name: "spaces/CLASSIFY/messages/1",
text: "hello",
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
},
} satisfies GoogleChatEvent;
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
ok: true,
commandAuthorized: undefined,
effectiveWasMentioned: undefined,
groupBotLoopProtection: undefined,
groupSystemPrompt: undefined,
});
await testing.processMessageWithPipeline({
event,
account,
config: {},
runtime: { error: vi.fn(), log: vi.fn() },
core,
mediaMaxMb: 10,
});
const isGroup = peerKind === "group";
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
expect.objectContaining({ isGroup }),
);
expect(resolveAgentRoute).toHaveBeenCalledWith({
cfg: {},
channel: "googlechat",
accountId: "work",
peer: { kind: peerKind, id: "spaces/CLASSIFY" },
});
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({
conversation: expect.objectContaining({ kind: isGroup ? "channel" : "direct" }),
extra: expect.objectContaining({ ChatType: isGroup ? "channel" : "direct" }),
}),
);
expect(runTurn).toHaveBeenCalledOnce();
});
});
describe("googlechat monitor direct messages", () => {
it("creates typing messages by default", async () => {
const runTurn = vi.fn();

View File

@@ -29,7 +29,7 @@ import type {
} from "./monitor-types.js";
import { warnAppPrincipalMisconfiguration } from "./monitor-webhook.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
import type { GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace } from "./types.js";
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
@@ -62,6 +62,20 @@ function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
function isGoogleChatGroupSpace(space: GoogleChatSpace): boolean {
const spaceType = (space.spaceType ?? "").toUpperCase();
// Google Chat deprecates `type` in favor of `spaceType`; known modern
// values must win if the fields disagree. Fall back to the bot-DM flag and
// legacy type so incomplete payloads retain their existing direct routing.
if (spaceType === "DIRECT_MESSAGE") {
return false;
}
if (spaceType === "SPACE" || spaceType === "GROUP_CHAT") {
return true;
}
return space.singleUserBotDm !== true && (space.type ?? "").toUpperCase() !== "DM";
}
function resolveGoogleChatBotLoopProtection(params: {
allowBots: boolean;
isBotSender: boolean;
@@ -186,8 +200,7 @@ async function processMessageWithPipeline(params: {
if (!spaceId) {
return;
}
const spaceType = (space.type ?? "").toUpperCase();
const isGroup = spaceType !== "DM";
const isGroup = isGoogleChatGroupSpace(space);
const sender = message.sender ?? event.user;
const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? "";

View File

@@ -3,6 +3,10 @@ export type GoogleChatSpace = {
name?: string;
displayName?: string;
type?: string;
/** Current Google Chat field that replaces the deprecated `type` field. */
spaceType?: string;
/** True when the space is a 1:1 DM between a user and the Chat app. */
singleUserBotDm?: boolean;
};
export type GoogleChatUser = {

View File

@@ -1647,7 +1647,10 @@ export function registerMatrixCli(params: { program: Command }): void {
.description("Enable Matrix E2EE, bootstrap verification, and print next steps")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option(
"--force-reset-cross-signing",
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
)
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
@@ -2121,7 +2124,10 @@ export function registerMatrixCli(params: { program: Command }): void {
"Recovery key to apply before bootstrap (prefer --recovery-key-stdin)",
)
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option(
"--force-reset-cross-signing",
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
)
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(

View File

@@ -1645,6 +1645,54 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
});
it("rejects recovery keys when secret-storage metadata cannot authenticate them", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const checkKey = vi.fn(async () => true);
Object.assign(matrixJsClient, {
secretStorage: {
getDefaultKeyId: vi.fn(async () => "SSSSKEY"),
getKey: vi.fn(async () => [
"SSSSKEY",
{
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv: "authenticated-iv",
},
]),
checkKey,
},
});
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
const bootstrapper = (
client as unknown as {
cryptoBootstrapper: {
deps: { canUnlockSecretStorage: () => Promise<boolean> };
};
}
).cryptoBootstrapper;
await expect(bootstrapper.deps.canUnlockSecretStorage()).resolves.toBe(false);
expect(checkKey).not.toHaveBeenCalled();
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");

View File

@@ -484,6 +484,39 @@ export class MatrixClient {
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => this.password,
canUnlockSecretStorage: async () => {
const secretStorage = (
this.client as {
secretStorage?: Partial<
Pick<MatrixJsClient["secretStorage"], "checkKey" | "getDefaultKeyId" | "getKey">
>;
}
).secretStorage;
// Partial test/runtime facades can omit secretStorage; forced reset must fail closed
// without turning missing recovery access into a noisy caught TypeError.
if (
!secretStorage ||
typeof secretStorage.getDefaultKeyId !== "function" ||
typeof secretStorage.getKey !== "function" ||
typeof secretStorage.checkKey !== "function"
) {
return false;
}
const defaultKeyId = await secretStorage.getDefaultKeyId();
if (!defaultKeyId) {
return false;
}
const keyTuple = await secretStorage.getKey(defaultKeyId);
const key = this.recoveryKeyStore.getSecretStorageKeyCandidate(defaultKeyId);
if (!keyTuple || !key) {
return false;
}
const keyInfo = keyTuple[1];
if (!keyInfo.iv?.trim() || !keyInfo.mac?.trim()) {
return false;
}
return await secretStorage.checkKey(key, keyInfo);
},
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
@@ -747,13 +780,9 @@ export class MatrixClient {
"Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.",
);
} else {
// No password guard: passwordless token-auth bots should still attempt repair.
// UIA failures inside bootstrap() are caught below and logged as warnings.
// Forced reset validates the active SSSS recovery key before rotating local keys.
// Missing or stale recovery material fails without mutating crypto state.
try {
// The repair path already force-resets cross-signing; allow secret storage
// recreation so the new keys can be persisted. Without this, a device that
// lost its recovery key enters a permanent failure loop because the new
// cross-signing keys have nowhere to be stored.
const repaired = await cryptoBootstrapper.bootstrap(
crypto,
MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS,

View File

@@ -1,6 +1,7 @@
// Matrix tests cover crypto bootstrap plugin behavior.
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
type BootstrapCrossSigningMock = Mock<MatrixCryptoBootstrapApi["bootstrapCrossSigning"]>;
@@ -39,12 +40,15 @@ function createBootstrapperDeps() {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getPassword: vi.fn<() => string | undefined>(() => "super-secret-password"),
canUnlockSecretStorage: vi.fn(async () => true),
getDeviceId: vi.fn(() => "DEVICE123"),
verificationManager: {
trackVerificationRequest: vi.fn(),
},
recoveryKeyStore: {
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
bootstrapSecretStorageWithRecoveryKey: vi.fn<
MatrixRecoveryKeyStore["bootstrapSecretStorageWithRecoveryKey"]
>(async () => {}),
},
decryptBridge: {
bindCryptoRetrySignals: vi.fn(),
@@ -132,17 +136,6 @@ function createForcedResetHarness(bootstrapCrossSigning: BootstrapCrossSigningMo
});
}
function expectForcedResetCrossSigningCalls(
bootstrapCrossSigning: BootstrapCrossSigningMock,
params: { setupNewCall: number; totalCalls: number },
) {
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(params.totalCalls);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.setupNewCall, {
setupNewCrossSigning: true,
});
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.totalCalls);
}
async function bootstrapWithVerificationRequestListener(overrides?: {
deps?: Partial<ReturnType<typeof createBootstrapperDeps>>;
crypto?: Partial<MatrixCryptoBootstrapApi>;
@@ -406,32 +399,72 @@ describe("MatrixCryptoBootstrapper", () => {
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
});
it("recreates secret storage and retries a forced reset when stale server SSSS blocks it", async () => {
it("rejects forced reset before mutation when the active recovery key is unavailable", async () => {
const deps = createBootstrapperDeps();
deps.canUnlockSecretStorage = vi.fn(async () => false);
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({ bootstrapCrossSigning });
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(
bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
).rejects.toThrow(
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
});
it("fails closed without recreating SSSS when forced reset cannot unlock it", async () => {
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"))
.mockResolvedValueOnce(undefined);
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"));
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
await bootstrapper.bootstrap(crypto, {
strict: true,
await expect(
bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
).rejects.toThrow(
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
});
it("does not repair SSSS after a non-strict forced reset failure", async () => {
const bootstrapCrossSigning = vi.fn(async () => {
throw new Error("getSecretStorageKey callback returned falsey");
});
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
const result = await bootstrapper.bootstrap(crypto, {
strict: false,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
},
);
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
setupNewCall: 2,
totalCalls: 3,
expect(result).toEqual({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: null,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledOnce();
});
it("re-exports cross-signing keys after forced reset creates secret storage", async () => {
@@ -444,16 +477,16 @@ describe("MatrixCryptoBootstrapper", () => {
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledOnce();
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
expect.objectContaining({
allowSecretStorageRecreateWithoutRecoveryKey: true,
},
}),
);
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
setupNewCall: 1,
totalCalls: 2,
});
// No redundant second bootstrapCrossSigning call — no double reset (gh-78396).
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
});
it("trusts the fresh own identity after a forced cross-signing reset", async () => {

View File

@@ -20,6 +20,7 @@ import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
canUnlockSecretStorage: () => Promise<boolean>;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
@@ -51,11 +52,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
const deferSecretStorageBootstrapUntilAfterCrossSigning =
options.forceResetCrossSigning === true;
const forceReset = options.forceResetCrossSigning === true;
const deferSecretStorageBootstrapUntilAfterCrossSigning = forceReset;
if (forceReset && !(await this.deps.canUnlockSecretStorage())) {
throw new Error(
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
);
}
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
if (!deferSecretStorageBootstrapUntilAfterCrossSigning) {
await this.bootstrapSecretStorage(crypto, {
strict,
@@ -63,30 +70,33 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
}
let crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: forceReset,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
// A repair retry would generate another identity after the SDK already rotated local keys.
// Fail closed instead; the server identity and existing recovery material remain authoritative.
allowSecretStorageRecreateWithoutRecoveryKey: forceReset
? false
: options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
// Forced repair may need password UIA to upload new cross-signing keys. Delay any
// secret-storage repair/recreation until after that step succeeds so passwordless bots do
// not partially mutate SSSS on homeservers that require password-based UIA.
if (forceReset && (!crossSigning.ready || !crossSigning.published)) {
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
ownDeviceVerified: null,
};
}
// Second SSSS pass to pick up cross-signing keys published during bootstrap.
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
if (deferSecretStorageBootstrapUntilAfterCrossSigning) {
crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: false,
allowAutomaticCrossSigningReset: false,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
}
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
strict,
});
@@ -234,6 +244,12 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
}
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
if (isRepairableSecretStorageAccessError(err)) {
throw new Error(
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
{ cause: err },
);
}
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };

View File

@@ -148,6 +148,7 @@ describe("MatrixRecoveryKeyStore", () => {
expect(fs.existsSync(recoveryKeyPath)).toBe(false);
expect(fs.existsSync(`${recoveryKeyPath}.migrated`)).toBe(true);
const callbacks = store.buildCryptoCallbacks();
expect(store.getSecretStorageKeyCandidate("SSSS")).toEqual(new Uint8Array([1, 2, 3, 4]));
const resolved = await callbacks.getSecretStorageKey?.(
{ keys: { SSSS: { name: "test" } } },
"m.cross_signing.master",
@@ -155,6 +156,12 @@ describe("MatrixRecoveryKeyStore", () => {
expect(resolved?.[0]).toBe("SSSS");
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
const resolvedFromMultipleKeys = await callbacks.getSecretStorageKey?.(
{ keys: { OLD: { name: "old" }, SSSS: { name: "active" } } },
"m.cross_signing.master",
);
expect(resolvedFromMultipleKeys?.[0]).toBe("SSSS");
});
it("keeps a readable legacy recovery key usable when SQLite migration fails", async () => {
@@ -233,6 +240,15 @@ describe("MatrixRecoveryKeyStore", () => {
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
});
it("does not authorize destructive reset from an ephemeral cached key", () => {
const store = new MatrixRecoveryKeyStore();
const callbacks = store.buildCryptoCallbacks();
callbacks.cacheSecretStorageKey?.("KEY123", { name: "openclaw" }, new Uint8Array([9, 8, 7]));
expect(store.getSecretStorageKeyCandidate("KEY123")).toBeNull();
});
it("creates and persists a recovery key when secret storage is missing", async () => {
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({

View File

@@ -135,6 +135,27 @@ export class MatrixRecoveryKeyStore {
};
}
getSecretStorageKeyCandidate(keyId: string): Uint8Array | null {
const normalizedKeyId = keyId.trim();
if (!normalizedKeyId) {
return null;
}
const staged = this.resolveStagedSecretStorageKey([normalizedKeyId]);
if (staged) {
return staged[1];
}
const stored = this.loadStoredRecoveryKey();
if (!stored?.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
if (privateKey.length === 0) {
return null;
}
this.rememberSecretStorageKey(normalizedKeyId, privateKey, stored.keyInfo);
return privateKey;
}
private resolveEncodedRecoveryKeyInput(params: {
encodedPrivateKey: string;
keyId?: string | null;

View File

@@ -14,6 +14,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { responseWithRelease } from "../response-with-release.js";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -576,52 +577,6 @@ export async function resolveAndValidateIP(
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (err) {
await releaseOnce();
throw err;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => {});
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).

View File

@@ -4,13 +4,13 @@ import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import { createMSTeamsHttpError } from "./http-error.js";
import { responseWithRelease } from "./response-with-release.js";
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
import { buildUserAgent } from "./user-agent.js";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export type GraphUser = {
id?: string;
@@ -31,50 +31,6 @@ type GraphChannel = {
export type GraphResponse<T> = { value?: T[] };
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}

View File

@@ -0,0 +1,45 @@
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

View File

@@ -11,6 +11,7 @@ import { isMap, isScalar, isSeq, type Node, type Pair } from "yaml";
import type { MdAst } from "./ast.js";
import type { JsoncValue } from "./jsonc/ast.js";
import type { JsonlAst, JsonlLine } from "./jsonl/ast.js";
import { pickJsonlLine } from "./jsonl/line.js";
import type { OcPath } from "./oc-path.js";
import {
MAX_TRAVERSAL_DEPTH,
@@ -364,7 +365,7 @@ const jsonlOps: WalkOps<JsonlAst> = {
}
},
lookup(ast, key) {
const line = pickLine(ast, key);
const line = pickJsonlLine(ast, key);
if (line === null) {
return null;
}
@@ -440,37 +441,6 @@ function topLevelLeafText(value: JsoncValue, key: string): string | null {
return null;
}
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === "$first") {
for (const l of ast.lines) {
if (l.kind === "value") {
return l;
}
}
return null;
}
if (addr === "$last") {
for (let i = ast.lines.length - 1; i >= 0; i--) {
const l = ast.lines[i];
if (l !== undefined && l.kind === "value") {
return l;
}
}
return null;
}
const m = /^L(\d+)$/.exec(addr);
if (m === null || m[1] === undefined) {
return null;
}
const target = Number(m[1]);
for (const l of ast.lines) {
if (l.line === target) {
return l;
}
}
return null;
}
// ---------- YAML walker ----------------------------------------------------
function walkYaml(

View File

@@ -0,0 +1,33 @@
import { POS_FIRST, POS_LAST } from "../oc-path.js";
import type { JsonlAst, JsonlLine } from "./ast.js";
export function pickJsonlLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === POS_FIRST) {
for (const line of ast.lines) {
if (line.kind === "value") {
return line;
}
}
return null;
}
if (addr === POS_LAST) {
for (let index = ast.lines.length - 1; index >= 0; index -= 1) {
const line = ast.lines[index];
if (line !== undefined && line.kind === "value") {
return line;
}
}
return null;
}
const match = /^L(\d+)$/.exec(addr);
if (match === null || match[1] === undefined) {
return null;
}
const target = Number(match[1]);
for (const line of ast.lines) {
if (line.line === target) {
return line;
}
}
return null;
}

View File

@@ -18,14 +18,9 @@
import type { JsoncEntry, JsoncValue } from "../jsonc/ast.js";
import { resolveJsoncValueOcPath } from "../jsonc/resolve-value.js";
import type { OcPath } from "../oc-path.js";
import {
POS_FIRST,
POS_LAST,
isQuotedSeg,
splitRespectingBrackets,
unquoteSeg,
} from "../oc-path.js";
import { isQuotedSeg, splitRespectingBrackets, unquoteSeg } from "../oc-path.js";
import type { JsonlAst, JsonlLine } from "./ast.js";
import { pickJsonlLine } from "./line.js";
export type JsonlOcPathMatch =
| { readonly kind: "root"; readonly node: JsonlAst }
@@ -50,7 +45,7 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
return { kind: "root", node: ast };
}
const lineEntry = pickLine(ast, head);
const lineEntry = pickJsonlLine(ast, head);
if (lineEntry === null) {
return null;
}
@@ -90,34 +85,3 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
}
return { kind: "value", node: match.node, line: lineEntry.line, path: match.path };
}
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === POS_FIRST) {
for (const l of ast.lines) {
if (l.kind === "value") {
return l;
}
}
return null;
}
if (addr === POS_LAST) {
for (let i = ast.lines.length - 1; i >= 0; i--) {
const l = ast.lines[i];
if (l !== undefined && l.kind === "value") {
return l;
}
}
return null;
}
const m = /^L(\d+)$/.exec(addr);
if (m === null || m[1] === undefined) {
return null;
}
const target = Number(m[1]);
for (const l of ast.lines) {
if (l.line === target) {
return l;
}
}
return null;
}

View File

@@ -1536,6 +1536,8 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
): Promise<MatrixQaScenarioExecution> {
requireMatrixQaPassword(context, "driver");
return await withMatrixQaE2eeDriver(context, "matrix-e2ee-bootstrap-success", async (client) => {
const initial = await client.bootstrapOwnDeviceVerification();
assertMatrixQaBootstrapSucceeded("driver initial", initial);
const result = await client.bootstrapOwnDeviceVerification({
forceResetCrossSigning: true,
});
@@ -1550,7 +1552,7 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
recoveryKeyStored: result.verification.recoveryKeyStored,
},
details: [
"driver bootstrap succeeded through real Matrix crypto bootstrap",
"driver bootstrap and guarded cross-signing reset succeeded through real Matrix crypto bootstrap",
`device verified: ${result.verification.verified ? "yes" : "no"}`,
`cross-signing verified: ${result.verification.crossSigningVerified ? "yes" : "no"}`,
`signed by owner: ${result.verification.signedByOwner ? "yes" : "no"}`,

View File

@@ -5,6 +5,7 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
createPayloadPatchStreamWrapper,
isOpenAICompatibleThinkingEnabled,
setQwenChatTemplateThinking,
} from "openclaw/plugin-sdk/provider-stream-shared";
type QwenThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"];
@@ -68,25 +69,6 @@ function patchQwenOAuthPayload(payload: Record<string, unknown>): void {
payload.vl_high_resolution_images = true;
}
function setQwenChatTemplateThinking(payload: Record<string, unknown>, enabled: boolean): void {
const existing = payload.chat_template_kwargs;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const next: Record<string, unknown> = {
...(existing as Record<string, unknown>),
enable_thinking: enabled,
};
if (!Object.hasOwn(next, "preserve_thinking")) {
next.preserve_thinking = true;
}
payload.chat_template_kwargs = next;
return;
}
payload.chat_template_kwargs = {
enable_thinking: enabled,
preserve_thinking: true,
};
}
function readQwenThinkingFormatFromModel(model: Parameters<StreamFn>[0]): QwenThinkingFormat {
if (model.api !== "openai-completions") {
return undefined;

View File

@@ -619,6 +619,37 @@ describe("createWebhookHandler", () => {
expectBotReplySentTo("123");
});
it("awaits deliver directly with no local hardcoded timeout wrapper", async () => {
// Previously this webhook handler wrapped deliver with a hardcoded 120s
// Promise.race that overrode the configurable agents.defaults.timeoutSeconds
// from core. That wrapper created a setTimeout(_, 120000) on every deliver
// call. We spy on setTimeout to prove no such call exists in the current code.
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
try {
const deliver = vi.fn().mockResolvedValue("late reply");
const handler = createWebhookHandler({
account: makeAccount({ accountId: "no-hardcoded-timeout-" + Date.now() }),
deliver,
log,
});
const res = makeRes();
const req = makeReq("POST", validBody);
await handler(req, res);
expect(res.status).toBe(204);
// Collect all setTimeout delays used during this handler run
const delays = setTimeoutSpy.mock.calls.map((call) => call[1]);
// Every delay should be well under 120s — the old hardcoded wrapper would
// have produced exactly one call with delay === 120000.
const longDelays = delays.filter((d) => typeof d === "number" && d >= 120_000);
expect(longDelays).toEqual([]);
} finally {
setTimeoutSpy.mockRestore();
}
});
it("sanitizes input before delivery", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({

View File

@@ -554,7 +554,7 @@ async function processAuthorizedSynologyWebhook(params: {
log: params.log,
});
const deliverPromise = params.deliver({
const reply = await params.deliver({
body: params.message.body,
from: authorizedWebhookUserId,
senderName: params.message.payload.username,
@@ -564,10 +564,6 @@ async function processAuthorizedSynologyWebhook(params: {
commandAuthorized: params.message.commandAuthorized,
chatUserId: deliveryUserId,
});
const timeoutPromise = new Promise<null>((_, reject) => {
setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000);
});
const reply = await Promise.race([deliverPromise, timeoutPromise]);
if (!reply) {
return;
}

View File

@@ -0,0 +1,22 @@
// Vercel Ai Gateway tests cover provider runtime hooks.
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("vercel ai gateway provider hooks", () => {
it("resolves live-only model ids for the embedded runner", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const model = provider.resolveDynamicModel?.({
provider: "vercel-ai-gateway",
modelId: "custom/provider-model",
modelRegistry: { find: () => null },
} as never);
expect(model).toMatchObject({
id: "custom/provider-model",
provider: "vercel-ai-gateway",
api: "anthropic-messages",
baseUrl: "https://ai-gateway.vercel.sh",
});
});
});

View File

@@ -4,6 +4,7 @@ import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from
import {
buildStaticVercelAiGatewayProvider,
buildVercelAiGatewayProvider,
resolveVercelAiGatewayModel,
} from "./provider-catalog.js";
import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js";
@@ -37,6 +38,7 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildVercelAiGatewayProvider,
buildStaticProvider: buildStaticVercelAiGatewayProvider,
},
resolveDynamicModel: ({ modelId }) => resolveVercelAiGatewayModel(modelId),
resolveThinkingProfile: ({ modelId }) => resolveVercelAiGatewayThinkingProfile(modelId),
},
});

View File

@@ -142,6 +142,21 @@ function getStaticFallbackModel(id: string): ModelDefinitionConfig | undefined {
return fallback ? buildStaticModelDefinition(fallback) : undefined;
}
/** Builds runtime metadata for models returned by the live gateway catalog. */
export function resolveVercelAiGatewayDynamicModel(modelId: string): ModelDefinitionConfig {
return (
getStaticFallbackModel(modelId) ?? {
id: modelId,
name: modelId,
reasoning: false,
input: ["text"],
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
cost: VERCEL_AI_GATEWAY_DEFAULT_COST,
}
);
}
export function getStaticVercelAiGatewayModelCatalog(): ModelDefinitionConfig[] {
return STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG.map(buildStaticModelDefinition);
}

View File

@@ -20,9 +20,11 @@ import {
VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
} from "./api.js";
import { resolveVercelAiGatewayDynamicModel } from "./models.js";
import {
buildStaticVercelAiGatewayProvider,
buildVercelAiGatewayProvider,
resolveVercelAiGatewayModel,
} from "./provider-catalog.js";
const STATIC_MODEL_IDS = [
@@ -83,6 +85,42 @@ describe("vercel ai gateway provider catalog", () => {
});
});
it("builds runtime metadata for live-only model ids", () => {
expect(resolveVercelAiGatewayDynamicModel("custom/provider-model")).toEqual({
id: "custom/provider-model",
name: "custom/provider-model",
reasoning: false,
input: ["text"],
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
});
});
it("adds transport metadata for runtime model resolution", () => {
expect(resolveVercelAiGatewayModel("custom/provider-model")).toMatchObject({
id: "custom/provider-model",
provider: "vercel-ai-gateway",
api: "anthropic-messages",
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
});
});
it("preserves provider thinking metadata for known live-only upstream models", () => {
expect(resolveVercelAiGatewayModel("openai/gpt-5.5")).toMatchObject({
reasoning: true,
input: ["text", "image"],
});
expect(resolveVercelAiGatewayModel("anthropic/claude-sonnet-4-6")).toMatchObject({
input: ["text", "image"],
});
});
it("falls back to the static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
clearLiveCatalogCacheForTests();

View File

@@ -3,8 +3,43 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-sha
import {
discoverVercelAiGatewayModels,
getStaticVercelAiGatewayModelCatalog,
resolveVercelAiGatewayDynamicModel,
VERCEL_AI_GATEWAY_BASE_URL,
VERCEL_AI_GATEWAY_PROVIDER_ID,
} from "./models.js";
import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js";
const VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS = new Set([
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.3-codex",
"openai/gpt-5.3-codex-spark",
"openai/gpt-5.2",
"openai/gpt-5.2-codex",
"openai/gpt-5.1-codex",
]);
export function resolveVercelAiGatewayModel(modelId: string) {
const model = resolveVercelAiGatewayDynamicModel(modelId);
const input: Array<"text" | "image"> = model.input.includes("image")
? ["text", "image"]
: VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS.has(modelId) ||
/^anthropic\/claude-(?:opus|sonnet|haiku)-/.test(modelId)
? ["text", "image"]
: ["text"];
return {
...model,
reasoning: model.reasoning || Boolean(resolveVercelAiGatewayThinkingProfile(modelId)),
input,
api: "anthropic-messages" as const,
provider: VERCEL_AI_GATEWAY_PROVIDER_ID,
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
};
}
export function buildStaticVercelAiGatewayProvider(): ModelProviderConfig {
return {

View File

@@ -5,6 +5,7 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
createPayloadPatchStreamWrapper,
isOpenAICompatibleThinkingEnabled,
setQwenChatTemplateThinking,
} from "openclaw/plugin-sdk/provider-stream-shared";
import {
resolveVllmQwenThinkingFormatFromCompat,
@@ -23,25 +24,6 @@ function resolveVllmQwenThinkingFormat(
return resolveVllmQwenThinkingFormatFromCompat(ctx.model?.compat);
}
function setQwenChatTemplateThinking(payload: Record<string, unknown>, enabled: boolean): void {
const existing = payload.chat_template_kwargs;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const next: Record<string, unknown> = {
...(existing as Record<string, unknown>),
enable_thinking: enabled,
};
if (!Object.hasOwn(next, "preserve_thinking")) {
next.preserve_thinking = true;
}
payload.chat_template_kwargs = next;
return;
}
payload.chat_template_kwargs = {
enable_thinking: enabled,
preserve_thinking: true,
};
}
function isVllmNemotronModel(model: { api?: unknown; provider?: unknown; id?: unknown }): boolean {
return (
model.api === "openai-completions" &&

View File

@@ -669,4 +669,52 @@ describe("WhatsAppConnectionController", () => {
vi.useRealTimers();
}
});
it("uses messageTimeoutMs * 4 as the app-silence window for fresh connections with no inbound", async () => {
// Verifies the watchdog respects appSilenceTimeoutMs = messageTimeoutMs * 4 on first open.
// Transport is kept well within its own timeout so only app-silence fires.
vi.useFakeTimers();
const msgTimeoutMs = 100;
const controllerLocal = new WhatsAppConnectionController({
accountId: "work",
authDir: "/tmp/wa-auth",
verbose: false,
keepAlive: true,
heartbeatSeconds: 1,
transportTimeoutMs: 10_000,
messageTimeoutMs: msgTimeoutMs,
watchdogCheckMs: 10,
reconnectPolicy: {
initialMs: 250,
maxMs: 1_000,
factor: 2,
jitter: 0,
maxAttempts: 5,
},
});
try {
const sock = createSocketWithTransportEmitter();
createWaSocketMock.mockResolvedValueOnce(sock as never);
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
const timeouts: string[] = [];
await controllerLocal.openConnection({
connectionId: "conn-app-silence",
createListener: async () => createListenerStub() as never,
onWatchdogTimeout: () => timeouts.push("timeout"),
});
// Just before messageTimeoutMs * 4 — no force-close expected
await vi.advanceTimersByTimeAsync(msgTimeoutMs * 4 - 20);
expect(timeouts).toHaveLength(0);
// Past messageTimeoutMs * 4 — force-close must fire
await vi.advanceTimersByTimeAsync(40);
expect(timeouts.length).toBeGreaterThanOrEqual(1);
} finally {
await controllerLocal.shutdown();
vi.useRealTimers();
}
});
});

View File

@@ -404,7 +404,7 @@ export class WhatsAppConnectionController {
this.heartbeatSeconds = params.heartbeatSeconds;
this.transportTimeoutMs = params.transportTimeoutMs;
this.messageTimeoutMs = params.messageTimeoutMs;
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
this.appSilenceTimeoutMs = params.messageTimeoutMs * 4;
this.watchdogCheckMs = params.watchdogCheckMs;
this.reconnectPolicy = params.reconnectPolicy;
this.abortSignal = params.abortSignal;

View File

@@ -767,6 +767,154 @@ describe("agentLoop tool termination", () => {
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
it("does not request another model turn after a tool aborts the run", async () => {
const controller = new AbortController();
let streamCalls = 0;
const streamFn: StreamFn = () => {
streamCalls += 1;
if (streamCalls > 1) {
throw new Error("model was called after abort");
}
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message = makeAssistantMessage([
{ type: "toolCall", id: "call-abort", name: "abort_tool", arguments: {} },
]);
stream.push({ type: "done", reason: "toolUse", message });
stream.end();
});
return stream;
};
const abortTool: AgentTool = {
name: "abort_tool",
label: "abort_tool",
description: "Abort the active run",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
controller.abort(new Error("user aborted"));
return {
content: [{ type: "text", text: "aborted" }],
details: { aborted: true },
};
},
};
const events: AgentEvent[] = [];
const messages = await runAgentLoop(
[{ role: "user", content: "abort during tool", timestamp: 1 }],
{
systemPrompt: "",
messages: [],
tools: [abortTool],
},
config,
(event) => {
events.push(event);
},
controller.signal,
streamFn,
);
expect(streamCalls).toBe(1);
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"assistant",
]);
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
expect(events.map((event) => event.type)).toEqual([
"agent_start",
"turn_start",
"message_start",
"message_end",
"message_start",
"message_end",
"tool_execution_start",
"tool_execution_end",
"message_start",
"message_end",
"turn_end",
"turn_start",
"message_start",
"message_end",
"turn_end",
"agent_end",
]);
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
it("does not request another model turn when an async turn hook aborts the run", async () => {
const controller = new AbortController();
let streamCalls = 0;
const streamFn: StreamFn = () => {
streamCalls += 1;
if (streamCalls > 1) {
throw new Error("model was called after abort");
}
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message = makeAssistantMessage([
{ type: "toolCall", id: "call-hook-abort", name: "hook_abort", arguments: {} },
]);
stream.push({ type: "done", reason: "toolUse", message });
stream.end();
});
return stream;
};
const events: AgentEvent[] = [];
const messages = await runAgentLoop(
[{ role: "user", content: "abort from hook", timestamp: 1 }],
{
systemPrompt: "",
messages: [],
tools: [makeTool("hook_abort", [])],
},
{
...config,
prepareNextTurn: async () => {
await Promise.resolve();
controller.abort(new Error("user aborted"));
return undefined;
},
},
(event) => {
events.push(event);
},
controller.signal,
streamFn,
);
expect(streamCalls).toBe(1);
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"assistant",
]);
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
expect(events.map((event) => event.type)).toEqual([
"agent_start",
"turn_start",
"message_start",
"message_end",
"message_start",
"message_end",
"tool_execution_start",
"tool_execution_end",
"message_start",
"message_end",
"turn_end",
"turn_start",
"message_start",
"message_end",
"turn_end",
"agent_end",
]);
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
});
describe("agentLoop thinking state", () => {

View File

@@ -267,8 +267,32 @@ async function runLoop(
let currentContext = initialContext;
let config = initialConfig;
let firstTurn = true;
let turnOpen = true;
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
const stopIfAborted = async (): Promise<boolean> => {
if (!signal?.aborted) {
return false;
}
// Persist an aborted assistant outcome so session post-processing does not
// compact or continue from the preceding toolUse message.
const abortedMessage = createLoopFailureMessage(
config,
signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted"),
true,
);
newMessages.push(abortedMessage);
if (!turnOpen) {
await emit({ type: "turn_start" });
turnOpen = true;
}
await emit({ type: "message_start", message: abortedMessage });
await emit({ type: "message_end", message: abortedMessage });
await emit({ type: "turn_end", message: abortedMessage, toolResults: [] });
turnOpen = false;
await emit({ type: "agent_end", messages: newMessages });
return true;
};
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
@@ -276,8 +300,13 @@ async function runLoop(
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (await stopIfAborted()) {
return;
}
if (!firstTurn) {
await emit({ type: "turn_start" });
turnOpen = true;
} else {
firstTurn = false;
}
@@ -292,6 +321,10 @@ async function runLoop(
}
}
if (await stopIfAborted()) {
return;
}
// Stream assistant response
const message = await streamAssistantResponse(
currentContext,
@@ -332,6 +365,10 @@ async function runLoop(
}
await emit({ type: "turn_end", message, toolResults });
turnOpen = false;
if (await stopIfAborted()) {
return;
}
const nextTurnContext = {
message,
@@ -357,6 +394,9 @@ async function runLoop(
reasoning: nextReasoning,
});
}
if (await stopIfAborted()) {
return;
}
if (
await config.shouldStopAfterTurn?.({
@@ -371,6 +411,9 @@ async function runLoop(
}
pendingMessages = (await config.getSteeringMessages?.()) || [];
if (await stopIfAborted()) {
return;
}
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];

View File

@@ -6,6 +6,7 @@ import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveNpmRunner } from "../../../scripts/npm-runner.mjs";
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
import { getWindowsSystem32ExePath } from "../../../src/infra/windows-install-roots.js";
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
@@ -162,6 +163,24 @@ function runPnpmCommand(
});
}
function runNpmCommand(
args: string[],
options: { cwd: string; timeoutMs?: number },
): Promise<CommandResult> {
const env = createCommandEnv();
const runner = resolveNpmRunner({
env,
npmArgs: args,
});
return runCommand(runner.command, runner.args, {
cwd: options.cwd,
env: runner.env ?? env,
shell: runner.shell,
timeoutMs: options.timeoutMs,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
function normalizeWorkspaceDependencies(
dependencies: Record<string, string> | undefined,
): Record<string, string> | undefined {
@@ -340,7 +359,7 @@ describe("OpenClaw SDK package e2e", () => {
}
for (const packageRoot of packageRoots) {
const stagingRoot = await createPackStagingRoot(packageRoot, tempDir);
await runCommand("npm", ["pack", "--ignore-scripts", "--pack-destination", tempDir], {
await runNpmCommand(["pack", "--ignore-scripts", "--pack-destination", tempDir], {
cwd: stagingRoot,
});
}
@@ -363,13 +382,9 @@ describe("OpenClaw SDK package e2e", () => {
);
await fs.writeFile(path.join(tempDir, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
try {
await runCommand(
"npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball],
{
cwd: tempDir,
},
);
await runNpmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball], {
cwd: tempDir,
});
} finally {
await registry.close();
}

10
pnpm-lock.yaml generated
View File

@@ -1930,8 +1930,8 @@ importers:
specifier: workspace:*
version: link:../packages/normalization-core
dompurify:
specifier: 3.4.9
version: 3.4.9
specifier: 3.4.11
version: 3.4.11
highlight.js:
specifier: 11.11.1
version: 11.11.1
@@ -5112,8 +5112,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.4.9:
resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==}
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -11078,7 +11078,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.4.9:
dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7

View File

@@ -3,6 +3,7 @@
// Summarizes GitHub Actions run/job timings for CI analysis.
import { execFileSync } from "node:child_process";
import { parsePositiveInt } from "./lib/numeric-options.mjs";
import { execPlainGh } from "./lib/plain-gh.mjs";
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
const RUN_JOBS_PAGE_SIZE = 20;
@@ -17,16 +18,21 @@ function parseJsonCommand(command, args, options = {}) {
let lastError;
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
try {
return JSON.parse(
execFileSync(command, args, {
encoding: "utf8",
...options,
}),
);
const stdout =
command === "gh"
? execPlainGh(args, {
encoding: "utf8",
...options,
})
: execFileSync(command, args, {
encoding: "utf8",
...options,
});
return JSON.parse(stdout);
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const retryable = /HTTP 5\d\d|Server Error|ETIMEDOUT|ECONNRESET|EAI_AGAIN/u.test(message);
const retryable = isRetryableGhJsonErrorMessage(message);
if (!retryable || attempt === GH_JSON_RETRY_DELAYS_MS.length) {
throw error;
}
@@ -36,6 +42,12 @@ function parseJsonCommand(command, args, options = {}) {
throw lastError;
}
export function isRetryableGhJsonErrorMessage(message) {
return /HTTP 5\d\d|HTTP 429|Server Error|secondary rate limit|abuse detection|ETIMEDOUT|ECONNRESET|EAI_AGAIN/iu.test(
message,
);
}
function normalizeRunJob(job) {
return {
completedAt: job.completedAt ?? job.completed_at ?? null,
@@ -210,8 +222,7 @@ export function selectLatestMainPushCiRun(runs, headSha = null) {
}
function getLatestCiRunId() {
const raw = execFileSync(
"gh",
const raw = execPlainGh(
["run", "list", "--branch", "main", "--workflow", "CI", "--limit", "1", "--json", "databaseId"],
{ encoding: "utf8" },
);
@@ -234,8 +245,7 @@ function getRemoteMainSha() {
function getLatestMainPushCiRunId() {
const headSha = getRemoteMainSha();
const raw = execFileSync(
"gh",
const raw = execPlainGh(
[
"run",
"list",
@@ -258,8 +268,7 @@ function getLatestMainPushCiRunId() {
}
function listRecentSuccessfulCiRuns(limit) {
const raw = execFileSync(
"gh",
const raw = execPlainGh(
[
"run",
"list",

84
scripts/lib/plain-gh.mjs Normal file
View File

@@ -0,0 +1,84 @@
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
function isExecutable(filePath) {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function pathEntries(env) {
return String(env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
}
export function plainGhEnv(env = process.env) {
const next = { ...env };
delete next.CLICOLOR;
delete next.CLICOLOR_FORCE;
delete next.COLORTERM;
delete next.GH_FORCE_TTY;
next.NO_COLOR = "1";
next.FORCE_COLOR = "0";
next.CLICOLOR = "0";
next.CLICOLOR_FORCE = "0";
return next;
}
export function resolvePlainGhBin(env = process.env) {
if (env.OPENCLAW_GH_BIN) {
if (isExecutable(env.OPENCLAW_GH_BIN)) {
return env.OPENCLAW_GH_BIN;
}
throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`);
}
for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) {
if (isExecutable(candidate)) {
return candidate;
}
}
const homeBin = env.HOME ? path.join(env.HOME, "bin") : "";
for (const entry of pathEntries(env)) {
if (homeBin && entry === homeBin) {
continue;
}
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
if (isExecutable(candidate)) {
return candidate;
}
}
for (const entry of pathEntries(env)) {
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
if (isExecutable(candidate)) {
return candidate;
}
}
throw new Error("missing required command: gh");
}
export function execPlainGh(args, options = {}) {
const env = plainGhEnv(options.env ?? process.env);
const ghBin = resolvePlainGhBin(env);
return execFileSync(ghBin, args, {
...options,
env,
});
}
export function spawnPlainGh(args, options = {}) {
const env = plainGhEnv(options.env ?? process.env);
const ghBin = resolvePlainGhBin(env);
return spawnSync(ghBin, args, {
...options,
env,
});
}

70
scripts/lib/plain-gh.sh Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
plain_gh_env() {
env \
-u CLICOLOR \
-u CLICOLOR_FORCE \
-u COLORTERM \
-u GH_FORCE_TTY \
NO_COLOR=1 \
FORCE_COLOR=0 \
CLICOLOR=0 \
CLICOLOR_FORCE=0 \
"$@"
}
resolve_plain_gh_bin() {
if [ -n "${OPENCLAW_GH_BIN:-}" ]; then
if [ -x "$OPENCLAW_GH_BIN" ]; then
printf '%s\n' "$OPENCLAW_GH_BIN"
return 0
fi
printf 'OPENCLAW_GH_BIN is not executable: %s\n' "$OPENCLAW_GH_BIN" >&2
return 1
fi
local candidate
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then
printf '%s\n' "$candidate"
return 0
fi
type -P gh 2>/dev/null
}
plain_gh_search_path() {
local path_value="${PATH:-}"
local home_bin="${HOME:-}/bin"
local item
local output=""
local first=true
local path_parts=()
IFS=':' read -r -a path_parts <<<"$path_value"
for item in "${path_parts[@]}"; do
if [ -n "${HOME:-}" ] && [ "$item" = "$home_bin" ]; then
continue
fi
if [ "$first" = "true" ]; then
output="$item"
first=false
else
output="${output}:$item"
fi
done
printf '%s\n' "$output"
}
gh_plain() {
local gh_bin
gh_bin=$(resolve_plain_gh_bin) || return 1
plain_gh_env "$gh_bin" "$@"
}

View File

@@ -146,7 +146,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"provider-auth-login": 3,
"provider-model-shared": 29,
"provider-stream-family": 40,
"provider-stream-shared": 28,
"provider-stream-shared": 29,
"provider-stream": 40,
"provider-web-search": 1,
"provider-zai-endpoint": 3,
@@ -163,8 +163,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10327),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5184),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10328),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5185),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -21,6 +21,9 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute
fi
fi
# shellcheck disable=SC1091
source "$script_parent_dir/lib/plain-gh.sh"
usage() {
cat <<USAGE
Usage:
@@ -48,11 +51,16 @@ USAGE
require_cmds() {
local missing=()
local cmd
for cmd in git gh jq rg pnpm node; do
for cmd in git jq rg pnpm node; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if ! OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)"; then
missing+=("gh")
else
export OPENCLAW_GH_BIN
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Missing required command(s): ${missing[*]}"
@@ -60,6 +68,10 @@ require_cmds() {
fi
}
gh() {
gh_plain "$@"
}
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/worktree.sh"
# shellcheck disable=SC1091

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { execPlainGh } from "./lib/plain-gh.mjs";
export const SCHEDULED_HOSTED_WORKFLOWS = [
"Blacksmith Testbox",
@@ -209,8 +209,7 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
}
function loadWorkflowRuns(repo, sha) {
const raw = execFileSync(
"gh",
const raw = execPlainGh(
["api", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`, "--paginate", "--slurp"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);

View File

@@ -509,6 +509,52 @@ describe("startAcpSpawnParentStreamRelay", () => {
relay.dispose();
});
it("relays the latest replaceable assistant snapshot instead of superseded drafts", () => {
const relay = startAcpSpawnParentStreamRelay({
runId: "run-replaceable-assistant",
parentSessionKey: "agent:main:main",
childSessionKey: "agent:codex:acp:child-replaceable-assistant",
agentId: "codex",
streamFlushMs: 10,
noOutputNoticeMs: 120_000,
emitStartNotice: false,
});
emitAgentEvent({
runId: "run-replaceable-assistant",
stream: "assistant",
data: {
text: "coordination draft",
delta: "coordination draft",
replaceable: true,
},
});
emitAgentEvent({
runId: "run-replaceable-assistant",
stream: "assistant",
data: {
text: "final answer",
delta: "",
replace: true,
replaceable: true,
},
});
emitAgentEvent({
runId: "run-replaceable-assistant",
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1_000,
endedAt: 2_000,
},
});
const texts = collectedTexts();
expectNoTextWithFragment(texts, "coordination draft");
expectTextWithFragment(texts, "codex: final answer");
relay.dispose();
});
it("relays commentary-phase assistant text in parent progress mode by default", () => {
const relay = startAcpSpawnParentStreamRelay({
runId: "run-commentary-default",

View File

@@ -431,6 +431,7 @@ export function startAcpSpawnParentStreamRelay(params: {
let disposed = false;
let pendingText = "";
let pendingProgressKind: string | undefined;
let replaceableAssistantSnapshot: string | undefined;
const itemProgressTextById = new Map<string, string>();
let lastProgressAt = Date.now();
let stallNotified = false;
@@ -511,6 +512,15 @@ export function startAcpSpawnParentStreamRelay(params: {
scheduleFlush();
};
const flushReplaceableAssistantSnapshot = () => {
const snapshot = replaceableAssistantSnapshot;
replaceableAssistantSnapshot = undefined;
if (!snapshot?.trim()) {
return;
}
appendVisibleProgress(snapshot, "assistant:replaceable");
};
const appendItemProgressSnapshot = (snapshot: { itemId: string; text: string }) => {
const previous = itemProgressTextById.get(snapshot.itemId) ?? "";
if (snapshot.text === previous) {
@@ -605,10 +615,27 @@ export function startAcpSpawnParentStreamRelay(params: {
const assistantPhase = normalizeAssistantPhase(
(data as { phase?: unknown } | undefined)?.phase,
);
const deltaCandidate =
(data as { delta?: unknown } | undefined)?.delta ??
(data as { text?: unknown } | undefined)?.text;
const delta = typeof deltaCandidate === "string" ? deltaCandidate : undefined;
const textCandidate = (data as { text?: unknown } | undefined)?.text;
const deltaCandidate = (data as { delta?: unknown } | undefined)?.delta;
const snapshot =
typeof textCandidate === "string"
? textCandidate
: typeof deltaCandidate === "string"
? deltaCandidate
: undefined;
if ((data as { replaceable?: unknown } | undefined)?.replaceable === true) {
if (snapshot?.trim()) {
replaceableAssistantSnapshot = snapshot;
lastProgressAt = Date.now();
logEvent("assistant_replaceable_snapshot", {
text: snapshot,
...(assistantPhase ? { phase: assistantPhase } : {}),
});
}
return;
}
const delta = typeof deltaCandidate === "string" ? deltaCandidate : snapshot;
if (!delta || !delta.trim()) {
return;
}
@@ -622,6 +649,7 @@ export function startAcpSpawnParentStreamRelay(params: {
return;
}
replaceableAssistantSnapshot = undefined;
appendVisibleProgress(delta, `assistant:${assistantPhase ?? "unknown"}`);
return;
}
@@ -697,6 +725,7 @@ export function startAcpSpawnParentStreamRelay(params: {
const phase = normalizeOptionalString((event.data as { phase?: unknown } | undefined)?.phase);
logEvent("lifecycle", { phase: phase ?? "unknown", data: event.data });
if (phase === "end") {
flushReplaceableAssistantSnapshot();
flushPending();
const startedAt = asFiniteNumber(
(event.data as { startedAt?: unknown } | undefined)?.startedAt,
@@ -719,6 +748,7 @@ export function startAcpSpawnParentStreamRelay(params: {
}
if (phase === "error") {
flushReplaceableAssistantSnapshot();
flushPending();
const errorText = normalizeOptionalString(
(event.data as { error?: unknown } | undefined)?.error,

View File

@@ -29,6 +29,7 @@ const resolveEmbeddedAgentStreamFnMock = vi.fn();
const prepareCliRunContextMock = vi.fn();
const executePreparedCliRunMock = vi.fn();
const diagDebugMock = vi.fn();
const ensureSelectedAgentHarnessPluginMock = vi.fn();
vi.mock("../llm/stream.js", async () => {
const original = await vi.importActual<typeof import("../llm/stream.js")>("../llm/stream.js");
@@ -119,7 +120,7 @@ vi.mock("./model-runtime-aliases.js", () => ({
}
}
}
return runtime || undefined;
return runtime === "claude-cli" ? runtime : undefined;
},
}));
@@ -131,6 +132,11 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({
executePreparedCliRun: (...args: unknown[]) => executePreparedCliRunMock(...args),
}));
vi.mock("./harness/runtime-plugin.js", () => ({
ensureSelectedAgentHarnessPlugin: (...args: unknown[]) =>
ensureSelectedAgentHarnessPluginMock(...args),
}));
vi.mock("./embedded-agent-runner/runs.js", () => ({
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
}));
@@ -455,6 +461,7 @@ describe("runBtwSideQuestion", () => {
prepareCliRunContextMock.mockReset();
executePreparedCliRunMock.mockReset();
diagDebugMock.mockReset();
ensureSelectedAgentHarnessPluginMock.mockReset();
clearAgentHarnesses();
readFileMock.mockResolvedValue("mock transcript");
@@ -835,6 +842,55 @@ describe("runBtwSideQuestion", () => {
expect(streamSimpleMock).toHaveBeenCalledTimes(1);
});
it("loads a cold Copilot harness before selecting the /btw provider fallback", async () => {
let loaded = false;
ensureSelectedAgentHarnessPluginMock.mockImplementation(async () => {
if (loaded) {
return;
}
loaded = true;
registerAgentHarness({
id: "copilot",
label: "Copilot test harness",
supports: () => ({ supported: true, priority: 100 }),
runAttempt: vi.fn(),
});
});
resolveModelWithRegistryMock.mockReturnValue({
provider: "github-copilot",
id: "gpt-4o",
api: "openai-completions",
});
mockDoneAnswer("Copilot fallback answer.");
const result = await runSideQuestion({
cfg: {
agents: {
defaults: {
models: {
"github-copilot/gpt-4o": { agentRuntime: { id: "copilot" } },
},
},
},
} as never,
provider: "github-copilot",
model: "gpt-4o",
sessionKey: DEFAULT_SESSION_KEY,
});
expect(result).toEqual({ text: "Copilot fallback answer." });
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce();
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({
provider: "github-copilot",
modelId: "gpt-4o",
config: expect.any(Object),
agentId: "main",
sessionKey: DEFAULT_SESSION_KEY,
workspaceDir: "/tmp/workspace",
});
expect(streamSimpleMock).toHaveBeenCalledOnce();
});
it("runs CLI-runtime alias BTW as an ephemeral CLI side question", async () => {
const cleanup = vi.fn(async () => undefined);
prepareCliRunContextMock.mockResolvedValueOnce({

View File

@@ -30,6 +30,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-
import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js";
import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js";
import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js";
import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js";
import {
resolveAvailableAgentHarnessPolicy,
resolvePluginHarnessPolicyToolsAllow,
@@ -479,13 +480,32 @@ export async function runBtwSideQuestion(
config: params.cfg,
});
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId);
const harness = selectAgentHarness({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
const preparedHarnesses = new Map<string, AgentHarness>();
const prepareHarness = async (provider: string, modelId: string): Promise<AgentHarness> => {
const key = `${provider}/${modelId}`;
const cached = preparedHarnesses.get(key);
if (cached) {
return cached;
}
await ensureSelectedAgentHarnessPlugin({
provider,
modelId,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
workspaceDir,
});
const harness = selectAgentHarness({
provider,
modelId,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
preparedHarnesses.set(key, harness);
return harness;
};
const harness = await prepareHarness(params.provider, params.model);
let runtimeSelection: Awaited<ReturnType<typeof resolveRuntimeModel>> | undefined;
const resolveRuntimeSelection = async () => {
if (!runtimeSelection) {
@@ -647,13 +667,12 @@ export async function runBtwSideQuestion(
}
const runtimeSelectionForHarness = await resolveRuntimeSelection();
const runtimeHarness = selectAgentHarness({
provider: runtimeSelectionForHarness.model.provider,
modelId: runtimeSelectionForHarness.model.id,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
// Model resolution can canonicalize a legacy provider alias, so reselect against the resolved
// provider/model instead of reusing the raw route's selection.
const runtimeHarness = await prepareHarness(
runtimeSelectionForHarness.model.provider,
runtimeSelectionForHarness.model.id,
);
if (runtimeHarness.runSideQuestion) {
return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness);
}

View File

@@ -504,7 +504,7 @@ export function buildEmbeddedRunPayloads(params: {
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = hasSourceReplyPayload;
let hasUserFacingAssistantReply = hasSourceReplyPayload || deliveredSourceReplyViaMessageTool;
const hasUserFacingErrorReply = replyItems.some((item) => item.isError === true);
let hasUserFacingFailureAcknowledgement = false;
for (const text of answerTexts) {

View File

@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
ensurePluginRegistryLoaded: vi.fn(),
resolveActivatableProviderOwnerPluginIds: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
resolveManifestActivationPlan: vi.fn(),
resolveOwningPluginIdsForProvider: vi.fn(),
}));
@@ -20,6 +21,10 @@ vi.mock("../../plugins/providers.js", () => ({
resolveOwningPluginIdsForProviderRef: mocks.resolveOwningPluginIdsForProvider,
}));
vi.mock("../../plugins/activation-planner.js", () => ({
resolveManifestActivationPlan: mocks.resolveManifestActivationPlan,
}));
describe("ensureSelectedAgentHarnessPlugin", () => {
let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin;
@@ -27,7 +32,30 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
mocks.ensurePluginRegistryLoaded.mockReset();
mocks.resolveActivatableProviderOwnerPluginIds.mockReset();
mocks.resolveBundledProviderCompatPluginIds.mockReset();
mocks.resolveManifestActivationPlan.mockReset();
mocks.resolveOwningPluginIdsForProvider.mockReset();
mocks.resolveManifestActivationPlan.mockImplementation(
({
trigger,
config,
}: {
trigger: { kind: "agentHarness"; runtime: string };
config?: OpenClawConfig;
}) => {
const pluginId = trigger.runtime;
const allow = config?.plugins?.allow ?? [];
if (
config?.plugins?.entries?.[pluginId]?.enabled === false ||
(allow.length > 0 && !allow.includes(pluginId))
) {
return { entries: [] };
}
return {
entries:
pluginId === "codex" || pluginId === "copilot" ? [{ pluginId, origin: "bundled" }] : [],
};
},
);
mocks.resolveOwningPluginIdsForProvider.mockImplementation(
({ provider }: { provider: string }) => (provider === "openai" ? ["openai"] : undefined),
);
@@ -132,6 +160,61 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
);
});
it("loads a manifest-owned custom harness runtime before selection", async () => {
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }],
});
await ensureSelectedAgentHarnessPlugin({
provider: "custom-provider",
modelId: "custom-model",
config: {
plugins: {
entries: {
"custom-harness-plugin": { enabled: true },
},
},
} as OpenClawConfig,
agentHarnessRuntimeOverride: "custom-harness",
workspaceDir: "/tmp/workspace",
});
expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({
trigger: { kind: "agentHarness", runtime: "custom-harness" },
config: expect.any(Object),
workspaceDir: "/tmp/workspace",
requireExplicitManifestOwnerTrust: true,
});
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["custom-harness-plugin", "memory-core"],
}),
);
});
it("does not activate an untrusted workspace harness from manifest metadata alone", async () => {
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
entries: [],
});
await ensureSelectedAgentHarnessPlugin({
provider: "custom-provider",
modelId: "custom-model",
agentHarnessRuntimeOverride: "custom-harness",
workspaceDir: "/tmp/workspace",
});
expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({
trigger: { kind: "agentHarness", runtime: "custom-harness" },
config: undefined,
workspaceDir: "/tmp/workspace",
requireExplicitManifestOwnerTrust: true,
});
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
});
it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => {
// A configured harness can request loading, but explicit plugin allowlists
// remain the operator's boundary and are not widened implicitly.
@@ -158,21 +241,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
workspaceDir: "/tmp/workspace",
});
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["copilot"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["telegram"],
entries: expect.not.objectContaining({
copilot: expect.anything(),
}),
}),
}),
}),
);
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
});
it("widens a scoped harness allowlist with the provider owner for openai models", async () => {
@@ -337,22 +406,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled();
expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled();
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["telegram"],
entries: expect.not.objectContaining({
codex: expect.anything(),
openai: expect.anything(),
}),
}),
}),
}),
);
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
});
it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => {
@@ -417,5 +471,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
expect(mocks.resolveManifestActivationPlan).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@
*/
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withActivatedPluginIds } from "../../plugins/activation-context.js";
import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js";
import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js";
import {
@@ -16,16 +17,9 @@ import {
} from "../../plugins/providers.js";
import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js";
import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js";
import { resolveAgentHarnessPolicy } from "./policy.js";
/**
* Lazy-loads plugin-backed harness runtimes before selection.
*
* Only cold-loadable runtimes live here; always-loaded core/openclaw runtimes should not trigger
* plugin registry scans on every embedded-agent turn.
*/
const COLD_LOADABLE_HARNESS_PLUGIN_IDS = new Set(["codex", "copilot"]);
function dedupePluginIds(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
@@ -82,13 +76,26 @@ function resolveHarnessPluginIds(params: {
config?: OpenClawConfig;
workspaceDir: string;
}): string[] {
const activationPlan = resolveManifestActivationPlan({
trigger: { kind: "agentHarness", runtime: params.runtime },
config: params.config,
workspaceDir: params.workspaceDir,
requireExplicitManifestOwnerTrust: true,
});
const harnessPluginIds = activationPlan.entries.map((entry) => entry.pluginId);
if (harnessPluginIds.length === 0) {
return [];
}
if (params.runtime !== "codex") {
return [params.runtime];
return harnessPluginIds;
}
if (!harnessPluginIds.includes("codex")) {
return harnessPluginIds;
}
if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) {
// Respect a restrictive allowlist even when Codex would normally pull in provider owner
// plugins. Operators who set an allowlist expect no implicit plugin expansion.
return ["codex"];
return harnessPluginIds;
}
const providerOwnerPluginIds = dedupePluginIds(
resolveOwningPluginIdsForProviderRef({
@@ -98,7 +105,7 @@ function resolveHarnessPluginIds(params: {
}) ?? [],
);
if (providerOwnerPluginIds.length === 0) {
return ["codex"];
return harnessPluginIds;
}
const safeProviderOwnerPluginIds = dedupePluginIds([
...resolveBundledProviderCompatPluginIds({
@@ -114,6 +121,7 @@ function resolveHarnessPluginIds(params: {
]);
return dedupePluginIds([
"codex",
...harnessPluginIds,
...providerOwnerPluginIds.filter(
(pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId),
),
@@ -164,7 +172,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
if (
isDefaultAgentRuntimeId(runtime) ||
runtime === OPENCLAW_AGENT_RUNTIME_ID ||
!COLD_LOADABLE_HARNESS_PLUGIN_IDS.has(runtime)
isCliRuntimeAliasForProvider({
runtime,
provider: params.provider,
cfg: params.config,
})
) {
return;
}
@@ -177,6 +189,9 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
config: params.config,
workspaceDir: params.workspaceDir,
});
if (pluginIds.length === 0) {
return;
}
const memoryPluginIds = resolveSelectedMemoryPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,

View File

@@ -71,6 +71,8 @@ export type SupplementalContextFacts = {
};
untrustedContext?: Array<{ label: string; source?: string; type?: string; payload: unknown }>;
groupSystemPrompt?: string;
/** Prompt-like group metadata from user-controlled sources; never enters the system prompt. */
untrustedGroupSystemPrompt?: string;
};
/** Raw inbound message context accepted from channels before finalization. */

View File

@@ -13,6 +13,7 @@ import type {
InboundSourceModality,
MentionSource,
MsgContext,
SupplementalContextFacts,
} from "../../auto-reply/templating.js";
import type { GroupKeyResolution } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -28,6 +29,7 @@ import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.ty
import type { ChannelBotLoopProtectionFacts } from "./bot-loop-protection.js";
export type { InboundEventKind } from "../inbound-event/kind.js";
export type { SupplementalContextFacts } from "../../auto-reply/templating.js";
/** Admission decision for an inbound channel event before agent dispatch. */
export type ChannelTurnAdmission =
@@ -219,39 +221,6 @@ export type CommandFacts = {
authorized?: boolean;
};
/** Quoted, forwarded, thread, and untrusted context facts attached to an inbound turn. */
export type SupplementalContextFacts = {
quote?: {
id?: string;
fullId?: string;
body?: string;
sender?: string;
senderAllowed?: boolean;
isExternal?: boolean;
isQuote?: boolean;
};
forwarded?: {
from?: string;
fromType?: string;
fromId?: string;
date?: number;
senderAllowed?: boolean;
};
thread?: {
id?: string;
starterBody?: string;
historyBody?: string;
label?: string;
parentSessionKey?: string;
modelParentSessionKey?: string;
senderAllowed?: boolean;
};
untrustedContext?: Array<{ label: string; source?: string; type?: string; payload: unknown }>;
groupSystemPrompt?: string;
/** Prompt-like group metadata from user-controlled sources; never enters the system prompt. */
untrustedGroupSystemPrompt?: string;
};
/** Inbound media facts supplied to the agent context. */
export type InboundMediaFacts = {
path?: string;

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import JSON5 from "json5";
import { beforeAll, describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { runConfigSet } from "./config-cli.js";
function createTestRuntime() {
@@ -91,8 +91,8 @@ async function withExecDryRunConfigHarness(
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
clearConfigCache();
clearRuntimeConfigSnapshot();
@@ -117,8 +117,8 @@ describe("config cli integration", () => {
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
try {
fs.writeFileSync(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
clearConfigCache();
clearRuntimeConfigSnapshot();
await runConfigSet({
@@ -152,8 +152,8 @@ describe("config cli integration", () => {
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
clearConfigCache();
clearRuntimeConfigSnapshot();
@@ -222,9 +222,9 @@ describe("config cli integration", () => {
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.DISCORD_BOT_TOKEN = "test-token";
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
setTestEnvValue("DISCORD_BOT_TOKEN", "test-token");
clearConfigCache();
clearRuntimeConfigSnapshot();
@@ -293,9 +293,9 @@ describe("config cli integration", () => {
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
delete process.env.MISSING_TEST_SECRET;
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
deleteTestEnvValue("MISSING_TEST_SECRET");
clearConfigCache();
clearRuntimeConfigSnapshot();

View File

@@ -23,12 +23,16 @@ vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: mocks.getChannelsCommandSecretTargetIds,
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: mocks.loadConfig,
loadConfig: mocks.loadConfig,
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
replaceConfigFile: mocks.replaceConfigFile,
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
getRuntimeConfig: mocks.loadConfig,
loadConfig: mocks.loadConfig,
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
replaceConfigFile: mocks.replaceConfigFile,
};
});
vi.mock("../cli/plugins-registry-refresh.js", () => ({
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,

View File

@@ -18,12 +18,16 @@ const mocks = vi.hoisted(() => ({
listReadOnlyChannelPluginsForConfig: vi.fn(),
}));
vi.mock("./shared.js", () => ({
requireValidConfig: vi.fn(async () => ({ channels: {} })),
formatChannelAccountLabel: vi.fn(
({ channel, accountId }: { channel: string; accountId: string }) => `${channel}:${accountId}`,
),
}));
vi.mock("./shared.js", async () => {
const actual = await vi.importActual<typeof import("./shared.js")>("./shared.js");
return {
...actual,
requireValidConfig: vi.fn(async () => ({ channels: {} })),
formatChannelAccountLabel: vi.fn(
({ channel, accountId }: { channel: string; accountId: string }) => `${channel}:${accountId}`,
),
};
});
vi.mock("../../channels/plugins/index.js", () => ({
listChannelPlugins: vi.fn(),
@@ -242,9 +246,7 @@ describe("channelsCapabilitiesCommand", () => {
await channelsCapabilitiesCommand({ channel: "slack", timeout: "999999" }, runtime);
expect(probeAccount).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 30_000 }),
);
expect(probeAccount).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
expect(buildCapabilitiesDiagnostics).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 30_000 }),
);
@@ -274,10 +276,7 @@ describe("channelsCapabilitiesCommand", () => {
configChanged: false,
});
await channelsCapabilitiesCommand(
{ channel: "slack", json: true, timeout: "1" },
runtime,
);
await channelsCapabilitiesCommand({ channel: "slack", json: true, timeout: "1" }, runtime);
const payload = JSON.parse(logs[0] ?? "{}") as {
channels?: Array<{ probe?: unknown }>;
@@ -343,10 +342,7 @@ describe("channelsCapabilitiesCommand", () => {
configChanged: false,
});
await channelsCapabilitiesCommand(
{ channel: "slack", json: true, timeout: "1" },
runtime,
);
await channelsCapabilitiesCommand({ channel: "slack", json: true, timeout: "1" }, runtime);
const payload = JSON.parse(logs[0] ?? "{}") as {
channels?: Array<{ diagnostics?: unknown }>;

View File

@@ -19,17 +19,13 @@ import type {
import { formatCliCommand } from "../../cli/command-format.js";
import { formatUnknownChannelMessage } from "../../cli/error-format.js";
import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import {
readConfigFileSnapshot,
replaceConfigFile,
type OpenClawConfig,
} from "../../config/config.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
import { persistResolvedChannelPluginConfig } from "./plugin-config-persistence.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsCapabilitiesOptions = {
@@ -351,35 +347,11 @@ export async function channelsCapabilitiesCommand(
allowInstall: true,
});
if (resolved.configChanged) {
cfg = resolved.cfg;
const shouldMovePluginInstalls = Boolean(
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
);
if (shouldMovePluginInstalls) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
cfg = committed.config;
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
installRecords: committed.installRecords,
logger: { warn: (message) => runtime.log(message) },
});
} else {
await replaceConfigFile({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
if (resolved.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
}
cfg = await persistResolvedChannelPluginConfig({
resolved,
baseHash: (await sourceSnapshotPromise)?.hash,
runtime,
});
}
return resolved.plugin ? [resolved.plugin] : null;
})();

View File

@@ -0,0 +1,50 @@
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { replaceConfigFile } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { RuntimeEnv } from "../../runtime.js";
export async function persistResolvedChannelPluginConfig(params: {
resolved: {
cfg: OpenClawConfig;
configChanged: boolean;
pluginInstalled: boolean;
};
baseHash?: string;
runtime: RuntimeEnv;
}): Promise<OpenClawConfig> {
if (!params.resolved.configChanged) {
return params.resolved.cfg;
}
const cfg = params.resolved.cfg;
const shouldMovePluginInstalls = Boolean(
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
);
if (shouldMovePluginInstalls) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: cfg,
baseHash: params.baseHash,
});
await refreshPluginRegistryAfterConfigMutation({
config: committed.config,
reason: "source-changed",
installRecords: committed.installRecords,
logger: { warn: (message) => params.runtime.log(message) },
});
return committed.config;
}
await replaceConfigFile({
nextConfig: cfg,
baseHash: params.baseHash,
});
if (params.resolved.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
logger: { warn: (message) => params.runtime.log(message) },
});
}
return cfg;
}

View File

@@ -13,17 +13,12 @@ import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolu
import { formatCliCommand } from "../../cli/command-format.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { formatUnsupportedChannelActionMessage } from "../../cli/error-format.js";
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import {
getRuntimeConfig,
readConfigFileSnapshot,
replaceConfigFile,
} from "../../config/config.js";
import { getRuntimeConfig, readConfigFileSnapshot } from "../../config/config.js";
import { danger } from "../../globals.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
import { persistResolvedChannelPluginConfig } from "./plugin-config-persistence.js";
export type ChannelsResolveOptions = {
channel?: string;
@@ -156,35 +151,11 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
);
}
if (resolvedExplicit?.configChanged) {
cfg = resolvedExplicit.cfg;
const shouldMovePluginInstalls = Boolean(
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
);
if (shouldMovePluginInstalls) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
cfg = committed.config;
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
installRecords: committed.installRecords,
logger: { warn: (message) => runtime.log(message) },
});
} else {
await replaceConfigFile({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
if (resolvedExplicit.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
}
cfg = await persistResolvedChannelPluginConfig({
resolved: resolvedExplicit,
baseHash: (await sourceSnapshotPromise)?.hash,
runtime,
});
}
const selection = explicitChannel

View File

@@ -245,16 +245,16 @@ describe("maybeRepairGatewayDaemon", () => {
});
}
async function runAutoRepair() {
async function runAutoRepair(options: { repair?: boolean; yes?: boolean } = { repair: true }) {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime,
prompter: createDoctorPrompter({
runtime,
options: { repair: true },
options,
}),
options: { deep: false, repair: true },
options: { deep: false, ...options },
gatewayDetailsMessage: "details",
healthOk: false,
});
@@ -542,6 +542,39 @@ describe("maybeRepairGatewayDaemon", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("skips running service restart during non-interactive repairs", async () => {
setPlatform("linux");
await runNonInteractiveRepair();
expect(service.restart).not.toHaveBeenCalled();
});
it("starts stopped service during non-interactive repairs", async () => {
setPlatform("linux");
service.readRuntime.mockResolvedValue({ status: "stopped" });
await runNonInteractiveRepair();
expect(service.restart).toHaveBeenCalledTimes(1);
});
it("restarts running service when repair is explicitly approved", async () => {
setPlatform("linux");
await runAutoRepair();
expect(service.restart).toHaveBeenCalledTimes(1);
});
it("restarts running service when --yes explicitly approves repairs", async () => {
setPlatform("linux");
await runAutoRepair({ yes: true });
expect(service.restart).toHaveBeenCalledTimes(1);
});
it("skips gateway service install when service repair policy is external", async () => {
setPlatform("linux");
service.isLoaded.mockResolvedValue(false);

View File

@@ -459,6 +459,10 @@ export async function maybeRepairGatewayDaemon(params: {
// Health probe failed — fall through to the restart prompt below.
}
}
if (params.options.nonInteractive === true) {
// --fix auto-approves runtime repairs; do not let a headless doctor kill its live gateway.
return;
}
const restart = await confirmDoctorServiceRepair(
params.prompter,

View File

@@ -600,6 +600,144 @@ describe("maybeRepairLegacyCronStore", () => {
expectNoteContaining("1 job still uses legacy", "Cron");
});
it("advises on isolated shell-prompt jobs without a non-actionable --fix repair note (#94655)", async () => {
const storePath = await makeTempStorePath();
const shellPromptJobs: Array<Record<string, unknown>> = [
createCurrentCronJob({
id: "shell-prompt-job-1",
name: "Shell prompt job 1",
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message:
"Run python3 scripts/check_mail.py and send a compact summary if anything changed.",
toolsAllow: ["*"],
},
delivery: { mode: "announce" },
}),
createCurrentCronJob({
id: "shell-prompt-job-2",
name: "Shell prompt job 2",
schedule: { kind: "cron", expr: "15 * * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "Run node scripts/check_mail.js and summarize any new messages.",
toolsAllow: ["bash"],
},
delivery: { mode: "announce" },
}),
createCurrentCronJob({
id: "shell-prompt-job-3",
name: "Shell prompt job 3",
schedule: { kind: "cron", expr: "45 * * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "Execute ./scripts/check_mail.sh and report changed mailbox counts.",
toolsAllow: ["shell"],
},
delivery: { mode: "announce" },
}),
];
const shellPromptJob = requirePersistedJob(shellPromptJobs, 0);
await writeCurrentCronStore(storePath, shellPromptJobs);
const prompter = makePrompter(true);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter,
});
// The advisory is informational only: doctor --fix cannot rewrite a working
// isolated agentTurn job, so the misleading repair note must stay absent.
expectNoNoteContaining("Cron store issues detected", "Cron");
expectNoteContaining(
"3 isolated cron jobs drive shell/process tools from the agent prompt and keep running as-is: `Shell prompt job 1`, `Shell prompt job 2`, `Shell prompt job 3`.",
"Cron",
);
expectNoteContaining("informational only", "Cron");
expectNoteContaining("Shell prompt job 1", "Cron");
expectNoteContaining("Shell prompt job 2", "Cron");
expectNoteContaining("Shell prompt job 3", "Cron");
expectNoNoteContaining("openclaw doctor --fix", "Cron");
expectNoNoteContaining("jobs.json", "Cron");
expect(prompter.confirm).not.toHaveBeenCalled();
// No churn: the advisory does not rewrite the still-working jobs.
const persistedJobs = await readPersistedJobs(storePath);
expect(persistedJobs).toEqual(shellPromptJobs);
const job = requirePersistedJob(persistedJobs, 0);
expect(job).toEqual(shellPromptJob);
const reloaded = await loadCronJobsStoreWithConfigJobs(storePath);
expect(reloaded.configJobIndexes).toEqual([0, 1, 2]);
expect(reloaded.invalidConfigRows).toEqual([]);
const configJob = requirePersistedJob(reloaded.configJobs, 0);
expect(configJob).toEqual(
Object.fromEntries(Object.entries(shellPromptJob).filter(([key]) => key !== "updatedAtMs")),
);
expect(reloaded.configJobRuntimeEntries[0]).toEqual({
updatedAtMs: shellPromptJob.updatedAtMs,
state: {},
scheduleIdentity: JSON.stringify({
version: 1,
enabled: shellPromptJob.enabled,
schedule: shellPromptJob.schedule,
}),
});
const payload = requireRecord(job.payload, "cron payload");
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain("python3 scripts/check_mail.py");
});
it("keeps restricted command prompts actionable without a --fix repair note", async () => {
const storePath = await makeTempStorePath();
const commandPromptJob = createCurrentCronJob({
id: "restricted-command-prompt",
name: "Restricted command prompt",
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: [
"Command to run:",
"- command: python3 scripts/check_mail.py",
"- workdir: /home/openclaw/.razor/clawd",
].join("\n"),
toolsAllow: ["read", "message"],
},
delivery: { mode: "announce" },
});
await writeCurrentCronStore(storePath, [commandPromptJob]);
const prompter = makePrompter(true);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter,
});
expectNoNoteContaining("Cron store issues detected", "Cron");
expectNoteContaining(
"1 isolated cron job describes a shell command in the agent prompt but lacks shell/process tool access: `Restricted command prompt`.",
"Cron",
);
expectNoteContaining("not the supported shell-tool prompt shape", "Cron");
expectNoteContaining("Recreate the job as a command cron job", "Cron");
expectNoNoteContaining("informational only", "Cron");
expectNoNoteContaining("keep running as-is", "Cron");
expectNoNoteContaining("openclaw doctor --fix", "Cron");
expect(prompter.confirm).not.toHaveBeenCalled();
const job = requirePersistedJob(await readPersistedJobs(storePath), 0);
const payload = requireRecord(job.payload, "cron payload");
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain("python3 scripts/check_mail.py");
expect(payload.toolsAllow).toEqual(["read", "message"]);
});
it("repairs malformed persisted cron ids before list rendering sees them", async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [

View File

@@ -30,6 +30,8 @@ import {
} from "./legacy-store-migration.js";
import {
formatLegacyIssuePreview,
formatUnresolvedCommandPromptAdvisory,
formatUnresolvedShellPromptAdvisory,
mergeLegacyCronJobs,
mergeRuntimeEntryIntoConfigJob,
needsSqliteProjectionBackfill,
@@ -336,9 +338,21 @@ export async function maybeRepairLegacyCronStore(params: {
const normalized = normalizeStoredCronJobs(rawJobs);
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);
const previewLines = formatLegacyIssuePreview(normalized.issues, {
unresolvedAgentTurnShellToolPrompt: normalized.unresolvedAgentTurnShellToolPromptJobs,
});
// Unresolved agentTurn command prompts are not auto-fixable; keep them out of the
// --fix preview so the repair note does not promise a fix that never lands (#94655).
const commandPromptAdvisory = formatUnresolvedCommandPromptAdvisory(
normalized.unresolvedAgentTurnCommandPromptJobs,
);
if (commandPromptAdvisory) {
note(commandPromptAdvisory, "Cron");
}
const shellPromptAdvisory = formatUnresolvedShellPromptAdvisory(
normalized.unresolvedAgentTurnShellToolPromptJobs,
);
if (shellPromptAdvisory) {
note(shellPromptAdvisory, "Cron");
}
const previewLines = formatLegacyIssuePreview(normalized.issues);
if (legacyStoreDetected) {
previewLines.unshift(
legacyImportCount > 0

View File

@@ -12,6 +12,10 @@ type LegacyAgentTurnCommandPayload = {
timeoutSeconds?: number;
};
export type UnresolvedAgentTurnShellToolPromptKind =
| "commandPromptWithoutShellAccess"
| "shellToolPrompt";
const LEGACY_AGENT_TURN_COMMAND_MARKER_RE = /\bCommand to run\s*:/iu;
const LEGACY_AGENT_TURN_COMMAND_FIELD_RE = /^\s*-\s*(command|workdir|timeout)\s*:\s*(.*?)\s*$/iu;
const SHELL_TOOL_NAMES = new Set(["bash", "command", "exec", "process", "shell", "sh"]);
@@ -217,17 +221,27 @@ export function migrateLegacyAgentTurnCommandPayload(payload: UnknownRecord): bo
return true;
}
export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean {
export function classifyUnresolvedAgentTurnShellToolPrompt(
payload: UnknownRecord,
): UnresolvedAgentTurnShellToolPromptKind | null {
if (payload.kind !== "agentTurn") {
return false;
return null;
}
const message = readString(payload.message);
if (typeof message !== "string") {
return false;
return null;
}
const parsed = parseLegacyAgentTurnCommandMessage(message);
return (
Boolean(parsed) ||
(hasShellToolAccess(payload.toolsAllow) && SHELL_COMMAND_MESSAGE_RE.test(message))
);
const shellToolAccess = hasShellToolAccess(payload.toolsAllow);
if (parsed && !shellToolAccess) {
return "commandPromptWithoutShellAccess";
}
if (shellToolAccess && SHELL_COMMAND_MESSAGE_RE.test(message)) {
return "shellToolPrompt";
}
return null;
}
export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean {
return classifyUnresolvedAgentTurnShellToolPrompt(payload) !== null;
}

View File

@@ -5,28 +5,55 @@ import { normalizeCronJobInput } from "../../../cron/normalize.js";
import type { CronJob } from "../../../cron/types.js";
type CronLegacyIssueCounts = Partial<Record<string, number>>;
type CronLegacyIssueDetails = {
unresolvedAgentTurnShellToolPrompt?: string[];
};
function pluralize(count: number, noun: string) {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}
function formatJobNameList(names: string[] | undefined): string {
if (!names || names.length === 0) {
return "";
}
function formatJobNameList(names: string[]): string {
const preview = names.slice(0, 5).map((name) => `\`${name}\``);
const remaining = names.length - preview.length;
return remaining > 0 ? `: ${preview.join(", ")} (+${remaining} more)` : `: ${preview.join(", ")}`;
}
/**
* Advisory for isolated agentTurn cron jobs that describe a command but cannot access shell tools.
* These need operator attention, but `doctor --fix` cannot safely infer whether to grant tool
* access or recreate them as command cron jobs.
*/
export function formatUnresolvedCommandPromptAdvisory(names: string[]): string | null {
if (names.length === 0) {
return null;
}
const describeVerb = names.length === 1 ? "describes" : "describe";
const accessVerb = names.length === 1 ? "lacks" : "lack";
return [
`${pluralize(names.length, "isolated cron job")} ${describeVerb} a shell command in the agent prompt but ${accessVerb} shell/process tool access${formatJobNameList(names)}.`,
"- This is not the supported shell-tool prompt shape, so doctor cannot prove the job will execute the requested command.",
'- Recreate the job as a command cron job (`openclaw cron add ... --command "<shell>"`) or grant explicit shell/process tool access before relying on it.',
].join("\n");
}
/**
* Advisory for isolated agentTurn cron jobs that drive shell/process tools from the prompt.
* These keep running and are not a legacy store row, so `doctor --fix` cannot rewrite them;
* routing this through the auto-repair preview made the finding persist after every --fix.
*/
export function formatUnresolvedShellPromptAdvisory(names: string[]): string | null {
if (names.length === 0) {
return null;
}
const verb = names.length === 1 ? "drives" : "drive";
const keepVerb = names.length === 1 ? "keeps" : "keep";
return [
`${pluralize(names.length, "isolated cron job")} ${verb} shell/process tools from the agent prompt and ${keepVerb} running as-is${formatJobNameList(names)}.`,
"- This is a supported shape, not a legacy store row, so the doctor fix path cannot convert it and the finding is informational only.",
'- For a deterministic run, recreate the job as a command cron job (`openclaw cron add ... --command "<shell>"`).',
].join("\n");
}
/** Convert legacy cron issue counts into doctor preview lines. */
export function formatLegacyIssuePreview(
issues: CronLegacyIssueCounts,
details: CronLegacyIssueDetails = {},
): string[] {
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
const lines: string[] = [];
if (issues.jobId) {
lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``);
@@ -58,11 +85,6 @@ export function formatLegacyIssuePreview(
`- ${pluralize(issues.legacyAgentTurnCommandPayload, "job")} uses an agent prompt to run a shell command`,
);
}
if (issues.unresolvedAgentTurnShellToolPrompt) {
lines.push(
`- ${pluralize(issues.unresolvedAgentTurnShellToolPrompt, "job")} asks an isolated agent for shell/process tools and needs manual command conversion${formatJobNameList(details.unresolvedAgentTurnShellToolPrompt)}`,
);
}
if (issues.legacyPayloadProvider) {
lines.push(
`- ${pluralize(issues.legacyPayloadProvider, "job")} still uses payload \`provider\` as a delivery alias`,

View File

@@ -190,6 +190,8 @@ describe("normalizeStoredCronJobs", () => {
expect(result.issues.legacyAgentTurnCommandPayload).toBeUndefined();
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
expect(result.unresolvedAgentTurnCommandPromptJobs).toEqual(["Legacy job"]);
expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual([]);
const payload = job.payload as Record<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain(command);

View File

@@ -14,7 +14,7 @@ import { inferCronJobName } from "../../../cron/service/normalize.js";
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js";
import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js";
import {
hasUnresolvedAgentTurnShellToolPrompt,
classifyUnresolvedAgentTurnShellToolPrompt,
hasLegacyOpenAICodexCronModelRef,
migrateLegacyAgentTurnCommandPayload,
migrateLegacyCronPayload,
@@ -41,6 +41,7 @@ type CronStoreIssues = Partial<Record<CronStoreIssueKey, number>>;
type NormalizeCronStoreJobsResult = {
issues: CronStoreIssues;
unresolvedAgentTurnCommandPromptJobs: string[];
unresolvedAgentTurnShellToolPromptJobs: string[];
jobs: Array<Record<string, unknown>>;
mutated: boolean;
@@ -246,7 +247,12 @@ export function normalizeStoredCronJobs(
jobs: Array<Record<string, unknown>>,
): NormalizeCronStoreJobsResult {
const issues: CronStoreIssues = {};
const unresolvedAgentTurnCommandPromptJobs: string[] = [];
const unresolvedAgentTurnShellToolPromptJobs: string[] = [];
const unresolvedAgentTurnPromptJobsByKind = {
commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs,
shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs,
};
let mutated = false;
const keptJobs: Array<Record<string, unknown>> = [];
const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = [];
@@ -421,11 +427,14 @@ export function normalizeStoredCronJobs(
if (migrateLegacyAgentTurnCommandPayload(payloadRecord)) {
mutated = true;
trackIssue("legacyAgentTurnCommandPayload");
} else if (hasUnresolvedAgentTurnShellToolPrompt(payloadRecord)) {
trackIssue("unresolvedAgentTurnShellToolPrompt");
const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
if (name) {
unresolvedAgentTurnShellToolPromptJobs.push(name);
} else {
const unresolvedPromptKind = classifyUnresolvedAgentTurnShellToolPrompt(payloadRecord);
if (unresolvedPromptKind) {
trackIssue("unresolvedAgentTurnShellToolPrompt");
const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
if (name) {
unresolvedAgentTurnPromptJobsByKind[unresolvedPromptKind].push(name);
}
}
}
}
@@ -626,5 +635,12 @@ export function normalizeStoredCronJobs(
jobs.splice(0, jobs.length, ...keptJobs);
}
return { issues, unresolvedAgentTurnShellToolPromptJobs, jobs, mutated, removedJobs };
return {
issues,
unresolvedAgentTurnCommandPromptJobs,
unresolvedAgentTurnShellToolPromptJobs,
jobs,
mutated,
removedJobs,
};
}

View File

@@ -983,6 +983,15 @@ describe("gateway-status command", () => {
]);
});
it("passes the full caller timeout through to active configured remote probes", async () => {
const { runtime } = createRuntimeCapture();
probeGateway.mockClear();
await runGatewayStatus(runtime, { timeout: "15000", json: true });
expect(requireProbeCall("wss://remote.example:18789").timeoutMs).toBe(15_000);
});
it("uses configured handshake timeout as the default local probe budget", async () => {
const { runtime } = createRuntimeCapture();
probeGateway.mockClear();

View File

@@ -436,20 +436,30 @@ describe("resolveProbeBudgetMs", () => {
).toBe(2_500);
});
it("keeps non-local probe caps unchanged", () => {
it("lets active remote probes use the full caller budget", () => {
expect(
resolveProbeBudgetMs(15_000, {
kind: "configRemote",
active: true,
url: "wss://gateway.example/ws",
}),
).toBe(1500);
).toBe(15_000);
expect(
resolveProbeBudgetMs(15_000, {
kind: "explicit",
active: true,
url: "wss://gateway.example/ws",
}),
).toBe(15_000);
});
it("keeps inactive remote and SSH tunnel probes on the short cap", () => {
expect(
resolveProbeBudgetMs(15_000, {
kind: "configRemote",
active: false,
url: "wss://gateway.example/ws",
}),
).toBe(1500);
expect(
resolveProbeBudgetMs(15_000, {

View File

@@ -150,15 +150,15 @@ export function resolveProbeBudgetMs(
if (target.kind === "sshTunnel") {
return Math.min(2000, overallMs);
}
if (target.active) {
return overallMs;
}
if (target.kind === "localLoopback") {
return Math.min(800, overallMs);
}
if (!isLoopbackProbeTarget(target)) {
return Math.min(1500, overallMs);
}
if (target.kind === "localLoopback" && !target.active) {
return Math.min(800, overallMs);
}
// Active/discovered loopback probes and explicit loopback URLs should honor
// the caller budget because healthy local detail RPCs can legitimately take
// longer than the legacy short caps.
return overallMs;
}

View File

@@ -9,6 +9,7 @@ import {
upsertApiKeyProfile,
writeOAuthCredentials,
} from "../plugins/provider-auth-helpers.js";
import { setTestEnvValue } from "../test-utils/env.js";
import {
createAuthTestLifecycle,
readAuthProfilesForAgent,
@@ -162,7 +163,8 @@ describe("writeOAuthCredentials", () => {
it("writes OAuth credentials to all sibling agent dirs when syncSiblingAgents=true", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-sync-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
lifecycle.setStateDir(tempStateDir);
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
@@ -171,7 +173,7 @@ describe("writeOAuthCredentials", () => {
await fs.mkdir(kidAgentDir, { recursive: true });
await fs.mkdir(workerAgentDir, { recursive: true });
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
setTestEnvValue("OPENCLAW_AGENT_DIR", kidAgentDir);
const creds = {
refresh: "refresh-sync",
@@ -198,14 +200,15 @@ describe("writeOAuthCredentials", () => {
it("writes OAuth credentials only to target dir by default", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-nosync-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
lifecycle.setStateDir(tempStateDir);
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await fs.mkdir(kidAgentDir, { recursive: true });
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
setTestEnvValue("OPENCLAW_AGENT_DIR", kidAgentDir);
const creds = {
refresh: "refresh-kid",
@@ -229,7 +232,8 @@ describe("writeOAuthCredentials", () => {
it("syncs siblings from explicit agentDir outside OPENCLAW_STATE_DIR", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-external-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
lifecycle.setStateDir(tempStateDir);
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
// Create standard-layout agents tree *outside* OPENCLAW_STATE_DIR
const externalRoot = path.join(tempStateDir, "external", "agents");

View File

@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { captureEnv } from "../test-utils/env.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { createThrowingRuntime } from "./onboard-non-interactive.test-helpers.js";
import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js";
@@ -336,8 +336,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
throw new Error("temp home not initialized");
}
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
process.env.OPENCLAW_STATE_DIR = stateDir;
delete process.env.OPENCLAW_CONFIG_PATH;
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
return stateDir;
};
const withStateDir = async (
@@ -364,16 +364,16 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
]);
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1");
setTestEnvValue("OPENCLAW_SKIP_GMAIL_WATCHER", "1");
setTestEnvValue("OPENCLAW_SKIP_CRON", "1");
setTestEnvValue("OPENCLAW_SKIP_CANVAS_HOST", "1");
setTestEnvValue("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "1");
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
deleteTestEnvValue("OPENCLAW_GATEWAY_PASSWORD");
tempHome = await makeTempWorkspace("openclaw-onboard-");
process.env.HOME = tempHome;
setTestEnvValue("HOME", tempHome);
await loadGatewayOnboardModules();
});
@@ -969,8 +969,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
return;
}
await withStateDir("state-lan-", async (stateDir) => {
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const port = getPseudoPort(40_000);
const workspace = path.join(stateDir, "openclaw");

View File

@@ -67,9 +67,10 @@ export async function listConfiguredMcpServers(): Promise<ConfigMcpReadResult> {
};
}
export async function updateConfiguredMcpServerTools(params: {
async function updateConfiguredMcpServerConfig(params: {
name: string;
tools: McpServerToolSelection | null;
update: (server: Record<string, unknown>) => Record<string, unknown>;
errorLabel: string;
}): Promise<ConfigMcpWriteResult> {
const name = params.name.trim();
if (!name) {
@@ -92,22 +93,7 @@ export async function updateConfiguredMcpServerTools(params: {
const next = structuredClone(loaded.config);
const servers = normalizeConfiguredMcpServers(next.mcp?.servers);
const server = { ...servers[name] };
if (params.tools === null) {
delete server.toolFilter;
} else {
const include = normalizeToolSelectionList(params.tools.include);
const exclude = normalizeToolSelectionList(params.tools.exclude);
if (include || exclude) {
server.toolFilter = {
...(include ? { include } : {}),
...(exclude ? { exclude } : {}),
};
} else {
delete server.toolFilter;
}
}
servers[name] = server;
servers[name] = params.update({ ...servers[name] });
next.mcp = {
...next.mcp,
servers,
@@ -119,7 +105,7 @@ export async function updateConfiguredMcpServerTools(params: {
return {
ok: false,
path: loaded.path,
error: `Config invalid after MCP tool selection update (${issue.path}: ${issue.message}).`,
error: `Config invalid after MCP ${params.errorLabel} (${issue.path}: ${issue.message}).`,
};
}
await replaceConfigFile({
@@ -135,57 +121,42 @@ export async function updateConfiguredMcpServerTools(params: {
};
}
export async function updateConfiguredMcpServerTools(params: {
name: string;
tools: McpServerToolSelection | null;
}): Promise<ConfigMcpWriteResult> {
return updateConfiguredMcpServerConfig({
name: params.name,
errorLabel: "tool selection update",
update: (server) => {
if (params.tools === null) {
delete server.toolFilter;
} else {
const include = normalizeToolSelectionList(params.tools.include);
const exclude = normalizeToolSelectionList(params.tools.exclude);
if (include || exclude) {
server.toolFilter = {
...(include ? { include } : {}),
...(exclude ? { exclude } : {}),
};
} else {
delete server.toolFilter;
}
}
return server;
},
});
}
export async function updateConfiguredMcpServer(params: {
name: string;
update: (server: Record<string, unknown>) => Record<string, unknown>;
}): Promise<ConfigMcpWriteResult> {
const name = params.name.trim();
if (!name) {
return { ok: false, path: "", error: "MCP server name is required." };
}
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) {
return loaded;
}
if (!Object.hasOwn(loaded.mcpServers, name)) {
return {
ok: true,
path: loaded.path,
config: loaded.config,
mcpServers: loaded.mcpServers,
updated: false,
};
}
const next = structuredClone(loaded.config);
const servers = normalizeConfiguredMcpServers(next.mcp?.servers);
servers[name] = canonicalizeConfiguredMcpServer(params.update({ ...servers[name] }));
next.mcp = {
...next.mcp,
servers,
};
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
const issue = validated.issues[0];
return {
ok: false,
path: loaded.path,
error: `Config invalid after MCP configure (${issue.path}: ${issue.message}).`,
};
}
await replaceConfigFile({
nextConfig: validated.config,
baseHash: loaded.baseHash,
return updateConfiguredMcpServerConfig({
name: params.name,
errorLabel: "configure",
update: (server) => canonicalizeConfiguredMcpServer(params.update(server)),
});
return {
ok: true,
path: loaded.path,
config: validated.config,
mcpServers: servers,
updated: true,
};
}
export async function setConfiguredMcpServer(params: {

View File

@@ -28,6 +28,9 @@ vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => {
if (params.channelId === "cache-channel") {
return Boolean(params.env?.CACHE_CHANNEL_TOKEN?.trim());
}
if (params.channelId === "irc") {
return Boolean(params.env?.IRC_HOST?.trim() && params.env?.IRC_NICK?.trim());
}
@@ -1198,10 +1201,11 @@ describe("applyPluginAutoEnable core", () => {
it("does not reuse same-turn auto-enable results after discovery mutates in place", () => {
const config: OpenClawConfig = {};
const mutableDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] };
const manifestRegistry = makeRegistry([{ id: "irc-plugin", channels: ["irc"] }]);
const manifestRegistry = makeRegistry([
{ id: "cache-channel-plugin", channels: ["cache-channel"] },
]);
const configuredEnv = makeIsolatedEnv({
IRC_HOST: "irc.libera.chat",
IRC_NICK: "openclaw-bot",
CACHE_CHANNEL_TOKEN: "configured",
});
const first = applyPluginAutoEnable({
@@ -1211,7 +1215,10 @@ describe("applyPluginAutoEnable core", () => {
manifestRegistry,
});
mutableDiscovery.candidates.push(
makeBundledChannelCandidate({ pluginId: "irc-plugin", channelId: "irc" }),
makeBundledChannelCandidate({
pluginId: "cache-channel-plugin",
channelId: "cache-channel",
}),
);
const second = applyPluginAutoEnable({
config,
@@ -1220,8 +1227,8 @@ describe("applyPluginAutoEnable core", () => {
manifestRegistry,
});
expect(first.config.plugins?.entries?.["irc-plugin"]).toBeUndefined();
expect(second.config.plugins?.entries?.["irc-plugin"]?.enabled).toBe(true);
expect(first.config.plugins?.entries?.["cache-channel-plugin"]).toBeUndefined();
expect(second.config.plugins?.entries?.["cache-channel-plugin"]?.enabled).toBe(true);
expect(second).not.toBe(first);
});

View File

@@ -11,3 +11,15 @@ export function resolveAssistantStreamDeltaText(evt: AgentEventPayload): string
const text = evt.data.text;
return typeof delta === "string" ? delta : typeof text === "string" ? text : "";
}
export function isReplaceableAssistantStreamEvent(evt: AgentEventPayload): boolean {
return evt.data.replaceable === true;
}
export function resolveAssistantStreamSnapshotText(evt: AgentEventPayload): string {
const text = evt.data.text;
if (typeof text === "string") {
return text;
}
return resolveAssistantStreamDeltaText(evt);
}

View File

@@ -2313,6 +2313,81 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
},
);
it("buffers replaceable assistant events for streaming chat completions", async () => {
const port = enabledPort;
agentCommand.mockClear();
agentCommand.mockImplementationOnce((async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
});
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "final answer", delta: "", replace: true, replaceable: true },
});
emitAgentEvent({ runId, stream: "assistant", data: { text: "final answer" } });
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
return { payloads: [{ text: "final answer" }] };
}) as never);
const res = await postChatCompletions(port, {
stream: true,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const data = parseSseDataLines(await res.text());
const chunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = chunks
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((content): content is string => typeof content === "string")
.join("");
expect(allContent).toBe("final answer");
expect(allContent).not.toContain("coordination draft");
});
it("prefers final result text over buffered replaceable chat drafts", async () => {
const port = enabledPort;
agentCommand.mockClear();
agentCommand.mockImplementationOnce((async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
});
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
return { payloads: [{ text: "final answer" }] };
}) as never);
const res = await postChatCompletions(port, {
stream: true,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const data = parseSseDataLines(await res.text());
const chunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = chunks
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((content): content is string => typeof content === "string")
.join("");
expect(allContent).toBe("final answer");
});
it("includes usage in final stream chunk when stream_options.include_usage=true", async () => {
const port = enabledPort;
agentCommand.mockClear();

View File

@@ -35,7 +35,11 @@ import {
type InputImageSource,
} from "../media/input-files.js";
import { defaultRuntime } from "../runtime.js";
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
import {
isReplaceableAssistantStreamEvent,
resolveAssistantStreamDeltaText,
resolveAssistantStreamSnapshotText,
} from "./agent-event-assistant-text.js";
import {
buildAgentMessageFromConversationEntries,
type ConversationEntry,
@@ -1180,6 +1184,7 @@ export async function handleOpenAiHttpRequest(
let wroteStopChunk = false;
let sawAssistantDelta = false;
let bufferedAssistantContent = "";
let bufferedReplaceableAssistantContent = "";
let finalUsage: OpenAiChatCompletionsUsage | undefined;
let finalizeRequested = false;
let finalizeFinishReason: "stop" | "tool_calls" = "stop";
@@ -1226,6 +1231,20 @@ export async function handleOpenAiHttpRequest(
}
if (evt.stream === "assistant") {
const text = evt.data?.text;
const replace = evt.data?.replace === true;
if (replace && typeof text === "string") {
bufferedReplaceableAssistantContent = text;
}
if (isReplaceableAssistantStreamEvent(evt)) {
const snapshot = resolveAssistantStreamSnapshotText(evt);
if (snapshot) {
bufferedReplaceableAssistantContent = snapshot;
}
return;
}
const content = resolveAssistantStreamDeltaText(evt) ?? "";
if (!content) {
return;
@@ -1313,7 +1332,10 @@ export async function handleOpenAiHttpRequest(
writeAssistantRoleChunk(res, { runId, model });
}
if (!sawAssistantDelta) {
const commentary = bufferedAssistantContent || resolveAgentResponseCommentary(result);
const commentary =
bufferedAssistantContent ||
resolveAgentResponseCommentary(result) ||
bufferedReplaceableAssistantContent;
if (commentary) {
sawAssistantDelta = true;
writeAssistantContentChunk(res, {
@@ -1339,7 +1361,11 @@ export async function handleOpenAiHttpRequest(
writeAssistantRoleChunk(res, { runId, model });
}
const content = resolveAgentResponseText(result);
const content =
resolveAgentResponseCommentary(result) ||
bufferedReplaceableAssistantContent ||
resolveAgentResponseText(result) ||
"No response from OpenClaw.";
sawAssistantDelta = true;
writeAssistantContentChunk(res, {

View File

@@ -1372,6 +1372,83 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(response?.output?.[1]?.name).toBe("get_weather");
});
it("buffers replaceable assistant events for streaming responses", async () => {
const port = enabledPort;
agentCommand.mockClear();
agentCommand.mockImplementationOnce((async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
});
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "final answer", delta: "", replace: true, replaceable: true },
});
emitAgentEvent({ runId, stream: "assistant", data: { text: "final answer" } });
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
return { payloads: [{ text: "final answer" }] };
}) as never);
const res = await postResponses(port, {
stream: true,
model: "openclaw",
input: "hi",
});
expect(res.status).toBe(200);
const events = parseSseEvents(await res.text());
const deltas = events
.filter((event) => event.event === "response.output_text.delta")
.map((event) => {
const parsed = JSON.parse(event.data) as { delta?: string };
return parsed.delta ?? "";
})
.join("");
const completed = JSON.parse(findSseEvent(events, "response.completed").data) as {
response?: { output?: Array<{ content?: Array<{ text?: string }> }> };
};
expect(deltas).toBe("final answer");
expect(completed.response?.output?.[0]?.content?.[0]?.text).toBe("final answer");
expect(deltas).not.toContain("coordination draft");
});
it("prefers final result text over buffered replaceable response drafts", async () => {
const port = enabledPort;
agentCommand.mockClear();
agentCommand.mockImplementationOnce((async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({
runId,
stream: "assistant",
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
});
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
return { payloads: [{ text: "final answer" }] };
}) as never);
const res = await postResponses(port, {
stream: true,
model: "openclaw",
input: "hi",
});
expect(res.status).toBe(200);
const events = parseSseEvents(await res.text());
const deltas = events
.filter((event) => event.event === "response.output_text.delta")
.map((event) => {
const parsed = JSON.parse(event.data) as { delta?: string };
return parsed.delta ?? "";
})
.join("");
expect(deltas).toBe("final answer");
});
it("falls back to payload text for streamed function_call responses", async () => {
const port = enabledPort;
agentCommand.mockClear();

View File

@@ -33,7 +33,11 @@ import {
type InputImageSource,
} from "../media/input-files.js";
import { defaultRuntime } from "../runtime.js";
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
import {
isReplaceableAssistantStreamEvent,
resolveAssistantStreamDeltaText,
resolveAssistantStreamSnapshotText,
} from "./agent-event-assistant-text.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import {
@@ -912,11 +916,13 @@ export async function handleOpenResponsesHttpRequest(
setSseHeaders(res);
let accumulatedText = "";
let bufferedReplaceableAssistantContent = "";
let sawAssistantDelta = false;
let closed = false;
let unsubscribe = () => {};
let stopWatchingDisconnect = () => {};
let finalUsage: Usage | undefined;
let finalizeStatus: ResponseResource["status"] | null = null;
let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null;
const maybeFinalize = () => {
@@ -982,6 +988,7 @@ export async function handleOpenResponsesHttpRequest(
if (finalizeRequested) {
return;
}
finalizeStatus = status;
finalizeRequested = { status, text };
maybeFinalize();
};
@@ -1028,13 +1035,39 @@ export async function handleOpenResponsesHttpRequest(
}
if (evt.stream === "assistant") {
if (isReplaceableAssistantStreamEvent(evt)) {
const snapshot = resolveAssistantStreamSnapshotText(evt);
if (snapshot) {
bufferedReplaceableAssistantContent = snapshot;
}
return;
}
const text = evt.data?.text;
const replace = evt.data?.replace === true;
const hadAssistantDelta = sawAssistantDelta;
if (replace && typeof text === "string") {
accumulatedText = text;
}
const content = resolveAssistantStreamDeltaText(evt);
if (!content) {
if (
replace &&
typeof text === "string" &&
text &&
!toolChoiceConstraint &&
!hadAssistantDelta
) {
sawAssistantDelta = true;
writeSseEvent(res, {
type: "response.output_text.delta",
item_id: outputItemId,
output_index: 0,
content_index: 0,
delta: text,
});
}
return;
}
@@ -1064,7 +1097,8 @@ export async function handleOpenResponsesHttpRequest(
if (evt.stream === "lifecycle") {
const phase = evt.data?.phase;
if (phase === "end" || phase === "error") {
const finalText = accumulatedText || "No response from OpenClaw.";
const finalText =
accumulatedText || bufferedReplaceableAssistantContent || "No response from OpenClaw.";
const finalStatus = phase === "error" ? "failed" : "completed";
requestFinalize(finalStatus, finalText);
}
@@ -1097,6 +1131,12 @@ export async function handleOpenResponsesHttpRequest(
// Check for pending client tool calls BEFORE maybeFinalize() because the
// lifecycle:end event may already have requested finalization.
const resultAny = result as { payloads?: Array<{ text?: string }>; meta?: unknown };
const resultPayloadText = Array.isArray(resultAny.payloads)
? resultAny.payloads
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "";
const meta = resultAny.meta;
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
@@ -1137,22 +1177,16 @@ export async function handleOpenResponsesHttpRequest(
) {
const usage = finalUsage ?? createEmptyUsage();
const finalText =
accumulatedText ||
(Array.isArray(resultAny.payloads)
? resultAny.payloads
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "");
accumulatedText || resultPayloadText || bufferedReplaceableAssistantContent;
if (toolChoiceConstraint && accumulatedText) {
if (toolChoiceConstraint && finalText && !sawAssistantDelta) {
sawAssistantDelta = true;
writeSseEvent(res, {
type: "response.output_text.delta",
item_id: outputItemId,
output_index: 0,
content_index: 0,
delta: accumulatedText,
delta: finalText,
});
}
writeSseEvent(res, {
@@ -1237,25 +1271,16 @@ export async function handleOpenResponsesHttpRequest(
return;
}
maybeFinalize();
if (closed) {
return;
}
// Fallback: if no streaming deltas were received, send the full response as text
if (!sawAssistantDelta) {
const payloads = resultAny.payloads;
const content =
Array.isArray(payloads) && payloads.length > 0
? payloads
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "No response from OpenClaw.";
resultPayloadText || bufferedReplaceableAssistantContent || "No response from OpenClaw.";
accumulatedText = content;
sawAssistantDelta = true;
if (finalizeStatus !== null) {
finalizeRequested = { status: finalizeStatus, text: content };
}
writeSseEvent(res, {
type: "response.output_text.delta",
@@ -1265,6 +1290,8 @@ export async function handleOpenResponsesHttpRequest(
delta: content,
});
}
maybeFinalize();
} catch (err) {
if (closed || abortController.signal.aborted) {
return;

View File

@@ -60,6 +60,29 @@ describe("server chat stream text merge", () => {
).toBe("Before tool call\nAfter tool call");
});
it("replaces prior live text when Codex assistant item switches with empty delta", () => {
const prior = "coordination draft";
const replacement = resolveMergedAssistantText({
previousText: prior,
nextText: "final answer",
nextDelta: "",
});
expect(replacement).toBe("final answer");
expect(replacement).not.toContain(prior);
});
it("would concatenate superseded text if replacement incorrectly included delta", () => {
const prior = "coordination draft";
const buggy = resolveMergedAssistantText({
previousText: prior,
nextText: "final ",
nextDelta: "final ",
});
expect(buggy).toBe("coordination draftfinal ");
});
it("caps merged live text while preserving the newest assistant output", () => {
const result = resolveMergedAssistantText({
previousText: "a".repeat(MAX_LIVE_CHAT_BUFFER_CHARS - 2),

View File

@@ -149,7 +149,6 @@ import {
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
canonicalizeSpawnedByForAgent,
loadGatewaySessionRow,
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
resolveGatewaySessionStoreTarget,
@@ -166,6 +165,7 @@ import {
waitForTerminalGatewayDedupe,
} from "./agent-wait-dedupe.js";
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type {
GatewayRequestContext,
GatewayRequestHandlerOptions,
@@ -571,92 +571,6 @@ function requestGroupMatchesTrusted(params: {
return Boolean(params.trustedGroupId && requestGroupId === params.trustedGroupId);
}
function emitSessionsChanged(
context: Pick<
GatewayRequestHandlerOptions["context"],
"broadcastToConnIds" | "getSessionEventSubscriberConnIds"
>,
payload: { sessionKey?: string; agentId?: string; reason: string },
) {
const connIds = context.getSessionEventSubscriberConnIds();
if (connIds.size === 0) {
return;
}
const sessionRow = payload.sessionKey
? loadGatewaySessionRow(
payload.sessionKey,
payload.sessionKey === "global" && payload.agentId
? { agentId: payload.agentId }
: undefined,
)
: null;
// Unscoped global updates must not leak one agent's goal into another agent's UI row.
const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId;
context.broadcastToConnIds(
"sessions.changed",
{
...payload,
ts: Date.now(),
...(sessionRow
? {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
groupChannel: sessionRow.groupChannel,
space: sessionRow.space,
chatType: sessionRow.chatType,
origin: sessionRow.origin,
spawnedBy: sessionRow.spawnedBy,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
forkedFromParent: sessionRow.forkedFromParent,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
label: sessionRow.label,
displayName: sessionRow.displayName,
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
thinkingLevel: sessionRow.thinkingLevel,
fastMode: sessionRow.fastMode,
verboseLevel: sessionRow.verboseLevel,
traceLevel: sessionRow.traceLevel,
reasoningLevel: sessionRow.reasoningLevel,
elevatedLevel: sessionRow.elevatedLevel,
sendPolicy: sessionRow.sendPolicy,
systemSent: sessionRow.systemSent,
abortedLastRun: sessionRow.abortedLastRun,
inputTokens: sessionRow.inputTokens,
outputTokens: sessionRow.outputTokens,
lastChannel: sessionRow.lastChannel,
lastTo: sessionRow.lastTo,
lastAccountId: sessionRow.lastAccountId,
lastThreadId: sessionRow.lastThreadId,
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
responseUsage: sessionRow.responseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
}
: {}),
},
connIds,
{ dropIfSlow: true },
);
}
type GatewayAgentTaskTerminalStatus = Extract<
TaskStatus,
"succeeded" | "failed" | "timed_out" | "cancelled"

View File

@@ -1,5 +1,6 @@
// Shared sessions.changed broadcaster for gateway RPC and chat-command mutations.
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { buildGatewaySessionEventFields } from "../session-event-payload.js";
import { loadGatewaySessionRow } from "../session-utils.js";
import { hasTrackedActiveSessionRun } from "./session-active-runs.js";
import type { GatewayRequestContext } from "./types.js";
@@ -33,7 +34,6 @@ export function emitSessionsChanged(
: undefined,
)
: null;
const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId;
const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig());
context.broadcastToConnIds(
"sessions.changed",
@@ -42,66 +42,21 @@ export function emitSessionsChanged(
ts: Date.now(),
...(sessionRow
? {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
groupChannel: sessionRow.groupChannel,
space: sessionRow.space,
chatType: sessionRow.chatType,
origin: sessionRow.origin,
spawnedBy: sessionRow.spawnedBy,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
forkedFromParent: sessionRow.forkedFromParent,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
label: sessionRow.label,
displayName: sessionRow.displayName,
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
thinkingLevel: sessionRow.thinkingLevel,
fastMode: sessionRow.fastMode,
...buildGatewaySessionEventFields({
sessionRow,
agentId: payload.agentId,
hasActiveRun: hasTrackedActiveSessionRun({
context,
requestedKey: payload.sessionKey ?? sessionRow.key,
canonicalKey: sessionRow.key,
agentId: sessionRow.key === "global" ? payload.agentId : undefined,
defaultAgentId,
}),
}),
effectiveFastMode: sessionRow.effectiveFastMode,
effectiveFastModeSource: sessionRow.effectiveFastModeSource,
fastAutoOnSeconds: sessionRow.fastAutoOnSeconds,
verboseLevel: sessionRow.verboseLevel,
traceLevel: sessionRow.traceLevel,
reasoningLevel: sessionRow.reasoningLevel,
elevatedLevel: sessionRow.elevatedLevel,
sendPolicy: sessionRow.sendPolicy,
systemSent: sessionRow.systemSent,
abortedLastRun: sessionRow.abortedLastRun,
inputTokens: sessionRow.inputTokens,
outputTokens: sessionRow.outputTokens,
lastChannel: sessionRow.lastChannel,
lastTo: sessionRow.lastTo,
lastAccountId: sessionRow.lastAccountId,
lastThreadId: sessionRow.lastThreadId,
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
responseUsage: sessionRow.responseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
hasActiveRun: hasTrackedActiveSessionRun({
context,
requestedKey: payload.sessionKey ?? sessionRow.key,
canonicalKey: sessionRow.key,
agentId: sessionRow.key === "global" ? payload.agentId : undefined,
defaultAgentId,
}),
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
pluginExtensions: sessionRow.pluginExtensions,
}
: {}),

View File

@@ -15,6 +15,7 @@ import type {
SessionMessageSubscriberRegistry,
} from "./server-chat.js";
import { hasTrackedActiveSessionRun } from "./server-methods/session-active-runs.js";
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js";
import {
attachOpenClawTranscriptMeta,
@@ -59,11 +60,10 @@ function buildGatewaySessionSnapshot(params: {
if (!sessionRow) {
return {};
}
const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId;
// The unscoped global row hides goal state to avoid presenting one agent's
// scoped goal as the global/default session goal.
const session = params.includeSession ? { ...sessionRow } : undefined;
if (session && omitUnscopedGlobalGoal) {
if (session && sessionRow.key === "global" && !params.agentId) {
// The unscoped global row hides goal state to avoid presenting one agent's
// scoped goal as the global/default session goal.
delete session.goal;
}
if (session && params.hasActiveRun !== undefined) {
@@ -71,58 +71,16 @@ function buildGatewaySessionSnapshot(params: {
}
return {
...(session ? { session } : {}),
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
groupChannel: sessionRow.groupChannel,
space: sessionRow.space,
chatType: sessionRow.chatType,
origin: sessionRow.origin,
spawnedBy: sessionRow.spawnedBy,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
forkedFromParent: sessionRow.forkedFromParent,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
label: params.label ?? sessionRow.label,
displayName: params.displayName ?? sessionRow.displayName,
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: params.parentSessionKey ?? sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
thinkingLevel: sessionRow.thinkingLevel,
fastMode: sessionRow.fastMode,
verboseLevel: sessionRow.verboseLevel,
reasoningLevel: sessionRow.reasoningLevel,
elevatedLevel: sessionRow.elevatedLevel,
sendPolicy: sessionRow.sendPolicy,
systemSent: sessionRow.systemSent,
abortedLastRun: sessionRow.abortedLastRun,
inputTokens: sessionRow.inputTokens,
outputTokens: sessionRow.outputTokens,
lastChannel: sessionRow.lastChannel,
lastTo: sessionRow.lastTo,
lastAccountId: sessionRow.lastAccountId,
lastThreadId: sessionRow.lastThreadId,
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
responseUsage: sessionRow.responseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }),
...buildGatewaySessionEventFields({
sessionRow,
agentId: params.agentId,
label: params.label,
displayName: params.displayName,
parentSessionKey: params.parentSessionKey,
hasActiveRun: params.hasActiveRun,
}),
subagentRunState: sessionRow.subagentRunState,
hasActiveSubagentRun: sessionRow.hasActiveSubagentRun,
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
};
}

View File

@@ -53,7 +53,7 @@ import {
pinActivePluginSessionExtensionRegistry,
} from "../plugins/runtime.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { getTotalQueueSize } from "../process/command-queue.js";
import { getTotalQueueSize, isGatewayDraining } from "../process/command-queue.js";
import type { RuntimeEnv } from "../runtime.js";
import {
clearSecretsRuntimeSnapshot,
@@ -876,6 +876,7 @@ export async function startGatewayServer(
startedAt: serverStartedAt,
getStartupPending: isGatewayStartupPending,
getStartupPendingReason: () => startupPendingReason,
getGatewayDraining: isGatewayDraining,
getEventLoopHealth: readinessEventLoopHealth.snapshot,
shouldSkipChannelReadiness: () =>
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||

View File

@@ -68,6 +68,7 @@ function createReadinessHarness(params: {
accounts?: Record<string, Partial<ChannelAccountSnapshot>>;
getStartupPending?: () => boolean;
getStartupPendingReason?: Parameters<typeof createReadinessChecker>[0]["getStartupPendingReason"];
getGatewayDraining?: Parameters<typeof createReadinessChecker>[0]["getGatewayDraining"];
getEventLoopHealth?: Parameters<typeof createReadinessChecker>[0]["getEventLoopHealth"];
shouldSkipChannelReadiness?: Parameters<
typeof createReadinessChecker
@@ -83,6 +84,7 @@ function createReadinessHarness(params: {
startedAt,
getStartupPending: params.getStartupPending,
getStartupPendingReason: params.getStartupPendingReason,
getGatewayDraining: params.getGatewayDraining,
getEventLoopHealth: params.getEventLoopHealth,
shouldSkipChannelReadiness: params.shouldSkipChannelReadiness,
cacheTtlMs: params.cacheTtlMs,
@@ -178,6 +180,35 @@ describe("createReadinessChecker", () => {
});
});
it("reports not ready while the gateway command queue is draining for restart", () => {
withReadinessClock(() => {
const { manager, readiness } = createReadinessHarness({
getGatewayDraining: () => true,
cacheTtlMs: 1_000,
});
expect(readiness()).toEqual(failingSnapshot(["gateway-draining"]));
expect(manager.getRuntimeSnapshot).not.toHaveBeenCalled();
});
});
it("does not cache gateway-draining readiness", () => {
withReadinessClock(() => {
let gatewayDraining = true;
const { manager, readiness } = createReadinessHarness({
getGatewayDraining: () => gatewayDraining,
cacheTtlMs: 1_000,
});
expect(readiness()).toEqual(failingSnapshot(["gateway-draining"]));
expect(manager.getRuntimeSnapshot).not.toHaveBeenCalled();
gatewayDraining = false;
expect(readiness()).toEqual(readySnapshot());
expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(1);
});
});
it("ignores disabled and unconfigured channels", () => {
withReadinessClock(() => {
const { readiness } = createReadinessHarness({

View File

@@ -42,6 +42,7 @@ export function createReadinessChecker(deps: {
startedAt: number;
getStartupPending?: () => boolean;
getStartupPendingReason?: () => string | undefined;
getGatewayDraining?: () => boolean;
getEventLoopHealth?: () => GatewayEventLoopHealth | undefined;
shouldSkipChannelReadiness?: () => boolean;
cacheTtlMs?: number;
@@ -61,6 +62,12 @@ export function createReadinessChecker(deps: {
deps.getEventLoopHealth,
);
}
if (deps.getGatewayDraining?.()) {
return withEventLoopHealth(
{ ready: false, failing: ["gateway-draining"], uptimeMs },
deps.getEventLoopHealth,
);
}
if (deps.shouldSkipChannelReadiness?.()) {
return withEventLoopHealth({ ready: true, failing: [], uptimeMs }, deps.getEventLoopHealth);
}

View File

@@ -0,0 +1,65 @@
import type { GatewaySessionRow } from "./session-utils.js";
export function buildGatewaySessionEventFields(params: {
sessionRow: GatewaySessionRow;
agentId?: string;
label?: string;
displayName?: string;
parentSessionKey?: string;
hasActiveRun?: boolean;
}): Record<string, unknown> {
const { sessionRow } = params;
const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId;
return {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
groupChannel: sessionRow.groupChannel,
space: sessionRow.space,
chatType: sessionRow.chatType,
origin: sessionRow.origin,
spawnedBy: sessionRow.spawnedBy,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
forkedFromParent: sessionRow.forkedFromParent,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
label: params.label ?? sessionRow.label,
displayName: params.displayName ?? sessionRow.displayName,
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: params.parentSessionKey ?? sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
thinkingLevel: sessionRow.thinkingLevel,
fastMode: sessionRow.fastMode,
verboseLevel: sessionRow.verboseLevel,
reasoningLevel: sessionRow.reasoningLevel,
elevatedLevel: sessionRow.elevatedLevel,
sendPolicy: sessionRow.sendPolicy,
systemSent: sessionRow.systemSent,
abortedLastRun: sessionRow.abortedLastRun,
inputTokens: sessionRow.inputTokens,
outputTokens: sessionRow.outputTokens,
lastChannel: sessionRow.lastChannel,
lastTo: sessionRow.lastTo,
lastAccountId: sessionRow.lastAccountId,
lastThreadId: sessionRow.lastThreadId,
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
responseUsage: sessionRow.responseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }),
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
};
}

View File

@@ -1691,6 +1691,45 @@ describe("gateway session utils", () => {
});
test("listAgentsForGateway reports per-agent thinking defaults from the agent model", () => {
const resolveDeepSeekThinkingProfile = vi.fn(() => ({
levels: [
{ id: "off" as const },
{ id: "minimal" as const },
{ id: "low" as const },
{ id: "medium" as const },
{ id: "high" as const },
{ id: "xhigh" as const },
],
defaultLevel: "medium" as const,
}));
const registry = createEmptyPluginRegistry();
registry.providers.push(
{
pluginId: "test-minimax",
source: "test",
provider: {
id: "minimax",
label: "MiniMax",
auth: [],
resolveThinkingProfile: () => ({
levels: [{ id: "off" }],
defaultLevel: "off",
}),
},
},
{
pluginId: "test-deepseek",
source: "test",
provider: {
id: "deepseek",
label: "DeepSeek",
auth: [],
resolveThinkingProfile: resolveDeepSeekThinkingProfile,
},
},
);
setActivePluginRegistry(registry);
const cfg = {
session: { mainKey: "main" },
agents: {
@@ -1713,6 +1752,12 @@ describe("gateway session utils", () => {
const agent = result.agents.find((row) => row.id === "investment-master");
expect(agent?.model).toEqual({ primary: "deepseek/deepseek-v4-flash" });
expect(resolveDeepSeekThinkingProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: "deepseek",
modelId: "deepseek-v4-flash",
}),
);
expect(agent?.thinkingDefault).toBe("xhigh");
expect(agent?.thinkingLevels?.map((level) => level.id)).toEqual(
expect.arrayContaining(["off", "minimal", "low", "medium", "high", "xhigh"]),

View File

@@ -29,7 +29,7 @@ type GlobalRuntimeDotEnvOptions = {
stateEnvPath?: string;
};
function readGlobalRuntimeDotEnvFile(params: {
export function readDotEnvFile(params: {
entryFilter?: (key: string, value: string) => boolean;
filePath: string;
quiet?: boolean;
@@ -137,12 +137,12 @@ export function loadGlobalRuntimeDotEnvFiles(opts?: GlobalRuntimeDotEnvOptions)
process.env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
const globalEnvs = globalEnvPaths.map((filePath) =>
readGlobalRuntimeDotEnvFile({ entryFilter: opts?.entryFilter, filePath, quiet }),
readDotEnvFile({ entryFilter: opts?.entryFilter, filePath, quiet }),
);
const parsedFiles = [...globalEnvs];
let gatewayEnv: LoadedDotEnvFile | null = null;
if (!hasExplicitNonDefaultStateDir) {
gatewayEnv = readGlobalRuntimeDotEnvFile({
gatewayEnv = readDotEnvFile({
entryFilter: opts?.entryFilter,
filePath: path.join(
resolveRequiredHomeDir(process.env, os.homedir),

View File

@@ -1,18 +1,13 @@
// Loads dotenv files while blocking unsafe workspace env keys.
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js";
import { loadGlobalRuntimeDotEnvFiles } from "./dotenv-global.js";
import { loadGlobalRuntimeDotEnvFiles, readDotEnvFile } from "./dotenv-global.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeEnvVarKey,
} from "./host-env-security.js";
const logger = createSubsystemLogger("infra:dotenv");
const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [
"AI_GATEWAY_API_KEY",
"ANTHROPIC_API_KEY",
@@ -222,55 +217,6 @@ function shouldBlockWorkspaceDotEnvKey(
);
}
type DotEnvEntry = {
key: string;
value: string;
};
type LoadedDotEnvFile = {
filePath: string;
entries: DotEnvEntry[];
};
function readDotEnvFile(params: {
filePath: string;
shouldBlockKey: (key: string) => boolean;
quiet?: boolean;
}): LoadedDotEnvFile | null {
let content: string;
try {
content = fs.readFileSync(params.filePath, "utf8");
} catch (error) {
if (!params.quiet) {
const code =
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
if (code !== "ENOENT") {
logger.warn(`Failed to read ${params.filePath}: ${String(error)}`, { error });
}
}
return null;
}
let parsed: Record<string, string>;
try {
parsed = dotenv.parse(content);
} catch (error) {
if (!params.quiet) {
logger.warn(`Failed to parse ${params.filePath}: ${String(error)}`, { error });
}
return null;
}
const entries: DotEnvEntry[] = [];
for (const [rawKey, value] of Object.entries(parsed)) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key || params.shouldBlockKey(key)) {
continue;
}
entries.push({ key, value });
}
return { filePath: params.filePath, entries };
}
export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
let providerAuthBlockedKeys: ReadonlySet<string> | undefined;
const getProviderAuthBlockedKeys = () => {
@@ -279,7 +225,7 @@ export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boole
};
const parsed = readDotEnvFile({
filePath,
shouldBlockKey: (key) => shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys),
entryFilter: (key) => !shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys),
quiet: opts?.quiet ?? true,
});
if (!parsed) {

View File

@@ -13,6 +13,7 @@ import {
createPlainTextToolCallCompatWrapper,
defaultToolStreamExtraParams,
isOpenAICompatibleThinkingEnabled,
setQwenChatTemplateThinking,
stripTrailingAnthropicAssistantPrefillWhenThinking,
} from "./provider-stream-shared.js";
@@ -160,6 +161,38 @@ describe("isOpenAICompatibleThinkingEnabled", () => {
});
});
describe("setQwenChatTemplateThinking", () => {
it("preserves existing chat-template kwargs and enables thinking", () => {
const payload = {
chat_template_kwargs: {
custom_flag: "keep",
preserve_thinking: false,
},
};
setQwenChatTemplateThinking(payload, true);
expect(payload.chat_template_kwargs).toEqual({
custom_flag: "keep",
preserve_thinking: false,
enable_thinking: true,
});
});
it("creates the required chat-template kwargs when absent", () => {
const payload: Record<string, unknown> = {};
setQwenChatTemplateThinking(payload, false);
expect(payload).toEqual({
chat_template_kwargs: {
enable_thinking: false,
preserve_thinking: true,
},
});
});
});
describe("createDeepSeekV4OpenAICompatibleThinkingWrapper", () => {
it("backfills reasoning_content on every replayed assistant message when thinking is enabled", () => {
const payload = {

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