Merge remote-tracking branch 'origin/main' into meow/workboard-modal-drawer-a11y

# Conflicts:
#	ui/src/ui/views/workboard.ts
This commit is contained in:
Val Alexander
2026-06-03 03:54:39 -07:00
235 changed files with 13063 additions and 2398 deletions
+156
View File
@@ -0,0 +1,156 @@
name: Blacksmith ARM Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
pull_request:
paths:
- ".github/workflows/**"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
jobs:
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
-136
View File
@@ -139,139 +139,3 @@ jobs:
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+15
View File
@@ -120,6 +120,21 @@ jobs:
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
reset_crabbox_pnpm_path() {
local path="$1"
if [ -z "$path" ]; then
return
fi
case "$path" in
/var/tmp/openclaw-pnpm-*) rm -rf "$path" ;;
esac
}
reset_crabbox_pnpm_path "${PNPM_CONFIG_MODULES_DIR:-}"
reset_crabbox_pnpm_path "${PNPM_CONFIG_STORE_DIR:-}"
reset_crabbox_pnpm_path "${PNPM_CONFIG_VIRTUAL_STORE_DIR:-}"
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
rm -f node_modules
fi
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
+23
View File
@@ -47,6 +47,29 @@ Docs: https://docs.openclaw.ai
- Agents/providers: avoid loading owner plugin runtimes for explicitly configured custom provider models during OpenAI-compatible transport setup.
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
- Release/CI/E2E: reset shared Crabbox pnpm hydrate state before installs so stale `/var/tmp` stores cannot leave `pnpm install` spinning after completion.
- Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet.
- Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs.
- Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts.
- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling.
- Release/CI/E2E: bound Discord smoke API calls in cross-OS release checks so host-side round trips cannot hang on stalled fetches.
- Release/CI/E2E: bound RPC RTT gateway readiness probes so a half-open local HTTP response cannot stall cleanup past the readiness deadline.
- Scripts/UI: stop descendant processes from wrapped non-interactive commands when `run-with-env` receives shutdown signals.
- Release/CI/E2E: write multi-node update Docker artifacts to unique per-run directories by default so parallel runs cannot overwrite evidence.
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
- Release/CI/E2E: keep plugin lifecycle matrix resource artifacts under a unique per-run scratch root so parallel runs cannot overwrite tarballs or inspect output.
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
- Release/CI/E2E: fail RPC RTT probes when temporary state cleanup fails instead of hiding leftover scratch directories.
- Release/CI/E2E: fail Kitchen Sink RPC walks when temporary state cleanup still fails after retries instead of silently preserving scratch roots.
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
}
}
public struct ChatMetadataParams: Codable, Sendable {
public let agentid: String?
public init(
agentid: String? = nil)
{
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
}
}
public struct ChatMessageGetParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
@@ -1,2 +1,2 @@
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
a9501e226bb26befb02072cf5e60c3dc124cbd5dc0b16eb281789d0843f72f71 plugin-sdk-api-baseline.json
b106090dc12bf7e46beac4ed160f0cff0ef8039291f24172b693e8d8b752d571 plugin-sdk-api-baseline.jsonl
+1
View File
@@ -319,6 +319,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
+2
View File
@@ -205,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is
present in `policy.jsonc`. The observed state is existing OpenClaw config or
workspace metadata; policy reports drift but does not rewrite runtime behavior
unless a repair path is explicitly available and enabled.
Policy files are strict: unsupported sections or rule keys are reported as
`policy/policy-jsonc-invalid` instead of being ignored.
Policy overlays keep broad top-level rules global, then let named scope blocks
add stricter normal policy sections for explicit selectors. A scope name is a
+6 -4
View File
@@ -194,10 +194,12 @@ OpenClaw resolves that behavior by conversation type:
`message(action=send)`.
- Internal orchestration allows silence by default.
OpenClaw also uses silent replies for internal runner failures that happen
before any assistant reply in non-direct chats, so groups/channels do not see
gateway error boilerplate. Direct chats show compact failure copy by default;
raw runner details are shown only when `/verbose full` is enabled.
OpenClaw also uses silent replies for generic internal runner failures in
non-direct chats, so groups/channels do not see gateway error boilerplate.
Classified failures with user-facing recovery copy, such as missing auth,
rate-limit, or overload notices, can still be delivered. Direct chats show
compact failure copy by default; raw runner details are shown only when
`/verbose full` is enabled.
Defaults live under `agents.defaults.silentReply`; `surfaces.<id>.silentReply`
can override group/internal policy per surface.
+2 -1
View File
@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
2. Create a card with a title, notes, priority, labels, optional agent, and
optional linked session.
3. Or open Sessions and choose Add to Workboard for an existing session.
4. Drag the card between columns or use the column controls.
4. Drag the card between columns or focus the compact status control on the card
and use its menu or ArrowLeft/ArrowRight.
5. Start work from the card to create or reuse a dashboard session.
6. Open the linked session from the card while the agent works.
7. Let lifecycle sync move running work into review or blocked, then manually
@@ -7,7 +7,11 @@ import { startCodexAttemptThread } from "./attempt-startup.js";
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerClient } from "./client.js";
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { clearSharedCodexAppServerClient } from "./shared-client.js";
import {
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
type ClientHarness = ReturnType<typeof createClientHarness>;
@@ -51,14 +55,24 @@ function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: st
function startThreadWithHarness(
startupTimeoutMs: number,
signal = new AbortController().signal,
overrides?: { pluginConfig?: CodexPluginConfig },
overrides?: {
pluginConfig?: CodexPluginConfig;
attemptClientFactory?: (
harness: ClientHarness,
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
harness?: ClientHarness;
skipStartSpy?: boolean;
},
) {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const harness = overrides?.harness ?? createClientHarness();
if (!overrides?.skipStartSpy) {
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
}
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
attemptClientFactory: defaultLeasedCodexAppServerClientFactory,
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -147,8 +161,50 @@ describe("startCodexAttemptThread", () => {
expect(harness.process.stdin.destroyed).toBe(true);
});
it("retires a failed startup client after another active lease releases", async () => {
const retained = createClientHarness();
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
skipStartSpy: true,
});
const threadStart = await waitForThreadStart(retained);
retained.send({
id: threadStart.id,
error: { code: -32000, message: "401 authentication_error: Invalid bearer token" },
});
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
const { harness, run } = startThreadWithHarness(200);
const { harness, run } = startThreadWithHarness(2_000);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -166,6 +222,78 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
skipStartSpy: true,
});
const threadStart = await waitForThreadStart(retained);
await expect(run).rejects.toThrow("codex app-server startup timed out");
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
});
it("closes the shared app-server when startup times out during initialize", async () => {
const { harness, run } = startThreadWithHarness(100);
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
await expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
});
it("closes a startup client that arrives after startup timeout", async () => {
let observedFactoryOptions:
| {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
}
| undefined;
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
},
});
await expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
const abortController = new AbortController();
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
@@ -44,8 +44,10 @@ import {
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import {
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
clearSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
import {
startOrResumeThread,
@@ -102,13 +104,23 @@ export async function startCodexAttemptThread(params: {
let releaseSharedClientLease: (() => void) | undefined;
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
let startupAbandoned = false;
const startupAbandonController = new AbortController();
const abandonStartupAcquire = () => startupAbandonController.abort();
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
try {
const startupResult = await withCodexStartupTimeout({
timeoutMs: params.startupTimeoutMs,
signal: params.signal,
onTimeout: async () => {
startupAbandoned = true;
startupAbandonController.abort();
await params.onStartupTimeout();
await releaseStartupResourcesOnTimeout?.();
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
},
operation: async () => {
const threadConfig = mergeCodexThreadConfigs(
@@ -172,25 +184,48 @@ export async function startCodexAttemptThread(params: {
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
let startupClientLease: (() => void) | undefined;
let startupClient: CodexAppServerClient | undefined;
let startupAttemptError: unknown;
let startupAttemptSucceeded = false;
try {
const startupClient = await params.attemptClientFactory(
startupClient = await params.attemptClientFactory(
params.appServer.start,
params.startupAuthProfileId,
params.agentDir,
params.config,
{
onStartedClient: (client) => {
startupClientForAbandonedRequestCleanup = client;
if (startupAbandoned || startupAbandonController.signal.aborted) {
void closeAbandonedStartupClient(client);
}
},
abandonSignal: startupAbandonController.signal,
},
);
const activeStartupClient = startupClient;
let startupClientLeaseReleased = false;
startupClientLease = () => {
releaseLeasedSharedCodexAppServerClient(startupClient);
if (startupClientLeaseReleased) {
return;
}
startupClientLeaseReleased = true;
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
};
releaseSharedClientLease = startupClientLease;
attemptedClient = startupClient;
startupClientForAbandonedRequestCleanup = startupClient;
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
await ensureCodexComputerUse({
client: startupClient,
client: activeStartupClient,
pluginConfig: params.pluginConfig,
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal: startupAbandonController.signal,
});
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
let startupSandboxEnvironmentAcquired = false;
@@ -208,15 +243,15 @@ export async function startCodexAttemptThread(params: {
sandboxExecServerEnabled: params.sandboxExecServerEnabled,
})
? await ensureCodexSandboxExecServerEnvironment({
client: startupClient,
client: activeStartupClient,
sandbox: params.sandbox ?? null,
appServerStartOptions: params.appServer.start,
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal: startupAbandonController.signal,
})
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (params.signal.aborted) {
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
@@ -246,9 +281,9 @@ export async function startCodexAttemptThread(params: {
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
: undefined;
const buildThreadLifecycleParams = () =>
const buildThreadLifecycleParams = (signal: AbortSignal) =>
({
client: startupClient,
client: activeStartupClient,
params: params.buildAttemptParams(),
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
@@ -266,7 +301,7 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
signal: params.signal,
signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
@@ -276,9 +311,9 @@ export async function startCodexAttemptThread(params: {
buildCodexPluginThreadConfig({
pluginConfig: pluginThreadConfigPluginConfig,
request: (method, requestParams) =>
startupClient.request(method, requestParams, {
activeStartupClient.request(method, requestParams, {
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
@@ -287,22 +322,24 @@ export async function startCodexAttemptThread(params: {
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
try {
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
if (params.signal.aborted) {
const startupThread = await startOrResumeThread(
buildThreadLifecycleParams(startupAbandonController.signal),
);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
startupSandboxEnvironmentAcquired = false;
startupAttemptSucceeded = true;
return {
client: startupClient,
client: activeStartupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
restartContextEngineCodexThread: () =>
startOrResumeThread(buildThreadLifecycleParams()),
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
};
} catch (error) {
await releaseStartupSandboxEnvironment();
@@ -312,12 +349,32 @@ export async function startCodexAttemptThread(params: {
releaseStartupResourcesOnTimeout = undefined;
}
}
} catch (error) {
startupAttemptError = error;
throw error;
} finally {
if (!startupAttemptSucceeded) {
if (releaseSharedClientLease === startupClientLease) {
releaseSharedClientLease = undefined;
}
startupClientLease?.();
if (startupAbandoned || params.signal.aborted) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeAbandonedStartupClient(startupClient);
} else if (
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
shouldClearSharedClientAfterStartupFailure({
error: startupAttemptError,
spawnedBy: params.spawnedBy,
})
) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await evictFailedStartupClient(startupClient);
}
}
}
};
@@ -375,26 +432,115 @@ export async function startCodexAttemptThread(params: {
releaseSharedClientLease,
};
} catch (error) {
if (
params.signal.aborted ||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
} else if (
shouldClearSharedClientAfterStartupRace(error) ||
shouldClearSharedClientAfterStartupFailure({
error,
spawnedBy: params.spawnedBy,
})
) {
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
}
throw error;
} finally {
params.signal.removeEventListener("abort", abandonStartupAcquire);
}
}
async function closeAbandonedStartupClient(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
const closeable = client as {
close?: CodexAppServerClient["close"];
closeAndWait?: CodexAppServerClient["closeAndWait"];
};
if (typeof closeable.closeAndWait === "function") {
await closeable.closeAndWait();
return;
}
closeable.close?.();
}
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted" ||
error.message.endsWith(" timed out"))
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
);
}
@@ -11,6 +11,10 @@ export type CodexAppServerClientFactory = (
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
},
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
@@ -25,9 +29,17 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
@@ -35,7 +47,15 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
getLeasedSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);
+8 -2
View File
@@ -4,13 +4,14 @@ import {
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
defaultCodexAppServerClientFactory,
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
@@ -177,7 +178,8 @@ async function compactCodexNativeThread(
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
@@ -211,6 +213,10 @@ async function compactCodexNativeThread(
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",
@@ -189,6 +189,28 @@ describe("shared Codex app-server client", () => {
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("keeps a pending shared app-server alive when another acquire still owns startup", async () => {
const harness = createClientHarness();
const abandonController = new AbortController();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const abandonedAcquire = getSharedCodexAppServerClient({
timeoutMs: 1000,
abandonSignal: abandonController.signal,
});
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
abandonController.abort();
expect(harness.process.stdin.destroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(abandonedAcquire).resolves.toBe(harness.client);
await expect(activeAcquire).resolves.toBe(harness.client);
expect(harness.process.stdin.destroyed).toBe(false);
});
it("does not wait for isolated initialize after a timeout closes the client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -18,6 +18,7 @@ type SharedCodexAppServerClientEntry = {
client?: CodexAppServerClient;
promise?: Promise<CodexAppServerClient>;
activeLeases: number;
pendingAcquires: number;
closeWhenIdle: boolean;
};
@@ -48,6 +49,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
for (const entry of clients.values()) {
entry.activeLeases ??= 0;
entry.pendingAcquires ??= 0;
entry.closeWhenIdle ??= false;
}
const nextState: SharedCodexAppServerClientState = {
@@ -66,6 +68,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
client: legacyState.client,
promise: legacyState.promise,
activeLeases: 0,
pendingAcquires: 0,
closeWhenIdle: false,
});
legacyState.client?.addCloseHandler((closedClient) =>
@@ -102,6 +105,8 @@ type CodexAppServerClientOptions = {
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
};
type ResolvedCodexAppServerClientStartContext = {
@@ -194,11 +199,27 @@ async function acquireSharedCodexAppServerClient(
});
const state = getSharedCodexAppServerClientState();
const entry = getOrCreateSharedClientEntry(state, key);
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
let cleanupAbandonSignal: (() => void) | undefined;
if (options?.abandonSignal) {
const abandon = () => {
// Release this acquire before cleanup checks ownership; only other
// pending callers should keep the startup client alive.
releasePendingAcquire();
closeSharedClientEntryIfUnclaimed(key, entry);
};
options.abandonSignal.addEventListener("abort", abandon, { once: true });
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
if (options.abandonSignal.aborted) {
abandon();
}
}
const sharedPromise =
entry.promise ??
(entry.promise = (async () => {
const client = CodexAppServerClient.start(startOptions);
entry.client = client;
options?.onStartedClient?.(client);
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
try {
@@ -233,6 +254,9 @@ async function acquireSharedCodexAppServerClient(
clearSharedClientEntry(key, currentEntry);
}
throw error;
} finally {
cleanupAbandonSignal?.();
releasePendingAcquire();
}
}
@@ -386,7 +410,7 @@ function getOrCreateSharedClientEntry(
): SharedCodexAppServerClientEntry {
let entry = state.clients.get(key);
if (!entry) {
entry = { activeLeases: 0, closeWhenIdle: false };
entry = { activeLeases: 0, pendingAcquires: 0, closeWhenIdle: false };
state.clients.set(key, entry);
}
return entry;
@@ -409,6 +433,39 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
}
}
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
client: CodexAppServerClient | undefined,
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
if (!client) {
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
}
const state = getSharedCodexAppServerClientState();
for (const [key, entry] of state.clients) {
if (entry.client === client) {
return {
found: true,
closed: closeSharedClientEntryIfUnclaimed(key, entry),
activeLeases: entry.activeLeases,
pendingAcquires: entry.pendingAcquires,
};
}
}
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
}
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
let released = false;
entry.pendingAcquires += 1;
return () => {
if (released) {
return;
}
released = true;
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
closeRetiredSharedClientEntryIfIdle(entry);
};
}
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
let released = false;
entry.activeLeases += 1;
@@ -423,7 +480,12 @@ function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () =>
}
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
if (
!entry.closeWhenIdle ||
entry.activeLeases > 0 ||
entry.pendingAcquires > 0 ||
!entry.client
) {
return false;
}
const client = entry.client;
@@ -433,6 +495,22 @@ function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEn
return true;
}
function closeSharedClientEntryIfUnclaimed(
key: string,
entry: SharedCodexAppServerClientEntry,
): boolean {
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
return false;
}
const state = getSharedCodexAppServerClientState();
if (state.clients.get(key) !== entry) {
return false;
}
state.clients.delete(key);
entry.client?.close();
return Boolean(entry.client);
}
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
return [
...new Set(
@@ -22,6 +22,15 @@ export function createClientHarness() {
const stdout = new PassThrough();
const writes: string[] = [];
let stdinDestroyed = false;
let exitEmitted = false;
let emitProcessExit: () => void = () => undefined;
type HarnessProcess = EventEmitter & {
stdin: Writable;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals) => unknown;
};
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
@@ -31,17 +40,25 @@ export function createClientHarness() {
const destroyStdin = stdin.destroy.bind(stdin);
stdin.destroy = ((error?: Error) => {
stdinDestroyed = true;
return destroyStdin(error);
const result = destroyStdin(error);
if (!exitEmitted) {
exitEmitted = true;
queueMicrotask(emitProcessExit);
}
return result;
}) as typeof stdin.destroy;
const process = Object.assign(new EventEmitter(), {
const process: HarnessProcess = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr: new PassThrough(),
killed: false,
kill: vi.fn(() => {
kill: vi.fn((_signal?: NodeJS.Signals) => {
process.killed = true;
}),
});
emitProcessExit = () => {
process.emit("exit", 0, null);
};
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,
@@ -1,15 +1,8 @@
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
createChannelProgressDraftCompositor,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -79,86 +72,48 @@ export function createDiscordDraftPreviewController(params: {
let draftText = "";
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
let finalReplyStarted = false;
let finalReplyDelivered = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
const commentaryProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
const suppressDefaultToolProgressMessages =
Boolean(draftStream) &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
draftStreamActive: true,
previewToolProgressEnabled,
});
let previewToolProgressSuppressed = false;
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let reasoningProgressRawText = "";
let lastReasoningProgressLine: string | undefined;
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
const renderProgressDraft = async (options?: { flush?: boolean }) => {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (!previewText || previewText === lastPartialText) {
return;
}
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
if (options?.flush) {
await draftStream.flush();
}
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: () => renderProgressDraft({ flush: true }),
const progressDraft = createChannelProgressDraftCompositor({
entry: params.discordConfig,
mode: discordStreamMode,
active: Boolean(draftStream),
seed: progressSeed,
update: async (previewText, options) => {
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream?.update(previewText);
if (options?.flush) {
await draftStream?.flush();
}
},
deleteCurrent: async () => {
lastPartialText = "";
draftText = "";
hasStreamedMessage = false;
if (draftStream?.messageId()) {
await draftStream.deleteCurrentMessage();
}
},
isEmptyLine: isEmptyDiscordProgressLine,
shouldStartNow: shouldStartDiscordProgressDraftNow,
});
const clearProgressDraftLine = async (lineId: string) => {
const nextLines = previewToolProgressLines.filter(
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
);
if (nextLines.length === previewToolProgressLines.length) {
return;
}
previewToolProgressLines = nextLines;
if (!progressDraftGate.hasStarted) {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (previewText) {
await renderProgressDraft();
return;
}
lastPartialText = "";
draftText = "";
hasStreamedMessage = false;
if (draftStream?.messageId()) {
await draftStream.deleteCurrentMessage();
}
};
const resetProgressState = () => {
lastPartialText = "";
draftText = "";
draftChunker?.reset();
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
reasoningProgressRawText = "";
lastReasoningProgressLine = undefined;
progressDraft.reset();
};
const forceNewMessageIfNeeded = () => {
@@ -172,22 +127,23 @@ export function createDiscordDraftPreviewController(params: {
return {
draftStream,
previewToolProgressEnabled,
commentaryProgressEnabled,
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
suppressDefaultToolProgressMessages,
get isProgressMode() {
return discordStreamMode === "progress";
},
get hasProgressDraftStarted() {
return progressDraftGate.hasStarted;
return progressDraft.hasStarted;
},
get finalizedViaPreviewMessage() {
return finalizedViaPreviewMessage;
},
markFinalReplyStarted() {
finalReplyStarted = true;
progressDraft.markFinalReplyStarted();
},
markFinalReplyDelivered() {
finalReplyDelivered = true;
progressDraft.markFinalReplyDelivered();
},
markPreviewFinalized() {
finalizedViaPreviewMessage = true;
@@ -197,149 +153,19 @@ export function createDiscordDraftPreviewController(params: {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraftGate.startNow();
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
) {
if (!draftStream) {
return;
}
if (finalReplyStarted || finalReplyDelivered) {
return;
}
if (
options?.toolName !== undefined &&
!isChannelProgressDraftWorkToolName(options.toolName)
) {
return;
}
if (isEmptyDiscordProgressLine(line)) {
return;
}
const normalized = normalizeChannelProgressDraftLineIdentity(line);
if (!normalized) {
return;
}
const progressLine: string | ChannelProgressDraftLine =
typeof line === "object" && line !== undefined ? line : normalized;
if (discordStreamMode !== "progress") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
return;
}
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
if (nextLines === previewToolProgressLines) {
return;
}
previewToolProgressLines = nextLines;
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
previewToolProgressLines = mergeChannelProgressDraftLine(
previewToolProgressLines,
progressLine,
{
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
},
);
}
const alreadyStarted = progressDraftGate.hasStarted;
let progressActive;
if (shouldStartDiscordProgressDraftNow(line)) {
await progressDraftGate.startNow();
progressActive = progressDraftGate.hasStarted;
} else {
progressActive = await progressDraftGate.noteWork();
}
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
await progressDraft.pushToolProgress(line, options);
},
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
if (!draftStream || discordStreamMode !== "progress" || !text) {
return;
}
if (finalReplyDelivered) {
return;
}
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
snapshot: options?.snapshot === true,
});
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
if (!normalized) {
return;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
);
if (!displayLine) {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const priorIndex =
lastReasoningProgressLine === undefined
? -1
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
if (priorIndex >= 0) {
previewToolProgressLines = [...previewToolProgressLines];
previewToolProgressLines[priorIndex] = displayLine;
} else {
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
lastReasoningProgressLine = displayLine;
}
const progressActive = await progressDraftGate.noteWork();
if (progressActive && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
await progressDraft.pushReasoningProgress(text, options);
},
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
return;
}
if (finalReplyStarted || finalReplyDelivered) {
return;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
if (lineId) {
await clearProgressDraftLine(lineId);
}
return;
}
const line: ChannelProgressDraftLine = {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
await progressDraftGate.startNow();
await renderProgressDraft();
await progressDraft.pushCommentaryProgress(text, options);
},
resolvePreviewFinalText(text?: string) {
if (typeof text !== "string") {
@@ -390,8 +216,7 @@ export function createDiscordDraftPreviewController(params: {
if (discordStreamMode === "progress") {
return;
}
previewToolProgressSuppressed = true;
previewToolProgressLines = [];
progressDraft.suppress();
hasStreamedMessage = true;
if (discordStreamMode === "partial") {
if (
@@ -457,7 +282,7 @@ export function createDiscordDraftPreviewController(params: {
},
async cleanup() {
try {
progressDraftGate.cancel();
progressDraft.cancel();
if (!finalReplyDelivered) {
await draftStream?.discardPending();
}
@@ -471,106 +296,6 @@ export function createDiscordDraftPreviewController(params: {
};
}
function normalizeReasoningProgressLine(text: string): string {
return text
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/\s+/g, " ")
.trim();
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
function isSilentCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
function mergeReasoningProgressText(
current: string,
incoming: string,
options?: { snapshot?: boolean },
): string {
if (!current) {
return incoming;
}
const normalizedCurrent = normalizeReasoningProgressLine(current);
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
return current;
}
if (
options?.snapshot === true ||
isReasoningSnapshotText(incoming) ||
normalizedIncoming.startsWith(normalizedCurrent)
) {
return incoming;
}
return `${current}${incoming}`;
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
if (!line || typeof line === "string") {
return false;
@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when no recovered reply is available", async () => {
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when the recovered reply fails to send", async () => {
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "delivery failed" }],
});
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
});
it("keeps mutating tool warning finals after successful-looking replies", async () => {
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "Done." });
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
expectPreviewEditContent("Done.");
expect(draftStream.clear).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `write file (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("suppresses reasoning payload delivery to Discord", async () => {
@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
import {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -621,7 +625,7 @@ async function processDiscordMessageInner(
) => {
if (isProcessAborted(abortSignal)) {
// Surface so operators don't chase missing replies when an abort
// drops a model-produced text payload (see PR for the incident).
// drops a model-produced text payload.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
})
: payload.text;
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
kind: info.kind,
});
if (!deliverablePayload) {
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "internal-only payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
const draftStream = draftPreview.draftStream;
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
if (!reply.hasMedia && !payload.isError) {
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
if (!reply.hasMedia && !deliverablePayload.isError) {
return { visibleReplySent: false };
}
}
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
draftStream &&
isFinal &&
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
!payload.isError;
!deliverablePayload.isError;
if (shouldFinalizeDraftPreview) {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
const hasMedia = reply.hasMedia;
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
const previewReplyToId = replyReference.peek();
const hasExplicitReplyDirective =
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
(typeof previewSourceText === "string" &&
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: info.kind,
payload: effectivePayload,
payload: deliverablePayload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: () => draftPreview.flush(),
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
(hasMedia && !ttsSupplement) ||
typeof previewFinalText !== "string" ||
hasExplicitReplyDirective ||
payload.isError
deliverablePayload.isError
) {
return undefined;
}
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
replyReference.markSent();
},
buildSupplementalPayload: () =>
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
deliverSupplemental: async (supplementalPayload) => {
if (isProcessAborted(abortSignal)) {
return false;
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
const fallbackPayload =
ttsSupplement &&
ttsSupplement.visibleTextAlreadyDelivered !== true &&
!effectivePayload.text?.trim()
? { ...effectivePayload, text: ttsSupplement.spokenText }
: effectivePayload;
!deliverablePayload.text?.trim()
? { ...deliverablePayload, text: ttsSupplement.spokenText }
: deliverablePayload;
const replyToId = replyReference.use();
notifyFinalReplyStart();
await deliverDiscordReply({
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
}
await deliverDiscordReply({
cfg,
replies: [effectivePayload],
replies: [deliverablePayload],
target: deliverTarget,
token,
accountId,
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
kind: info.kind,
});
replyReference.markSent();
if (isFinal && payload.isError !== true) {
if (isFinal && deliverablePayload.isError !== true) {
markUserFacingFinalDelivered();
}
return { visibleReplySent: true };
@@ -176,6 +176,33 @@ describe("deliverDiscordReply", () => {
);
});
it("strips assistant scaffolding from explicit tool progress payloads", async () => {
await deliverDiscordReply({
replies: [
{
text: [
"<think>private reasoning</think>",
'<tool_call>{"name":"x"}</tool_call>',
"🛠️ run git status",
].join("\n"),
},
],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "tool",
});
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
payloads: [{ text: "🛠️ run git status" }],
}),
);
});
it("strips internal execution trace lines at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
@@ -183,6 +210,7 @@ describe("deliverDiscordReply", () => {
text: [
"📊 Session Status: current",
"🛠️ run git status",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"🛠️ `gh pr view`",
"🛠️ `docker compose up`",
"🛠️ elevated · `cd /tmp && pnpm test`",
@@ -204,6 +232,26 @@ describe("deliverDiscordReply", () => {
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
});
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "final",
});
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
});
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
+17 -23
View File
@@ -1,14 +1,13 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import {
sanitizeAssistantVisibleText,
sanitizeAssistantVisibleTextWithProfile,
} from "openclaw/plugin-sdk/text-chunking";
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
const DISCORD_INTERNAL_TRACE_LINE_RE =
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
/^(?:>\s*)?(?:analysis|commentary|thinking|reasoning)\s*[:=]/i;
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
return Boolean(
@@ -36,7 +35,11 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
);
}
function stripDiscordInternalTraceLines(text: string): string {
function collapseExcessBlankLines(text: string): string {
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
}
function stripDiscordInternalChannelLines(text: string): string {
let inFence = false;
const kept: string[] = [];
for (const line of text.split(/\r?\n/)) {
@@ -45,31 +48,20 @@ function stripDiscordInternalTraceLines(text: string): string {
kept.push(line);
continue;
}
if (!inFence) {
const trimmed = line.trim();
if (
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
) {
continue;
}
if (!inFence && DISCORD_INTERNAL_CHANNEL_LINE_RE.test(line.trim())) {
continue;
}
kept.push(line);
}
return kept.join("\n");
}
function collapseExcessBlankLines(text: string): string {
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
}
export function sanitizeDiscordFrontChannelText(text: string): string {
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
return collapseExcessBlankLines(withoutTraceLines).trim();
const withoutChannelLines = stripDiscordInternalChannelLines(withoutResidualToolCallBlocks);
return collapseExcessBlankLines(withoutChannelLines).trim();
}
export function sanitizeDiscordFrontChannelReplyPayloads(
@@ -82,7 +74,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
const safeText =
typeof payload.text === "string"
? preserveVerboseToolProgress
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
? collapseExcessBlankLines(
sanitizeAssistantVisibleTextWithProfile(payload.text, "tool-progress"),
).trim()
: sanitizeDiscordFrontChannelText(payload.text)
: payload.text;
const nextPayload =
+176 -1
View File
@@ -661,7 +661,6 @@ describe("registerPolicyDoctorChecks", () => {
["tools settings array", { tools: { settings: [] } }, "oc://policy.jsonc/tools/settings"],
["tools entries object", { tools: { entries: {} } }, "oc://policy.jsonc/tools/entries"],
["tools profiles array", { tools: { profiles: [] } }, "oc://policy.jsonc/tools/profiles"],
[
"tools profiles allow string",
{ tools: { profiles: { allow: "coding" } } },
@@ -994,6 +993,182 @@ describe("registerPolicyDoctorChecks", () => {
]);
});
it("rejects unsupported policy keys across policy namespaces", async () => {
const cases: readonly {
readonly label: string;
readonly policy: unknown;
readonly target: string;
}[] = [
{ label: "top-level", policy: { channel: {} }, target: "oc://policy.jsonc/channel" },
{
label: "tools top-level",
policy: { tools: { execPolicy: { allowHosts: ["sandbox"] } } },
target: "oc://policy.jsonc/tools/execPolicy",
},
{
label: "tools settings",
policy: { tools: { settings: {} } },
target: "oc://policy.jsonc/tools/settings",
},
{
label: "tools entries",
policy: { tools: { entries: [] } },
target: "oc://policy.jsonc/tools/entries",
},
{
label: "tools profile",
policy: { tools: { profiles: { deny: ["full"] } } },
target: "oc://policy.jsonc/tools/profiles/deny",
},
{
label: "tools exec",
policy: { tools: { exec: { allowShells: ["bash"] } } },
target: "oc://policy.jsonc/tools/exec/allowShells",
},
{
label: "tools fs",
policy: { tools: { fs: { allowOutsideWorkspace: true } } },
target: "oc://policy.jsonc/tools/fs/allowOutsideWorkspace",
},
{
label: "tools alsoAllow",
policy: { tools: { alsoAllow: { denied: ["exec"] } } },
target: "oc://policy.jsonc/tools/alsoAllow/denied",
},
{
label: "channels",
policy: { channels: { allowRules: [] } },
target: "oc://policy.jsonc/channels/allowRules",
},
{
label: "channel deny rule",
policy: { channels: { denyRules: [{ when: { provider: "telegram" }, action: "deny" }] } },
target: "oc://policy.jsonc/channels/denyRules/#0/action",
},
{
label: "channel deny selector",
policy: {
channels: { denyRules: [{ when: { provider: "telegram", channel: "stable" } }] },
},
target: "oc://policy.jsonc/channels/denyRules/#0/when/channel",
},
{
label: "ingress top-level",
policy: { ingress: { directMessages: {} } },
target: "oc://policy.jsonc/ingress/directMessages",
},
{
label: "ingress session",
policy: { ingress: { session: { requiredScope: "per-channel-peer" } } },
target: "oc://policy.jsonc/ingress/session/requiredScope",
},
{
label: "ingress channels",
policy: { ingress: { channels: { allowOpenGroups: false } } },
target: "oc://policy.jsonc/ingress/channels/allowOpenGroups",
},
{ label: "mcp", policy: { mcp: { clients: {} } }, target: "oc://policy.jsonc/mcp/clients" },
{
label: "mcp servers",
policy: { mcp: { servers: { require: ["docs"] } } },
target: "oc://policy.jsonc/mcp/servers/require",
},
{
label: "models",
policy: { models: { modelRefs: {} } },
target: "oc://policy.jsonc/models/modelRefs",
},
{
label: "models providers",
policy: { models: { providers: { require: ["openai"] } } },
target: "oc://policy.jsonc/models/providers/require",
},
{
label: "network",
policy: { network: { publicNetwork: {} } },
target: "oc://policy.jsonc/network/publicNetwork",
},
{
label: "network privateNetwork",
policy: { network: { privateNetwork: { deny: true } } },
target: "oc://policy.jsonc/network/privateNetwork/deny",
},
{
label: "gateway top-level",
policy: { gateway: { bind: { allowNonLoopback: false } } },
target: "oc://policy.jsonc/gateway/bind",
},
{
label: "gateway exposure",
policy: { gateway: { exposure: { allowPublicBind: false } } },
target: "oc://policy.jsonc/gateway/exposure/allowPublicBind",
},
{
label: "gateway auth",
policy: { gateway: { auth: { allowDisabled: false } } },
target: "oc://policy.jsonc/gateway/auth/allowDisabled",
},
{
label: "agents",
policy: { agents: { tools: {} } },
target: "oc://policy.jsonc/agents/tools",
},
{
label: "agents workspace",
policy: { agents: { workspace: { requireReadOnly: true } } },
target: "oc://policy.jsonc/agents/workspace/requireReadOnly",
},
{
label: "dataHandling",
policy: { dataHandling: { logs: { requireRedaction: true } } },
target: "oc://policy.jsonc/dataHandling/logs",
},
{
label: "dataHandling nested",
policy: { dataHandling: { telemetry: { allowCaptureContent: false } } },
target: "oc://policy.jsonc/dataHandling/telemetry/allowCaptureContent",
},
{
label: "secrets",
policy: { secrets: { requireVault: true } },
target: "oc://policy.jsonc/secrets/requireVault",
},
{
label: "auth",
policy: { auth: { providers: {} } },
target: "oc://policy.jsonc/auth/providers",
},
{
label: "auth profiles",
policy: { auth: { profiles: { requireProvider: true } } },
target: "oc://policy.jsonc/auth/profiles/requireProvider",
},
];
for (const testCase of cases) {
const configPath = join(workspaceDir, `${testCase.label.replaceAll(" ", "-")}.jsonc`);
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify(testCase.policy),
"utf-8",
);
clearHealthChecksForTest();
resetPolicyDoctorChecksForTest();
const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings, testCase.label).toEqual([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
severity: "error",
path: "policy.jsonc",
target: testCase.target,
}),
]);
}
});
it("reports a policy hash mismatch when expectedHash is configured", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
+379 -20
View File
@@ -523,6 +523,28 @@ const KNOWN_SENSITIVITY_LEVELS = ["public", "internal", "confidential", "restric
const SUPPORTED_TOOL_METADATA = ["risk", "sensitivity", "owner"] as const;
const SUPPORTED_AUTH_PROFILE_METADATA = ["provider", "mode"] as const;
const SUPPORTED_AUTH_PROFILE_MODES = ["api_key", "aws-sdk", "oauth", "token"] as const;
const SUPPORTED_POLICY_SECTIONS = [
"auth",
"agents",
"channels",
"dataHandling",
"gateway",
"ingress",
"mcp",
"models",
"network",
"sandbox",
"scopes",
"secrets",
"tools",
] as const;
const SUPPORTED_GATEWAY_POLICY_SECTIONS = [
"auth",
"controlUi",
"exposure",
"http",
"remote",
] as const;
const SUPPORTED_GATEWAY_HTTP_ENDPOINTS = ["chatCompletions", "responses"] as const;
const SUPPORTED_DM_POLICIES = ["pairing", "allowlist", "open", "disabled"] as const;
const SUPPORTED_DM_SCOPES = [
@@ -1623,6 +1645,17 @@ export function policyContainerShapeFindings(
),
];
}
const unsupportedTopLevel = unsupportedPolicyKey(policy, SUPPORTED_POLICY_SECTIONS);
if (unsupportedTopLevel !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/${ocPathSegment(unsupportedTopLevel)}`,
`${policyPath} ${unsupportedTopLevel} is not a supported policy section.`,
`Remove ${unsupportedTopLevel} or use a supported policy section.`,
),
];
}
if (policy.tools !== undefined && !isRecord(policy.tools)) {
return [
policyShapeFinding(
@@ -1634,26 +1667,6 @@ export function policyContainerShapeFindings(
];
}
if (isRecord(policy.tools)) {
if (policy.tools.settings !== undefined && !isRecord(policy.tools.settings)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/tools/settings`,
`${policyPath} tools.settings must be an object.`,
`Fix ${policyPath} so tools.settings is an object.`,
),
];
}
if (policy.tools.entries !== undefined && !Array.isArray(policy.tools.entries)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/tools/entries`,
`${policyPath} tools.entries must be an array.`,
`Fix ${policyPath} so tools.entries is an array.`,
),
];
}
const postureFinding = toolPosturePolicyShapeFinding(policy.tools, {
policyDocName,
policyPath,
@@ -1672,6 +1685,19 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.channels)) {
const unsupportedChannelKey = unsupportedPolicyKey(policy.channels, ["denyRules"]);
if (unsupportedChannelKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/channels/${ocPathSegment(unsupportedChannelKey)}`,
`${policyPath} channels.${unsupportedChannelKey} is not supported in channel policy.`,
`Remove channels.${unsupportedChannelKey} or use channels.denyRules.`,
),
];
}
}
if (policy.mcp !== undefined && !isRecord(policy.mcp)) {
return [
policyShapeFinding(
@@ -1682,6 +1708,19 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.mcp)) {
const unsupportedMcpKey = unsupportedPolicyKey(policy.mcp, ["servers"]);
if (unsupportedMcpKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/mcp/${ocPathSegment(unsupportedMcpKey)}`,
`${policyPath} mcp.${unsupportedMcpKey} is not supported in MCP policy.`,
`Remove mcp.${unsupportedMcpKey} or use mcp.servers.`,
),
];
}
}
if (policy.dataHandling !== undefined && !isRecord(policy.dataHandling)) {
return [
policyShapeFinding(
@@ -1714,6 +1753,19 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.models)) {
const unsupportedModelsKey = unsupportedPolicyKey(policy.models, ["providers"]);
if (unsupportedModelsKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/models/${ocPathSegment(unsupportedModelsKey)}`,
`${policyPath} models.${unsupportedModelsKey} is not supported in model policy.`,
`Remove models.${unsupportedModelsKey} or use models.providers.`,
),
];
}
}
if (isRecord(policy.models)) {
const finding = policyStringArrayShapeFinding(policy.models.providers, {
property: "models.providers",
@@ -1737,6 +1789,17 @@ export function policyContainerShapeFindings(
];
}
if (isRecord(policy.network)) {
const unsupportedNetworkKey = unsupportedPolicyKey(policy.network, ["privateNetwork"]);
if (unsupportedNetworkKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/${ocPathSegment(unsupportedNetworkKey)}`,
`${policyPath} network.${unsupportedNetworkKey} is not supported in network policy.`,
`Remove network.${unsupportedNetworkKey} or use network.privateNetwork.`,
),
];
}
if (policy.network.privateNetwork !== undefined && !isRecord(policy.network.privateNetwork)) {
return [
policyShapeFinding(
@@ -1747,6 +1810,21 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.network.privateNetwork)) {
const unsupportedPrivateNetworkKey = unsupportedPolicyKey(policy.network.privateNetwork, [
"allow",
]);
if (unsupportedPrivateNetworkKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/privateNetwork/${ocPathSegment(unsupportedPrivateNetworkKey)}`,
`${policyPath} network.privateNetwork.${unsupportedPrivateNetworkKey} is not supported in network policy.`,
`Remove network.privateNetwork.${unsupportedPrivateNetworkKey} or use network.privateNetwork.allow.`,
),
];
}
}
if (
isRecord(policy.network.privateNetwork) &&
policy.network.privateNetwork.allow !== undefined &&
@@ -1772,6 +1850,23 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.secrets)) {
const unsupportedSecretsKey = unsupportedPolicyKey(policy.secrets, [
"allowInsecureProviders",
"denySources",
"requireManagedProviders",
]);
if (unsupportedSecretsKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets/${ocPathSegment(unsupportedSecretsKey)}`,
`${policyPath} secrets.${unsupportedSecretsKey} is not supported in secrets policy.`,
`Remove secrets.${unsupportedSecretsKey} or use a supported secrets policy rule.`,
),
];
}
}
if (policy.auth !== undefined && !isRecord(policy.auth)) {
return [
policyShapeFinding(
@@ -1782,6 +1877,19 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.auth)) {
const unsupportedAuthKey = unsupportedPolicyKey(policy.auth, ["profiles"]);
if (unsupportedAuthKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/${ocPathSegment(unsupportedAuthKey)}`,
`${policyPath} auth.${unsupportedAuthKey} is not supported in auth policy.`,
`Remove auth.${unsupportedAuthKey} or use auth.profiles.`,
),
];
}
}
if (
isRecord(policy.auth) &&
policy.auth.profiles !== undefined &&
@@ -1796,6 +1904,22 @@ export function policyContainerShapeFindings(
),
];
}
if (isRecord(policy.auth) && isRecord(policy.auth.profiles)) {
const unsupportedProfilesKey = unsupportedPolicyKey(policy.auth.profiles, [
"allowModes",
"requireMetadata",
]);
if (unsupportedProfilesKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/profiles/${ocPathSegment(unsupportedProfilesKey)}`,
`${policyPath} auth.profiles.${unsupportedProfilesKey} is not supported in auth profile policy.`,
`Remove auth.profiles.${unsupportedProfilesKey} or use a supported auth profile policy rule.`,
),
];
}
}
const sandboxFinding = sandboxPolicyShapeFinding(policy.sandbox, {
policyDocName,
policyPath,
@@ -1867,6 +1991,15 @@ function ingressPolicyShapeFinding(
`Move session ingress rules to top-level ingress; scoped ingress currently supports ingress.channels.*.`,
);
}
const unsupportedIngressKey = unsupportedPolicyKey(value, ["channels", "session"]);
if (unsupportedIngressKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedIngressKey)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedIngressKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.${unsupportedIngressKey} or use ingress.session or ingress.channels.`,
);
}
for (const section of ["session", "channels"] as const) {
if (value[section] !== undefined && !isRecord(value[section])) {
return policyShapeFinding(
@@ -1878,6 +2011,15 @@ function ingressPolicyShapeFinding(
}
}
const session = isRecord(value.session) ? value.session : {};
const unsupportedSessionKey = unsupportedPolicyKey(session, ["requireDmScope"]);
if (unsupportedSessionKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/session/${ocPathSegment(unsupportedSessionKey)}`,
`${params.policyPath} ${propertyPrefix}.session.${unsupportedSessionKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.session.${unsupportedSessionKey} or use ${propertyPrefix}.session.requireDmScope.`,
);
}
if (
session.requireDmScope !== undefined &&
!SUPPORTED_DM_SCOPES.includes(session.requireDmScope as (typeof SUPPORTED_DM_SCOPES)[number])
@@ -1890,6 +2032,19 @@ function ingressPolicyShapeFinding(
);
}
const channels = isRecord(value.channels) ? value.channels : {};
const unsupportedChannelsKey = unsupportedPolicyKey(channels, [
"allowDmPolicies",
"denyOpenGroups",
"requireMentionInGroups",
]);
if (unsupportedChannelsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/channels/${ocPathSegment(unsupportedChannelsKey)}`,
`${params.policyPath} ${propertyPrefix}.channels.${unsupportedChannelsKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.channels.${unsupportedChannelsKey} or use a supported ingress channel policy rule.`,
);
}
const allowDmPoliciesFinding = policyStringArrayPropertyShapeFinding(channels.allowDmPolicies, {
allowed: SUPPORTED_DM_POLICIES,
policyDocName: params.policyDocName,
@@ -1932,6 +2087,15 @@ function agentsPolicyShapeFinding(
`Fix ${params.policyPath} so agents is an object.`,
);
}
const unsupportedAgentsKey = unsupportedPolicyKey(value, ["workspace"]);
if (unsupportedAgentsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/agents/${ocPathSegment(unsupportedAgentsKey)}`,
`${params.policyPath} agents.${unsupportedAgentsKey} is not supported in agents policy.`,
`Remove agents.${unsupportedAgentsKey} or use agents.workspace.`,
);
}
const workspaceFinding = agentWorkspacePolicyShapeFinding(value.workspace, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
@@ -2309,6 +2473,15 @@ function agentWorkspacePolicyShapeFinding(
`Fix ${params.policyPath} so ${params.propertyPrefix} is an object.`,
);
}
const unsupportedWorkspaceKey = unsupportedPolicyKey(value, ["allowedAccess", "denyTools"]);
if (unsupportedWorkspaceKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/${ocPathSegment(unsupportedWorkspaceKey)}`,
`${params.policyPath} ${params.propertyPrefix}.${unsupportedWorkspaceKey} is not supported in agent workspace policy.`,
`Remove ${params.propertyPrefix}.${unsupportedWorkspaceKey} or use a supported agent workspace policy rule.`,
);
}
const allowedAccess = value.allowedAccess;
if (allowedAccess !== undefined && !Array.isArray(allowedAccess)) {
return policyShapeFinding(
@@ -2371,6 +2544,24 @@ function toolPosturePolicyShapeFinding(
): HealthFinding | undefined {
const targetPrefix = params.targetPrefix ?? "tools";
const propertyPrefix = params.propertyPrefix ?? "tools";
const allowedTopLevel = [
"alsoAllow",
"denyTools",
"elevated",
"exec",
"fs",
"profiles",
"requireMetadata",
];
const unsupportedTopLevel = unsupportedPolicyKey(tools, allowedTopLevel);
if (unsupportedTopLevel !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in tools policy.`,
`Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported tools policy rule.`,
);
}
for (const section of ["profiles", "fs", "exec", "elevated", "alsoAllow"] as const) {
if (tools[section] !== undefined && !isRecord(tools[section])) {
return policyShapeFinding(
@@ -2383,6 +2574,15 @@ function toolPosturePolicyShapeFinding(
}
const profiles = isRecord(tools.profiles) ? tools.profiles : {};
const unsupportedProfileKey = unsupportedPolicyKey(profiles, ["allow"]);
if (unsupportedProfileKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/profiles/${ocPathSegment(unsupportedProfileKey)}`,
`${params.policyPath} ${propertyPrefix}.profiles.${unsupportedProfileKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.profiles.${unsupportedProfileKey} or use ${propertyPrefix}.profiles.allow.`,
);
}
const profileAllowFinding = policyStringArrayPropertyShapeFinding(profiles.allow, {
allowed: SUPPORTED_TOOL_PROFILES,
policyDocName: params.policyDocName,
@@ -2396,6 +2596,15 @@ function toolPosturePolicyShapeFinding(
}
const fs = isRecord(tools.fs) ? tools.fs : {};
const unsupportedFsKey = unsupportedPolicyKey(fs, ["requireWorkspaceOnly"]);
if (unsupportedFsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/fs/${ocPathSegment(unsupportedFsKey)}`,
`${params.policyPath} ${propertyPrefix}.fs.${unsupportedFsKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.fs.${unsupportedFsKey} or use ${propertyPrefix}.fs.requireWorkspaceOnly.`,
);
}
if (fs.requireWorkspaceOnly !== undefined && typeof fs.requireWorkspaceOnly !== "boolean") {
return policyShapeFinding(
params.policyPath,
@@ -2406,6 +2615,19 @@ function toolPosturePolicyShapeFinding(
}
const exec = isRecord(tools.exec) ? tools.exec : {};
const unsupportedExecKey = unsupportedPolicyKey(exec, [
"allowHosts",
"allowSecurity",
"requireAsk",
]);
if (unsupportedExecKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/exec/${ocPathSegment(unsupportedExecKey)}`,
`${params.policyPath} ${propertyPrefix}.exec.${unsupportedExecKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.exec.${unsupportedExecKey} or use a supported tools exec policy rule.`,
);
}
const execLists = [
["allowSecurity", SUPPORTED_TOOL_EXEC_SECURITY, "exec security mode"],
["requireAsk", SUPPORTED_TOOL_EXEC_ASK, "exec ask mode"],
@@ -2426,6 +2648,15 @@ function toolPosturePolicyShapeFinding(
}
const elevated = isRecord(tools.elevated) ? tools.elevated : {};
const unsupportedElevatedKey = unsupportedPolicyKey(elevated, ["allow"]);
if (unsupportedElevatedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/elevated/${ocPathSegment(unsupportedElevatedKey)}`,
`${params.policyPath} ${propertyPrefix}.elevated.${unsupportedElevatedKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.elevated.${unsupportedElevatedKey} or use ${propertyPrefix}.elevated.allow.`,
);
}
if (elevated.allow !== undefined && typeof elevated.allow !== "boolean") {
return policyShapeFinding(
params.policyPath,
@@ -2436,6 +2667,15 @@ function toolPosturePolicyShapeFinding(
}
const alsoAllow = isRecord(tools.alsoAllow) ? tools.alsoAllow : {};
const unsupportedAlsoAllowKey = unsupportedPolicyKey(alsoAllow, ["expected"]);
if (unsupportedAlsoAllowKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/alsoAllow/${ocPathSegment(unsupportedAlsoAllowKey)}`,
`${params.policyPath} ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} or use ${propertyPrefix}.alsoAllow.expected.`,
);
}
const alsoAllowExpectedFinding = policyStringArrayPropertyShapeFinding(alsoAllow.expected, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
@@ -2608,12 +2848,38 @@ function gatewayPolicyShapeFinding(
);
}
}
const unsupportedGatewayKey = unsupportedPolicyKey(value, SUPPORTED_GATEWAY_POLICY_SECTIONS);
if (unsupportedGatewayKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/${ocPathSegment(unsupportedGatewayKey)}`,
`${params.policyPath} gateway.${unsupportedGatewayKey} is not supported in Gateway policy.`,
`Remove gateway.${unsupportedGatewayKey} or use a supported Gateway policy section.`,
);
}
const exposure = isRecord(value.exposure) ? value.exposure : {};
const auth = isRecord(value.auth) ? value.auth : {};
const controlUi = isRecord(value.controlUi) ? value.controlUi : {};
const remote = isRecord(value.remote) ? value.remote : {};
const http = isRecord(value.http) ? value.http : {};
for (const [section, sectionValue, allowedKeys] of [
["exposure", exposure, ["allowNonLoopbackBind", "allowTailscaleFunnel"]],
["auth", auth, ["requireAuth", "requireExplicitRateLimit"]],
["controlUi", controlUi, ["allowInsecure"]],
["remote", remote, ["allow"]],
["http", http, ["denyEndpoints", "requireUrlAllowlists"]],
] as const) {
const unsupportedKey = unsupportedPolicyKey(sectionValue, allowedKeys);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/${section}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} gateway.${section}.${unsupportedKey} is not supported in Gateway policy.`,
`Remove gateway.${section}.${unsupportedKey} or use a supported Gateway policy rule.`,
);
}
}
const booleanRules = [
[
"gateway/exposure/allowNonLoopbackBind",
@@ -2700,6 +2966,15 @@ function policyStringArrayShapeFinding(
`Fix ${params.policyPath} so ${params.property} is an object.`,
);
}
const unsupportedKey = unsupportedPolicyKey(value, ["allow", "deny"]);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} ${params.property}.${unsupportedKey} is not supported in policy.`,
`Remove ${params.property}.${unsupportedKey} or use ${params.property}.allow or ${params.property}.deny.`,
);
}
for (const key of ["allow", "deny"] as const) {
const entries = value[key];
if (entries === undefined) {
@@ -2857,6 +3132,41 @@ function invalidChannelDenyRuleFindings(
},
];
}
for (const [index, rule] of policy.channels.denyRules.entries()) {
if (!isRecord(rule)) {
continue;
}
const unsupportedRuleKey = unsupportedPolicyKey(rule, ["id", "reason", "when"]);
if (unsupportedRuleKey !== undefined) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules[${index}].${unsupportedRuleKey} is not supported in channel deny rules.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules/#${index}/${ocPathSegment(unsupportedRuleKey)}`,
fixHint: `Remove channels.denyRules[${index}].${unsupportedRuleKey} or use id, when.provider, and reason.`,
},
];
}
if (isRecord(rule.when)) {
const unsupportedWhenKey = unsupportedPolicyKey(rule.when, ["provider"]);
if (unsupportedWhenKey !== undefined) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules[${index}].when.${unsupportedWhenKey} is not supported in channel deny rules.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules/#${index}/when/${ocPathSegment(unsupportedWhenKey)}`,
fixHint: `Remove channels.denyRules[${index}].when.${unsupportedWhenKey} or use when.provider.`,
},
];
}
}
}
const invalid = policy.channels.denyRules.findIndex((rule) => !isChannelDenyRule(rule));
if (invalid < 0) {
return [];
@@ -4742,6 +5052,14 @@ function dataHandlingPolicyShapeFindings(
return [];
}
return [
policySectionUnsupportedKeyFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling",
targetPath: "dataHandling",
sectionName: "data-handling",
allowedKeys: ["memory", "retention", "sensitiveLogging", "telemetry"],
}),
dataHandlingSectionShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
@@ -4801,6 +5119,29 @@ function dataHandlingPolicyShapeFindings(
].filter((finding): finding is HealthFinding => finding !== undefined);
}
function policySectionUnsupportedKeyFinding(
value: Record<string, unknown>,
params: {
readonly policyPath: string;
readonly policyDocName: string;
readonly propertyPath: string;
readonly targetPath: string;
readonly sectionName: string;
readonly allowedKeys: readonly string[];
},
): HealthFinding | undefined {
const unsupportedKey = unsupportedPolicyKey(value, params.allowedKeys);
if (unsupportedKey === undefined) {
return undefined;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} ${params.propertyPath}.${unsupportedKey} is not supported in ${params.sectionName} policy.`,
`Remove ${params.propertyPath}.${unsupportedKey} or use a supported ${params.sectionName} policy rule.`,
);
}
function dataHandlingSectionShapeFinding(
dataHandling: Record<string, unknown>,
params: {
@@ -4834,6 +5175,24 @@ function dataHandlingBooleanShapeFinding(
},
): HealthFinding | undefined {
const value = getPolicyPath(dataHandling, params.path);
if (isRecord(dataHandling) && typeof params.path[0] === "string") {
const section = dataHandling[params.path[0]];
if (isRecord(section) && typeof params.path[1] === "string") {
const sectionPath = params.path.slice(0, -1).join(".");
const unsupportedKey = unsupportedPolicyKey(section, [params.path[1]]);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath
.split("/")
.slice(0, -1)
.join("/")}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} dataHandling.${sectionPath}.${unsupportedKey} is not supported in data-handling policy.`,
`Remove dataHandling.${sectionPath}.${unsupportedKey} or use ${params.propertyPath}.`,
);
}
}
}
if (value === undefined || typeof value === "boolean") {
return undefined;
}
@@ -2317,6 +2317,81 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.flush).toHaveBeenCalled();
});
it("composes streamed reasoning with tool progress in Telegram progress drafts", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await replyOptions?.onReasoningStream?.({ text: "<think>Checking files</think>" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createReasoningStreamContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`\n• _Checking files_");
});
it("renders configured Telegram commentary progress from preamble item events", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "preamble-1",
progressText: "Checking recent context",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: { label: "Shelling", commentary: true },
},
},
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
});
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "preamble-1",
progressText: "Checking recent context",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: { label: "Shelling" },
},
},
});
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
});
it("keeps the progress draft label when tool progress lines are hidden", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
+83 -121
View File
@@ -24,14 +24,10 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import {
buildChannelProgressDraftLineForEntry,
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
createChannelProgressDraftCompositor,
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewNativeToolProgress,
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
@@ -408,6 +404,13 @@ function formatProgressAsMarkdownCode(text: string): string {
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
}
function formatTelegramProgressLine(text: string): string {
const trimmed = text.trim();
return trimmed.startsWith("_") && trimmed.endsWith("_")
? trimmed
: formatProgressAsMarkdownCode(text);
}
function normalizeTelegramThreadId(value: unknown): number | undefined {
return parseStrictPositiveInteger(value);
}
@@ -873,7 +876,10 @@ export const dispatchTelegramMessage = async ({
!hasTelegramQuoteReply &&
!accountBlockStreamingEnabled &&
!forceBlockStreamingForReasoning;
const canStreamReasoningDraft = !isRoomEvent && streamReasoningDraft;
const streamReasoningInProgressDraft =
streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft;
const canStreamReasoningDraft =
!isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft;
const draftReplyToMessageId =
replyToMode !== "off" && typeof msg.message_id === "number"
? (replyQuoteMessageId ?? msg.message_id)
@@ -936,8 +942,6 @@ export const dispatchTelegramMessage = async ({
log: logVerbose,
})
: undefined;
let streamToolProgressSuppressed = false;
let streamToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let lastAnswerPartialText = "";
let activeAnswerDraftIsToolProgressOnly = false;
function resetAnswerToolProgressDraft() {
@@ -952,33 +956,25 @@ export const dispatchTelegramMessage = async ({
}
activeAnswerDraftIsToolProgressOnly = true;
}
const renderProgressDraft = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!answerLane.stream || streamMode !== "progress") {
return false;
}
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: streamToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
if (!streamText || streamText === answerLane.lastPartialText) {
return false;
}
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream.update(streamText);
if (options?.flush) {
await answerLane.stream.flush();
}
return true;
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: async () => {
await renderProgressDraft({ flush: true });
const progressDraft = createChannelProgressDraftCompositor({
entry: telegramCfg,
mode: streamMode,
active: Boolean(answerLane.stream),
seed: progressSeed,
formatLine: formatTelegramProgressLine,
update: async (streamText, options) => {
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream?.update(streamText);
if (options?.flush) {
await answerLane.stream?.flush();
}
},
tryNativeUpdate: nativeToolProgressDraft
? async (streamText) => await nativeToolProgressDraft.update(streamText)
: undefined,
});
let finalAnswerDeliveryStarted = false;
let finalAnswerDelivered = false;
@@ -986,80 +982,37 @@ export const dispatchTelegramMessage = async ({
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!answerLane.stream) {
if (
!answerLane.stream ||
answerLane.finalized ||
finalAnswerDeliveryStarted ||
finalAnswerDelivered
) {
return false;
}
if (answerLane.finalized || finalAnswerDeliveryStarted || finalAnswerDelivered) {
return false;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return false;
}
const rawText = typeof line === "string" ? line : line?.text;
const normalized = sanitizeProgressMarkdownText(rawText?.replace(/\s+/g, " ").trim() ?? "");
if (streamToolProgressSuppressed) {
return false;
}
if (streamMode !== "progress" && !streamToolProgressEnabled) {
return false;
}
const shouldUpdateProgressLines =
streamToolProgressEnabled && !streamToolProgressSuppressed && Boolean(normalized);
if (!shouldUpdateProgressLines && streamMode !== "progress") {
return false;
}
const progressLine =
typeof line === "object" && line !== undefined ? { ...line, text: normalized } : normalized;
const nextLines = shouldUpdateProgressLines
? mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(telegramCfg),
})
: streamToolProgressLines;
if (shouldUpdateProgressLines && nextLines === streamToolProgressLines) {
return false;
}
if (nativeToolProgressDraft && shouldUpdateProgressLines) {
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: nextLines,
seed: progressSeed,
});
if (streamText && (await nativeToolProgressDraft.update(streamText))) {
streamToolProgressLines = nextLines;
return true;
}
}
if (streamMode !== "progress") {
streamToolProgressLines = nextLines;
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: streamToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream.update(streamText);
return true;
}
streamToolProgressLines = nextLines;
if (options?.startImmediately) {
await progressDraftGate.startNow();
if (progressDraftGate.hasStarted) {
await renderProgressDraft();
return true;
}
return progressDraftGate.hasStarted;
}
const alreadyStarted = progressDraftGate.hasStarted;
const progressActive = await progressDraftGate.noteWork();
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
await renderProgressDraft();
return true;
}
return false;
return await progressDraft.pushToolProgress(line, options);
};
const pushStreamReasoningProgress = async (payload: {
text?: string;
isReasoningSnapshot?: boolean;
}) => {
return await progressDraft.pushReasoningProgress(payload.text, {
snapshot: payload.isReasoningSnapshot === true,
});
};
const markProgressFinalStarted = () => {
finalAnswerDeliveryStarted = true;
progressDraft.markFinalReplyStarted();
};
const markProgressFinalDelivered = () => {
finalAnswerDelivered = true;
progressDraft.markFinalReplyDelivered();
};
const resetProgressDraftState = () => {
progressDraft.reset();
};
const suppressProgressDraftState = () => {
progressDraft.suppress();
};
let splitReasoningOnNextStream = false;
let draftLaneEventQueue = Promise.resolve();
@@ -1143,8 +1096,7 @@ export const dispatchTelegramMessage = async ({
await answerLane.stream?.clear();
answerLane.stream?.forceNewMessage();
resetDraftLaneState(answerLane);
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
suppressProgressDraftState();
return true;
};
const prepareAnswerLaneForText = async () => {
@@ -1172,8 +1124,7 @@ export const dispatchTelegramMessage = async ({
return;
}
resetAnswerToolProgressDraft();
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
suppressProgressDraftState();
}
lane.hasStreamedMessage = true;
lane.finalized = false;
@@ -1587,7 +1538,7 @@ export const dispatchTelegramMessage = async ({
return { kind: "skipped" };
}
answerLane.finalized = true;
finalAnswerDelivered = true;
markProgressFinalDelivered();
return { kind: "sent" };
};
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
@@ -1702,7 +1653,7 @@ export const dispatchTelegramMessage = async ({
const segments = split.segments;
const reply = resolveSendableOutboundReplyParts(effectivePayload);
if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) {
finalAnswerDeliveryStarted = true;
markProgressFinalStarted();
}
if (info.kind === "final") {
await enqueueDraftLaneEvent(async () => {});
@@ -1734,7 +1685,7 @@ export const dispatchTelegramMessage = async ({
buttons,
});
if (result.kind !== "skipped") {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
return result;
};
@@ -1849,7 +1800,7 @@ export const dispatchTelegramMessage = async ({
});
}
if (info.kind === "final" && delivered) {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
@@ -1875,7 +1826,7 @@ export const dispatchTelegramMessage = async ({
durable: info.kind === "final",
});
if (info.kind === "final" && delivered) {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
@@ -1957,13 +1908,19 @@ export const dispatchTelegramMessage = async ({
}
await ingestDraftLaneSegments(payload, true);
})
: undefined,
: streamReasoningInProgressDraft
? (payload) =>
enqueueDraftLaneEvent(async () => {
await pushStreamReasoningProgress(payload);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
streamToolProgressSuppressed = false;
streamToolProgressLines = [];
if (streamMode !== "progress") {
resetProgressDraftState();
}
if (answerLane.finalized) {
await rotateLaneForNewMessage(answerLane);
}
@@ -1973,8 +1930,7 @@ export const dispatchTelegramMessage = async ({
? () =>
enqueueDraftLaneEvent(async () => {
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
streamToolProgressSuppressed = false;
streamToolProgressLines = [];
resetProgressDraftState();
})
: undefined,
suppressDefaultToolProgressMessages:
@@ -2002,6 +1958,12 @@ export const dispatchTelegramMessage = async ({
await progressPromise;
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
await progressDraft.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});
return;
}
await pushStreamToolProgress(
buildChannelProgressDraftLineForEntry(telegramCfg, {
event: "item",
@@ -2106,7 +2068,7 @@ export const dispatchTelegramMessage = async ({
dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
} finally {
progressDraftGate.cancel();
progressDraft.cancel();
await draftLaneEventQueue;
nativeToolProgressDraft?.stop();
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
@@ -106,6 +106,22 @@ describe("telegram custom commands schema", () => {
});
});
it("accepts Telegram progress commentary config", () => {
expectTelegramConfigValid({
streaming: {
mode: "progress",
progress: { commentary: true },
},
accounts: {
ops: {
streaming: {
progress: { commentary: true },
},
},
},
});
});
it("rejects removed DM thread reply policy keys", () => {
expectTelegramConfigIssue({ dm: { threadReplies: "off" } }, "");
expectTelegramConfigIssue(
@@ -109,6 +109,10 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.progress.commentary": {
label: "Telegram Progress Commentary",
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",
@@ -760,6 +760,45 @@ describe("telegram message cache", () => {
expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction);
});
it("uses the current reset command as the session boundary", async () => {
const cache = createTelegramMessageCache();
const chat = { id: 7, type: "group", title: "Ops" } as const;
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 100,
date: 1736380800,
text: "stale context",
from: { id: 100, is_bot: false, first_name: "Requester" },
} as Message,
});
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 101,
date: 1736380860,
text: "/new",
from: { id: 101, is_bot: false, first_name: "Requester" },
} as Message,
});
const context = await buildTelegramConversationContext({
cache,
accountId: "default",
chatId: 7,
messageId: "101",
replyChainNodes: [],
recentLimit: 10,
replyTargetWindowSize: 1,
});
expect(context).toEqual([]);
});
it("does not select messages before the persisted session start when the reset command is absent", async () => {
const cache = createTelegramMessageCache();
const beforeSession = Date.parse("2026-05-10T12:40:00.000Z");
+47 -16
View File
@@ -54,6 +54,13 @@ export type TelegramMessageCache = {
before: number;
after: number;
}) => Promise<TelegramCachedMessageNode[]>;
latestMatchingAtOrBefore: (params: {
accountId: string;
chatId: string | number;
messageId?: string;
threadId?: number;
matches: (node: TelegramCachedMessageNode) => boolean;
}) => Promise<TelegramCachedMessageNode | null>;
};
type MessageWithExternalReply = Message & { external_reply?: Message };
@@ -712,6 +719,40 @@ export function createTelegramMessageCache(params?: {
targetIndex + Math.max(0, after) + 1,
);
},
latestMatchingAtOrBefore: async ({ accountId, chatId, messageId, threadId, matches }) => {
if (!messageId) {
return null;
}
const targetId = parseSafeMessageId(messageId);
if (targetId === undefined) {
return null;
}
await hydrateMessageCacheBucket(bucket, maxMessages, scopeKey);
const prefix = telegramMessageCacheKeyPrefix({ scopeKey, accountId, chatId });
const normalizedThreadId = normalizeTelegramCacheThreadId(threadId);
if (threadId != null && normalizedThreadId === undefined) {
return null;
}
const normalizedThread =
normalizedThreadId !== undefined ? String(normalizedThreadId) : undefined;
let latest: TelegramCachedMessageNode | null = null;
for (const [key, entry] of messages) {
if (!key.startsWith(prefix)) {
continue;
}
if (normalizedThread !== undefined && entry.threadId !== normalizedThread) {
continue;
}
const entryId = parseSafeMessageId(entry.messageId);
if (entryId === undefined || entryId > targetId || !matches(entry)) {
continue;
}
if (!latest || compareCachedMessageNodes(entry, latest) > 0) {
latest = entry;
}
}
return latest;
},
};
}
@@ -789,25 +830,15 @@ async function resolveSessionBoundaryNode(params: {
if (!params.messageId) {
return undefined;
}
const { messageId } = params;
const candidates = (
await params.cache.recentBefore({
return (
(await params.cache.latestMatchingAtOrBefore({
accountId: params.accountId,
chatId: params.chatId,
messageId,
messageId: params.messageId,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
limit: Number.MAX_SAFE_INTEGER,
})
).filter(isSessionBoundaryCommandNode);
const current = await params.cache.get({
accountId: params.accountId,
chatId: params.chatId,
messageId,
});
if (current && isSessionBoundaryCommandNode(current)) {
candidates.push(current);
}
return candidates.toSorted(compareCachedMessageNodes).at(-1);
matches: isSessionBoundaryCommandNode,
})) ?? undefined
);
}
export async function buildTelegramReplyChain(params: {
+2 -2
View File
@@ -1482,8 +1482,8 @@
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx knip@6.8.0 --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
"deadcode:knip": "pnpm dlx knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
"deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
@@ -5,6 +5,7 @@ import {
formatValidationErrors,
validateChatAbortParams,
validateChatHistoryParams,
validateChatMetadataParams,
validateChatSendParams,
validateChatEvent,
validateCommandsListParams,
@@ -104,6 +105,13 @@ describe("lazy protocol validators", () => {
).toBe(true);
});
it("accepts selected-agent scope on chat metadata params", () => {
expect(validateChatMetadataParams({})).toBe(true);
expect(validateChatMetadataParams({ agentId: "work" })).toBe(true);
expect(validateChatMetadataParams({ agentId: "" })).toBe(false);
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
});
it("can still compile every exported protocol validator", () => {
const failures: string[] = [];
const validators: Array<[string, ProtocolValidator]> = [];
+5
View File
@@ -126,6 +126,8 @@ import {
type ChatEvent,
ChatEventSchema,
ChatHistoryParamsSchema,
type ChatMetadataParams,
ChatMetadataParamsSchema,
ChatMessageGetResultSchema,
ChatMessageGetParamsSchema,
type ChatInjectParams,
@@ -846,6 +848,7 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
);
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
@@ -1115,6 +1118,7 @@ export {
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatSendParamsSchema,
ChatInjectParamsSchema,
UpdateRunParamsSchema,
@@ -1223,6 +1227,7 @@ export type {
ArtifactsDownloadResult,
AgentsListParams,
AgentsListResult,
ChatMetadataParams,
CommandsListParams,
CommandsListResult,
CommandEntry,
@@ -34,6 +34,13 @@ export const ChatHistoryParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChatMetadataParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ChatMessageGetParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
@@ -191,6 +191,7 @@ import {
ChatEventSchema,
ChatFinalEventSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatMessageGetParamsSchema,
ChatMessageGetResultSchema,
ChatInjectParamsSchema,
@@ -534,6 +535,7 @@ export const ProtocolSchemas = {
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatMetadataParams: ChatMetadataParamsSchema,
ChatMessageGetParams: ChatMessageGetParamsSchema,
ChatMessageGetResult: ChatMessageGetResultSchema,
ChatSendParams: ChatSendParamsSchema,
@@ -160,6 +160,7 @@ export type AgentsListResult = SchemaType<"AgentsListResult">;
export type ModelChoice = SchemaType<"ModelChoice">;
export type ModelsListParams = SchemaType<"ModelsListParams">;
export type ModelsListResult = SchemaType<"ModelsListResult">;
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
export type CommandEntry = SchemaType<"CommandEntry">;
export type CommandsListParams = SchemaType<"CommandsListParams">;
export type CommandsListResult = SchemaType<"CommandsListResult">;
+161 -24
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
@@ -8,6 +8,8 @@ import {
const KNIP_VERSION = "6.8.0";
export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
export const KNIP_KILL_GRACE_MS = 5_000;
export const KNIP_HEARTBEAT_MS = 60_000;
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
const KNIP_ARGS = [
"--config",
@@ -114,28 +116,163 @@ function spawnErrorCode(error) {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export function runKnipUnusedFiles(params = {}) {
const run = params.spawnSyncCommand ?? spawnSync;
const result = run(
"pnpm",
["--config.minimum-release-age=0", "dlx", `knip@${KNIP_VERSION}`, ...KNIP_ARGS],
{
encoding: "utf8",
killSignal: "SIGTERM",
maxBuffer: params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
timeout: params.timeoutMs ?? KNIP_TIMEOUT_MS,
},
);
return {
status: result.status,
signal: result.signal,
errorCode: spawnErrorCode(result.error),
errorMessage: result.error?.message,
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
};
function signalProcessTree(child, signal) {
if (!child.pid) {
return;
}
try {
if (process.platform === "win32") {
process.kill(child.pid, signal);
} else {
process.kill(-child.pid, signal);
}
} catch {
// The child may have exited between the timeout and signal delivery.
}
}
export async function runKnipUnusedFiles(params = {}) {
const run = params.spawnCommand ?? spawn;
const timeoutMs = params.timeoutMs ?? KNIP_TIMEOUT_MS;
const heartbeatMs = params.heartbeatMs ?? KNIP_HEARTBEAT_MS;
const maxBufferBytes = params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES;
const killGraceMs = params.killGraceMs ?? KNIP_KILL_GRACE_MS;
const writeStatus = params.writeStatus ?? ((message) => process.stderr.write(`${message}\n`));
const args = [
"--config.minimum-release-age=0",
"dlx",
"--package",
`knip@${KNIP_VERSION}`,
"knip",
...KNIP_ARGS,
];
return await new Promise((resolve) => {
const startedAt = Date.now();
let settled = false;
let timedOut = false;
let bufferExceeded = false;
let outputBytes = 0;
const output = [];
let killTimer;
let exitStatus = null;
let exitSignal = null;
const child = run("pnpm", args, {
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const heartbeatTimer = setInterval(() => {
writeStatus(
`[deadcode] Knip unused-file scan still running after ${Math.round(
(Date.now() - startedAt) / 1000,
)}s.`,
);
}, heartbeatMs);
const timeoutTimer = setTimeout(() => {
timedOut = true;
clearInterval(heartbeatTimer);
writeStatus(
`[deadcode] Knip unused-file scan timed out after ${Math.round(timeoutMs / 1000)}s; terminating.`,
);
signalProcessTree(child, "SIGTERM");
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutTimer);
clearInterval(heartbeatTimer);
clearTimeout(killTimer);
resolve({
...result,
output: output.join(""),
});
};
const appendOutput = (chunk) => {
if (settled) {
return;
}
if (bufferExceeded) {
return;
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remainingBytes = maxBufferBytes - outputBytes;
if (buffer.length <= remainingBytes) {
output.push(buffer.toString("utf8"));
outputBytes += buffer.length;
return;
}
if (remainingBytes > 0) {
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
outputBytes = maxBufferBytes;
}
if (!bufferExceeded) {
bufferExceeded = true;
writeStatus(
`[deadcode] Knip unused-file scan exceeded ${maxBufferBytes} output bytes; terminating.`,
);
child.stdout?.off?.("data", appendOutput);
child.stderr?.off?.("data", appendOutput);
child.stdout?.destroy?.();
child.stderr?.destroy?.();
clearInterval(heartbeatTimer);
signalProcessTree(child, "SIGTERM");
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
}
};
child.stdout?.on("data", appendOutput);
child.stderr?.on("data", appendOutput);
child.on("error", (error) =>
finish({
errorCode: spawnErrorCode(error),
errorMessage: error.message,
signal: null,
status: null,
}),
);
child.on("exit", (status, signal) => {
exitStatus = status;
exitSignal = signal;
});
child.on("close", (status, signal) => {
exitStatus = exitStatus ?? status;
exitSignal = exitSignal ?? signal;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
if (timedOut) {
finish({
errorCode: "ETIMEDOUT",
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
signal: exitSignal,
status: exitStatus,
});
return;
}
if (bufferExceeded) {
finish({
errorCode: "ENOBUFS",
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
signal: exitSignal,
status: exitStatus,
});
return;
}
finish({
errorCode: undefined,
errorMessage: undefined,
signal: exitSignal,
status: exitStatus,
});
});
});
}
export function checkUnusedFiles(
output,
allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST,
@@ -154,8 +291,8 @@ export function checkUnusedFiles(
};
}
function main() {
const result = runKnipUnusedFiles();
async function main() {
const result = await runKnipUnusedFiles();
if (result.errorCode || result.status === null) {
console.error(
`deadcode unused-file scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
@@ -183,5 +320,5 @@ function main() {
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
await main();
}
@@ -63,6 +63,7 @@ try {
}),
);
if (extract.status !== 0) {
fs.rmSync(extractDir, { recursive: true, force: true });
fail(`tar -xf failed for ${tarball}: ${extract.stderr || extract.status}`);
}
} catch (error) {
+24 -12
View File
@@ -1659,7 +1659,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) {
'mkdir -p "$tool_root" || { status=$?; return "$status"; };',
'install_lock="$tool_root/.node-${node_version}-${node_arch}.lock";',
"lock_acquired=0;",
'lock_deadline=$((SECONDS + 300));',
"lock_deadline=$((SECONDS + 300));",
"while true; do",
'if mkdir "$install_lock" 2>/dev/null; then lock_acquired=1; printf "%s\\n" "$$" >"$install_lock/pid" || { status=$?; rm -rf "$install_lock"; return "$status"; }; break; fi;',
'if [ -x "$node_dir/bin/node" ] && [ -f "$ready_marker" ]; then break; fi;',
@@ -1668,11 +1668,11 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) {
'if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then echo "timed out waiting for active macOS Node toolchain install lock: $install_lock pid=$lock_pid" >&2; return 1; fi;',
'echo "reclaiming stale macOS Node toolchain install lock: $install_lock" >&2;',
'rm -rf "$install_lock" || return 1;',
'lock_deadline=$((SECONDS + 300));',
"lock_deadline=$((SECONDS + 300));",
"fi;",
"sleep 1;",
"done;",
"release_install_lock() { if [ \"$lock_acquired\" = \"1\" ]; then rm -rf \"$install_lock\" 2>/dev/null || true; fi; };",
'release_install_lock() { if [ "$lock_acquired" = "1" ]; then rm -rf "$install_lock" 2>/dev/null || true; fi; };',
'if [ ! -x "$node_dir/bin/node" ] || [ ! -f "$ready_marker" ]; then',
'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };',
'pkg="node-v${node_version}-darwin-${node_arch}.tar.gz";',
@@ -1938,15 +1938,20 @@ function fullCheckoutSyncRoot() {
return root;
}
function parsePositiveIntegerEnv(name, fallback) {
function parseNonNegativeIntegerEnv(name, fallback, unit) {
const raw = process.env[name]?.trim();
if (!raw) {
return fallback;
}
if (!/^\d+$/u.test(raw)) {
throw new Error(`${name} must be a non-negative integer byte count, got ${JSON.stringify(raw)}`);
throw new Error(`${name} must be a non-negative integer ${unit}, got ${JSON.stringify(raw)}`);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
throw new Error(
`${name} must be a safe non-negative integer ${unit}, got ${JSON.stringify(raw)}`,
);
}
const parsed = Number.parseInt(raw, 10);
return parsed;
}
@@ -1965,9 +1970,10 @@ function formatByteCount(bytes) {
}
function assertFullCheckoutSyncDisk(root) {
const requiredBytes = parsePositiveIntegerEnv(
const requiredBytes = parseNonNegativeIntegerEnv(
"OPENCLAW_CRABBOX_SYNC_MIN_FREE_BYTES",
1024 * 1024 * 1024,
"byte count",
);
if (requiredBytes === 0) {
return;
@@ -2066,11 +2072,12 @@ function startFullCheckoutKeepalive(checkout) {
};
refresh();
const intervalMs = Number.parseInt(
process.env.OPENCLAW_CRABBOX_SYNC_KEEPALIVE_MS ?? "5000",
10,
const intervalMs = parseNonNegativeIntegerEnv(
"OPENCLAW_CRABBOX_SYNC_KEEPALIVE_MS",
5000,
"millisecond interval",
);
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
if (intervalMs <= 0) {
return () => {};
}
@@ -2361,7 +2368,12 @@ const childArgs =
remoteChangedGateBase,
);
if (fullCheckout) {
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout);
try {
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout);
} catch (error) {
cleanupOnce();
throw error;
}
}
const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform);
const child = spawn(childInvocation.command, childInvocation.args, {
+2 -2
View File
@@ -2,8 +2,8 @@
// generated/build inputs, manifest-discovered plugin surfaces, live-test
// helpers, or package bridge files that static production scanning cannot see.
export const KNIP_UNUSED_FILE_ALLOWLIST = [
// Per-agent SQLite scaffold is intentionally landed before runtime migration
// callers so the schema and scoped cache API can be reviewed together.
// Per-agent SQLite scaffold is intentionally ahead of mainline runtime callers.
// The pending SQLite session/runtime branch wires these files into production.
"src/agents/cache/agent-cache-store.sqlite.ts",
"src/agents/cache/agent-cache-store.ts",
"src/state/openclaw-agent-db.paths.ts",
+4 -11
View File
@@ -6,13 +6,11 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
import type { GatewayRpcClient } from "./mcp-channels-harness.ts";
const execFileAsync = promisify(execFile);
const PROBE_PID_WAIT_MS = readPositiveInt(
process.env.OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS,
120_000,
);
const PROBE_PID_WAIT_MS = readCronMcpCleanupProbePidWaitMs();
type McpChannelsHarness = typeof import("./mcp-channels-harness.ts");
let mcpChannelsHarness: McpChannelsHarness | undefined;
@@ -25,13 +23,8 @@ async function loadMcpChannelsHarness(): Promise<McpChannelsHarness> {
return mcpChannelsHarness;
}
function readPositiveInt(raw: string | undefined, fallback: number): number {
const text = (raw ?? "").trim();
if (!/^\d+$/u.test(text)) {
return fallback;
}
const parsed = Number(text);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
export function readCronMcpCleanupProbePidWaitMs(env: NodeJS.ProcessEnv = process.env): number {
return readPositiveIntEnv("OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS", 120_000, env);
}
async function readProbePid(pidPath: string): Promise<number | undefined> {
+6 -1
View File
@@ -155,6 +155,11 @@ export async function cleanupKitchenSinkEnv(root, options = {}) {
const message = lastError instanceof Error ? lastError.message : String(lastError);
console.error(`Kitchen Sink RPC temp root cleanup failed; preserved ${root}: ${message}`);
}
if (options.throwOnFailure) {
throw new Error(`failed to remove Kitchen Sink RPC temp root: ${root}`, {
cause: lastError,
});
}
return false;
}
return true;
@@ -1686,7 +1691,7 @@ export async function main() {
}
await stopGateway(child);
if (!failed && !keepTmp) {
await cleanupKitchenSinkEnv(root);
await cleanupKitchenSinkEnv(root, { throwOnFailure: true });
} else if (failed || keepTmp) {
console.error(`Kitchen Sink RPC temp root preserved: ${root}`);
}
@@ -88,20 +88,44 @@ function assertMockModelConfig() {
function assertChannelConfig() {
const channel = process.argv[3];
const expectedTokens = process.argv.slice(4);
if (expectedTokens.length === 0) {
throw new Error("assert-channel-config requires at least one expected token");
}
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
const entry = cfg.channels?.[channel];
if (!entry || entry.enabled === false) {
throw new Error(`${channel} was not enabled`);
}
const serializedEntry = JSON.stringify(entry);
for (const token of expectedTokens) {
if (!serializedEntry.includes(token)) {
throw new Error(`${channel} token was not persisted`);
const assertTokenField = (field, expected) => {
if (entry[field] !== expected) {
throw new Error(
`${channel} config did not persist ${field}; expected ${expected}, got ${JSON.stringify(entry[field])}`,
);
}
};
switch (channel) {
case "telegram": {
if (expectedTokens.length !== 1) {
throw new Error("telegram channel config assertion requires one bot token");
}
assertTokenField("botToken", expectedTokens[0]);
return;
}
case "discord": {
if (expectedTokens.length !== 1) {
throw new Error("discord channel config assertion requires one bot token");
}
assertTokenField("token", expectedTokens[0]);
return;
}
case "slack": {
if (expectedTokens.length !== 2) {
throw new Error("slack channel config assertion requires bot and app tokens");
}
assertTokenField("botToken", expectedTokens[0]);
assertTokenField("appToken", expectedTokens[1]);
return;
}
default:
throw new Error(`unsupported channel config assertion: ${channel}`);
}
}
@@ -64,13 +64,7 @@ MOCK_PORT="$MOCK_PORT" \
node scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs >"$MOCK_LOG" 2>&1 &
mock_pid="$!"
for _ in $(seq 1 80); do
if node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
break
fi
sleep 0.1
done
node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360
@@ -19,27 +19,19 @@ plugin_id="lifecycle-claw"
package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
pack_root=""
registry_root=""
tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"
tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"
inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"
cleanup() {
openclaw_plugins_cleanup_fixture_servers
rm -rf "$resource_dir"
if [ -n "$pack_root" ]; then
rm -rf "$pack_root"
fi
if [ -n "$registry_root" ]; then
rm -rf "$registry_root"
fi
rm -f \
/tmp/lifecycle-claw-1.0.0.tgz \
/tmp/lifecycle-claw-2.0.0.tgz \
/tmp/plugin-lifecycle-inspect-v1.json
}
trap cleanup EXIT
mkdir -p "$resource_dir"
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
@@ -51,19 +43,19 @@ run_measured() {
node "$measure" "$summary_tsv" "$phase" -- "$@"
}
pack_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-pack.XXXXXX")"
registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" "$tarball_v2" "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root" "$package_name" 2.0.0 "$tarball_v2"
trap cleanup EXIT
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id"
node "$probe" assert-inspect-loaded "$plugin_id" /tmp/plugin-lifecycle-inspect-v1.json
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
run_measured disable node "$entry" plugins disable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" false
@@ -20,6 +20,12 @@ PORT="18789"
MOCK_PORT="44190"
SUCCESS_MARKER="OPENCLAW_E2E_OK_TYPED_ONBOARDING"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-typed-onboarding.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
INSTALL_LOG="$LOG_DIR/install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
AGENT_LOG="$LOG_DIR/agent.log"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
export SUCCESS_MARKER MOCK_REQUEST_LOG
@@ -41,11 +47,11 @@ dump_debug_logs() {
local status="$1"
echo "release typed onboarding failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-release-typed-onboarding-install.log \
/tmp/openclaw-release-typed-onboarding.log \
/tmp/openclaw-release-typed-onboarding-openai.log \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
/tmp/openclaw-release-typed-onboarding-agent.log \
"$AGENT_LOG" \
"$OPENCLAW_CONFIG_PATH" \
"$HOME/.openclaw/agents/main/agent/auth-profiles.json"
}
@@ -64,36 +70,36 @@ wait_for_log() {
local start_s
start_s="$(date +%s)"
while true; do
if [ -f /tmp/openclaw-release-typed-onboarding.log ]; then
if grep -a -F -q "$needle" /tmp/openclaw-release-typed-onboarding.log; then
if [ -f "$ONBOARD_LOG" ]; then
if grep -a -F -q "$needle" "$ONBOARD_LOG"; then
return 0
fi
if node scripts/e2e/lib/onboard/log-contains.mjs /tmp/openclaw-release-typed-onboarding.log "$needle"; then
if node scripts/e2e/lib/onboard/log-contains.mjs "$ONBOARD_LOG" "$needle"; then
return 0
fi
fi
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
echo "Timeout waiting for log: $needle" >&2
tail -n 120 /tmp/openclaw-release-typed-onboarding.log 2>/dev/null || true
tail -n 120 "$ONBOARD_LOG" 2>/dev/null || true
return 1
fi
sleep 0.2
done
}
openclaw_e2e_install_package /tmp/openclaw-release-typed-onboarding-install.log
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-typed-onboarding-openai.log)"
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
input_fifo_dir="$(mktemp -d "/tmp/openclaw-release-typed-onboarding.XXXXXX")"
input_fifo_dir="$(mktemp -d "$scenario_tmp/input.XXXXXX")"
input_fifo="$input_fifo_dir/stdin.fifo"
mkfifo "$input_fifo"
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" /tmp/openclaw-release-typed-onboarding.log <"$input_fifo" >/dev/null 2>&1 &
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" "$ONBOARD_LOG" <"$input_fifo" >/dev/null 2>&1 &
wizard_pid="$!"
exec 3>"$input_fifo"
@@ -124,7 +130,7 @@ openclaw onboard \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >>/tmp/openclaw-release-typed-onboarding.log 2>&1
--skip-health >>"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-openai-env-ref "$OPENAI_API_KEY"
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
@@ -134,7 +140,7 @@ openclaw agent --local \
--session-id release-typed-onboarding-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >/tmp/openclaw-release-typed-onboarding-agent.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-typed-onboarding-agent.log "$MOCK_REQUEST_LOG"
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
echo "Release typed onboarding scenario passed."
@@ -22,6 +22,22 @@ MOCK_PORT="44210"
CLICKCLACK_PORT="44211"
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_UPGRADE"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-upgrade-user-journey.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
BASELINE_INSTALL_LOG="$LOG_DIR/baseline-install.log"
CANDIDATE_INSTALL_LOG="$LOG_DIR/candidate-install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"
PLUGIN_CLI_BEFORE_LOG="$LOG_DIR/plugin-cli-before.log"
PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log"
AGENT_LOG="$LOG_DIR/agent.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
GATEWAY_LOG="$LOG_DIR/gateway.log"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
BASELINE_SPEC="${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}"
@@ -47,19 +63,19 @@ dump_debug_logs() {
local status="$1"
echo "release upgrade user journey failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-release-upgrade-baseline-install.log \
/tmp/openclaw-release-upgrade-candidate-install.log \
/tmp/openclaw-release-upgrade-onboard.log \
/tmp/openclaw-release-upgrade-openai.log \
"$BASELINE_INSTALL_LOG" \
"$CANDIDATE_INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
/tmp/openclaw-release-upgrade-plugin-install.log \
/tmp/openclaw-release-upgrade-plugin-cli-before.log \
/tmp/openclaw-release-upgrade-plugin-cli-after.log \
/tmp/openclaw-release-upgrade-agent.log \
/tmp/openclaw-release-upgrade-status.json \
/tmp/openclaw-release-upgrade-clickclack-outbound.json \
/tmp/openclaw-release-upgrade-clickclack-server.log \
/tmp/openclaw-release-upgrade-gateway.log \
"$PLUGIN_INSTALL_LOG" \
"$PLUGIN_CLI_BEFORE_LOG" \
"$PLUGIN_CLI_AFTER_LOG" \
"$AGENT_LOG" \
"$STATUS_JSON" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$CLICKCLACK_SERVER_LOG" \
"$GATEWAY_LOG" \
"$CLICKCLACK_STATE"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
@@ -71,8 +87,8 @@ start_gateway() {
}
echo "Installing published baseline $BASELINE_SPEC..."
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >/tmp/openclaw-release-upgrade-baseline-install.log 2>&1; then
cat /tmp/openclaw-release-upgrade-baseline-install.log >&2 || true
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then
cat "$BASELINE_INSTALL_LOG" >&2 || true
exit 1
fi
command -v openclaw >/dev/null
@@ -80,13 +96,13 @@ baseline_root="$(openclaw_e2e_package_root)"
baseline_entry="$(openclaw_e2e_package_entrypoint "$baseline_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-upgrade-openai.log)"
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >/tmp/openclaw-release-upgrade-clickclack-server.log 2>&1 &
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
clickclack_pid="$!"
for _ in $(seq 1 100); do
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
@@ -108,10 +124,10 @@ openclaw_e2e_run_command node "$baseline_entry" onboard \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >/tmp/openclaw-release-upgrade-onboard.log 2>&1
--skip-health >"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
plugin_dir="$(mktemp -d "/tmp/openclaw-release-upgrade-plugin.XXXXXX")"
plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"$plugin_dir" \
release-upgrade-plugin \
@@ -120,12 +136,12 @@ node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"Release Upgrade Plugin" \
release-upgrade \
"release-upgrade-plugin:pong"
openclaw plugins install "$plugin_dir" >/tmp/openclaw-release-upgrade-plugin-install.log 2>&1
openclaw release-upgrade ping >/tmp/openclaw-release-upgrade-plugin-cli-before.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-upgrade-plugin-cli-before.log "release-upgrade-plugin:pong"
openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1
openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong"
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw_e2e_install_package /tmp/openclaw-release-upgrade-candidate-install.log "candidate OpenClaw package"
openclaw_e2e_install_package "$CANDIDATE_INSTALL_LOG" "candidate OpenClaw package"
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
@@ -136,22 +152,22 @@ openclaw agent --local \
--session-id release-upgrade-user-journey-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >/tmp/openclaw-release-upgrade-agent.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-upgrade-agent.log "$MOCK_REQUEST_LOG"
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
openclaw release-upgrade ping >/tmp/openclaw-release-upgrade-plugin-cli-after.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-upgrade-plugin-cli-after.log "release-upgrade-plugin:pong"
openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong"
openclaw channels status --json >/tmp/openclaw-release-upgrade-status.json 2>/tmp/openclaw-release-upgrade-status.err
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-upgrade-status.json
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
openclaw message send \
--channel clickclack \
--target channel:general \
--message "release upgrade outbound" \
--json >/tmp/openclaw-release-upgrade-clickclack-outbound.json 2>/tmp/openclaw-release-upgrade-clickclack-outbound.err
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release upgrade outbound"
start_gateway /tmp/openclaw-release-upgrade-gateway.log
start_gateway "$GATEWAY_LOG"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
@@ -23,6 +23,30 @@ MOCK_PORT="44180"
CLICKCLACK_PORT="44181"
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
INSTALL_LOG="$LOG_DIR/install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
AGENT_LOG="$LOG_DIR/agent.log"
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
DOCTOR_LOG="$LOG_DIR/doctor.log"
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
@@ -43,24 +67,23 @@ dump_debug_logs() {
local status="$1"
echo "release user journey failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-release-user-journey-install.log \
/tmp/openclaw-release-user-journey-onboard.log \
/tmp/openclaw-release-user-journey-openai.log \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
/tmp/openclaw-release-user-journey-agent.log \
/tmp/openclaw-release-user-journey-plugin-a-install.log \
/tmp/openclaw-release-user-journey-plugin-a-cli.log \
/tmp/openclaw-release-user-journey-plugin-a-uninstall.log \
/tmp/openclaw-release-user-journey-plugin-b-install.log \
/tmp/openclaw-release-user-journey-plugin-b-cli.log \
/tmp/openclaw-release-user-journey-clickclack.log \
/tmp/openclaw-release-user-journey-clickclack-server.log \
/tmp/openclaw-release-user-journey-clickclack-outbound.json \
/tmp/openclaw-release-user-journey-clickclack-inbound.json \
/tmp/openclaw-release-user-journey-gateway-1.log \
/tmp/openclaw-release-user-journey-gateway-2.log \
/tmp/openclaw-release-user-journey-status.json \
/tmp/openclaw-release-user-journey-doctor.log \
"$AGENT_LOG" \
"$PLUGIN_A_INSTALL_LOG" \
"$PLUGIN_A_CLI_LOG" \
"$PLUGIN_A_UNINSTALL_LOG" \
"$PLUGIN_B_INSTALL_LOG" \
"$PLUGIN_B_CLI_LOG" \
"$CLICKCLACK_SERVER_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$GATEWAY_1_LOG" \
"$GATEWAY_2_LOG" \
"$STATUS_JSON" \
"$STATUS_AFTER_RESTART_JSON" \
"$DOCTOR_LOG" \
"$CLICKCLACK_STATE"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
@@ -114,19 +137,19 @@ fs.writeFileSync(
NODE
}
openclaw_e2e_install_package /tmp/openclaw-release-user-journey-install.log
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-user-journey-openai.log)"
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >/tmp/openclaw-release-user-journey-clickclack-server.log 2>&1 &
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
clickclack_pid="$!"
for _ in $(seq 1 100); do
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
@@ -149,7 +172,7 @@ openclaw onboard \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >/tmp/openclaw-release-user-journey-onboard.log 2>&1
--skip-health >"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
@@ -159,26 +182,26 @@ openclaw agent --local \
--session-id release-user-journey-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >/tmp/openclaw-release-user-journey-agent.log 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-user-journey-agent.log "$MOCK_REQUEST_LOG"
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
echo "Installing first external plugin..."
plugin_a_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-a.XXXXXX")"
plugin_a_install_path_file="/tmp/openclaw-release-user-journey-plugin-a-install-path.txt"
plugin_a_source_path_file="/tmp/openclaw-release-user-journey-plugin-a-source-path.txt"
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
openclaw plugins install "$plugin_a_dir" >/tmp/openclaw-release-user-journey-plugin-a-install.log 2>&1
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
remember-plugin-install-path \
journey-plugin-a \
"$plugin_a_install_path_file" \
"$plugin_a_source_path_file" \
"$plugin_a_dir"
openclaw journey-a ping >/tmp/openclaw-release-user-journey-plugin-a-cli.log 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-a-cli.log "journey-plugin-a:pong"
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
echo "Uninstalling first external plugin..."
openclaw plugins uninstall journey-plugin-a --force >/tmp/openclaw-release-user-journey-plugin-a-uninstall.log 2>&1
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
assert-plugin-uninstalled \
journey-plugin-a \
@@ -186,41 +209,41 @@ node scripts/e2e/lib/release-user-journey/assertions.mjs \
"$plugin_a_source_path_file"
echo "Installing replacement external plugin..."
plugin_b_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-b.XXXXXX")"
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
openclaw plugins install "$plugin_b_dir" >/tmp/openclaw-release-user-journey-plugin-b-install.log 2>&1
openclaw journey-b ping >/tmp/openclaw-release-user-journey-plugin-b-cli.log 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-cli.log "journey-plugin-b:pong"
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
echo "Configuring ClickClack..."
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw channels status --json >/tmp/openclaw-release-user-journey-status.json 2>/tmp/openclaw-release-user-journey-status.err
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status.json
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
echo "Sending ClickClack outbound message..."
openclaw message send \
--channel clickclack \
--target channel:general \
--message "release journey outbound" \
--json >/tmp/openclaw-release-user-journey-clickclack-outbound.json 2>/tmp/openclaw-release-user-journey-clickclack-outbound.err
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
echo "Starting Gateway for ClickClack inbound..."
start_gateway /tmp/openclaw-release-user-journey-gateway-1.log
start_gateway "$GATEWAY_1_LOG"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
echo "Restarting Gateway and checking state survival..."
stop_gateway
start_gateway /tmp/openclaw-release-user-journey-gateway-2.log
openclaw plugins inspect journey-plugin-b --runtime --json >/tmp/openclaw-release-user-journey-plugin-b-after-restart.json 2>&1
openclaw channels status --json >/tmp/openclaw-release-user-journey-status-after-restart.json 2>/tmp/openclaw-release-user-journey-status-after-restart.err
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status-after-restart.json
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-after-restart.json "journey-plugin-b"
start_gateway "$GATEWAY_2_LOG"
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
stop_gateway
echo "Running doctor at end of release journey..."
openclaw doctor --repair --non-interactive >/tmp/openclaw-release-user-journey-doctor.log 2>&1
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
echo "Release user journey scenario passed."
+1 -1
View File
@@ -24,8 +24,8 @@ export async function createE2eStateDir(prefix: string, env = process.env): Prom
const cleanup = () => {
if (created && !cleaned) {
cleaned = true;
rmSync(stateDir, { force: true, recursive: true });
cleaned = true;
}
};
+2 -1
View File
@@ -22,7 +22,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="openclaw-multi-node-update-e2e"
DOCKER_RUN_TIMEOUT="${OPENCLAW_MULTI_NODE_DOCKER_TIMEOUT:-300s}"
ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update}"
RUN_ID="${OPENCLAW_MULTI_NODE_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update/$RUN_ID}"
mkdir -p "$ARTIFACT_DIR"
chmod -R a+rwX "$ARTIFACT_DIR" || true
+2 -1
View File
@@ -11,7 +11,8 @@ DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live}"
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}"
resolve_credential_source() {
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
+21 -2
View File
@@ -9,7 +9,8 @@ DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-rtt}"
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-rtt/$RUN_ID}"
resolve_credential_source() {
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
@@ -387,12 +388,30 @@ installed_version="$(node -p "require('/npm-global/lib/node_modules/openclaw/pac
node /app/scripts/e2e/mock-openai-server.mjs >"$mock_log" 2>&1 &
mock_pid="$!"
mock_ready=0
for _ in $(seq 1 60); do
if node -e "fetch('http://127.0.0.1:${mock_port}/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then
if node --input-type=module -e '
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);
try {
const response = await fetch(process.argv[1], { signal: controller.signal });
process.exit(response.ok ? 0 : 1);
} catch {
process.exit(1);
} finally {
clearTimeout(timer);
}
' "http://127.0.0.1:${mock_port}/health"; then
mock_ready=1
break
fi
sleep 1
done
if [ "$mock_ready" != "1" ]; then
echo "Mock OpenAI server did not become ready" >&2
cat "$mock_log" >&2 || true
exit 1
fi
mkdir -p "$(dirname "$config_path")" "$HOME/.openclaw/workspace" "$HOME/.openclaw/agents/main/sessions" "$HOME/workspace"
+9 -8
View File
@@ -10,6 +10,7 @@ import {
parseBoolEnv,
parseMode,
parseProvider,
readPositiveIntEnv,
modelProviderConfigBatchJson,
posixProviderOnlyPluginIsolationScript,
repoRoot,
@@ -234,6 +235,10 @@ function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private auth: ProviderAuth;
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
private agentTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S",
1500,
);
private artifact: PackageArtifact | null = null;
private latestVersion = "";
private snapshot!: SnapshotInfo;
@@ -320,10 +325,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
);
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.freshGateway = "pass";
await this.phase(
"fresh.first-local-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 1500),
() => this.verifyLocalTurn(),
await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.freshAgent = "pass";
}
@@ -352,10 +355,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
);
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.upgradeGateway = "pass";
await this.phase(
"upgrade.first-local-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 1500),
() => this.verifyLocalTurn(),
await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.upgradeAgent = "pass";
}
+18 -19
View File
@@ -8,6 +8,7 @@ import {
makeTempDir,
parseMode,
parseProvider,
readPositiveIntEnv,
resolveLatestVersion,
resolveParallelsModelTimeoutSeconds,
resolveWindowsProviderAuth,
@@ -226,6 +227,16 @@ function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
private auth: ProviderAuth;
private agentTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S",
2700,
);
private updateTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S",
1200,
);
private gatewayRecoveryAfterMs =
readPositiveIntEnv("OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S", 180) * 1000;
private artifact: PackageArtifact | null = null;
private minGitZipPath = "";
private latestVersion = "";
@@ -346,11 +357,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.freshGateway = "pass";
await this.phase(
"fresh.first-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700),
() => this.verifyTurn(),
);
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
this.status.freshAgent = "pass";
}
@@ -392,10 +399,8 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
this.status.upgradePrecheck = "latest-ref-fail";
}
await this.phase("upgrade.gateway-stop-before-update", 420, () => this.gatewayAction("stop"));
await this.phase(
"upgrade.update-dev",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200),
() => this.runDevChannelUpdate(),
await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
this.runDevChannelUpdate(),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
@@ -404,11 +409,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.upgradeGateway = "pass";
await this.phase(
"upgrade.first-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700),
() => this.verifyTurn(),
);
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
this.status.upgradeAgent = "pass";
}
@@ -645,7 +646,7 @@ Invoke-WithScopedEnv @{ OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'; O
if ($script:OpenClawUpdateExit -ne 0) { throw "openclaw update failed with exit code $script:OpenClawUpdateExit" }
Invoke-OpenClaw --version
Invoke-OpenClaw update status --json`,
{ timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200) * 1000 },
{ timeoutMs: this.updateTimeoutSeconds * 1000 },
);
}
@@ -676,8 +677,6 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE
const deadline = Date.now() + 420_000;
let attempt = 1;
let recoveryTried = false;
const recoveryAfter =
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S || 180) * 1000;
const start = Date.now();
while (Date.now() < deadline) {
const probe = this.guestPowerShell(
@@ -687,7 +686,7 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE
if (/"ok"\s*:\s*true/.test(probe)) {
return;
}
if (!recoveryTried && Date.now() - start >= recoveryAfter) {
if (!recoveryTried && Date.now() - start >= this.gatewayRecoveryAfterMs) {
warn(
`gateway-reachable recovery: gateway start after ${Math.floor((Date.now() - start) / 1000)}s`,
);
@@ -759,7 +758,7 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
}
}
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700) * 1000,
this.agentTimeoutSeconds * 1000,
);
}
+12 -4
View File
@@ -203,19 +203,26 @@ function makeEnv(name) {
return { root, home, stateDir, env };
}
async function cleanupEnv(root) {
async function cleanupEnv(root, options = {}) {
if (process.env.OPENCLAW_SECRET_PROOF_KEEP_TMP === "1") {
console.log(`[keep] ${root}`);
return;
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const attempts = options.attempts ?? 5;
const retryDelayMs = options.retryDelayMs ?? 250;
let lastError;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
fs.rmSync(root, { recursive: true, force: true });
return;
} catch {
await delay(250);
} catch (error) {
lastError = error;
if (attempt < attempts - 1) {
await delay(retryDelayMs);
}
}
}
throw new Error(`failed to remove secret proof temp root ${root}`, { cause: lastError });
}
function runCommand(command, args, options = {}) {
@@ -1728,6 +1735,7 @@ async function main() {
}
export {
cleanupEnv,
expectGatewayStartupFails,
gatewayCall,
runCommand,
+9 -3
View File
@@ -12,11 +12,17 @@ const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
function positiveEnvInt(name, env, fallback) {
const raw = env[name]?.trim();
if (raw === undefined || raw === "" || !/^[0-9]+$/.test(raw)) {
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function resolveCliStartupBuildTimeoutMs(env = process.env) {
+9 -3
View File
@@ -14,11 +14,17 @@ const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
function positiveEnvInt(name, env, fallback) {
const raw = env[name]?.trim();
if (raw === undefined || raw === "" || !/^[0-9]+$/.test(raw)) {
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function resolveExtensionMemoryBuildTimeoutMs(env = process.env) {
+42 -2
View File
@@ -73,6 +73,15 @@ docker_build_timeout_required() {
return 1
}
docker_build_heartbeat_seconds() {
local configured="${OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS:-30}"
if [[ "$configured" =~ ^[0-9]+$ ]] && [ "$configured" -ge 1 ]; then
echo "$((10#$configured))"
return
fi
echo 30
}
docker_build_run_command() {
local timeout_value="$1"
shift
@@ -85,6 +94,37 @@ docker_build_run_command() {
"$@"
}
docker_build_run_logged() {
local label="$1"
local timeout_value="$2"
local log_file="$3"
shift 3
local heartbeat_seconds
heartbeat_seconds="$(docker_build_heartbeat_seconds)"
local started_at="$SECONDS"
local next_heartbeat=$heartbeat_seconds
local build_status=0
docker_build_run_command "$timeout_value" "$@" >"$log_file" 2>&1 &
local build_pid="$!"
while kill -0 "$build_pid" 2>/dev/null; do
/bin/sleep 1
local elapsed_seconds=$((SECONDS - started_at))
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$build_pid" 2>/dev/null; then
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..."
next_heartbeat=$((elapsed_seconds + heartbeat_seconds))
fi
done
wait "$build_pid" || build_status="$?"
return "$build_status"
}
docker_build_with_retries() {
local label="$1"
shift
@@ -101,7 +141,7 @@ docker_build_with_retries() {
local timeout_value="${OPENCLAW_DOCKER_BUILD_TIMEOUT:-3600s}"
while true; do
log_file="$(docker_e2e_run_log "$label")"
if docker_build_run_command "$timeout_value" "${command[@]}" >"$log_file" 2>&1; then
if docker_build_run_logged "$label" "$timeout_value" "$log_file" "${command[@]}"; then
rm -f "$log_file"
return 0
fi
@@ -116,7 +156,7 @@ docker_build_with_retries() {
docker_e2e_print_log "$log_file"
rm -f "$log_file"
attempt=$((attempt + 1))
sleep "$attempt"
/bin/sleep "$attempt"
done
}
+17 -7
View File
@@ -35,19 +35,29 @@ run_logged_print_heartbeat() {
local label="$1"
local interval_seconds="$2"
shift 2
if ! [[ "$interval_seconds" =~ ^[0-9]+$ ]] || [ "$interval_seconds" -lt 1 ]; then
interval_seconds="30"
else
interval_seconds="$((10#$interval_seconds))"
fi
local log_file
log_file="$(docker_e2e_run_log "$label")"
"$@" >"$log_file" 2>&1 &
local command_pid=$!
local started_at
started_at="$(date +%s)"
local started_at="$SECONDS"
local next_heartbeat=$interval_seconds
local status=0
while kill -0 "$command_pid" 2>/dev/null; do
sleep "$interval_seconds"
if kill -0 "$command_pid" 2>/dev/null; then
local now
now="$(date +%s)"
echo "still running $label ($((now - started_at))s elapsed)"
/bin/sleep 1
local elapsed_seconds=$((SECONDS - started_at))
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$command_pid" 2>/dev/null; then
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)"
next_heartbeat=$((elapsed_seconds + interval_seconds))
fi
done
set +e
+9 -3
View File
@@ -10,12 +10,18 @@ const DEFAULT_NPM_VERIFY_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_NPM_VERIFY_COMMAND_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
function positiveEnvInt(name: string, fallback: number): number {
const raw = process.env[name];
const raw = process.env[name]?.trim();
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function runNpmVerifyCommand(
+47 -5
View File
@@ -298,21 +298,63 @@ openclaw_e2e_run_script_with_pty() {
openclaw_e2e_maybe_timeout "$timeout_value" script -q -F "$log_path" /bin/bash -lc "$command"
fi
}
openclaw_e2e_start_tracked_process() {
local log_path="${1:?missing OpenClaw E2E process log path}"
shift
if command -v setsid >/dev/null 2>&1; then
setsid "$@" >"$log_path" 2>&1 &
printf '%s\n' "$!"
return
fi
node --input-type=module - "$log_path" "$@" <<'NODE'
import { closeSync, openSync } from "node:fs";
import { spawn } from "node:child_process";
const [logPath, command, ...args] = process.argv.slice(2);
if (!command) {
console.error("missing command for OpenClaw E2E tracked process");
process.exit(1);
}
const logFd = openSync(logPath, "a");
const child = spawn(command, args, {
detached: process.platform !== "win32",
env: process.env,
stdio: ["ignore", logFd, logFd],
});
closeSync(logFd);
child.unref();
console.log(child.pid);
NODE
}
openclaw_e2e_signal_process() {
local pid="${1:-}" signal="${2:-TERM}"
[ -n "$pid" ] || return 0
if kill -0 -- "-$pid" >/dev/null 2>&1; then
kill "-$signal" -- "-$pid" >/dev/null 2>&1 || true
return 0
fi
kill "-$signal" "$pid" >/dev/null 2>&1 || true
}
openclaw_e2e_process_alive() {
local pid="${1:-}"
[ -n "$pid" ] || return 1
kill -0 "$pid" >/dev/null 2>&1 || kill -0 -- "-$pid" >/dev/null 2>&1
}
openclaw_e2e_stop_process() {
local pid="${1:-}" _
[ -n "$pid" ] || return 0
kill "$pid" >/dev/null 2>&1 || true
openclaw_e2e_signal_process "$pid" TERM
for _ in $(seq 1 40); do
! kill -0 "$pid" >/dev/null 2>&1 && { wait "$pid" >/dev/null 2>&1 || true; return 0; }
! openclaw_e2e_process_alive "$pid" && { wait "$pid" >/dev/null 2>&1 || true; return 0; }
sleep 0.25
done
kill -9 "$pid" >/dev/null 2>&1 || true
openclaw_e2e_signal_process "$pid" KILL
wait "$pid" >/dev/null 2>&1 || true
}
openclaw_e2e_terminate_gateways() {
openclaw_e2e_stop_process "${1:-}"
}
openclaw_e2e_start_mock_openai() { MOCK_PORT="$1" node scripts/e2e/mock-openai-server.mjs >"$2" 2>&1 & printf '%s\n' "$!"; }
openclaw_e2e_start_mock_openai() { openclaw_e2e_start_tracked_process "$2" env "MOCK_PORT=$1" node scripts/e2e/mock-openai-server.mjs; }
openclaw_e2e_wait_mock_openai() {
local port="$1" attempts="${2:-80}" timeout_ms="${3:-400}" _
for _ in $(seq 1 "$attempts"); do
@@ -321,7 +363,7 @@ openclaw_e2e_wait_mock_openai() {
done
openclaw_e2e_probe_http "http://127.0.0.1:${port}/health" ok "$timeout_ms"
}
openclaw_e2e_start_gateway() { node "$1" gateway --port "$2" --bind loopback --allow-unconfigured >"$3" 2>&1 & printf '%s\n' "$!"; }
openclaw_e2e_start_gateway() { openclaw_e2e_start_tracked_process "$3" node "$1" gateway --port "$2" --bind loopback --allow-unconfigured; }
openclaw_e2e_exec_gateway() { exec node "$1" gateway --port "$2" --bind "${3:-loopback}" --allow-unconfigured >"$4" 2>&1; }
openclaw_e2e_wait_gateway_ready() {
local pid="$1" log="$2" attempts="${3:-300}" _
+565
View File
@@ -0,0 +1,565 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import net from "node:net";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath, pathToFileURL } from "node:url";
const DEFAULT_METHODS = ["health", "config.get"];
const DEFAULT_ITERATIONS = 10;
export const READY_TIMEOUT_MS = 120_000;
export const READY_PROBE_TIMEOUT_MS = 1_000;
const IS_DIRECT_RUN =
typeof process.argv[1] === "string" &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
function usage() {
return [
"Usage: node --import tsx scripts/measure-rpc-rtt.mjs",
" --output-dir <dir>",
" [--repo-root <openclaw-repo>]",
" [--iterations <count>]",
" [--methods <comma-separated-methods>]",
].join("\n");
}
function parseArgs(argv) {
const args = {
iterations: DEFAULT_ITERATIONS,
methods: DEFAULT_METHODS,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--output-dir") {
args.outputDir = argv[(index += 1)];
continue;
}
if (arg === "--repo-root") {
args.repoRoot = argv[(index += 1)];
continue;
}
if (arg === "--iterations") {
args.iterations = Number(argv[(index += 1)]);
continue;
}
if (arg === "--methods") {
args.methods = argv[(index += 1)]
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
continue;
}
throw new Error(`Unknown argument: ${arg}\n${usage()}`);
}
if (!args.outputDir) {
throw new Error(usage());
}
if (!Number.isInteger(args.iterations) || args.iterations < 1) {
throw new Error("--iterations must be a positive integer.");
}
if (args.methods.length === 0) {
throw new Error("--methods must include at least one gateway method.");
}
return args;
}
async function getFreePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (address && typeof address === "object") {
resolve(address.port);
return;
}
reject(new Error("failed to allocate loopback port"));
});
});
});
}
async function sleep(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function formatErrorMessage(error) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return String(error);
}
export async function waitForGatewayReady({
child,
fetchImpl = fetch,
port,
probeTimeoutMs = READY_PROBE_TIMEOUT_MS,
readyTimeoutMs = READY_TIMEOUT_MS,
sleepMs = 250,
stderrPath,
}) {
const startedAt = Date.now();
let childExit = null;
child.once("exit", (code, signal) => {
childExit = { code, signal };
});
const getChildExit = () =>
childExit ??
(child.exitCode != null || child.signalCode != null
? { code: child.exitCode, signal: child.signalCode }
: null);
while (Date.now() - startedAt < readyTimeoutMs) {
const observedExit = getChildExit();
if (observedExit) {
const stderr = await fs.readFile(stderrPath, "utf8").catch(() => "");
throw new Error(
`gateway exited before readiness code=${observedExit.code ?? "null"} signal=${observedExit.signal ?? "null"}\n${stderr.slice(-4000)}`,
);
}
for (const endpoint of ["/readyz", "/healthz"]) {
try {
const response = await fetchImpl(`http://127.0.0.1:${port}${endpoint}`, {
signal: AbortSignal.timeout(probeTimeoutMs),
});
if (response.ok) {
return;
}
} catch {
// The gateway may not have bound the port yet.
}
}
await sleep(sleepMs);
}
const stderr = await fs.readFile(stderrPath, "utf8").catch(() => "");
throw new Error(`gateway did not become ready after ${readyTimeoutMs}ms\n${stderr.slice(-4000)}`);
}
async function stopGateway(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const exited = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), 1_500);
child.once("exit", () => {
clearTimeout(timer);
resolve(true);
});
});
if (!exited && child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}
async function closeFileHandles(handles) {
const results = await Promise.allSettled(handles.filter(Boolean).map((handle) => handle.close()));
const failedClose = results.find((result) => result.status === "rejected");
if (failedClose) {
throw failedClose.reason;
}
}
export async function startGateway({
configPath,
env = process.env,
openImpl = fs.open,
port,
repoRoot,
spawnImpl = spawn,
stderrPath,
stdoutPath,
tempRoot,
token,
}) {
const stdout = await openImpl(stdoutPath, "w");
let stderr;
try {
stderr = await openImpl(stderrPath, "w");
} catch (error) {
try {
await closeFileHandles([stdout]);
} catch {}
throw error;
}
let child;
try {
child = spawnImpl(
"pnpm",
[
"openclaw",
"gateway",
"run",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: repoRoot,
env: {
...env,
HOME: path.join(tempRoot, "home"),
XDG_CONFIG_HOME: path.join(tempRoot, "xdg-config"),
XDG_DATA_HOME: path.join(tempRoot, "xdg-data"),
XDG_CACHE_HOME: path.join(tempRoot, "xdg-cache"),
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: path.join(tempRoot, "state"),
OPENCLAW_GATEWAY_TOKEN: token,
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_TEST_FAST: "1",
},
stdio: ["ignore", stdout.fd, stderr.fd],
},
);
} catch (error) {
try {
await closeFileHandles([stdout, stderr]);
} catch {}
throw error;
}
try {
await closeFileHandles([stdout, stderr]);
} catch (error) {
try {
await stopGateway(child);
} catch {}
throw error;
}
return child;
}
export async function cleanupTempRoot(tempRoot, { rmImpl = fs.rm } = {}) {
try {
await rmImpl(tempRoot, { force: true, recursive: true });
} catch (error) {
throw new Error(`failed to remove RPC RTT temp root: ${formatErrorMessage(error)}`, {
cause: error,
});
}
}
function quantile(sorted, q) {
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * q) - 1))];
}
function stats(samples) {
const sorted = samples.toSorted((left, right) => left - right);
return {
avgMs: Math.round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
maxMs: Math.round(sorted.at(-1)),
minMs: Math.round(sorted[0]),
p50Ms: Math.round(quantile(sorted, 0.5)),
p95Ms: Math.round(quantile(sorted, 0.95)),
};
}
function toText(data) {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}
function createGatewayClient({ WebSocket, url }) {
const ws = new WebSocket(url, { handshakeTimeout: 8_000 });
const pending = new Map();
const rejectPending = (error) => {
for (const waiter of pending.values()) {
clearTimeout(waiter.timeout);
waiter.reject(error);
}
pending.clear();
};
ws.on("message", (data) => {
let frame;
try {
frame = JSON.parse(toText(data));
} catch {
return;
}
if (frame?.type === "res") {
const waiter = pending.get(frame.id);
if (!waiter) {
return;
}
pending.delete(frame.id);
clearTimeout(waiter.timeout);
waiter.resolve(frame);
}
});
ws.on("close", (code, reason) => {
rejectPending(new Error(`gateway websocket closed (${code}): ${toText(reason)}`));
});
ws.on("error", (error) => {
rejectPending(error instanceof Error ? error : new Error(String(error)));
});
const waitOpen = async () =>
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("gateway websocket open timeout")), 8_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
});
});
const request = async (method, params, timeoutMs = 10_000) =>
await new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error(`gateway websocket is not open for ${method}`));
return;
}
const id = randomUUID();
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`timeout waiting for ${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timeout });
ws.send(JSON.stringify({ type: "req", id, method, params }), (error) => {
if (!error) {
return;
}
const waiter = pending.get(id);
if (!waiter) {
return;
}
pending.delete(id);
clearTimeout(waiter.timeout);
waiter.reject(error instanceof Error ? error : new Error(String(error)));
});
});
const close = () => {
rejectPending(new Error("gateway websocket client closed"));
ws.close();
};
return { close, request, waitOpen };
}
async function writeSummary({
details,
events,
finishedAt,
outputDir,
measurement,
startedAt,
status,
}) {
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(
path.join(outputDir, "rpc-events.json"),
`${JSON.stringify(events, null, 2)}\n`,
);
await fs.writeFile(
path.join(outputDir, "qa-suite-summary.json"),
`${JSON.stringify(
{
counts: {
total: 1,
passed: status === "pass" ? 1 : 0,
failed: status === "pass" ? 0 : 1,
},
run: {
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
providerMode: "gateway-rpc",
scenarioIds: ["rpc-gateway-smoke"],
},
scenarios: [
{
id: "rpc-gateway-smoke",
title: "Gateway RPC loopback smoke",
status,
details,
...(measurement ? { rttMeasurement: measurement } : {}),
},
],
},
null,
2,
)}\n`,
);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(args.repoRoot ?? process.env.OPENCLAW_REPO_ROOT ?? process.cwd());
const outputDir = path.resolve(args.outputDir);
await fs.mkdir(outputDir, { recursive: true });
const tempRoot = await fs.mkdtemp(path.join(outputDir, "..", ".rpc-rtt-"));
const startedAt = new Date();
const token = `rpc-rtt-${randomUUID()}`;
const port = await getFreePort();
const configPath = path.join(tempRoot, "openclaw.json");
const stdoutPath = path.join(tempRoot, "gateway.stdout.log");
const stderrPath = path.join(tempRoot, "gateway.stderr.log");
let gatewayChild;
let status = "fail";
let details = "";
let measurement;
let cleanupError;
const events = [];
try {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
mode: "local",
bind: "loopback",
port,
auth: { mode: "token", token },
controlUi: { enabled: false },
},
plugins: { enabled: false },
},
null,
2,
)}\n`,
);
gatewayChild = await startGateway({
configPath,
port,
repoRoot,
stderrPath,
stdoutPath,
tempRoot,
token,
});
await waitForGatewayReady({ child: gatewayChild, port, stderrPath });
const requireFromOpenClaw = createRequire(path.join(repoRoot, "package.json"));
const WebSocket = requireFromOpenClaw("ws");
const protocol = await import(
pathToFileURL(path.join(repoRoot, "packages/gateway-protocol/src/version.ts")).href
);
const client = createGatewayClient({ WebSocket, url: `ws://127.0.0.1:${port}` });
await client.waitOpen();
const connectStarted = performance.now();
const connect = await client.request(
"connect",
{
minProtocol: protocol.MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: protocol.PROTOCOL_VERSION,
client: {
id: "gateway-client",
displayName: "openclaw-rtt rpc probe",
version: "rtt",
platform: process.platform,
mode: "backend",
instanceId: `openclaw-rtt-rpc-${randomUUID()}`,
},
locale: "en-US",
userAgent: "openclaw-rtt-rpc",
role: "operator",
scopes: ["operator.admin"],
caps: [],
auth: { token },
},
10_000,
);
if (!connect.ok) {
throw new Error(`connect failed: ${JSON.stringify(connect.error)}`);
}
events.push({
event: "gateway-rpc.connect",
payload: {
method: "connect",
ok: true,
durationMs: Math.round(performance.now() - connectStarted),
},
});
const samples = [];
for (const method of args.methods) {
for (let iteration = 1; iteration <= args.iterations; iteration += 1) {
const requestStartedAtMs = performance.now();
const response = await client.request(method, {}, 10_000);
const durationMs = Math.round(performance.now() - requestStartedAtMs);
if (!response.ok) {
throw new Error(`${method} failed: ${JSON.stringify(response.error)}`);
}
samples.push({ method, durationMs });
events.push({
event: "gateway-rpc",
payload: { kind: "gateway-rpc", method, ok: true, durationMs, iteration },
});
}
}
client.close();
const sampleStats = stats(samples.map((sample) => sample.durationMs));
const byMethod = Object.fromEntries(
args.methods.map((method) => [
method,
stats(
samples.filter((sample) => sample.method === method).map((sample) => sample.durationMs),
),
]),
);
measurement = {
finalMatchedReplyRttMs: sampleStats.p50Ms,
durationMs: sampleStats.p50Ms,
method: args.methods.join(","),
source: "gateway-rpc",
};
details = JSON.stringify({
iterations: args.iterations,
methods: args.methods,
stats: sampleStats,
byMethod,
});
status = "pass";
} catch (error) {
details = error instanceof Error ? (error.stack ?? error.message) : String(error);
} finally {
if (gatewayChild) {
await stopGateway(gatewayChild).catch(() => {});
}
try {
await cleanupTempRoot(tempRoot);
} catch (error) {
cleanupError = error;
}
}
if (cleanupError) {
const cleanupDetails = formatErrorMessage(cleanupError);
details = details ? `${details}\n${cleanupDetails}` : cleanupDetails;
status = "fail";
}
const finishedAt = new Date();
await writeSummary({ details, events, finishedAt, outputDir, measurement, startedAt, status });
if (status !== "pass") {
throw new Error(details || "RPC RTT measurement failed");
}
}
if (IS_DIRECT_RUN) {
main().catch(
/** @param {unknown} error */ (error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
},
);
}
+36 -19
View File
@@ -153,6 +153,10 @@ const OMITTED_QA_EXTENSION_PREFIXES = [
];
export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000;
export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000;
export const CROSS_OS_DISCORD_FETCH_TIMEOUT_MS = parsePositiveIntegerEnv(
"OPENCLAW_CROSS_OS_DISCORD_FETCH_TIMEOUT_MS",
10_000,
);
export const CROSS_OS_FETCH_BODY_MAX_CHARS = 1024 * 1024;
export const CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS = 30_000;
export const CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS =
@@ -204,11 +208,14 @@ export function parseArgs(argv) {
return parsed;
}
function parsePositiveIntegerEnv(name, fallback) {
const raw = process.env[name]?.trim();
export function parsePositiveIntegerEnv(name, fallback, env = process.env) {
const raw = env[name]?.trim();
if (!raw) {
return fallback;
}
if (!/^\d+$/u.test(raw)) {
throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`);
@@ -2558,15 +2565,18 @@ export async function readBoundedCrossOsResponseText(
async function waitForDiscordMessage(params) {
const deadline = Date.now() + 3 * 60 * 1000;
while (Date.now() < deadline) {
const response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
{
headers: {
Authorization: `Bot ${params.token}`,
},
},
);
const text = await readBoundedCrossOsResponseText(response);
let response;
let text;
try {
response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
buildDiscordFetchInit(params.token),
);
text = await readBoundedCrossOsResponseText(response);
} catch {
await sleep(2_000);
continue;
}
if (!response.ok) {
await sleep(2_000);
continue;
@@ -2579,20 +2589,30 @@ async function waitForDiscordMessage(params) {
throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`);
}
export function buildDiscordFetchInit(token, init = {}) {
return {
...init,
signal: init.signal ?? AbortSignal.timeout(CROSS_OS_DISCORD_FETCH_TIMEOUT_MS),
headers: {
...init.headers,
Authorization: `Bot ${token}`,
},
};
}
async function postDiscordMessage(params) {
const response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages`,
{
buildDiscordFetchInit(params.token, {
method: "POST",
headers: {
Authorization: `Bot ${params.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: params.content,
flags: 4096,
}),
},
}),
);
const text = await readBoundedCrossOsResponseText(response);
if (!response.ok) {
@@ -2611,12 +2631,9 @@ async function deleteDiscordMessage(params) {
}
await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`,
{
buildDiscordFetchInit(params.token, {
method: "DELETE",
headers: {
Authorization: `Bot ${params.token}`,
},
},
}),
).catch(() => undefined);
}
+9 -3
View File
@@ -303,11 +303,17 @@ export function utcCalendarDayDistance(left: Date, right: Date): number {
function positiveEnvInt(name: string, env: NodeJS.ProcessEnv, fallback: number): number {
const raw = env[name]?.trim();
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function resolveNpmReleaseCheckCommandTimeoutMs(
+9 -3
View File
@@ -95,11 +95,17 @@ function ensurePreparedArtifacts(): void {
function positiveEnvInt(name: string, env: NodeJS.ProcessEnv, fallback: number): number {
const raw = env[name]?.trim();
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function resolvePrepackCommandTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
+1
View File
@@ -729,6 +729,7 @@ async function withBulkAdvisoryTimeout({ label, timeoutMs, run }) {
async function readBoundedResponseText(response, maxBytes, label) {
const contentLength = Number.parseInt(response.headers?.get?.("content-length") ?? "", 10);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
await response.body?.cancel().catch(() => undefined);
throw Object.assign(new Error(`${label} exceeded ${maxBytes} bytes`), { code: "ETOOBIG" });
}
+16 -3
View File
@@ -116,23 +116,36 @@ const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
const DEFAULT_COMMAND_TIMEOUT_MS = readPositiveInt(
process.env.OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS,
10 * 60_000,
"OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS",
);
const TELEGRAM_POLL_INTERVAL_MS = readPositiveInt(
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS,
30_000,
"OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS",
);
const TELEGRAM_POLL_TIMEOUT_MS = readPositiveInt(
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS,
4 * 60 * 60_000,
"OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS",
);
function readPositiveInt(raw: string | undefined, fallback: number): number {
export function readPositiveInt(
raw: string | undefined,
fallback: number,
label = "value",
): number {
const text = (raw ?? "").trim();
if (!/^\d+$/u.test(text)) {
if (!text) {
return fallback;
}
if (!/^\d+$/u.test(text)) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`);
}
const parsed = Number(text);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`);
}
return parsed;
}
export function run(command: string, args: string[], input?: RunOptions): string {
+38 -9
View File
@@ -12,6 +12,7 @@ const DEFAULT_RELEASE_PROFILE = "beta";
const DEFAULT_NPM_DIST_TAG = "beta";
const DEFAULT_PLUGIN_SCOPE = "all-publishable";
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
function usage() {
return `Usage: pnpm release:candidate -- --tag vYYYY.M.D-beta.N [options]
@@ -182,15 +183,43 @@ function readJson(path, label) {
}
}
async function githubApi(path) {
const token = run("gh", ["auth", "token"], { capture: true }).trim();
const response = await fetch(`https://api.github.com/${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
function githubApiTimeoutMs() {
const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS;
if (!raw) {
return DEFAULT_GITHUB_API_TIMEOUT_MS;
}
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive number");
}
return Math.trunc(value);
}
function githubApiTimedOut(error) {
return (
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")
);
}
export async function githubApi(path, options = {}) {
const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim();
const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs();
let response;
try {
response = await (options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, {
signal: AbortSignal.timeout(timeoutMs),
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
} catch (error) {
if (githubApiTimedOut(error)) {
throw new Error(`GitHub API ${path} timed out after ${timeoutMs}ms`, { cause: error });
}
throw error;
}
if (!response.ok) {
throw new Error(`GitHub API ${path} failed with ${response.status}: ${await response.text()}`);
}
+9 -3
View File
@@ -177,11 +177,17 @@ const PACKED_PLUGIN_SDK_TYPESCRIPT_SMOKE_FIXTURE = resolve(
function positiveEnvInt(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
if (raw === undefined || raw === "") {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error(`invalid ${name}: ${raw}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
export function runReleaseCheckCommand(
+29 -9
View File
@@ -185,14 +185,11 @@ function run(command, args, options = {}) {
};
const terminateChild = () => {
killChild("SIGTERM");
killTimer = setTimeout(
() => {
killTimer = undefined;
killChild("SIGKILL");
timeoutReject?.();
},
options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS,
);
killTimer = setTimeout(() => {
killTimer = undefined;
killChild("SIGKILL");
timeoutReject?.();
}, options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS);
};
const timeout =
options.timeoutMs === undefined
@@ -442,6 +439,25 @@ async function preparePackageSourceWorktree(ref) {
return { selectedSha, sourceDir, trustedReason };
}
async function cleanupPackageSourceWorktree(
sourceDir,
{ resolveError, runImpl = run, consoleError = console.error } = {},
) {
try {
await runImpl("git", ["worktree", "remove", "--force", sourceDir]);
} catch (cleanupError) {
if (!resolveError) {
throw cleanupError;
}
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
consoleError(
`warning: failed to remove temporary package source worktree ${sourceDir}: ${message}`,
);
}
}
export const cleanupPackageSourceWorktreeForTest = cleanupPackageSourceWorktree;
async function installPackageSourceDeps(sourceDir) {
await run(
"pnpm",
@@ -1122,6 +1138,7 @@ async function resolveCandidate(options) {
let packageTrustedSourceId = "";
let packageWorktreeDir = "";
let artifactMetadata = {};
let resolveError;
try {
if (options.source === "ref") {
@@ -1198,9 +1215,12 @@ async function resolveCandidate(options) {
`source must be one of: ref, npm, url, trusted-url, artifact. Got: ${options.source}`,
);
}
} catch (error) {
resolveError = error;
throw error;
} finally {
if (packageWorktreeDir) {
await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {});
await cleanupPackageSourceWorktree(packageWorktreeDir, { resolveError });
}
}
+68 -7
View File
@@ -66,19 +66,80 @@ function main(argv = process.argv.slice(2)) {
}
const spawnCommand = resolveSpawnCommand(parsed.command, parsed.args);
const useChildProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
const child = spawn(spawnCommand.command, spawnCommand.args, {
detached: useChildProcessGroup,
env: {
...process.env,
...parsed.env,
},
stdio: "inherit",
});
const forceKillDelayMs = Math.max(
1,
Number.parseInt(process.env.OPENCLAW_RUN_WITH_ENV_FORCE_KILL_MS ?? "5000", 10) || 5_000,
);
let forwardedSignal = null;
let forceKillTimer = null;
// Keep the child in the foreground process group so TTY signals such as
// Ctrl-C, Ctrl-Z, and window resizes stay native. Forward direct wrapper
// shutdown signals that would otherwise only kill this small parent process.
const forwardedSignals = ["SIGTERM", "SIGHUP"];
const forwardedSignals = useChildProcessGroup
? ["SIGTERM", "SIGHUP", "SIGINT"]
: ["SIGTERM", "SIGHUP"];
const signalChild = (signal) => {
if (useChildProcessGroup && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if (error?.code !== "ESRCH") {
child.kill(signal);
return;
}
}
}
child.kill(signal);
};
const childProcessGroupAlive = () => {
if (!useChildProcessGroup || typeof child.pid !== "number") {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch {
return false;
}
};
const exitWithForwardedSignal = () => {
if (!forwardedSignal) {
return;
}
const finish = () => {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
process.kill(process.pid, forwardedSignal);
};
if (!childProcessGroupAlive()) {
finish();
return;
}
const deadline = Date.now() + forceKillDelayMs;
const drainTimer = setInterval(() => {
if (!childProcessGroupAlive()) {
clearInterval(drainTimer);
finish();
return;
}
if (Date.now() >= deadline) {
clearInterval(drainTimer);
signalChild("SIGKILL");
finish();
}
}, 50);
};
const cleanupSignalHandlers = () => {
for (const signal of forwardedSignals) {
@@ -90,8 +151,8 @@ function main(argv = process.argv.slice(2)) {
signal,
() => {
forwardedSignal ??= signal;
child.kill(signal);
forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 5_000);
signalChild(signal);
forceKillTimer ??= setTimeout(() => signalChild("SIGKILL"), forceKillDelayMs);
},
]),
);
@@ -101,13 +162,13 @@ function main(argv = process.argv.slice(2)) {
child.on("exit", (code, signal) => {
cleanupSignalHandlers();
if (forwardedSignal) {
exitWithForwardedSignal();
return;
}
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
if (forwardedSignal) {
process.kill(process.pid, forwardedSignal);
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
+44 -3
View File
@@ -394,6 +394,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
".github/workflows/ci-check-testbox.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
],
[
".github/workflows/ci-check-arm-testbox.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
],
[
".github/workflows/crabbox-hydrate.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
@@ -604,6 +608,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["test/scripts/docker-build-helper.test.ts", "test/scripts/openclaw-test-state.test.ts"],
],
["scripts/e2e/plugin-lifecycle-matrix-docker.sh", ["test/scripts/docker-build-helper.test.ts"]],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
["test/scripts/plugin-lifecycle-measure.test.ts"],
],
[
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
["test/scripts/plugin-lifecycle-probe.test.ts"],
@@ -1639,8 +1647,10 @@ function resolveToolingChangedTestTargets(changedPaths, cwd = process.cwd()) {
return [...new Set(targets)];
}
const TOOLING_SCRIPT_PATH_PATTERN = /^scripts\/(.+)\.(?:mjs|cjs|js|mts|cts|ts|sh|py|ps1)$/u;
function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd()) {
const match = /^scripts\/(.+)\.(?:mjs|ts|js|sh|py)$/u.exec(changedPath);
const match = TOOLING_SCRIPT_PATH_PATTERN.exec(changedPath);
if (!match) {
return null;
}
@@ -1659,14 +1669,45 @@ function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd())
return targets.length > 0 ? targets : null;
}
function isToolingScriptPath(changedPath) {
return TOOLING_SCRIPT_PATH_PATTERN.test(changedPath);
}
function resolveParallelsToolingTestTargets(changedPath) {
if (!/^scripts\/e2e\/parallels\/[^/]+\.ts$/u.test(changedPath)) {
return null;
}
const targets = ["test/scripts/parallels-smoke-model.test.ts"];
if (
[
"scripts/e2e/parallels/guest-transports.ts",
"scripts/e2e/parallels/host-command.ts",
"scripts/e2e/parallels/npm-update-scripts.ts",
"scripts/e2e/parallels/npm-update-smoke.ts",
].includes(changedPath)
) {
targets.push("test/scripts/parallels-npm-update-smoke.test.ts");
}
if (changedPath === "scripts/e2e/parallels/update-job-timeout.ts") {
targets.push("test/scripts/parallels-update-job-timeout.test.ts");
}
return targets;
}
function resolveToolingTestTargets(changedPath, cwd = process.cwd()) {
const explicitTargets =
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ??
TOOLING_TEST_TARGETS.get(changedPath) ??
resolveParallelsToolingTestTargets(changedPath);
const conventionalTargets = resolveConventionalToolingTestTargets(changedPath, cwd);
if (explicitTargets && conventionalTargets) {
return uniqueOrdered([...explicitTargets, ...conventionalTargets]);
}
return explicitTargets ?? conventionalTargets;
return (
explicitTargets ??
conventionalTargets ??
(isToolingScriptPath(changedPath) ? [TOOLING_VITEST_CONFIG] : null)
);
}
function shouldUseBroadChangedTargets(env = process.env) {
+24 -12
View File
@@ -294,26 +294,34 @@ function findFatalUnresolvedImport(lines) {
return null;
}
function parsePositiveInteger(value) {
function parsePositiveIntegerEnv(value, name) {
if (typeof value !== "string" || value.trim() === "") {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
const text = value.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer`);
}
return Math.trunc(parsed);
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive safe integer`);
}
return parsed;
}
function parseNonNegativeInteger(value) {
function parseNonNegativeIntegerEnv(value, name) {
if (typeof value !== "string" || value.trim() === "") {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
const text = value.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a non-negative integer`);
}
return Math.trunc(parsed);
const parsed = Number(text);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${name} must be a non-negative safe integer`);
}
return parsed;
}
function parseCgroupMemoryLimitBytes(value) {
@@ -582,9 +590,13 @@ export async function runTsdownBuildInvocation(invocation, params = {}) {
const stderr = params.stderr ?? process.stderr;
const env = params.env ?? process.env;
const scanner = params.scanner ?? createTsdownOutputScanner();
const timeoutMs = parsePositiveInteger(env.OPENCLAW_TSDOWN_TIMEOUT_MS);
const timeoutMs = parsePositiveIntegerEnv(
env.OPENCLAW_TSDOWN_TIMEOUT_MS,
"OPENCLAW_TSDOWN_TIMEOUT_MS",
);
const heartbeatMs =
parseNonNegativeInteger(env.OPENCLAW_TSDOWN_HEARTBEAT_MS) ?? DEFAULT_HEARTBEAT_MS;
parseNonNegativeIntegerEnv(env.OPENCLAW_TSDOWN_HEARTBEAT_MS, "OPENCLAW_TSDOWN_HEARTBEAT_MS") ??
DEFAULT_HEARTBEAT_MS;
let timedOut = false;
let settled = false;
let lastOutputAt = Date.now();
@@ -304,6 +304,39 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
});
it("strips internal tool trace warning lines from error-context delivery text", () => {
const input = [
"Visible intro.",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"⚠️ 🛠️ gh search issues --repo openclaw/openclaw --state open --no-search-pages.jsonl /tmp/openclaw_open_unlabeled_current.json (agent) failed",
"⚠️ 🛠️ gh search issues --repo openclaw/openclaw --state open (agent) failed: command timed out",
"🛠️ run git status",
"📖 Read: lines 1-40 from secret.md",
"Visible outro.",
].join("\n");
expect(sanitizeUserFacingText(input, { errorContext: true })).toBe(
"Visible intro.\nVisible outro.",
);
});
it("preserves explicit tool progress outside error-context delivery text", () => {
const input = "🛠️ Exec: echo queued-progress";
expect(sanitizeUserFacingText(input)).toBe(input);
});
it("preserves internal trace examples inside fenced code", () => {
const input = [
"Example:",
"```",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"```",
].join("\n");
expect(sanitizeUserFacingText(input)).toBe(input);
});
it("strips plural XML function-call wrappers before user-facing delivery", () => {
const input = [
"Before",
@@ -1,3 +1,7 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../../packages/normalization-core/src/string-coerce.js";
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
import {
@@ -11,10 +15,7 @@ import {
} from "../../shared/assistant-error-format.js";
import { coerceChatContentText } from "../../shared/chat-content.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../../packages/normalization-core/src/string-coerce.js";
import {
stripAssistantInternalTraceLines,
stripLegacyBracketToolCallBlocks,
stripMinimaxToolCallXml,
stripToolCallXmlTags,
@@ -414,8 +415,11 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
// It is internal scaffolding, so drop standalone placeholder lines before delivery
// while preserving ordinary inline mentions a user may be discussing.
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
const withoutInternalTraceLines = errorContext
? stripAssistantInternalTraceLines(withoutPlaceholder)
: withoutPlaceholder;
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(
stripLegacyBracketToolCallBlocks(withoutPlaceholder),
stripLegacyBracketToolCallBlocks(withoutInternalTraceLines),
);
const trimmed = withoutToolCallBlocks.trim();
if (!trimmed) {
+61
View File
@@ -450,6 +450,67 @@ describe("exec shell snapshots", () => {
await expect(runAlias()).resolves.toBe("new");
});
it("refreshes fresh cached snapshots that no longer parse", async () => {
const bash = resolveBashForTest();
if (!bash) {
return;
}
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-home-"));
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-state-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-cwd-"));
tempDirs.push(home, stateDir, cwd);
setSnapshotStateForTest(stateDir, { home });
fs.writeFileSync(path.join(home, ".bashrc"), "alias oc_clean_alias='printf ok'\n");
const env = {
...process.env,
HOME: home,
OPENCLAW_STATE_DIR: stateDir,
};
const shellArgs = getPosixShellArgs(bash);
const wrap = async (): Promise<string> =>
await maybeWrapCommandWithShellSnapshot({
command: "oc_clean_alias",
shell: bash,
shellArgs,
cwd,
env,
});
const firstWrapped = await wrap();
expect(firstWrapped).not.toBe("oc_clean_alias");
const snapshotDir = resolveShellSnapshotDir(env);
const snapshotFiles = fs.readdirSync(snapshotDir).filter((entry) => entry.endsWith(".sh"));
expect(snapshotFiles).toHaveLength(1);
const snapshotPath = path.join(snapshotDir, snapshotFiles[0]);
fs.writeFileSync(
snapshotPath,
[
"# OpenClaw exec shell snapshot. Generated; do not edit.",
"unalias -a 2>/dev/null || true",
"oc_broken_fn() {",
" --!(no-*)dir*",
"}",
"",
].join("\n"),
);
resetShellSnapshotCacheForTests();
const wrapped = await wrap();
const result = spawnSync(bash, [...shellArgs, wrapped], {
cwd,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("ok");
expect(result.stderr).toBe("");
expect(fs.readFileSync(snapshotPath, "utf8")).not.toContain("--!(no-*)dir*");
});
it("refuses to persist aliases or functions with literal secret-looking values", async () => {
const bash = resolveBashForTest();
if (!bash) {
+1 -1
View File
@@ -241,7 +241,7 @@ async function validateSnapshot(
shellArgs: opts.shellArgs,
cwd: opts.cwd,
env: buildTrustedSnapshotCaptureEnv(opts.env),
command: `. ${shQuote(snapshotPath)} >/dev/null 2>&1; :`,
command: `. ${shQuote(snapshotPath)} >/dev/null 2>&1`,
timeoutMs: 2_000,
});
return result.status === 0;
@@ -49,6 +49,8 @@ export type PartialReplyPayload = Pick<ReplyPayload, "text" | "mediaUrls"> & {
export type GetReplyOptions = {
/** Override run id for agent events (defaults to random UUID). */
runId?: string;
/** Stable provider prompt-cache affinity key; distinct from run id/idempotency. */
promptCacheKey?: string;
/** Abort signal for the underlying agent run. */
abortSignal?: AbortSignal;
/** Optional inbound images (used for webchat attachments). */
@@ -435,6 +435,28 @@ function createMinimalRunAgentTurnParams(overrides?: {
};
}
const NON_DIRECT_FAILURE_SURFACE_CASES = [
{ label: "Discord group", provider: "discord", chatType: "group" },
{ label: "Discord channel", provider: "discord", chatType: "channel" },
{ label: "Slack channel", provider: "slack", chatType: "channel" },
{ label: "Telegram group", provider: "telegram", chatType: "group" },
{ label: "WhatsApp group", provider: "whatsapp", chatType: "group" },
{ label: "Microsoft Teams channel", provider: "msteams", chatType: "channel" },
] as const;
function createNonDirectFailureSessionCtx(
testCase: (typeof NON_DIRECT_FAILURE_SURFACE_CASES)[number],
): TemplateContext {
return {
Provider: testCase.provider,
Surface: testCase.provider,
ChatType: testCase.chatType,
GroupSubject: `${testCase.label} fixture`,
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext;
}
describe("computeContextAwareReserveTokensFloor", () => {
it("returns 100000 for 1M context windows", () => {
expect(computeContextAwareReserveTokensFloor(1_000_000)).toBe(100_000);
@@ -2928,51 +2950,37 @@ describe("runAgentTurnWithFallback", () => {
).toBeUndefined();
});
it("surfaces model capacity errors from no-text mid-turn failures", async () => {
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "thinking", isReasoning: true }],
meta: {
error: {
kind: "server_overloaded",
message: "Selected model is at capacity. Please try a different model.",
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"surfaces model capacity errors from no-text mid-turn failures in $label chats",
async (testCase) => {
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "thinking", isReasoning: true }],
meta: {
error: {
kind: "server_overloaded",
message: "Selected model is at capacity. Please try a different model.",
},
},
},
});
});
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
expect(result.kind).toBe("success");
if (result.kind === "success") {
expect(result.runResult.payloads).toEqual([
{
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
isError: true,
},
]);
}
});
expect(result.kind).toBe("success");
if (result.kind === "success") {
expect(result.runResult.payloads).toEqual([
{
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
isError: true,
},
]);
}
},
);
it("surfaces model capacity errors from pre-reply CLI failures", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(
@@ -3010,6 +3018,7 @@ describe("runAgentTurnWithFallback", () => {
expect(result).toEqual({
kind: "final",
payload: {
isError: true,
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
},
});
@@ -5118,9 +5127,9 @@ describe("runAgentTurnWithFallback", () => {
}
});
it.each(["group", "channel"] as const)(
"keeps raw runner failure boilerplate out of Discord %s chats",
async (chatType) => {
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"keeps raw runner failure boilerplate out of $label chats",
async (testCase) => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error("openai/gpt-5.5 ended with an incomplete terminal response"),
);
@@ -5128,14 +5137,7 @@ describe("runAgentTurnWithFallback", () => {
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: chatType,
GroupSubject: "agent group",
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext,
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
@@ -5225,12 +5227,9 @@ describe("runAgentTurnWithFallback", () => {
}
});
it.each(["group", "channel"] as const)(
"keeps default silent behavior in Discord %s chats when silentReply policy is unset",
async (chatType) => {
// Sanity check: explicit `{}` config (no silentReply) must still resolve
// to the documented default `group: "allow"` and produce a silent payload
// — the new policy hookup must not regress the default behavior.
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"keeps default silent behavior in $label chats when silentReply policy is unset",
async (testCase) => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error("openai/gpt-5.5 ended with an incomplete terminal response"),
);
@@ -5242,14 +5241,7 @@ describe("runAgentTurnWithFallback", () => {
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
followupRun,
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: chatType,
GroupSubject: "agent group",
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext,
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
@@ -5260,9 +5252,9 @@ describe("runAgentTurnWithFallback", () => {
},
);
it.each(["group", "channel"] as const)(
"keeps classified non-transient failures visible in Discord %s chats",
async (chatType) => {
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"keeps classified non-transient failures visible in $label chats",
async (testCase) => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error('No API key found for provider "openai"'),
);
@@ -5270,14 +5262,7 @@ describe("runAgentTurnWithFallback", () => {
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: chatType,
GroupSubject: "agent group",
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext,
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
@@ -5289,28 +5274,44 @@ describe("runAgentTurnWithFallback", () => {
},
);
it.each(["group", "channel"] as const)(
"keeps rate-limit fallback copy out of Discord %s chats",
async (chatType) => {
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"surfaces rate-limit fallback copy in $label chats",
async (testCase) => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded"));
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: chatType,
GroupSubject: "agent group",
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext,
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(SILENT_REPLY_TOKEN);
expect(result.payload.isError).toBe(true);
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
expect(result.payload.text).toContain("rate-limited");
}
},
);
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
"surfaces overloaded fallback copy in $label chats",
async (testCase) => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("model is overloaded"));
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: createNonDirectFailureSessionCtx(testCase),
}),
);
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.isError).toBe(true);
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
expect(result.payload.text).toContain("overloaded");
}
},
);
@@ -5344,6 +5345,7 @@ describe("runAgentTurnWithFallback", () => {
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.isError).toBe(true);
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
expect(result.payload.text).toContain("rate-limited");
}
+7 -11
View File
@@ -652,17 +652,12 @@ function resolveExternalRunFailureTextForConversation(params: {
text: string;
sessionCtx: TemplateContext;
isGenericRunnerFailure: boolean;
suppressInNonDirect?: boolean;
cfg?: OpenClawConfig;
}): string {
if (!isNonDirectConversationContext(params.sessionCtx)) {
return params.text;
}
if (
!params.suppressInNonDirect &&
!params.isGenericRunnerFailure &&
!params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)
) {
if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) {
return params.text;
}
// Match normal reply routing: default group/channel failures stay silent,
@@ -850,7 +845,11 @@ function buildExternalRunFailureReply(
}
function markAgentRunFailureReplyPayload<T extends ReplyPayload>(payload: T): T {
return markReplyPayloadForSourceSuppressionDelivery(payload);
const marked = markReplyPayloadForSourceSuppressionDelivery(payload);
if (!isSilentReplyText(marked.text, SILENT_REPLY_TOKEN)) {
marked.isError = true;
}
return marked;
}
export function buildKnownAgentRunFailureReplyPayload(params: {
@@ -904,7 +903,6 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
text: buildRateLimitCooldownMessage(params.err),
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
suppressInNonDirect: true,
cfg: params.cfg,
}),
});
@@ -916,7 +914,6 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
text: rateLimitOrOverloadedCopy,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
suppressInNonDirect: true,
cfg: params.cfg,
}),
});
@@ -2231,6 +2228,7 @@ export async function runAgentTurnWithFallback(params: {
hasRepliedRef: params.opts?.hasRepliedRef,
provider,
runId,
promptCacheKey: params.opts?.promptCacheKey,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
model,
});
@@ -2924,7 +2922,6 @@ export async function runAgentTurnWithFallback(params: {
text: fallbackText,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
suppressInNonDirect: Boolean(isRateLimit || rateLimitOrOverloadedCopy),
cfg: params.followupRun.run.config,
});
@@ -2993,7 +2990,6 @@ export async function runAgentTurnWithFallback(params: {
text: formattedErrorCandidate,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
suppressInNonDirect: true,
cfg: params.followupRun.run.config,
}),
isError: true,
@@ -55,6 +55,7 @@ export function buildEmbeddedRunBaseParams(params: {
provider: string;
model: string;
runId: string;
promptCacheKey?: string;
authProfile: ReturnType<typeof resolveProviderScopedAuthProfile>;
allowTransientCooldownProbe?: boolean;
isReasoningTagProvider?: ReasoningTagProviderResolver;
@@ -99,6 +100,7 @@ export function buildEmbeddedRunBaseParams(params: {
bashElevated: params.run.bashElevated,
timeoutMs: params.run.timeoutMs,
runId: params.runId,
promptCacheKey: params.promptCacheKey,
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
};
}
@@ -25,6 +25,7 @@ const {
buildThreadingToolContext,
buildEmbeddedRunBaseParams,
buildEmbeddedRunContexts,
buildEmbeddedRunExecutionParams,
resolveModelFallbackOptions,
resolveEnforceFinalTag,
resolveProviderScopedAuthProfile,
@@ -138,6 +139,7 @@ describe("agent-runner-utils", () => {
provider: "openai",
model: "gpt-4.1-mini",
runId: "run-1",
promptCacheKey: "webchat-cache-key",
authProfile,
});
@@ -160,6 +162,24 @@ describe("agent-runner-utils", () => {
expect(resolved.bashElevated).toBe(run.bashElevated);
expect(resolved.timeoutMs).toBe(run.timeoutMs);
expect(resolved.runId).toBe("run-1");
expect(resolved.promptCacheKey).toBe("webchat-cache-key");
});
it("threads prompt cache affinity through embedded execution params", () => {
const run = makeRun();
const resolved = buildEmbeddedRunExecutionParams({
run,
sessionCtx: { Provider: "webchat" },
hasRepliedRef: undefined,
provider: "openai",
model: "gpt-4.1-mini",
runId: "run-1",
promptCacheKey: "stable-session-cache-key",
});
expect(resolved.runBaseParams.runId).toBe("run-1");
expect(resolved.runBaseParams.promptCacheKey).toBe("stable-session-cache-key");
});
it("passes through recovered auto fallback provenance for embedded run params", () => {
@@ -273,6 +273,7 @@ export function buildEmbeddedRunExecutionParams(params: {
provider: string;
model: string;
runId: string;
promptCacheKey?: string;
allowTransientCooldownProbe?: boolean;
}) {
const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts(params);
@@ -281,6 +282,7 @@ export function buildEmbeddedRunExecutionParams(params: {
provider: params.provider,
model: params.model,
runId: params.runId,
promptCacheKey: params.promptCacheKey,
authProfile,
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
});
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from "vitest";
import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js";
describe("createChannelProgressDraftCompositor", () => {
it("keeps the progress label visible when tool lines are hidden", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: {
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
},
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
});
it("keeps reasoning details hidden when tool progress lines are hidden", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: {
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
},
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading files");
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
expect(update).not.toHaveBeenCalledWith(expect.stringContaining("Reading"), undefined);
});
it("does not resurrect progress after suppression", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
progress.suppress();
await progress.pushReasoningProgress("Reading files");
expect(update).not.toHaveBeenCalled();
});
it("composes reasoning deltas with tool progress", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading");
await progress.pushReasoningProgress(" files");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
});
it("preserves tagged reasoning content without leaking tags", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("<think>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
});
it("waits for complete reasoning tags before showing tagged progress", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
const calls = update.mock.calls.length;
await progress.pushReasoningProgress("<thin");
expect(update.mock.calls).toHaveLength(calls);
});
it("preserves partial reasoning tag buffers across deltas", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("<thin");
await progress.pushReasoningProgress("k>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
});
it("keeps literal reasoning tags inside code blocks", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("```html\n<think>literal</think>\n```");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _```html <think>literal</think> ```_",
undefined,
);
});
it("replaces repeated formatted reasoning snapshots", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Thinking\n\n_Reading_");
await progress.pushReasoningProgress("Thinking\n\n_Reading files_");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
});
});
+479
View File
@@ -0,0 +1,479 @@
import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
import { findCodeRegions, isInsideCode } from "../shared/text/code-regions.js";
import { stripInlineDirectiveTagsForDelivery } from "../utils/directive-tags.js";
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
type StreamingCompatEntry,
type StreamingMode,
} from "./streaming.js";
export type ChannelProgressDraftMode = StreamingMode;
export type ChannelProgressDraftCompositor = ReturnType<
typeof createChannelProgressDraftCompositor
>;
type ProgressDraftLine = string | ChannelProgressDraftLine;
export function createChannelProgressDraftCompositor(params: {
entry: StreamingCompatEntry | null | undefined;
mode: ChannelProgressDraftMode;
active: boolean;
seed: string;
update: (text: string, options?: { flush?: boolean }) => Promise<void> | void;
deleteCurrent?: () => Promise<void> | void;
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
formatLine?: (line: string) => string;
isEmptyLine?: (line: ProgressDraftLine | undefined) => boolean;
shouldStartNow?: (line: ProgressDraftLine | undefined) => boolean;
}) {
const previewToolProgressEnabled =
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
const commentaryProgressEnabled =
params.active && resolveChannelStreamingProgressCommentary(params.entry);
const suppressDefaultToolProgressMessages =
params.active &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
draftStreamActive: true,
previewToolProgressEnabled,
});
let progressSuppressed = false;
let lines: ProgressDraftLine[] = [];
let lastRenderedText = "";
let reasoningRawText = "";
let lastReasoningLine: string | undefined;
let finalReplyStarted = false;
let finalReplyDelivered = false;
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) =>
formatChannelProgressDraftText({
entry: params.entry,
lines: draftLines,
seed: params.seed,
formatLine: options?.formatted === false ? undefined : params.formatLine,
});
const clearProgressState = (suppressed: boolean) => {
progressSuppressed = suppressed;
lines = [];
lastRenderedText = "";
reasoningRawText = "";
lastReasoningLine = undefined;
};
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!params.active || params.mode !== "progress") {
return false;
}
const text = formatDraftText();
if (!text || text === lastRenderedText) {
return false;
}
lastRenderedText = text;
await params.update(text, options);
return true;
};
const gate = createChannelProgressDraftGate({
onStart: async () => {
await render({ flush: true });
},
});
const clearLine = async (lineId: string) => {
const nextLines = removeChannelProgressDraftLine(lines, lineId);
if (nextLines === lines) {
return;
}
lines = nextLines;
if (!gate.hasStarted) {
return;
}
const text = formatDraftText();
if (text) {
await render();
return;
}
lastRenderedText = "";
await params.deleteCurrent?.();
};
const noteProgress = async (
line?: ProgressDraftLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!params.active || finalReplyStarted || finalReplyDelivered) {
return false;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return false;
}
if (params.isEmptyLine?.(line)) {
return false;
}
const normalized = normalizeChannelProgressDraftLineIdentity(line);
if (!normalized || progressSuppressed) {
return false;
}
if (params.mode !== "progress" && !previewToolProgressEnabled) {
return false;
}
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
const shouldStoreLine = previewToolProgressEnabled;
const nextLines = shouldStoreLine
? mergeChannelProgressDraftLine(lines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
})
: lines;
if (shouldStoreLine && nextLines === lines) {
return false;
}
if (shouldStoreLine && params.tryNativeUpdate) {
const text = formatDraftText(nextLines, { formatted: false });
if (text && (await params.tryNativeUpdate(text))) {
lines = nextLines;
lastRenderedText = text;
return true;
}
}
lines = nextLines;
if (params.mode !== "progress") {
if (!shouldStoreLine) {
return false;
}
const text = formatDraftText();
if (!text || text === lastRenderedText) {
return false;
}
lastRenderedText = text;
await params.update(text);
return true;
}
if (options?.startImmediately || params.shouldStartNow?.(line)) {
await gate.startNow();
return gate.hasStarted ? await render() : false;
}
const alreadyStarted = gate.hasStarted;
const progressActive = await gate.noteWork();
if ((alreadyStarted || progressActive) && gate.hasStarted) {
return await render();
}
return false;
};
return {
get previewToolProgressEnabled() {
return previewToolProgressEnabled;
},
get commentaryProgressEnabled() {
return commentaryProgressEnabled;
},
get suppressDefaultToolProgressMessages() {
return suppressDefaultToolProgressMessages;
},
get hasStarted() {
return gate.hasStarted;
},
markFinalReplyStarted() {
finalReplyStarted = true;
},
markFinalReplyDelivered() {
finalReplyDelivered = true;
},
reset() {
clearProgressState(false);
},
suppress() {
clearProgressState(true);
},
cancel() {
gate.cancel();
},
start() {
return gate.startNow();
},
pushToolProgress: noteProgress,
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
if (
!params.active ||
params.mode !== "progress" ||
!text ||
progressSuppressed ||
finalReplyDelivered
) {
return false;
}
reasoningRawText = mergeReasoningProgressText(reasoningRawText, text, {
snapshot: options?.snapshot === true,
});
const normalized = normalizeReasoningProgressLine(reasoningRawText);
if (!normalized) {
return false;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.entry),
);
if (!displayLine) {
return false;
}
if (previewToolProgressEnabled) {
const priorIndex =
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
if (priorIndex >= 0) {
lines = [...lines];
lines[priorIndex] = displayLine;
} else {
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
}
lastReasoningLine = displayLine;
}
const progressActive = await gate.noteWork();
if (progressActive && gate.hasStarted) {
return await render();
}
return false;
},
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
if (!params.active || params.mode !== "progress" || !commentaryProgressEnabled) {
return false;
}
if (finalReplyStarted || finalReplyDelivered) {
return false;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return false;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
if (lineId) {
await clearLine(lineId);
}
return false;
}
const line: ChannelProgressDraftLine = {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
lines = mergeChannelProgressDraftLine(lines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
});
await gate.startNow();
return await render();
},
};
}
function normalizeReasoningProgressLine(text: string): string {
const reasoningText = readReasoningProgressTextOutsideCode(text);
if (reasoningText === undefined) {
return "";
}
return stripReasoningProgressTagsOutsideCode(reasoningText)
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/\s+/g, " ")
.trim();
}
const REASONING_PROGRESS_TAG_RE =
/<\s*(\/?)\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/giu;
const REASONING_PROGRESS_TAG_NAMES = [
"think",
"thinking",
"thought",
"antthinking",
"antml:think",
"antml:thinking",
"antml:thought",
] as const;
const REASONING_PROGRESS_TAG_PREFIXES = REASONING_PROGRESS_TAG_NAMES.flatMap((name) => [
`<${name}`,
`</${name}`,
]);
function readReasoningProgressTextOutsideCode(text: string): string | undefined {
if (isPartialReasoningProgressTagPrefix(text)) {
return undefined;
}
const codeRegions = findCodeRegions(text);
let hasTags = false;
let inReasoning = false;
let cursor = 0;
const chunks: string[] = [];
for (const match of text.matchAll(REASONING_PROGRESS_TAG_RE)) {
const offset = match.index ?? 0;
if (isInsideCode(offset, codeRegions)) {
continue;
}
hasTags = true;
if (match[1]) {
if (inReasoning) {
chunks.push(text.slice(cursor, offset));
}
inReasoning = false;
cursor = offset + match[0].length;
continue;
}
if (inReasoning) {
chunks.push(text.slice(cursor, offset));
}
inReasoning = true;
cursor = offset + match[0].length;
}
if (!hasTags) {
return text;
}
if (inReasoning) {
chunks.push(text.slice(cursor));
}
return chunks.join("").trim();
}
function isPartialReasoningProgressTagPrefix(text: string): boolean {
const normalized = text.trimStart().toLowerCase();
return (
normalized.startsWith("<") &&
!normalized.includes(">") &&
REASONING_PROGRESS_TAG_PREFIXES.some(
(prefix) => prefix.startsWith(normalized) || normalized.startsWith(prefix),
)
);
}
function stripReasoningProgressTagsOutsideCode(text: string): string {
const codeRegions = findCodeRegions(text);
return text.replace(REASONING_PROGRESS_TAG_RE, (match, _closing: string, offset: number) =>
isInsideCode(offset, codeRegions) ? match : "",
);
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
function isSilentCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
function mergeReasoningProgressText(
current: string,
incoming: string,
options?: { snapshot?: boolean },
): string {
if (!current) {
return incoming;
}
const normalizedCurrent = normalizeReasoningProgressInput(current);
const normalizedIncoming = normalizeReasoningProgressInput(incoming);
if (!normalizedIncoming) {
return shouldAppendEmptyReasoningProgressDelta(current, incoming)
? `${current}${incoming}`
: current;
}
if (normalizedIncoming === normalizedCurrent) {
return current;
}
if (
options?.snapshot === true ||
isReasoningSnapshotText(incoming) ||
(normalizedCurrent && normalizedIncoming.startsWith(normalizedCurrent))
) {
return incoming;
}
return `${current}${incoming}`;
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
}
function shouldAppendEmptyReasoningProgressDelta(current: string, incoming: string): boolean {
return (
isPartialReasoningProgressTagPrefix(current) ||
isPartialReasoningProgressTagPrefix(incoming) ||
hasReasoningProgressTagOutsideCode(incoming)
);
}
function hasReasoningProgressTagOutsideCode(text: string): boolean {
const codeRegions = findCodeRegions(text);
for (const match of text.matchAll(REASONING_PROGRESS_TAG_RE)) {
if (!isInsideCode(match.index ?? 0, codeRegions)) {
return true;
}
}
return false;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
import { buildChannelProgressDraftLine } from "./streaming.js";
describe("progress draft lines", () => {
it("removes keyed progress lines in place", () => {
const line = buildChannelProgressDraftLine({
event: "item",
itemId: "preamble-1",
itemKind: "preamble",
title: "Preamble",
progressText: "Checking the app-server stream",
});
if (!line) {
throw new Error("expected preamble progress line");
}
const lines: Array<string | typeof line> = ["🛠️ Exec", line];
expect(removeChannelProgressDraftLine(lines, "preamble-1")).toEqual(["🛠️ Exec"]);
expect(removeChannelProgressDraftLine(lines, "missing")).toBe(lines);
expect(removeChannelProgressDraftLine(lines, " ")).toBe(lines);
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { ChannelProgressDraftLine } from "./streaming.js";
export type ProgressDraftLine = string | ChannelProgressDraftLine;
export function removeChannelProgressDraftLine<TLine extends ProgressDraftLine>(
lines: TLine[],
id: string,
): TLine[] {
const lineId = id.trim();
if (!lineId) {
return lines;
}
const next = lines.filter((line) => typeof line !== "object" || line.id?.trim() !== lineId);
return next.length === lines.length ? lines : next;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export type {
} from "../config/types.base.js";
export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
type StreamingCompatEntry = {
export type StreamingCompatEntry = {
streaming?: unknown;
streamMode?: unknown;
chunkMode?: unknown;
+19 -6
View File
@@ -1,5 +1,16 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
const TOOL_USE_ID_FIELDS = [
"id",
"tool_call_id",
"toolCallId",
"tool_use_id",
"toolUseId",
] as const;
type ToolUseIdField = (typeof TOOL_USE_ID_FIELDS)[number];
/** Provider-agnostic chat content block shape used before SDK-specific narrowing. */
export type ToolContentBlock = Record<string, unknown>;
export type ToolContentBlock = Record<string, unknown> & Partial<Record<ToolUseIdField, unknown>>;
function normalizeToolContentType(value: unknown): string {
return typeof value === "string" ? value.toLowerCase() : "";
@@ -34,9 +45,11 @@ export function resolveToolBlockArgs(block: ToolContentBlock): unknown {
/** Reads the stable tool-use id across snake_case and camelCase provider field names. */
export function resolveToolUseId(block: ToolContentBlock): string | undefined {
const id =
(typeof block.id === "string" && block.id.trim()) ||
(typeof block.tool_use_id === "string" && block.tool_use_id.trim()) ||
(typeof block.toolUseId === "string" && block.toolUseId.trim());
return id || undefined;
for (const field of TOOL_USE_ID_FIELDS) {
const id = normalizeOptionalString(block[field]);
if (id) {
return id;
}
}
return undefined;
}
+62 -5
View File
@@ -1,14 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
} from "./gateway-health-auth-diagnostic.js";
const callGateway = vi.hoisted(() => vi.fn());
const isGatewayCredentialsRequiredError = vi.hoisted(() => vi.fn(() => false));
const probeGatewayStatus = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
const TEST_GATEWAY_URL = "ws://127.0.0.1:18789";
const TEST_AUTH_CLOSE_ERROR = "gateway closed (1008):";
const TEST_TLS_FINGERPRINT = "sha256:test-doctor-gateway-fingerprint";
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: vi.fn(() => ({
message: "Gateway target: ws://127.0.0.1:18789",
message: `Gateway target: ${TEST_GATEWAY_URL}`,
url: TEST_GATEWAY_URL,
})),
buildGatewayProbeConnectionDetails: vi.fn(() => ({
preauthHandshakeTimeoutMs: 4321,
tlsFingerprint: TEST_TLS_FINGERPRINT,
url: TEST_GATEWAY_URL,
})),
callGateway,
isGatewayCredentialsRequiredError,
}));
vi.mock("../cli/daemon-cli/probe.js", () => ({
probeGatewayStatus,
}));
vi.mock("../../packages/terminal-core/src/note.js", () => ({
@@ -26,6 +46,9 @@ describe("checkGatewayHealth", () => {
beforeEach(() => {
callGateway.mockReset();
isGatewayCredentialsRequiredError.mockReset();
isGatewayCredentialsRequiredError.mockReturnValue(false);
probeGatewayStatus.mockReset();
note.mockReset();
});
@@ -35,7 +58,7 @@ describe("checkGatewayHealth", () => {
await expect(
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
).resolves.toEqual({ healthOk: true, status: { ok: true } });
).resolves.toEqual({ authenticated: true, healthOk: true, status: { ok: true } });
expect(callGateway).toHaveBeenNthCalledWith(1, {
method: "status",
@@ -58,7 +81,11 @@ describe("checkGatewayHealth", () => {
await expect(
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
).resolves.toEqual({ healthOk: true, status: { runtimeVersion: "2026.4.23" } });
).resolves.toEqual({
authenticated: true,
healthOk: true,
status: { runtimeVersion: "2026.4.23" },
});
const mismatchNotes = note.mock.calls
.filter(([, title]) => title === "OpenClaw version mismatch")
@@ -78,13 +105,43 @@ describe("checkGatewayHealth", () => {
await expect(
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
).resolves.toEqual({ healthOk: false });
).resolves.toEqual({ authenticated: false, healthOk: false, status: undefined });
expect(callGateway).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("gateway timeout after 3000ms"),
);
});
it("reports credentials-required when status RPC auth blocks a reachable gateway", async () => {
callGateway.mockRejectedValueOnce(new Error());
isGatewayCredentialsRequiredError.mockReturnValueOnce(true);
probeGatewayStatus.mockResolvedValueOnce({
ok: false,
kind: "connect",
error: TEST_AUTH_CLOSE_ERROR,
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await expect(
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
).resolves.toEqual({ authenticated: false, healthOk: true });
expect(probeGatewayStatus).toHaveBeenCalledWith({
url: TEST_GATEWAY_URL,
timeoutMs: 3000,
tlsFingerprint: TEST_TLS_FINGERPRINT,
preauthHandshakeTimeoutMs: 4321,
config: cfg,
json: true,
});
expect(runtime.error).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
);
expect(callGateway).toHaveBeenCalledTimes(1);
});
});
describe("probeGatewayMemoryStatus", () => {
@@ -116,7 +173,7 @@ describe("probeGatewayMemoryStatus", () => {
// A transport timeout must NOT be treated as a skipped probe. It is a real
// diagnostic signal and the renderer should warn for key-optional providers.
callGateway.mockRejectedValue(
new Error("gateway timeout after 8000ms\nGateway target: ws://127.0.0.1:18789"),
new Error(`gateway timeout after 8000ms\nGateway target: ${TEST_GATEWAY_URL}`),
);
const result = await probeGatewayMemoryStatus({ cfg });
+43 -15
View File
@@ -1,11 +1,22 @@
import { note } from "../../packages/terminal-core/src/note.js";
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import {
buildGatewayConnectionDetails,
buildGatewayProbeConnectionDetails,
callGateway,
isGatewayCredentialsRequiredError,
} from "../gateway/call.js";
import type { DoctorMemoryStatusPayload } from "../gateway/server-methods/doctor.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { VERSION } from "../version.js";
import {
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
gatewayProbeResultSawGateway,
} from "./gateway-health-auth-diagnostic.js";
import { formatHealthCheckFailure } from "./health-format.js";
import type { StatusSummary } from "./status.types.js";
@@ -45,8 +56,7 @@ export async function checkGatewayHealth(params: {
runtime: RuntimeEnv;
cfg: OpenClawConfig;
timeoutMs?: number;
}): Promise<{ healthOk: boolean; status?: StatusSummary }> {
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
}): Promise<{ healthOk: boolean; authenticated: boolean; status?: StatusSummary }> {
const timeoutMs =
typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : 10_000;
let healthOk = false;
@@ -60,17 +70,6 @@ export async function checkGatewayHealth(params: {
});
healthOk = true;
noteCliGatewayVersionSkew(status);
} catch (err) {
const message = String(err);
if (message.includes("gateway closed")) {
note("Gateway not running.", "Gateway");
note(gatewayDetails.message, "Gateway connection");
} else {
params.runtime.error(formatHealthCheckFailure(err));
}
}
if (healthOk) {
try {
const statusLocal = await callGateway({
method: "channels.status",
@@ -94,9 +93,38 @@ export async function checkGatewayHealth(params: {
} catch {
// ignore: doctor already reported gateway health
}
return { healthOk, authenticated: true, status };
} catch (err) {
if (isGatewayCredentialsRequiredError(err)) {
const probeDetails = await buildGatewayProbeConnectionDetails({ config: params.cfg });
const probe = await probeGatewayStatus({
url: probeDetails.url,
timeoutMs,
tlsFingerprint: probeDetails.tlsFingerprint,
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
config: params.cfg,
json: true,
});
if (gatewayProbeResultSawGateway(probe)) {
note(
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
);
healthOk = true;
return { healthOk, authenticated: false };
}
}
const message = String(err);
if (message.includes("gateway closed")) {
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
note("Gateway not running.", "Gateway");
note(gatewayDetails.message, "Gateway connection");
} else {
params.runtime.error(formatHealthCheckFailure(err));
}
}
return { healthOk, status };
return { healthOk, authenticated: false, status };
}
export async function probeGatewayMemoryStatus(params: {
@@ -0,0 +1,41 @@
import type { DaemonStatus } from "../cli/daemon-cli/status.gather.js";
type GatewayProbeReachabilityEvidence = NonNullable<DaemonStatus["rpc"]>;
export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE =
"Gateway is reachable, but this CLI has no token/password or paired device token for read-scope health RPCs.";
export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE = "Gateway credentials required";
export const GATEWAY_HEALTH_REACHABLE_LINE = "Gateway: reachable";
export function gatewayProbeResultSawGateway(status: GatewayProbeReachabilityEvidence): boolean {
if (status.ok) {
return true;
}
const auth = status.auth;
if (auth?.capability && auth.capability !== "unknown") {
return true;
}
if (auth?.role || (auth?.scopes?.length ?? 0) > 0) {
return true;
}
const server = status.server;
if (server?.version || server?.connId) {
return true;
}
return /\bgateway closed \(\d+\):|\bpairing required\b|\bdevice identity required\b/i.test(
status.error ?? "",
);
}
export function buildCredentialsRequiredHealthDiagnostic() {
return {
ok: false,
error: {
type: "gateway_credentials_required",
message: GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
},
gateway: {
reachable: true,
},
};
}
+2 -19
View File
@@ -2,6 +2,7 @@ import type { DaemonStatus } from "../cli/daemon-cli/status.gather.js";
import { promptYesNo } from "../cli/prompt.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { gatewayProbeResultSawGateway } from "./gateway-health-auth-diagnostic.js";
const daemonStatusModuleLoader = createLazyImportLoader(
() => import("../cli/daemon-cli/status.gather.js"),
@@ -80,25 +81,7 @@ function gatewayIsRunning(status: DaemonStatus): boolean {
}
function gatewayProbeSawGateway(status: DaemonStatus): boolean {
const rpc = status.rpc;
if (!rpc) {
return false;
}
if (rpc.ok) {
return true;
}
if (rpc.auth?.capability && rpc.auth.capability !== "unknown") {
return true;
}
if (rpc.auth?.role || (rpc.auth?.scopes?.length ?? 0) > 0) {
return true;
}
if (rpc.server?.version || rpc.server?.connId) {
return true;
}
return /\bgateway closed \(\d+\):|\bpairing required\b|\bdevice identity required\b/i.test(
rpc.error ?? "",
);
return Boolean(status.rpc && gatewayProbeResultSawGateway(status.rpc));
}
function gatewayLooksReachable(status: DaemonStatus): boolean {
+80 -4
View File
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import {
buildCredentialsRequiredHealthDiagnostic,
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
GATEWAY_HEALTH_REACHABLE_LINE,
} from "./gateway-health-auth-diagnostic.js";
import { formatHealthCheckFailure } from "./health-format.js";
import type { HealthSummary } from "./health.js";
import {
@@ -57,16 +62,37 @@ const createHealthSummary = (params: {
};
const callGatewayMock = vi.fn();
const isGatewayCredentialsRequiredErrorMock = vi.fn((_value: unknown) => false);
const TEST_GATEWAY_URL = "ws://127.0.0.1:18789";
const TEST_GATEWAY_MESSAGE = `Gateway mode: local\nGateway target: ${TEST_GATEWAY_URL}`;
const TEST_AUTH_CLOSE_ERROR = "gateway closed (1008):";
const TEST_TLS_FINGERPRINT = "sha256:test-health-gateway-fingerprint";
const buildGatewayConnectionDetailsMock = vi.fn(() => ({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
message: TEST_GATEWAY_MESSAGE,
url: TEST_GATEWAY_URL,
}));
const buildGatewayProbeConnectionDetailsMock = vi.fn(() => ({
message: TEST_GATEWAY_MESSAGE,
preauthHandshakeTimeoutMs: 4321,
tlsFingerprint: TEST_TLS_FINGERPRINT,
url: TEST_GATEWAY_URL,
}));
const formatGatewayTransportErrorJsonMock = vi.fn();
const probeGatewayStatusMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) =>
Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args),
buildGatewayProbeConnectionDetails: (...args: [unknown, ...unknown[]]) =>
Reflect.apply(buildGatewayProbeConnectionDetailsMock, undefined, args),
formatGatewayTransportErrorJson: (...args: unknown[]) =>
formatGatewayTransportErrorJsonMock(...args),
isGatewayCredentialsRequiredError: (value: unknown) =>
isGatewayCredentialsRequiredErrorMock(value),
}));
vi.mock("../cli/daemon-cli/probe.js", () => ({
probeGatewayStatus: (...args: unknown[]) => probeGatewayStatusMock(...args),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
@@ -101,9 +127,18 @@ describe("healthCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
buildGatewayConnectionDetailsMock.mockReturnValue({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
message: TEST_GATEWAY_MESSAGE,
url: TEST_GATEWAY_URL,
});
buildGatewayProbeConnectionDetailsMock.mockReturnValue({
message: TEST_GATEWAY_MESSAGE,
preauthHandshakeTimeoutMs: 4321,
tlsFingerprint: TEST_TLS_FINGERPRINT,
url: TEST_GATEWAY_URL,
});
formatGatewayTransportErrorJsonMock.mockReturnValue(null);
isGatewayCredentialsRequiredErrorMock.mockReturnValue(false);
probeGatewayStatusMock.mockReset();
});
it("outputs JSON from gateway", async () => {
@@ -186,7 +221,7 @@ describe("healthCommand", () => {
expect(runtime.log.mock.calls.slice(0, 3)).toEqual([
["Gateway connection:"],
[" Gateway mode: local"],
[" Gateway target: ws://127.0.0.1:18789"],
[` Gateway target: ${TEST_GATEWAY_URL}`],
]);
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
});
@@ -229,7 +264,7 @@ describe("healthCommand", () => {
reason: "no close reason",
},
gateway: {
url: "ws://127.0.0.1:18789",
url: TEST_GATEWAY_URL,
urlSource: "local loopback",
bindDetail: "Bind: loopback",
},
@@ -244,6 +279,47 @@ describe("healthCommand", () => {
expect(JSON.parse(requireFirstRuntimeLog())).toEqual(payload);
});
it.each([
{ json: true, expectedLogs: 1 },
{ json: undefined, expectedLogs: 2 },
])(
"reports reachable gateway diagnostics when health RPC credentials are missing",
async ({ json, expectedLogs }) => {
callGatewayMock.mockRejectedValueOnce(new Error());
isGatewayCredentialsRequiredErrorMock.mockReturnValueOnce(true);
probeGatewayStatusMock.mockResolvedValueOnce({
ok: false,
kind: "connect",
error: TEST_AUTH_CLOSE_ERROR,
});
await healthCommand({ json, timeoutMs: 5000, config: {} }, runtime as never);
expect(probeGatewayStatusMock).toHaveBeenCalledWith({
url: TEST_GATEWAY_URL,
token: undefined,
password: undefined,
tlsFingerprint: TEST_TLS_FINGERPRINT,
preauthHandshakeTimeoutMs: 4321,
timeoutMs: 5000,
config: {},
json,
});
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).toHaveBeenCalledTimes(expectedLogs);
if (json) {
expect(JSON.parse(requireFirstRuntimeLog())).toEqual(
buildCredentialsRequiredHealthDiagnostic(),
);
} else {
expect(runtime.log.mock.calls).toEqual([
[GATEWAY_HEALTH_REACHABLE_LINE],
[GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE],
]);
}
},
);
it("formats degraded model-pricing health as a warning", () => {
const snapshot = createHealthSummary({
channels: {},
+38
View File
@@ -13,14 +13,17 @@ import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-on
import { buildChannelAccountSnapshotFromAccount } from "../channels/plugins/status.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
import { withProgress } from "../cli/progress.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { listContextEngineQuarantines } from "../context-engine/registry.js";
import {
buildGatewayConnectionDetails,
buildGatewayProbeConnectionDetails,
callGateway,
formatGatewayTransportErrorJson,
isGatewayCredentialsRequiredError,
} from "../gateway/call.js";
import {
DEFAULT_CHANNEL_CONNECT_GRACE_MS,
@@ -38,6 +41,11 @@ import { getActivePluginRegistry } from "../plugins/runtime.js";
import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import {
buildCredentialsRequiredHealthDiagnostic,
GATEWAY_HEALTH_REACHABLE_LINE,
gatewayProbeResultSawGateway,
} from "./gateway-health-auth-diagnostic.js";
import { formatHealthChannelLines } from "./health-format.js";
import type {
AgentHealthSummary,
@@ -647,6 +655,36 @@ export async function healthCommand(
}),
);
} catch (error) {
if (isGatewayCredentialsRequiredError(error)) {
const details = await buildGatewayProbeConnectionDetails({
config: cfg,
token: opts.token,
password: opts.password,
});
const probe = await probeGatewayStatus({
url: details.url,
token: opts.token,
password: opts.password,
tlsFingerprint: details.tlsFingerprint,
preauthHandshakeTimeoutMs: details.preauthHandshakeTimeoutMs,
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
config: cfg,
json: opts.json,
});
if (gatewayProbeResultSawGateway(probe)) {
const diagnostic = buildCredentialsRequiredHealthDiagnostic();
if (opts.json) {
writeRuntimeJson(runtime, diagnostic);
runtime.exit(1);
return;
}
runtime.log(GATEWAY_HEALTH_REACHABLE_LINE);
runtime.log(diagnostic.error.message);
runtime.exit(1);
return;
}
throw error;
}
if (opts.json) {
const payload = formatGatewayTransportErrorJson(error);
if (payload) {
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -284,7 +284,7 @@ describe("config schema", () => {
expect(progressPropsFor("discord")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("telegram")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("discord")).toHaveProperty("commentary");
expect(progressPropsFor("telegram")).not.toHaveProperty("commentary");
expect(progressPropsFor("telegram")).toHaveProperty("commentary");
expect(res.uiHints["channels.matrix"]?.label).toBe("Matrix");
expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true);
expect(res.uiHints["channels.matrix.streaming.progress.label"]?.label).toBe(
@@ -298,7 +298,9 @@ describe("config schema", () => {
expect(res.uiHints["channels.discord.streaming.progress.toolProgress"]?.label).toBe(
"Discord Progress Tool Lines",
);
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]).toBeUndefined();
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]?.label).toBe(
"Telegram Progress Commentary",
);
expect(res.uiHints["channels.mattermost.streaming.progress.label"]?.label).toBe(
"Mattermost Progress Label",
);
@@ -456,7 +458,7 @@ describe("config schema", () => {
).toBe(false);
});
it("accepts progress commentary only for Discord streaming config", () => {
it("accepts progress commentary for Discord and Telegram streaming config", () => {
expect(
DiscordConfigSchema.safeParse({
streaming: {
@@ -473,7 +475,7 @@ describe("config schema", () => {
progress: { commentary: true },
},
}).success,
).toBe(false);
).toBe(true);
});
it("keeps per-agent model overrides limited to model selection", () => {
+7
View File
@@ -1,5 +1,6 @@
import type {
ChannelPreviewStreamingConfig,
ChannelStreamingProgressConfig,
ChannelStreamingPreviewConfig,
ContextVisibilityMode,
DmPolicy,
@@ -76,6 +77,12 @@ export type TelegramStreamingPreviewConfig = ChannelStreamingPreviewConfig & {
export type TelegramPreviewStreamingConfig = Omit<ChannelPreviewStreamingConfig, "preview"> & {
preview?: TelegramStreamingPreviewConfig;
progress?: TelegramStreamingProgressConfig;
};
export type TelegramStreamingProgressConfig = ChannelStreamingProgressConfig & {
/** Include assistant commentary/preamble text in the progress draft. Default: false. */
commentary?: boolean;
};
export type TelegramExecApprovalConfig = {
+6 -2
View File
@@ -103,7 +103,7 @@ const ChannelStreamingProgressSchema = z
commandText: z.enum(["raw", "status"]).optional(),
})
.strict();
const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
const ChannelCommentaryStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
commentary: z.boolean().optional(),
}).strict();
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
@@ -122,7 +122,7 @@ const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema
preview: TelegramStreamingPreviewSchema.optional(),
}).strict();
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
progress: DiscordStreamingProgressSchema.optional(),
progress: ChannelCommentaryStreamingProgressSchema.optional(),
}).strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),
@@ -1704,3 +1704,7 @@ export const MSTeamsConfigSchema = z
// so we cannot require them in the config object itself.
// Runtime validation happens in resolveMSTeamsCredentials().
});
// Keep this runtime-only widening out of exported schema declarations.
TelegramPreviewStreamingConfigSchema.shape.progress =
ChannelCommentaryStreamingProgressSchema.optional();
@@ -27,12 +27,20 @@ const mocks = vi.hoisted(() => ({
config: {},
issues: [],
}),
checkGatewayHealth: vi.fn(),
probeGatewayMemoryStatus: vi.fn(),
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
logConfigUpdated: vi.fn(),
isRecord: vi.fn(
(value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value),
),
shortenHomePath: vi.fn((p: string) => p),
formatCliCommand: vi.fn((cmd: string) => cmd),
}));
const DOCTOR_GATEWAY_HEALTH_ID = "doctor:gateway-health";
vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () => ({
maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep,
}));
@@ -83,6 +91,11 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("../commands/doctor-gateway-health.js", () => ({
checkGatewayHealth: mocks.checkGatewayHealth,
probeGatewayMemoryStatus: mocks.probeGatewayMemoryStatus,
}));
vi.mock("../commands/onboard-helpers.js", () => ({
applyWizardMetadata: mocks.applyWizardMetadata,
}));
@@ -92,6 +105,7 @@ vi.mock("../config/logging.js", () => ({
}));
vi.mock("../utils.js", () => ({
isRecord: mocks.isRecord,
shortenHomePath: mocks.shortenHomePath,
}));
@@ -176,6 +190,8 @@ describe("doctor health contributions", () => {
config: {},
issues: [],
});
mocks.checkGatewayHealth.mockReset();
mocks.probeGatewayMemoryStatus.mockReset();
});
afterEach(() => {
@@ -193,6 +209,32 @@ describe("doctor health contributions", () => {
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("skips read-scope gateway probes when gateway health only proved reachability", async () => {
mocks.checkGatewayHealth.mockResolvedValue({
authenticated: false,
healthOk: true,
});
const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID);
const ctx = {
cfg: {},
configResult: { cfg: {} },
sourceConfigValid: true,
prompter: buildDoctorPrompter(false),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: {},
cfgForPersistence: {},
configPath: "/tmp/fake-openclaw.json",
env: {},
} as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(ctx.healthOk).toBe(true);
expect(ctx.gatewayHealthAuthenticated).toBe(false);
expect(ctx.gatewayMemoryProbe).toEqual({ checked: false, ready: false, skipped: true });
expect(mocks.probeGatewayMemoryStatus).not.toHaveBeenCalled();
});
it("keeps release configured plugin installs repair-only", async () => {
const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs");
const ctx = {

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