Compare commits

...

19 Commits

Author SHA1 Message Date
Peter Steinberger
e510042870 fix(qa): accept Telegram no-reply timeout details 2026-05-21 20:33:02 +01:00
Peter Steinberger
ec2d744ba7 test(release): respect stable ClawHub channel 2026-05-21 19:45:12 +01:00
Peter Steinberger
bf141fa663 chore(release): prepare 2026.5.20 stable 2026-05-21 19:27:09 +01:00
Peter Steinberger
bb00b2eee2 chore(release): refresh generated baselines 2026-05-21 15:54:08 +01:00
Peter Steinberger
a76234b54a chore(release): refresh config baseline 2026-05-21 15:53:33 +01:00
Peter Steinberger
a4ea646108 fix(agent): lock Pi session event persistence 2026-05-21 15:53:16 +01:00
Peter Steinberger
ca73aee40a fix(agent): allow prompt-owned assistant transcript writes 2026-05-21 15:53:16 +01:00
Peter Steinberger
63906cfda7 ci: reset SwiftPM artifacts on macOS retry 2026-05-21 15:53:16 +01:00
Peter Steinberger
631b1c50f6 test(whatsapp): stabilize release prerelease checks 2026-05-21 15:53:16 +01:00
Peter Steinberger
6762c79cc9 ci: retry macOS Swift binary artifacts cleanly 2026-05-21 15:53:16 +01:00
Peter Steinberger
59650ba258 fix(release): sync plugin manifest versions 2026-05-21 15:53:16 +01:00
Peter Steinberger
3bc560b8b1 test: restore secret-file rejection expectation 2026-05-21 15:53:16 +01:00
Peter Steinberger
eb05ef1692 chore(release): prepare 2026.5.20 beta 2 2026-05-21 15:53:16 +01:00
Peter Steinberger
4a2c8d0405 fix(docker): keep runtime prune offline 2026-05-21 15:52:31 +01:00
Peter Steinberger
c3f0672fbb test: align Docker prune assertion 2026-05-21 15:52:31 +01:00
Peter Steinberger
2ea100fdd4 fix(docker): allow prune to refill prod store 2026-05-21 15:52:31 +01:00
Peter Steinberger
1f160682ee test: refresh prerelease assertions 2026-05-21 15:52:31 +01:00
Peter Steinberger
295ab0b07d test: align beta release expectations 2026-05-21 15:52:31 +01:00
Peter Steinberger
680132d044 chore(release): prepare 2026.5.20 beta 1 2026-05-21 15:52:31 +01:00
15 changed files with 325 additions and 54 deletions

View File

@@ -1692,6 +1692,13 @@ jobs:
- name: Swift build (release)
run: |
set -euo pipefail
clear_swift_binary_artifacts() {
# SwiftPM can restore a partial binary artifact cache; force a
# redownload on retry instead of failing the same missing file.
rm -rf apps/macos/.build/artifacts
rm -rf "$HOME/Library/Caches/org.swift.swiftpm/artifacts"
swift package --package-path apps/macos reset || true
}
for attempt in 1 2 3; do
# The macOS lane validates the desktop app build; the CLI product is
# intentionally left to its own narrower surfaces instead of making
@@ -1700,6 +1707,7 @@ jobs:
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
clear_swift_binary_artifacts
sleep $((attempt * 20))
done
exit 1
@@ -1707,11 +1715,17 @@ jobs:
- name: Swift test
run: |
set -euo pipefail
clear_swift_binary_artifacts() {
rm -rf apps/macos/.build/artifacts
rm -rf "$HOME/Library/Caches/org.swift.swiftpm/artifacts"
swift package --package-path apps/macos reset || true
}
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
clear_swift_binary_artifacts
sleep $((attempt * 20))
done
exit 1

View File

@@ -1,4 +1,4 @@
352c794e55d080e2f3649e90cc982c3944e4d289c6e04bfe628ec7066624e444 config-baseline.json
6f45200d3ec3c34347491ee0a0e0f98f55c941d228518b4b84ad3a3e7c6bd0ce config-baseline.json
35d17c60d2858a9cbc875807cdfc7f2fc8ba4745aa4e140a3cdf7ecf38b8a034 config-baseline.core.json
6ca65e5c46c4e219c371ec660b1766f0a23092daff6bdeb64fc0574f001c7f81 config-baseline.channel.json
d455f53b424976f99990330503692728121cbbeff04014fb50b5eed23aae59d4 config-baseline.plugin.json
a0a88df97080adf50c2c2bccd2ca076ad43e81b24dd25f3c3cace41f09a7c8f0 config-baseline.plugin.json

View File

@@ -140,38 +140,38 @@ commands.
## Official external packages
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm; ClawHub | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm; ClawHub | providers: amazon-bedrock-mantle |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm; ClawHub | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm; ClawHub | providers: amazon-bedrock-mantle |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
## Source checkout only

View File

@@ -94,7 +94,7 @@ pnpm plugins:inventory:gen
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |

View File

@@ -1,5 +1,5 @@
---
summary: "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution."
summary: "Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution."
read_when:
- You are installing, configuring, or auditing the openshell plugin
title: "Openshell plugin"
@@ -7,7 +7,7 @@ title: "Openshell plugin"
# Openshell plugin
Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.
Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution.
## Distribution

View File

@@ -333,6 +333,23 @@ describe("telegram live qa runtime", () => {
);
});
it("recognizes Telegram observation timeouts with retry details", () => {
expect(
testing.isTelegramObservedMessageTimeoutError(
new Error(
"timed out after 8000ms waiting for Telegram message; last polling error: The operation was aborted due to timeout",
),
8000,
),
).toBe(true);
expect(
testing.isTelegramObservedMessageTimeoutError(
new Error("timed out after 9000ms waiting for Telegram message"),
8000,
),
).toBe(false);
});
it("includes mention gating in the Telegram live scenario catalog", () => {
const scenarios = testing.findScenario([
"telegram-help-command",

View File

@@ -1283,6 +1283,12 @@ function assertTelegramScenarioReply(params: {
}
}
function isTelegramObservedMessageTimeoutError(error: unknown, timeoutMs: number) {
return formatErrorMessage(error).startsWith(
`timed out after ${timeoutMs}ms waiting for Telegram message`,
);
}
function resolveTelegramQaScenarioSteps(run: TelegramQaScenarioRun): TelegramQaScenarioStep[] {
if (run.steps.length === 0) {
throw new Error("Telegram QA scenario must include at least one step");
@@ -1341,11 +1347,7 @@ async function runTelegramQaScenarioStep(params: {
sentMessageId: sent.message_id,
};
} catch (error) {
if (
!params.step.expectReply &&
formatErrorMessage(error) ===
`timed out after ${stepTimeoutMs}ms waiting for Telegram message`
) {
if (!params.step.expectReply && isTelegramObservedMessageTimeoutError(error, stepTimeoutMs)) {
return {
matched: undefined,
requestStartedAt: new Date(requestStartedAtMs).toISOString(),
@@ -2041,6 +2043,7 @@ export const testing = {
assertTelegramScenarioReply,
classifyCanaryReply,
findScenario,
isTelegramObservedMessageTimeoutError,
listTelegramQaScenarioCatalog,
matchesTelegramScenarioReply,
normalizeTelegramObservedMessage,

View File

@@ -19,6 +19,7 @@ describe("resolveTelegramAllowedUpdates", () => {
"business_message",
"edited_business_message",
"deleted_business_messages",
"guest_message",
"inline_query",
"chosen_inline_result",
"callback_query",

View File

@@ -123,9 +123,6 @@ describe("loginWeb coverage", () => {
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
await pendingLogin;
expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@@ -29,6 +29,7 @@ const hoisted = vi.hoisted(() => ({
detectWhatsAppLinked: vi.fn<(cfg: OpenClawConfig, accountId: string) => Promise<boolean>>(
async () => false,
),
hasWebCredsSync: vi.fn(() => false),
loginWeb: vi.fn(async () => {}),
pathExists: vi.fn(async () => false),
readWebAuthState: vi.fn<(authDir: string) => Promise<"linked" | "not-linked" | "unstable">>(
@@ -77,6 +78,13 @@ vi.mock("./auth-store.js", async () => {
});
});
vi.mock("./creds-files.js", async () => {
const actual = await vi.importActual<typeof import("./creds-files.js")>("./creds-files.js");
return Object.assign({}, actual, {
hasWebCredsSync: hoisted.hasWebCredsSync,
});
});
const createRuntime = (): RuntimeEnv =>
({
error: vi.fn(),
@@ -142,6 +150,8 @@ describe("whatsapp setup wizard", () => {
beforeEach(() => {
hoisted.detectWhatsAppLinked.mockReset();
hoisted.detectWhatsAppLinked.mockResolvedValue(false);
hoisted.hasWebCredsSync.mockReset();
hoisted.hasWebCredsSync.mockReturnValue(false);
hoisted.loginWeb.mockReset();
hoisted.pathExists.mockReset();
hoisted.pathExists.mockResolvedValue(false);
@@ -338,6 +348,8 @@ describe("whatsapp setup wizard", () => {
});
it("skips relink note when already linked and relink is declined", async () => {
hoisted.detectWhatsAppLinked.mockResolvedValue(true);
hoisted.hasWebCredsSync.mockReturnValue(true);
hoisted.pathExists.mockResolvedValue(true);
const harness = createSeparatePhoneHarness({
selectValues: ["separate", "disabled"],

View File

@@ -19,10 +19,15 @@ type PackageJson = {
};
};
type PluginManifestJson = {
version?: string;
};
type SyncPluginVersionsOptions = {
write?: boolean;
};
const OPENCLAW_VERSION_RE = /^\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u;
const OPENCLAW_VERSION_RANGE_RE = /^>=\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u;
function syncOpenClawDependencyRange(
@@ -68,6 +73,22 @@ function syncBuildOpenClawVersion(pkg: PackageJson, targetVersion: string): bool
return true;
}
function syncManifestVersion(manifestPath: string, targetVersion: string, write: boolean): boolean {
if (!existsSync(manifestPath)) {
return false;
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as PluginManifestJson;
const current = manifest.version;
if (!current || !OPENCLAW_VERSION_RE.test(current) || current === targetVersion) {
return false;
}
manifest.version = targetVersion;
if (write) {
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
return true;
}
function changelogVersionForPackageVersion(version: string): string {
return version.replace(/-beta\.\d+$/u, "");
}
@@ -143,12 +164,18 @@ export function syncPluginVersions(
// Keep it stable unless the owning plugin intentionally raises it.
const pluginApiChanged = syncPluginApiVersion(pkg, targetVersion);
const buildOpenClawVersionChanged = syncBuildOpenClawVersion(pkg, targetVersion);
const manifestVersionChanged = syncManifestVersion(
join(extensionsDir, dir.name, "openclaw.plugin.json"),
targetVersion,
write,
);
const packageChanged =
versionChanged ||
devDependencyChanged ||
peerDependencyChanged ||
pluginApiChanged ||
buildOpenClawVersionChanged;
buildOpenClawVersionChanged ||
manifestVersionChanged;
if (!packageChanged) {
skipped.push(pkg.name);
continue;

View File

@@ -239,6 +239,49 @@ describe("embedded attempt session lock lifecycle", () => {
expect(release).toHaveBeenCalledTimes(4);
});
it("allows append-only assistant output written by the active prompt", async () => {
const sessionFile = await createTempSessionFile();
const release = vi.fn(async () => {});
const acquireSessionWriteLock = vi.fn(async () => ({ release }));
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock,
lockOptions: { ...lockOptions, sessionFile },
});
await controller.releaseForPrompt();
await fs.appendFile(
sessionFile,
'{"type":"message","message":{"role":"assistant","content":"partial"}}\n',
"utf8",
);
await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize");
expect(controller.hasSessionTakeover()).toBe(false);
});
it("rejects assistant-looking appends when the session file identity changed", async () => {
const sessionFile = await createTempSessionFile();
const release = vi.fn(async () => {});
const acquireSessionWriteLock = vi.fn(async () => ({ release }));
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock,
lockOptions: { ...lockOptions, sessionFile },
});
await controller.releaseForPrompt();
await fs.writeFile(
`${sessionFile}.replacement`,
'{"type":"session","id":"replacement"}\n{"type":"message","message":{"role":"assistant","content":"partial"}}\n',
"utf8",
);
await fs.rename(`${sessionFile}.replacement`, sessionFile);
await expect(controller.withSessionWriteLock(() => "finalize")).rejects.toBeInstanceOf(
EmbeddedAttemptSessionTakeoverError,
);
expect(controller.hasSessionTakeover()).toBe(true);
});
it("refreshes the prompt fence after an owned transcript mirror append", async () => {
const sessionFile = await createTempSessionFile();
const release = vi.fn(async () => {});
@@ -495,6 +538,48 @@ describe("embedded attempt session lock lifecycle", () => {
]);
});
it("locks and reconnects the current Pi agent event handler", async () => {
const calls: string[] = [];
const session = {
_extensionRunner: { hasHandlers: vi.fn(() => false) },
_disconnectFromAgent: vi.fn(() => {
calls.push("disconnect");
}),
_reconnectToAgent: vi.fn(() => {
calls.push("reconnect");
}),
_handleAgentEvent: vi.fn(async (event: { type?: string }) => {
calls.push(`event:${event.type}`);
}),
};
installSessionEventWriteLock({
session,
withSessionWriteLock: async (run) => {
calls.push("lock");
try {
return await run();
} finally {
calls.push("unlock");
}
},
});
await session["_handleAgentEvent"]({ type: "message_end" });
await session["_handleAgentEvent"]({ type: "tool_execution_end" });
expect(session["_disconnectFromAgent"]).toHaveBeenCalledTimes(1);
expect(session["_reconnectToAgent"]).toHaveBeenCalledTimes(1);
expect(calls).toEqual([
"disconnect",
"reconnect",
"lock",
"event:message_end",
"unlock",
"event:tool_execution_end",
]);
});
it("locks Pi extension hooks that can mutate the session outside agent events", async () => {
const locked: string[] = [];
const called: string[] = [];

View File

@@ -19,6 +19,9 @@ type LockOptions = {
type SessionEventProcessor = {
_processAgentEvent?: (event: unknown) => Promise<void>;
_handleAgentEvent?: (event: unknown) => Promise<void>;
_disconnectFromAgent?: () => void;
_reconnectToAgent?: () => void;
_extensionRunner?: {
hasHandlers?: (eventType: string) => boolean;
};
@@ -123,6 +126,51 @@ type SessionFileFingerprint =
ctimeNs: bigint;
};
function readEntryRole(entry: unknown): string | undefined {
if (!entry || typeof entry !== "object") {
return undefined;
}
const topLevelRole = (entry as { role?: unknown }).role;
if (typeof topLevelRole === "string") {
return topLevelRole;
}
const message = (entry as { message?: unknown }).message;
return message && typeof message === "object"
? ((message as { role?: unknown }).role as string | undefined)
: undefined;
}
function isAssistantTranscriptEntry(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
try {
return readEntryRole(JSON.parse(trimmed)) === "assistant";
} catch {
return false;
}
}
async function readSessionFileRange(params: {
sessionFile: string;
start: bigint;
end: bigint;
}): Promise<string> {
const length = params.end - params.start;
if (length <= 0n || length > BigInt(Number.MAX_SAFE_INTEGER)) {
return "";
}
const handle = await fs.open(params.sessionFile, "r");
try {
const buffer = Buffer.alloc(Number(length));
await handle.read(buffer, 0, buffer.length, Number(params.start));
return buffer.toString("utf8");
} finally {
await handle.close();
}
}
function sameSessionFileFingerprint(
left: SessionFileFingerprint | undefined,
right: SessionFileFingerprint,
@@ -142,6 +190,37 @@ function sameSessionFileFingerprint(
);
}
function sameSessionFileIdentity(
left: SessionFileFingerprint | undefined,
right: SessionFileFingerprint,
): boolean {
return Boolean(left?.exists && right.exists && left.dev === right.dev && left.ino === right.ino);
}
async function changeLooksLikeOwnedPromptOutput(params: {
sessionFile: string;
previous: SessionFileFingerprint | undefined;
current: SessionFileFingerprint;
}): Promise<boolean> {
if (
!params.previous?.exists ||
!params.current.exists ||
!sameSessionFileIdentity(params.previous, params.current) ||
params.current.size < params.previous.size
) {
return false;
}
if (params.current.size === params.previous.size) {
return sameSessionFileFingerprint(params.previous, params.current);
}
const appended = await readSessionFileRange({
sessionFile: params.sessionFile,
start: params.previous.size,
end: params.current.size,
});
return appended.split(/\r?\n/u).every(isAssistantTranscriptEntry);
}
async function readSessionFileFingerprint(sessionFile: string): Promise<SessionFileFingerprint> {
try {
const stat = await fs.stat(sessionFile, { bigint: true });
@@ -244,9 +323,20 @@ export function installSessionEventWriteLock(params: {
session: unknown;
withSessionWriteLock: <T>(run: () => Promise<T> | T) => Promise<T>;
}): void {
installAwaitableSessionEventQueue(params.session);
const session = params.session as SessionEventProcessor;
const original = session["_processAgentEvent"];
const handlerKey =
typeof session["_processAgentEvent"] === "function"
? "_processAgentEvent"
: typeof session["_handleAgentEvent"] === "function"
? "_handleAgentEvent"
: undefined;
if (!handlerKey) {
return;
}
if (handlerKey === "_processAgentEvent") {
installAwaitableSessionEventQueue(params.session);
}
const original = session[handlerKey];
if (
typeof original !== "function" ||
session["__openclawSessionEventWriteLockInstalled"] === true
@@ -254,15 +344,18 @@ export function installSessionEventWriteLock(params: {
return;
}
session["__openclawSessionEventWriteLockInstalled"] = true;
session["_processAgentEvent"] = async function lockedProcessAgentEvent(
this: unknown,
event: unknown,
) {
if (handlerKey === "_handleAgentEvent") {
session["_disconnectFromAgent"]?.();
}
session[handlerKey] = async function lockedProcessAgentEvent(this: unknown, event: unknown) {
if (!eventMayReachTranscriptWriters(session, event)) {
return await original.call(this, event);
}
return await params.withSessionWriteLock(async () => await original.call(this, event));
};
if (handlerKey === "_handleAgentEvent") {
session["_reconnectToAgent"]?.();
}
}
export function installSessionExternalHookWriteLock(params: {
@@ -357,6 +450,17 @@ export async function createEmbeddedAttemptSessionLockController(params: {
}
const current = await readSessionFileFingerprint(params.lockOptions.sessionFile);
if (!sameSessionFileFingerprint(fenceFingerprint, current)) {
if (
current.exists &&
(await changeLooksLikeOwnedPromptOutput({
sessionFile: params.lockOptions.sessionFile,
previous: fenceFingerprint,
current,
}))
) {
fenceFingerprint = current;
return;
}
takeoverDetected = true;
throw new EmbeddedAttemptSessionTakeoverError(params.lockOptions.sessionFile);
}

View File

@@ -3,7 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js";
import { resolveNpmInstallSpecsForUpdateChannel } from "../../../plugins/install-channel-specs.js";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "../../../plugins/install-channel-specs.js";
import { VERSION } from "../../../version.js";
function expectedNpmInstallSpec(spec: string): string {
@@ -13,6 +16,13 @@ function expectedNpmInstallSpec(spec: string): string {
}).installSpec;
}
function expectedClawHubInstallSpec(spec: string): string {
return resolveClawHubInstallSpecsForUpdateChannel({
spec,
updateChannel: resolveRegistryUpdateChannel({ currentVersion: VERSION }),
}).installSpec;
}
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
if (!record || typeof record !== "object") {
throw new Error("Expected record");
@@ -1264,7 +1274,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), {
spec: "clawhub:@openclaw/whatsapp",
spec: expectedClawHubInstallSpec("clawhub:@openclaw/whatsapp"),
env: {
OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.5.19",
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",

View File

@@ -46,6 +46,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"memory-wiki",
"openshell",
"phone-control",
"policy",
"skill-workshop",
"talk-voice",
"thread-ownership",