mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
19 Commits
codex/fix-
...
v2026.5.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e510042870 | ||
|
|
ec2d744ba7 | ||
|
|
bf141fa663 | ||
|
|
bb00b2eee2 | ||
|
|
a76234b54a | ||
|
|
a4ea646108 | ||
|
|
ca73aee40a | ||
|
|
63906cfda7 | ||
|
|
631b1c50f6 | ||
|
|
6762c79cc9 | ||
|
|
59650ba258 | ||
|
|
3bc560b8b1 | ||
|
|
eb05ef1692 | ||
|
|
4a2c8d0405 | ||
|
|
c3f0672fbb | ||
|
|
2ea100fdd4 | ||
|
|
1f160682ee | ||
|
|
295ab0b07d | ||
|
|
680132d044 |
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("resolveTelegramAllowedUpdates", () => {
|
||||
"business_message",
|
||||
"edited_business_message",
|
||||
"deleted_business_messages",
|
||||
"guest_message",
|
||||
"inline_query",
|
||||
"chosen_inline_result",
|
||||
"callback_query",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -46,6 +46,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
|
||||
"memory-wiki",
|
||||
"openshell",
|
||||
"phone-control",
|
||||
"policy",
|
||||
"skill-workshop",
|
||||
"talk-voice",
|
||||
"thread-ownership",
|
||||
|
||||
Reference in New Issue
Block a user