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); }