diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 363caefe383..0a39241acde 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
- versionCode = 2026042700
- versionName = "2026.4.27"
+ versionCode = 2026043000
+ versionName = "2026.4.30"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md
index 3c4453b0aa6..f4929c7b718 100644
--- a/apps/ios/CHANGELOG.md
+++ b/apps/ios/CHANGELOG.md
@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
+## 2026.4.30 - 2026-04-30
+
+Maintenance update for the current OpenClaw development release.
+
## 2026.4.27 - 2026-04-27
Maintenance update for the current OpenClaw development release.
diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig
index 9c389f0b4f8..2846a950123 100644
--- a/apps/ios/Config/Version.xcconfig
+++ b/apps/ios/Config/Version.xcconfig
@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
-OPENCLAW_IOS_VERSION = 2026.4.27
-OPENCLAW_MARKETING_VERSION = 2026.4.27
+OPENCLAW_IOS_VERSION = 2026.4.30
+OPENCLAW_MARKETING_VERSION = 2026.4.30
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"
diff --git a/apps/ios/version.json b/apps/ios/version.json
index 4c5e892269d..dd3640844b1 100644
--- a/apps/ios/version.json
+++ b/apps/ios/version.json
@@ -1,3 +1,3 @@
{
- "version": "2026.4.27"
+ "version": "2026.4.30"
}
diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist
index 730c60378fe..4a8520a28af 100644
--- a/apps/macos/Sources/OpenClaw/Resources/Info.plist
+++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.4.27
+ 2026.4.30
CFBundleVersion
- 2026042700
+ 2026043000
CFBundleIconFile
OpenClaw
CFBundleURLTypes
diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts
index 07229680097..a039ccf625f 100644
--- a/extensions/codex/src/app-server/config.ts
+++ b/extensions/codex/src/app-server/config.ts
@@ -120,10 +120,12 @@ const codexAppServerApprovalPolicySchema = z.enum([
]);
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
-const codexAppServerServiceTierSchema = z.preprocess(
- (value) => (value === null ? null : resolveServiceTier(value)),
- z.enum(["fast", "flex"]).nullable().optional(),
-);
+const codexAppServerServiceTierSchema = z
+ .preprocess(
+ (value) => (value === null ? null : resolveServiceTier(value)),
+ z.enum(["fast", "flex"]).nullable().optional(),
+ )
+ .optional();
const codexPluginConfigSchema = z
.object({
diff --git a/extensions/discord/src/test-support/provider.test-support.ts b/extensions/discord/src/test-support/provider.test-support.ts
index 820d6ac6224..1a7501019c2 100644
--- a/extensions/discord/src/test-support/provider.test-support.ts
+++ b/extensions/discord/src/test-support/provider.test-support.ts
@@ -451,6 +451,8 @@ vi.mock("openclaw/plugin-sdk/error-runtime", async () => {
vi.mock(buildDiscordSourceModuleId("accounts.js"), () => ({
resolveDiscordAccount: resolveDiscordAccountMock,
+ resolveDiscordAccountAllowFrom: () => undefined,
+ resolveDiscordAccountDmPolicy: () => undefined,
}));
vi.mock(buildDiscordSourceModuleId("probe.js"), () => ({
diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts
index 3682237deb5..3bd4cd8f5ab 100644
--- a/extensions/google/transport-stream.test.ts
+++ b/extensions/google/transport-stream.test.ts
@@ -18,6 +18,7 @@ let buildGoogleGenerativeAiParams: typeof import("./transport-stream.js").buildG
let createGoogleGenerativeAiTransportStreamFn: typeof import("./transport-stream.js").createGoogleGenerativeAiTransportStreamFn;
let createGoogleVertexTransportStreamFn: typeof import("./transport-stream.js").createGoogleVertexTransportStreamFn;
let hasGoogleVertexAuthorizedUserAdcSync: typeof import("./vertex-adc.js").hasGoogleVertexAuthorizedUserAdcSync;
+let resetGoogleVertexAuthorizedUserTokenCacheForTest: typeof import("./vertex-adc.js").resetGoogleVertexAuthorizedUserTokenCacheForTest;
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
"openclaw.modelProviderRequestTransport",
@@ -91,13 +92,15 @@ describe("google transport stream", () => {
createGoogleGenerativeAiTransportStreamFn,
createGoogleVertexTransportStreamFn,
} = await import("./transport-stream.js"));
- ({ hasGoogleVertexAuthorizedUserAdcSync } = await import("./vertex-adc.js"));
+ ({ hasGoogleVertexAuthorizedUserAdcSync, resetGoogleVertexAuthorizedUserTokenCacheForTest } =
+ await import("./vertex-adc.js"));
});
beforeEach(() => {
buildGuardedModelFetchMock.mockReset();
guardedFetchMock.mockReset();
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
+ resetGoogleVertexAuthorizedUserTokenCacheForTest();
});
afterEach(() => {
@@ -377,7 +380,7 @@ describe("google transport stream", () => {
}),
"utf8",
);
- vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", undefined);
+ vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("APPDATA", appDataDir);
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project");
diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts
index 517bd1049fb..a6712df1030 100644
--- a/extensions/google/vertex-adc.ts
+++ b/extensions/google/vertex-adc.ts
@@ -22,6 +22,10 @@ const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined;
+export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void {
+ cachedGoogleVertexAuthorizedUserToken = undefined;
+}
+
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts
index 367903d47d0..330d24e73c1 100644
--- a/extensions/openai/openai-provider.test.ts
+++ b/extensions/openai/openai-provider.test.ts
@@ -785,7 +785,11 @@ describe("buildOpenAIProvider", () => {
payload,
});
- expect(mocks.openAIResponsesTransportStreamFn).toHaveBeenCalledTimes(1);
+ expect(mocks.openAIResponsesTransportStreamFn).not.toHaveBeenCalled();
+ expect(result.options?.headers).toMatchObject({
+ originator: "openclaw",
+ "User-Agent": expect.stringMatching(/^openclaw\//u),
+ });
expect(result.payload.store).toBe(false);
expect(result.payload.service_tier).toBe("priority");
expect(result.payload.text).toEqual({ verbosity: "high" });
diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts
index 238250504ac..09c68a501c3 100644
--- a/extensions/qa-lab/src/gateway-child.ts
+++ b/extensions/qa-lab/src/gateway-child.ts
@@ -44,6 +44,7 @@ import type { QaTransportAdapter } from "./qa-transport.js";
export type { QaCliBackendAuthMode } from "./providers/env.js";
const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5;
const QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS = 60_000;
+const QA_GATEWAY_CHILD_RESTART_BOUNDARY_TIMEOUT_MS = 90_000;
const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([
"OPENCLAW_QA_CONVEX_SECRET_CI",
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER",
@@ -288,7 +289,7 @@ async function waitForQaGatewayRestartBoundary(params: {
pollMs?: number;
timeoutMs?: number;
}) {
- const timeoutMs = params.timeoutMs ?? 30_000;
+ const timeoutMs = params.timeoutMs ?? QA_GATEWAY_CHILD_RESTART_BOUNDARY_TIMEOUT_MS;
const pollMs = params.pollMs ?? 100;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
diff --git a/package.json b/package.json
index 6df671924a6..d91b11a1123 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openclaw",
- "version": "2026.4.27",
+ "version": "2026.4.30",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
index c877753a123..7f9d3320732 100644
--- a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
+++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
@@ -35,7 +35,8 @@ start_kitchen_sink_clawhub_fixture_server() {
local server_pid="$!"
echo "$server_pid" >"$server_pid_file"
- for _ in $(seq 1 100); do
+ local wait_attempts="${OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS:-600}"
+ for _ in $(seq 1 "$wait_attempts"); do
if [[ -s "$server_port_file" ]]; then
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT
@@ -49,6 +50,7 @@ start_kitchen_sink_clawhub_fixture_server() {
done
cat "$server_log"
+ ps -p "$server_pid" -o pid=,stat=,etime=,command= || true
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
return 1
}
@@ -75,8 +77,8 @@ assert_kitchen_sink_removed() {
run_success_scenario() {
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
- configure_kitchen_sink_runtime
run_logged_print "kitchen-sink-install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
+ configure_kitchen_sink_runtime
run_logged_print "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID"
node "$OPENCLAW_ENTRY" plugins list --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json"
node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json >"/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json"
diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts
index fd062257b2b..e595a93592c 100644
--- a/scripts/e2e/parallels/npm-update-scripts.ts
+++ b/scripts/e2e/parallels/npm-update-scripts.ts
@@ -30,9 +30,10 @@ entries = plugins.get("entries")
if isinstance(entries, dict):
entries.pop("feishu", None)
entries.pop("whatsapp", None)
+ entries.pop("openai", None)
allow = plugins.get("allow")
if isinstance(allow, list):
- plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}]
+ plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp", "openai"}]
path.write_text(json.dumps(config, indent=2) + "\n")
PY
}
@@ -86,13 +87,13 @@ function Remove-FuturePluginEntries {
if (-not ($plugins -is [hashtable])) { return }
$entries = $plugins['entries']
if ($entries -is [hashtable]) {
- foreach ($pluginId in @('feishu', 'whatsapp')) {
+ foreach ($pluginId in @('feishu', 'whatsapp', 'openai')) {
if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) }
}
}
$allow = $plugins['allow']
if ($allow -is [array]) {
- $plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
+ $plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp', 'openai') })
}
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
}
@@ -119,8 +120,25 @@ if ($updateExit -ne 0) {
Write-Host "openclaw update returned a stale post-swap module import; continuing to post-update health checks"
}
${windowsVersionCheck(input.expectedNeedle)}
-Invoke-OpenClaw gateway restart
-Invoke-OpenClaw gateway status --deep --require-rpc
+function Wait-OpenClawGateway {
+ $deadline = (Get-Date).AddSeconds(180)
+ $attempt = 0
+ while ((Get-Date) -lt $deadline) {
+ Invoke-OpenClaw gateway status --deep --require-rpc --timeout 15000
+ if ($LASTEXITCODE -eq 0) { return }
+ $attempt += 1
+ if ($attempt -eq 4) {
+ Invoke-OpenClaw gateway start *>&1 | Out-Host
+ }
+ Start-Sleep -Seconds 5
+ }
+ throw "gateway did not become ready after update"
+}
+Invoke-OpenClaw gateway restart *>&1 | Out-Host
+if ($LASTEXITCODE -ne 0) {
+ "gateway restart exited with code $LASTEXITCODE; probing readiness before failing" | Out-Host
+}
+Wait-OpenClawGateway
Invoke-OpenClaw models set ${psSingleQuote(input.auth.modelId)}
Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
@@ -144,9 +162,10 @@ if (!plugins || typeof plugins !== "object") process.exit(0);
if (plugins.entries && typeof plugins.entries === "object") {
delete plugins.entries.feishu;
delete plugins.entries.whatsapp;
+ delete plugins.entries.openai;
}
if (Array.isArray(plugins.allow)) {
- plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp");
+ plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp" && id !== "openai");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
JS
diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts
index aa280e87392..5a00c619762 100755
--- a/scripts/e2e/parallels/npm-update-smoke.ts
+++ b/scripts/e2e/parallels/npm-update-smoke.ts
@@ -441,12 +441,22 @@ class NpmUpdateSmoke {
timeoutMs: number,
ctx: UpdateJobContext,
): Promise {
- const macosExecArgs = this.resolveMacosUpdateExecArgs(ctx);
const scriptPath = this.writeGuestScript(
macosVm,
script,
"openclaw-parallels-npm-update-macos",
);
+ const macosExecArgs = this.resolveMacosUpdateExecArgs(ctx);
+ const sudoUserArgIndex = macosExecArgs.indexOf("-u");
+ const sudoUser =
+ sudoUserArgIndex >= 0 && sudoUserArgIndex + 1 < macosExecArgs.length
+ ? macosExecArgs[sudoUserArgIndex + 1]
+ : "";
+ if (sudoUser) {
+ run("prlctl", ["exec", macosVm, "/usr/sbin/chown", sudoUser, scriptPath], {
+ timeoutMs: 30_000,
+ });
+ }
try {
const status = await this.runStreamingToJobLog(
"prlctl",
diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts
index 516a86b6a9a..c7812787a69 100755
--- a/scripts/e2e/parallels/windows-smoke.ts
+++ b/scripts/e2e/parallels/windows-smoke.ts
@@ -666,7 +666,7 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`,
);
let launched = false;
let lastLaunchStatus = 0;
- for (let attempt = 1; attempt <= 3; attempt++) {
+ for (let attempt = 1; attempt <= 5; attempt++) {
this.waitForGuestReady(120);
const launchLogPath = path.join(this.runDir, `${safeLabel}-launch-${attempt}.log`);
const launchStatus = await runStreaming(
@@ -675,17 +675,30 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`,
"exec",
this.options.vmName,
"--current-user",
- "cmd.exe",
- "/d",
- "/s",
- "/c",
- `start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`,
+ "powershell.exe",
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-EncodedCommand",
+ encodePowerShell(`${pathsScript}
+Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath)
+'started'`),
],
- { logPath: launchLogPath, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(20_000) },
+ { logPath: launchLogPath, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
);
const launchLog = await readFile(launchLogPath, "utf8").catch(() => "");
this.log(launchLog);
+ if (launchStatus === 0 && launchLog.includes("started")) {
+ launched = true;
+ break;
+ }
if (launchStatus === 0 || launchStatus === 124) {
+ const materialized = this.waitForBackgroundMaterialized(pathsScript, 45_000);
+ if (!materialized) {
+ warn(`${label} launch retry ${attempt}: background log/done file did not materialize`);
+ lastLaunchStatus = launchStatus;
+ continue;
+ }
launched = true;
break;
}
@@ -754,6 +767,31 @@ Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorActio
throw new Error(`${label} timed out`);
}
+ private waitForBackgroundMaterialized(pathsScript: string, timeoutMs: number): boolean {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const result = this.guest.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-EncodedCommand",
+ encodePowerShell(`${pathsScript}
+if ((Test-Path $logPath) -or (Test-Path $donePath)) {
+ 'materialized'
+}`),
+ ],
+ { check: false, timeoutMs: this.remainingPhaseTimeoutMs(15_000) },
+ );
+ if (result.stdout.includes("materialized")) {
+ return true;
+ }
+ run("sleep", ["2"], { quiet: true });
+ }
+ return false;
+ }
+
private runDevChannelUpdate(): void {
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
diff --git a/scripts/lib/workspace-bootstrap-smoke.mjs b/scripts/lib/workspace-bootstrap-smoke.mjs
index 9a62f4750eb..762e470c3d9 100644
--- a/scripts/lib/workspace-bootstrap-smoke.mjs
+++ b/scripts/lib/workspace-bootstrap-smoke.mjs
@@ -56,6 +56,7 @@ export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) {
OPENCLAW_HOME: homeDir,
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
AWS_EC2_METADATA_DISABLED: "true",
AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"),
@@ -135,8 +136,9 @@ export function runInstalledWorkspaceBootstrapSmoke(params) {
const workspaceDir = join(homeDir, ".openclaw", "workspace");
const missingFiles = collectMissingBootstrapWorkspaceFiles(workspaceDir);
if (missingFiles.length > 0) {
+ const outputDetails = combinedOutput.length > 0 ? `\nCommand output:\n${combinedOutput}` : "";
throw new Error(
- `installed workspace bootstrap did not create required files in ${workspaceDir}: ${missingFiles.join(", ")}`,
+ `installed workspace bootstrap did not create required files in ${workspaceDir}: ${missingFiles.join(", ")}${outputDetails}`,
);
}
} finally {
diff --git a/scripts/release-check.ts b/scripts/release-check.ts
index f73bc6ae268..f8cdcc4fc0b 100755
--- a/scripts/release-check.ts
+++ b/scripts/release-check.ts
@@ -522,8 +522,12 @@ function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void {
if (!existsSync(runtimePath)) {
throw new Error("release-check: packed task-registry control runtime is missing.");
}
+ const runtimeImportExpression = [
+ `(0, Function)("specifier", "return " + "im" + "port(specifier)")`,
+ `(${JSON.stringify(pathToFileURL(runtimePath).href)})`,
+ ].join("");
const source = `
-const runtime = await import(${JSON.stringify(pathToFileURL(runtimePath).href)});
+const runtime = await ${runtimeImportExpression};
if (typeof runtime.getAcpSessionManager !== "function") {
throw new Error("missing getAcpSessionManager export");
}
diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts
index 8cb093c910d..74762958582 100644
--- a/src/agents/cli-runner.reliability.test.ts
+++ b/src/agents/cli-runner.reliability.test.ts
@@ -33,6 +33,26 @@ vi.mock("../tts/tts.js", () => ({
}));
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
+const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
+
+type HookRunnerGlobalStateForTest = {
+ hookRunner: unknown;
+ registry: unknown;
+};
+
+function setHookRunnerForTest(hookRunner: unknown): void {
+ mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ const globalStore = globalThis as Record;
+ const state = (globalStore[hookRunnerGlobalStateKey] as
+ | HookRunnerGlobalStateForTest
+ | undefined) ?? {
+ hookRunner: null,
+ registry: null,
+ };
+ state.hookRunner = hookRunner;
+ state.registry = null;
+ globalStore[hookRunnerGlobalStateKey] = state;
+}
function createSessionFile(params?: { history?: Array<{ role: "user"; content: string }> }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
@@ -127,6 +147,7 @@ describe("runCliAgent reliability", () => {
afterEach(() => {
replyRunTesting.resetReplyRunRegistry();
mockGetGlobalHookRunner.mockReset();
+ setHookRunnerForTest(null);
vi.unstubAllEnvs();
});
@@ -217,7 +238,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockClear();
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -472,7 +493,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile();
supervisorSpawnMock.mockResolvedValueOnce(
@@ -572,7 +593,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -600,7 +621,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -644,7 +665,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile({
history: Array.from({ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 5 }, (_, index) => ({
role: "user" as const,
@@ -725,7 +746,7 @@ describe("runCliAgent reliability", () => {
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
const historySpy = vi.spyOn(sessionHistoryModule, "loadCliSessionHistoryMessages");
supervisorSpawnMock.mockResolvedValueOnce(
@@ -791,7 +812,7 @@ describe("runCliAgent reliability", () => {
runBeforePromptBuild: vi.fn(async () => ({ prependContext: "hook context" })),
runBeforeAgentStart: vi.fn(async () => undefined),
};
- mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
+ setHookRunnerForTest(hookRunner);
try {
const context = await prepareCliRunContext({
diff --git a/src/agents/skills.buildworkspaceskillsnapshot.test.ts b/src/agents/skills.buildworkspaceskillsnapshot.test.ts
index e1174952e6b..893d269efe9 100644
--- a/src/agents/skills.buildworkspaceskillsnapshot.test.ts
+++ b/src/agents/skills.buildworkspaceskillsnapshot.test.ts
@@ -247,9 +247,14 @@ describe("buildWorkspaceSkillSnapshot", () => {
);
// We should only have loaded a small subset.
- expect(snapshot.skills.length).toBeLessThanOrEqual(5);
- expect(snapshot.prompt).toContain("repo-skill-00");
- expect(snapshot.prompt).not.toContain("repo-skill-07");
+ const skillNames = snapshot.skills.map((skill) => skill.name);
+ expect(skillNames.length).toBeGreaterThan(0);
+ expect(skillNames.length).toBeLessThanOrEqual(5);
+ expect(new Set(skillNames).size).toBe(skillNames.length);
+ for (const name of skillNames) {
+ expect(name).toMatch(/^repo-skill-\d{2}$/);
+ expect(snapshot.prompt).toContain(name);
+ }
});
it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => {
diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts
index 793c8535dd3..c6451a4f7cc 100644
--- a/src/cli/command-catalog.ts
+++ b/src/cli/command-catalog.ts
@@ -147,6 +147,15 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { ensureCliPath: false, networkProxy: "bypass" },
route: { id: "sessions" },
},
+ {
+ commandPath: ["commitments"],
+ policy: {
+ ensureCliPath: false,
+ routeConfigGuard: "when-suppressed",
+ loadPlugins: "never",
+ networkProxy: "bypass",
+ },
+ },
{
commandPath: ["agents", "list"],
// Text and JSON output are derived from config plus read-only channel
diff --git a/src/cli/program/command-registry-core.ts b/src/cli/program/command-registry-core.ts
index 98591862f9b..c0b777147f5 100644
--- a/src/cli/program/command-registry-core.ts
+++ b/src/cli/program/command-registry-core.ts
@@ -121,7 +121,7 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
...withProgramOnlySpecs(
defineImportedProgramCommandGroupSpecs([
{
- commandNames: ["status", "health", "sessions", "tasks"],
+ commandNames: ["status", "health", "sessions", "commitments", "tasks"],
loadModule: () => import("./register.status-health-sessions.js"),
exportName: "registerStatusHealthSessionsCommands",
},
diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts
index 6f06e7f1a20..42b67210790 100644
--- a/src/cli/program/command-registry.test.ts
+++ b/src/cli/program/command-registry.test.ts
@@ -32,6 +32,7 @@ vi.mock("./register.status-health-sessions.js", () => ({
program.command("status");
program.command("health");
program.command("sessions");
+ program.command("commitments");
const tasks = program.command("tasks");
tasks.command("show");
},
@@ -86,6 +87,7 @@ describe("command-registry", () => {
expect(names).toContain("backup");
expect(names).toContain("mcp");
expect(names).toContain("sessions");
+ expect(names).toContain("commitments");
expect(names).toContain("tasks");
expect(names).not.toContain("agent");
expect(names).not.toContain("crestodian");
@@ -159,9 +161,22 @@ describe("command-registry", () => {
expect(names).toContain("status");
expect(names).toContain("health");
expect(names).toContain("sessions");
+ expect(names).toContain("commitments");
expect(names).toContain("tasks");
});
+ it("can eagerly register the status/session command group repeatedly for completion", async () => {
+ const program = createProgram();
+
+ for (const name of ["status", "health", "sessions", "commitments", "tasks"]) {
+ await expect(registerCoreCliByName(program, testProgramContext, name)).resolves.toBe(true);
+ }
+
+ const names = namesOf(program);
+ expect(names.filter((name) => name === "commitments")).toHaveLength(1);
+ expect(names.filter((name) => name === "tasks")).toHaveLength(1);
+ });
+
it("replaces placeholders when loading a grouped entry by secondary command name", async () => {
const program = createProgram();
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]);
diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts
index fd996b86bef..d54f03e0588 100644
--- a/src/cli/program/core-command-descriptors.ts
+++ b/src/cli/program/core-command-descriptors.ts
@@ -95,6 +95,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
description: "List stored conversation sessions",
hasSubcommands: true,
},
+ {
+ name: "commitments",
+ description: "List and manage inferred follow-up commitments",
+ hasSubcommands: true,
+ },
{
name: "tasks",
description: "Inspect durable background task state",
diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts
index b4edc118da9..92bda8e0816 100644
--- a/src/entry.respawn.test.ts
+++ b/src/entry.respawn.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import {
buildCliRespawnPlan,
EXPERIMENTAL_WARNING_FLAG,
@@ -7,26 +7,11 @@ import {
resolveCliRespawnCommand,
} from "./entry.respawn.js";
-const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => false));
-const isTruthyEnvValueMock = vi.hoisted(() =>
- vi.fn((value: string | undefined) => value === "1" || value === "true"),
-);
-
-vi.mock("./cli/respawn-policy.js", () => ({
- shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
-}));
-
-vi.mock("./infra/env.js", () => ({
- isTruthyEnvValue: isTruthyEnvValueMock,
-}));
-
describe("buildCliRespawnPlan", () => {
it("returns null when respawn policy skips the argv", () => {
- shouldSkipRespawnForArgvMock.mockReturnValueOnce(true);
-
expect(
buildCliRespawnPlan({
- argv: ["node", "openclaw", "status"],
+ argv: ["node", "openclaw", "--help"],
env: {},
execArgv: [],
autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt",
diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts
index d9e471c3d6b..dac32b46296 100644
--- a/src/plugins/bundled-plugin-metadata.test.ts
+++ b/src/plugins/bundled-plugin-metadata.test.ts
@@ -35,6 +35,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"diagnostics-otel",
"diagnostics-prometheus",
"diffs",
+ "file-transfer",
"google-meet",
"llm-task",
"lobster",
@@ -52,6 +53,7 @@ const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [
"bonjour",
"browser",
"device-pair",
+ "file-transfer",
"memory-core",
"phone-control",
"talk-voice",
diff --git a/test/release-check.test.ts b/test/release-check.test.ts
index bcd4618d386..0fe41c65e2f 100644
--- a/test/release-check.test.ts
+++ b/test/release-check.test.ts
@@ -72,6 +72,10 @@ describe("collectAppcastSparkleVersionErrors", () => {
});
describe("packed CLI smoke", () => {
+ it("keeps generated dynamic imports opaque to tsx's source lexer", () => {
+ expect(readFileSync("scripts/release-check.ts", "utf8")).not.toContain("import(");
+ });
+
it("keeps the expected packaged CLI smoke command list", () => {
expect(PACKED_CLI_SMOKE_COMMANDS).toEqual([
["--help"],
@@ -172,6 +176,7 @@ describe("workspace bootstrap smoke", () => {
TMPDIR: "/tmp/original-tmp",
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
AWS_EC2_METADATA_DISABLED: "true",
AWS_SHARED_CREDENTIALS_FILE: "/tmp/bootstrap-home/.aws/credentials",
diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts
index 53c5b66faec..46400820aa4 100644
--- a/test/scripts/parallels-npm-update-smoke.test.ts
+++ b/test/scripts/parallels-npm-update-smoke.test.ts
@@ -23,6 +23,13 @@ describe("parallels npm update smoke", () => {
expect(script).toContain("Windows update timed out");
});
+ it("keeps macOS sudo fallback update scripts readable by the desktop user", () => {
+ const script = readFileSync(SCRIPT_PATH, "utf8");
+
+ expect(script).toContain('macosExecArgs.indexOf("-u")');
+ expect(script).toContain('"/usr/sbin/chown", sudoUser, scriptPath');
+ });
+
it("scrubs future plugin entries before invoking old same-guest updaters", () => {
const script = readFileSync(UPDATE_SCRIPTS_PATH, "utf8");
diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts
index 716f100d15a..997fb77f162 100644
--- a/test/scripts/parallels-smoke-model.test.ts
+++ b/test/scripts/parallels-smoke-model.test.ts
@@ -374,7 +374,9 @@ console.log(resolveUbuntuVmName("Ubuntu missing"));
expect(script).toContain("__OPENCLAW_BACKGROUND_EXIT__");
expect(script).toContain("__OPENCLAW_LOG_OFFSET__");
expect(script).toContain("result.status !== 0 && result.status !== 124");
- expect(script).toContain('start "" /min powershell.exe');
+ expect(script).toContain("Start-Process -FilePath powershell.exe");
+ expect(script).toContain('launchLog.includes("started")');
+ expect(script).toContain("waitForBackgroundMaterialized(pathsScript, 45_000)");
});
it("returns timed-out host command status when check is disabled", () => {
diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts
index 611831bd154..db23f51e8b9 100644
--- a/test/scripts/plugin-prerelease-test-plan.test.ts
+++ b/test/scripts/plugin-prerelease-test-plan.test.ts
@@ -113,10 +113,10 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
sweepScript.indexOf("run_success_scenario()"),
sweepScript.indexOf("run_failure_scenario()"),
);
- expect(successScenario.indexOf("configure_kitchen_sink_runtime")).toBeLessThan(
- successScenario.indexOf('plugins install "$KITCHEN_SINK_SPEC"'),
- );
expect(successScenario.indexOf('plugins install "$KITCHEN_SINK_SPEC"')).toBeLessThan(
+ successScenario.indexOf("configure_kitchen_sink_runtime"),
+ );
+ expect(successScenario.indexOf("configure_kitchen_sink_runtime")).toBeLessThan(
successScenario.indexOf('plugins enable "$KITCHEN_SINK_ID"'),
);
expect(sweepScript).toContain("run_failure_scenario");
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index c647524519a..9e368971d1e 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -591,10 +591,11 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
}
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
- if (typeof window === "undefined") {
+ const href = typeof window === "undefined" ? undefined : window.location?.href;
+ if (!href) {
return;
}
- const url = new URL(window.location.href);
+ const url = new URL(href);
url.searchParams.set("session", sessionKey);
updateBrowserHistory(url, replace);
}