merge: refresh PR #93985 with main

* origin/main:
  fix(onboard): stop a full reset from resetting the default workspace when the config is unreadable (#114110)
  refactor(i18n): remove unused Apple contradiction report (#117182)
  fix(google): stop scraping Gemini CLI OAuth credentials (#117167)
  perf: count large histories before Gateway prewarm (#117118)
  fix(signal): restore provider-safe original attachment filenames (#115107)
  refactor(channels): unify setup ownership across bundled channels (#117188)
  fix: allow gateway service commands for named profiles (#116314)
  fix(plugins): invalidate bundled artifact locations after metadata refresh (#117041)
  fix(ollama): honor model requests and pull completion contracts (#117171)
  fix(agents): prevent fallback after stale lifecycle abort (#117168)
  fix(telegram): confirm polling before long poll (#116970)
  fix(tlon): wire autoDiscoverChannels through the settings-store round-trip (#114949)
  fix(gateway): yield before post-ready background work (#117083)
  test(google): cover live transcript overflow
  fix(google): bound live transcript accumulation
This commit is contained in:
Vincent Koc
2026-08-01 11:43:22 +08:00
111 changed files with 4160 additions and 14944 deletions
@@ -307,7 +307,6 @@ jobs:
auto-merge: "true"
generated-paths: |
apps/.i18n/native
apps/.i18n/apple-translation-contradictions.json
apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
apps/android/app/src/main/res/values*/assistant.xml
apps/android/app/src/main/res/values*/strings.xml
+160 -1
View File
@@ -38,7 +38,7 @@ on:
default: false
type: boolean
run_windows_ci:
description: "Run the focused Windows-native CI test shard after probing"
description: "Run the focused Windows CI shard and native Scheduled Task proof"
required: false
default: false
type: boolean
@@ -281,6 +281,165 @@ jobs:
export PATH="$NODE_BIN:$PATH"
pnpm test:windows:ci
- name: Preflight native Scheduled Task session
if: ${{ inputs.run_windows_ci }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$sessionId = (Get-Process -Id $PID).SessionId
Write-Host "identity=$($identity.Name)"
Write-Host "session_id=$sessionId"
Write-Host "user_interactive=$([Environment]::UserInteractive)"
Write-Host "administrator=$isAdmin"
query user 2>&1 | Write-Host
if (-not [Environment]::UserInteractive) {
throw "Native Scheduled Task proof requires an interactive Windows runner session."
}
- name: Run native Scheduled Task lifecycle proof
id: native_schtasks
if: ${{ inputs.run_windows_ci }}
timeout-minutes: 5
shell: bash
env:
CI_WINDOWS_SCHTASKS_PROOF_PATH: ${{ github.workspace }}\.artifacts\windows-schtasks\proof.json
CI_WINDOWS_SCHTASKS_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
CI_WINDOWS_SCHTASKS_TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
EXPECTED_HEAD: ${{ inputs.target_ref }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then
echo "Native Scheduled Task proof requires target_ref to be an exact 40-character commit SHA." >&2
exit 1
fi
CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)"
if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then
echo "Checked out $CI_WINDOWS_SCHTASKS_HEAD, expected frozen target $EXPECTED_HEAD." >&2
exit 1
fi
export CI_WINDOWS_SCHTASKS_HEAD
mkdir -p .artifacts/windows-schtasks
pnpm test:windows:schtasks:integration
- name: Clean native Scheduled Task residue
id: native_cleanup
if: ${{ always() && inputs.run_windows_ci }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$ErrorActionPreference = "Continue"
$cleanupErrors = @()
$profile = "schtasks-int-$env:TEST_ID"
$taskName = "OpenClaw Gateway ($profile)"
$stateDir = Join-Path $env:USERPROFILE ".openclaw-$profile"
New-Item -ItemType Directory -Force -Path $env:TEST_ROOT | Out-Null
schtasks.exe /End /TN $taskName 2>$null
Start-Sleep -Milliseconds 200
$activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt"
if (Test-Path -LiteralPath $activePidPath) {
try {
$probePid = 0
$activePid = (Get-Content -LiteralPath $activePidPath -Raw).Trim()
if (-not [int]::TryParse($activePid, [ref]$probePid) -or $probePid -le 1) {
throw "Invalid Scheduled Task active process id: $activePid"
}
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not inspect Scheduled Task probe process $probePid."
}
if ($process) {
$probePath = Join-Path $env:TEST_ROOT "probe.cjs"
$eventsPath = Join-Path $env:TEST_ROOT "runs.txt"
if (
$process.CommandLine -like "*$probePath*" -and
$process.CommandLine -like "*$eventsPath*"
) {
taskkill.exe /F /T /PID $probePid 2>$null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 200
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not verify Scheduled Task probe process $probePid exited."
}
} while ($process -and [DateTime]::UtcNow -lt $deadline)
if ($process) {
throw "Scheduled Task probe process $probePid survived cleanup."
}
} else {
throw "Refusing to kill reused or unverifiable process id $probePid."
}
}
} catch {
$cleanupErrors += $_.Exception.Message
}
}
schtasks.exe /Delete /F /TN $taskName 2>$null
$deleteExit = $LASTEXITCODE
try {
$service = New-Object -ComObject "Schedule.Service"
$service.Connect()
$null = $service.GetFolder("\").GetTask($taskName)
$taskExists = $true
} catch {
$exception = $_.Exception
while ($null -ne $exception.InnerException) {
$exception = $exception.InnerException
}
if ($exception.HResult -eq -2147024894 -or $exception.HResult -eq -2147024893) {
$taskExists = $false
} else {
$cleanupErrors += "Could not verify Scheduled Task cleanup for $taskName (HRESULT $($exception.HResult))."
$taskExists = $null
}
}
if ($taskExists -eq $true) {
$cleanupErrors += "Scheduled Task cleanup left $taskName registered (delete exit $deleteExit)."
}
@(
"task_name=$taskName"
"delete_exit=$deleteExit"
"task_exists=$taskExists"
"proof_outcome=${{ steps.native_schtasks.outcome }}"
"cleanup_errors=$($cleanupErrors -join ' ')"
) | Set-Content -LiteralPath (Join-Path $env:TEST_ROOT "cleanup-summary.txt")
if ($cleanupErrors.Count -gt 0) {
throw ($cleanupErrors -join " ")
}
exit 0
- name: Upload native Scheduled Task proof
id: native_proof_upload
if: ${{ always() && inputs.run_windows_ci }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-schtasks-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: |
.artifacts/windows-schtasks/proof.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\failure-diagnostics.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\cleanup-summary.txt
if-no-files-found: warn
retention-days: 7
- name: Remove retained native Scheduled Task evidence
if: ${{ always() && inputs.run_windows_ci && steps.native_cleanup.outcome == 'success' && steps.native_proof_upload.outcome == 'success' }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$profile = "schtasks-int-$env:TEST_ID"
Remove-Item -LiteralPath (Join-Path $env:USERPROFILE ".openclaw-$profile") -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $env:TEST_ROOT -Recurse -Force -ErrorAction SilentlyContinue
- name: Keep runner alive for SSH inspection
if: ${{ always() && !cancelled() }}
env:
File diff suppressed because it is too large Load Diff
-1
View File
@@ -118,7 +118,6 @@ extensions/file-transfer/src/shared/node-invoke-policy.ts
extensions/firecrawl/src/firecrawl-tools.test.ts
extensions/github-copilot/index.test.ts
extensions/google-meet/index.test.ts
extensions/google/oauth.test.ts
extensions/google/realtime-voice-provider.test.ts
extensions/google/realtime-voice-provider.ts
extensions/google/transport-stream.test.ts
+10
View File
@@ -129,6 +129,16 @@ openclaw gateway restart --wait 30s
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
</Warning>
### Install identity
Service management (`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling) belongs to the install that owns the host service. That is the canonical `.openclaw` directory under the OS account home, or the `.openclaw-<profile>` directory a named profile projects there. Named profiles use distinct native service identities.
`OPENCLAW_HOME`, or an `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` that points elsewhere, is treated as isolated state and skipped. A relocated or copied state tree cannot adopt and rewrite the account's host service.
On macOS and Windows, native service-managed profile names must be lowercase. Runtime-only profiles may still use uppercase, but case-distinct names such as `Main` and `main` share paths on normal case-insensitive filesystems and cannot safely own separate native services. On macOS, the lowercase names `gateway` and `node` are also unavailable for native service management because their historical LaunchAgent labels collide with the default Gateway and node-host services.
Named profiles must also use the native service identity derived from `OPENCLAW_PROFILE`. Unset `OPENCLAW_LAUNCHD_LABEL`, `OPENCLAW_SYSTEMD_UNIT`, or `OPENCLAW_WINDOWS_TASK_NAME` before service management; custom identities remain available for the default profile or runtime-only/external-supervisor setups.
### External supervisors
Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns the Gateway lifecycle. In this mode:
+1
View File
@@ -1641,6 +1641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Run the Gateway
- H3: Options
- H2: Restart the Gateway
- H3: Install identity
- H3: External supervisors
- H3: Gateway profiling
- H2: Query a running Gateway
+2
View File
@@ -250,6 +250,8 @@ unavailable instead of triggering a network request.
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`.
`OPENCLAW_HOME` does not grant ownership of the OS account's native Gateway service. Gateway service-management commands treat a relocated home as isolated state; use the OS account home and a named profile when a separate native service identity is required.
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
**Example** (macOS LaunchDaemon):
+1 -1
View File
@@ -637,7 +637,7 @@ describe("google gemini cli backend auth bridge", () => {
}
});
it("keeps expired but refreshable legacy OAuth profiles on the compatibility path", async () => {
it("stages expired legacy OAuth credentials for Gemini CLI-owned refresh", async () => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const context = buildGeminiOAuthPrepareContext(workspaceDir);
if (!context.authCredential) {
+2 -28
View File
@@ -1,25 +1,14 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Google provider module implements model/runtime integration.
import type {
OpenClawPluginApi,
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { GOOGLE_GEMINI_CLI_PROVIDER_ID } from "./gemini-cli-auth-home.js";
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
import { formatGoogleOauthApiKey } from "./oauth-token-shared.js";
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
const PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID;
const PROVIDER_LABEL = "Gemini CLI runtime";
const loadOauthRuntimeModule = createLazyRuntimeModule(() => import("./oauth.runtime.js"));
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
@@ -36,21 +25,6 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
refreshOAuth: async (cred) => {
const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule();
return await refreshGeminiCliOAuthToken(cred);
},
resolveUsageAuth: async (ctx) => {
const auth = await ctx.resolveOAuthToken();
if (!auth) {
return null;
}
return {
...auth,
token: parseGoogleUsageToken(auth.token),
};
},
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
};
}
@@ -1,25 +1,3 @@
type OAuthSettingsFs = {
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: "utf8") => string;
homedir: () => string;
};
type CredentialFs = {
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: "utf8") => string;
realpathSync: (path: string) => string;
readdirSync: (path: string, options: { withFileTypes: true }) => import("node:fs").Dirent[];
};
type OAuthCredentialsTestApi = {
clearCredentialsCache: () => void;
setFs: (overrides?: Partial<CredentialFs>) => void;
};
type OAuthSettingsTestApi = {
setFs: (overrides?: Partial<OAuthSettingsFs>) => void;
};
type VertexAdcTestApi = {
reset: () => void;
};
@@ -32,22 +10,6 @@ function requireTestApi(key: string): unknown {
return api;
}
export function clearGoogleOAuthCredentialsCache(): void {
(
requireTestApi("openclaw.google.oauthCredentialsTestApi") as OAuthCredentialsTestApi
).clearCredentialsCache();
}
export function setGoogleOAuthCredentialsFs(overrides?: Partial<CredentialFs>): void {
(requireTestApi("openclaw.google.oauthCredentialsTestApi") as OAuthCredentialsTestApi).setFs(
overrides,
);
}
export function setGoogleOAuthSettingsFs(overrides?: Partial<OAuthSettingsFs>): void {
(requireTestApi("openclaw.google.oauthSettingsTestApi") as OAuthSettingsTestApi).setFs(overrides);
}
export function resetGoogleVertexAdcState(): void {
(requireTestApi("openclaw.google.vertexAdcTestApi") as VertexAdcTestApi).reset();
}
+5 -45
View File
@@ -14,7 +14,7 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts";
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import googlePlugin from "./index.js";
import googleProviderDiscovery from "./provider-discovery.js";
@@ -27,12 +27,6 @@ const googleProviderPlugin = {
},
};
const refreshGeminiCliOAuthTokenMock = vi.hoisted(() => vi.fn());
vi.mock("./oauth.runtime.js", () => ({
refreshGeminiCliOAuthToken: refreshGeminiCliOAuthTokenMock,
}));
describe("google provider plugin hooks", () => {
it("owns replay policy and reasoning mode for the direct Gemini provider", async () => {
const { providers } = await registerProviderPlugin({
@@ -129,7 +123,7 @@ describe("google provider plugin hooks", () => {
).toBe("tagged");
});
it("keeps the Gemini CLI runtime without offering new OAuth setup", async () => {
it("keeps the Gemini CLI runtime without OpenClaw-owned OAuth surfaces", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
@@ -141,7 +135,9 @@ describe("google provider plugin hooks", () => {
expect(cliProvider.auth).toEqual([]);
expect(cliProvider.envVars).toEqual([]);
expect(cliProvider.wizard).toBeUndefined();
expect(cliProvider.refreshOAuth).toBeTypeOf("function");
expect(cliProvider.refreshOAuth).toBeUndefined();
expect(cliProvider.resolveUsageAuth).toBeUndefined();
expect(cliProvider.fetchUsageSnapshot).toBeUndefined();
});
it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => {
@@ -425,40 +421,4 @@ describe("google provider plugin hooks", () => {
expect(bridge.setMediaTimestamp(20)).toBeUndefined();
expect(bridge.sendUserMessage?.("hello")).toBeUndefined();
});
it("refreshes Gemini CLI OAuth through the provider-owned refresh hook", async () => {
refreshGeminiCliOAuthTokenMock.mockResolvedValueOnce({
type: "oauth",
provider: "google-gemini-cli",
access: "fresh-access",
refresh: "fresh-refresh",
expires: Date.now() + 60_000,
email: "user@example.com",
projectId: "project-1",
});
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-gemini-cli");
const credential = {
type: "oauth" as const,
provider: "google-gemini-cli",
access: "stale-access",
refresh: "stale-refresh",
expires: Date.now() - 60_000,
email: "user@example.com",
projectId: "project-1",
};
await expect(provider.refreshOAuth?.(credential)).resolves.toMatchObject({
access: "fresh-access",
refresh: "fresh-refresh",
email: "user@example.com",
projectId: "project-1",
});
expect(refreshGeminiCliOAuthTokenMock).toHaveBeenCalledWith(credential);
});
});
+1 -13
View File
@@ -1,10 +1,6 @@
// Google tests cover oauth token shared plugin behavior.
import { describe, expect, it } from "vitest";
import {
formatGoogleOauthApiKey,
parseGoogleOauthApiKey,
parseGoogleUsageToken,
} from "./oauth-token-shared.js";
import { formatGoogleOauthApiKey, parseGoogleOauthApiKey } from "./oauth-token-shared.js";
describe("google oauth token helpers", () => {
it("formats oauth credentials with project-aware payloads", () => {
@@ -21,10 +17,6 @@ describe("google oauth token helpers", () => {
expect(formatGoogleOauthApiKey({ type: "token", access: "token-123" })).toBe("");
});
it("parses project-aware oauth payloads for usage auth", () => {
expect(parseGoogleUsageToken(JSON.stringify({ token: "usage-token" }))).toBe("usage-token");
});
it("parses structured oauth payload fields", () => {
expect(
parseGoogleOauthApiKey(JSON.stringify({ token: "usage-token", projectId: "proj-1" })),
@@ -33,8 +25,4 @@ describe("google oauth token helpers", () => {
projectId: "proj-1",
});
});
it("falls back to the raw token when the payload is not JSON", () => {
expect(parseGoogleUsageToken("raw-token")).toBe("raw-token");
});
});
-10
View File
@@ -31,13 +31,3 @@ export function formatGoogleOauthApiKey(cred: GoogleOauthApiKeyCredential): stri
projectId: cred.projectId,
});
}
export function parseGoogleUsageToken(apiKey: string): string {
const parsed = parseGoogleOauthApiKey(apiKey);
if (parsed?.token) {
return parsed.token;
}
// Keep the raw token when the stored credential is not a project-aware JSON payload.
return apiKey;
}
-378
View File
@@ -1,378 +0,0 @@
// Google plugin module implements oauth.credentials behavior.
import { existsSync, readdirSync, realpathSync } from "node:fs";
import type { Dirent } from "node:fs";
import { delimiter, dirname, join } from "node:path";
import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js";
type CredentialFs = {
existsSync: (path: Parameters<typeof existsSync>[0]) => ReturnType<typeof existsSync>;
readFileSync: (path: string, encoding: "utf8") => string;
realpathSync: (path: Parameters<typeof realpathSync>[0]) => string;
readdirSync: (
path: Parameters<typeof readdirSync>[0],
options: { withFileTypes: true },
) => Dirent[];
};
const defaultFs: CredentialFs = {
existsSync,
readFileSync: (path) =>
readSecretFileSync(path, "Gemini CLI OAuth credentials", {
maxBytes: 1024 * 1024,
rejectHardlinks: false,
}),
realpathSync,
readdirSync,
};
const OAUTH_CREDENTIALS_TEST_API_KEY = Symbol.for("openclaw.google.oauthCredentialsTestApi");
let credentialFs: CredentialFs = defaultFs;
const GEMINI_CLI_TREE_SEARCH_DEPTH = 10;
type GeminiCliCredentialExtractDiagnostics = {
searchedPaths: string[];
recursiveSearchRoots: string[];
parseFailures: string[];
readErrors: string[];
};
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
let geminiCliCredentialExtractError: string | null = null;
function clearCredentialsCache(): void {
cachedGeminiCliCredentials = null;
geminiCliCredentialExtractError = null;
}
function setOAuthCredentialsFsForTest(overrides?: Partial<CredentialFs>): void {
credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs;
}
function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
geminiCliCredentialExtractError = null;
const diagnostics: GeminiCliCredentialExtractDiagnostics = {
searchedPaths: [],
recursiveSearchRoots: [],
parseFailures: [],
readErrors: [],
};
try {
const geminiPath = findInPath("gemini");
if (!geminiPath) {
geminiCliCredentialExtractError =
"Gemini CLI binary was not found in PATH during OAuth credential extraction.";
return null;
}
const resolvedPath = credentialFs.realpathSync(geminiPath);
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
for (const geminiCliDir of geminiCliDirs) {
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir, diagnostics);
if (directCredentials) {
cachedGeminiCliCredentials = directCredentials;
return directCredentials;
}
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir, diagnostics);
if (bundledCredentials) {
cachedGeminiCliCredentials = bundledCredentials;
return bundledCredentials;
}
diagnostics.recursiveSearchRoots.push(geminiCliDir);
const discoveredCredentials = findGeminiCliCredentialsInTree(
geminiCliDir,
GEMINI_CLI_TREE_SEARCH_DEPTH,
diagnostics,
);
if (discoveredCredentials) {
cachedGeminiCliCredentials = discoveredCredentials;
return discoveredCredentials;
}
}
geminiCliCredentialExtractError = formatGeminiCliCredentialExtractError({
geminiPath,
resolvedPath,
diagnostics,
});
} catch (error) {
geminiCliCredentialExtractError = `Unexpected error while extracting Gemini CLI OAuth credentials: ${formatError(error)}`;
}
return null;
}
function formatGeminiCliCredentialExtractError({
geminiPath,
resolvedPath,
diagnostics,
}: {
geminiPath: string;
resolvedPath: string;
diagnostics: GeminiCliCredentialExtractDiagnostics;
}): string {
const prefix = [
"Found Gemini CLI in PATH, but could not extract OAuth credentials.",
`geminiPath=${geminiPath}`,
`resolvedPath=${resolvedPath}`,
];
if (diagnostics.parseFailures.length > 0) {
return [
...prefix,
"Candidate credential files did not contain a parseable OAuth client id/secret.",
`candidates=${diagnostics.parseFailures.join(", ")}`,
].join(" ");
}
if (diagnostics.readErrors.length > 0) {
return [
...prefix,
"Unexpected errors occurred while reading candidate credential files/directories.",
`errors=${diagnostics.readErrors.join(", ")}`,
].join(" ");
}
return [
...prefix,
"Could not locate oauth2.js or bundled credential source.",
`searched=${diagnostics.searchedPaths.join(", ") || "(none)"}`,
`recursiveSearchRoots=${diagnostics.recursiveSearchRoots.join(", ") || "(none)"}`,
`recursiveSearchDepth=${GEMINI_CLI_TREE_SEARCH_DEPTH}`,
].join(" ");
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] {
const binDir = dirname(geminiPath);
const candidates = [
dirname(dirname(resolvedPath)),
join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"),
join(binDir, "node_modules", "@google", "gemini-cli"),
join(dirname(binDir), "node_modules", "@google", "gemini-cli"),
join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"),
];
const deduped: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
for (const searchDir of resolveGeminiCliSearchDirs(candidate)) {
const key =
process.platform === "win32"
? lowercasePreservingWhitespace(searchDir.replace(/\\/g, "/"))
: searchDir;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(searchDir);
}
}
return deduped;
}
function resolveGeminiCliSearchDirs(candidate: string): string[] {
const searchDirs = [
candidate,
join(candidate, "node_modules", "@google", "gemini-cli"),
join(candidate, "lib", "node_modules", "@google", "gemini-cli"),
];
return searchDirs.filter(looksLikeGeminiCliDir);
}
function looksLikeGeminiCliDir(candidate: string): boolean {
return (
credentialFs.existsSync(join(candidate, "package.json")) ||
credentialFs.existsSync(join(candidate, "node_modules", "@google", "gemini-cli-core"))
);
}
function findInPath(name: string): string | null {
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
for (const ext of exts) {
const path = join(dir, name + ext);
if (credentialFs.existsSync(path)) {
return path;
}
}
}
return null;
}
function readGeminiCliCredentialsFile(
path: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
try {
const credentials = parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
if (!credentials) {
diagnostics.parseFailures.push(path);
}
return credentials;
} catch (error) {
diagnostics.readErrors.push(`${path}: ${formatError(error)}`);
return null;
}
}
function parseGeminiCliCredentials(
content: string,
): { clientId: string; clientSecret: string } | null {
const clientId =
content.match(/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/)?.[1] ??
content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/)?.[1];
const clientSecret =
content.match(/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/)?.[1] ??
content.match(/(GOCSPX-[A-Za-z0-9_-]+)/)?.[1];
if (!clientId || !clientSecret) {
return null;
}
return { clientId, clientSecret };
}
function readGeminiCliCredentialsFromKnownPaths(
geminiCliDir: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
const searchPaths = [
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
),
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"code_assist",
"oauth2.js",
),
];
diagnostics.searchedPaths.push(...searchPaths);
for (const path of searchPaths) {
if (!credentialFs.existsSync(path)) {
continue;
}
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
if (credentials) {
return credentials;
}
}
return null;
}
function readGeminiCliCredentialsFromBundle(
geminiCliDir: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
const bundleDir = join(geminiCliDir, "bundle");
if (!credentialFs.existsSync(bundleDir)) {
return null;
}
try {
for (const entry of credentialFs.readdirSync(bundleDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".js")) {
continue;
}
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name), diagnostics);
if (credentials) {
return credentials;
}
}
} catch (error) {
diagnostics.readErrors.push(`${bundleDir}: ${formatError(error)}`);
// Preserve the read error for diagnostics and fall back to the recursive search.
}
return null;
}
function findGeminiCliCredentialsInTree(
dir: string,
depth: number,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isFile() && entry.name === "oauth2.js") {
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
if (credentials) {
return credentials;
}
continue;
}
if (entry.isDirectory() && !entry.name.startsWith(".")) {
const found = findGeminiCliCredentialsInTree(path, depth - 1, diagnostics);
if (found) {
return found;
}
}
}
} catch (error) {
diagnostics.readErrors.push(`${dir}: ${formatError(error)}`);
}
return null;
}
export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const detail = geminiCliCredentialExtractError
? ` Details: ${geminiCliCredentialExtractError}`
: "";
throw new Error(
`Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.${detail}`,
);
}
if (process.env.VITEST) {
(globalThis as Record<PropertyKey, unknown>)[OAUTH_CREDENTIALS_TEST_API_KEY] = {
clearCredentialsCache,
setFs: setOAuthCredentialsFsForTest,
};
}
-64
View File
@@ -1,64 +0,0 @@
// Google plugin module implements oauth.flow behavior.
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
import {
generateOAuthState,
parseOAuthCallbackInput,
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js";
export { generateOAuthState };
export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2Sync();
}
export function generatePkce(): { verifier: string; challenge: string } {
return generateHexPkceVerifierChallenge();
}
export function buildAuthUrl(challenge: string, state: string): string {
const { clientId } = resolveOAuthClientConfig();
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
export function parseCallbackInput(
input: string,
): { code: string; state: string } | { error: string } {
return parseOAuthCallbackInput(input, {
missingState: "Missing 'state' parameter. Paste the full URL.",
invalidInput: "Paste the full redirect URL, not just the code.",
});
}
export async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
signal?: AbortSignal;
}): Promise<{ code: string; state: string }> {
return await waitForLocalOAuthCallback({
expectedState: params.expectedState,
timeoutMs: params.timeoutMs,
port: 8085,
callbackPath: "/oauth2callback",
redirectUri: REDIRECT_URI,
successTitle: "Gemini CLI OAuth complete",
progressMessage: `Waiting for OAuth callback on ${REDIRECT_URI}`,
onProgress: params.onProgress,
...(params.signal ? { signal: params.signal } : {}),
});
}
-121
View File
@@ -1,121 +0,0 @@
// Google tests cover oauth.http proxy-mode selection for the Gemini CLI OAuth
// token-exchange/identity calls (issue openclaw#46184).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TOKEN_URL } from "./oauth.shared.js";
const fetchWithSsrFGuardMock = vi.fn();
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: (params: unknown) => fetchWithSsrFGuardMock(params),
};
});
const { fetchWithTimeout } = await import("./oauth.http.js");
const PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
] as const;
const savedEnv = new Map<string, string | undefined>();
type ProxyEnvOverrides = {
HTTP_PROXY?: string;
HTTPS_PROXY?: string;
ALL_PROXY?: string;
NO_PROXY?: string;
};
function setProxyEnv(values: ProxyEnvOverrides): void {
for (const key of PROXY_ENV_KEYS) {
delete process.env[key];
}
if (values.HTTP_PROXY !== undefined) {
process.env.HTTP_PROXY = values.HTTP_PROXY;
}
if (values.HTTPS_PROXY !== undefined) {
process.env.HTTPS_PROXY = values.HTTPS_PROXY;
}
if (values.ALL_PROXY !== undefined) {
process.env.ALL_PROXY = values.ALL_PROXY;
}
if (values.NO_PROXY !== undefined) {
process.env.NO_PROXY = values.NO_PROXY;
}
}
function lastGuardedOptions(): Record<string, unknown> {
const call = fetchWithSsrFGuardMock.mock.calls.at(-1)?.[0];
if (!call || typeof call !== "object") {
throw new Error("Expected fetchWithSsrFGuard to be called");
}
return call as Record<string, unknown>;
}
describe("oauth.http fetchWithTimeout proxy selection", () => {
beforeEach(() => {
for (const key of PROXY_ENV_KEYS) {
savedEnv.set(key, process.env[key]);
}
fetchWithSsrFGuardMock.mockReset();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("{}", { status: 200 }),
finalUrl: TOKEN_URL,
release: async () => {},
});
});
afterEach(() => {
for (const [key, value] of savedEnv) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
savedEnv.clear();
});
it("routes the Google token exchange through the env proxy when configured", async () => {
setProxyEnv({ HTTPS_PROXY: "http://127.0.0.1:7897", HTTP_PROXY: "http://127.0.0.1:7897" });
await fetchWithTimeout(TOKEN_URL, { method: "POST", body: "grant_type=refresh_token" });
expect(lastGuardedOptions().mode).toBe("trusted_env_proxy");
});
it("keeps the strict default when no proxy is configured", async () => {
setProxyEnv({});
await fetchWithTimeout(TOKEN_URL, { method: "POST" });
expect(lastGuardedOptions().mode).toBeUndefined();
});
it("keeps the strict default when NO_PROXY bypasses the target host", async () => {
setProxyEnv({ HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: "googleapis.com" });
await fetchWithTimeout(TOKEN_URL, { method: "POST" });
expect(lastGuardedOptions().mode).toBeUndefined();
});
it("keeps the strict default for ALL_PROXY-only environments", async () => {
setProxyEnv({ ALL_PROXY: "http://127.0.0.1:7897" });
await fetchWithTimeout(TOKEN_URL, { method: "POST" });
expect(lastGuardedOptions().mode).toBeUndefined();
});
});
-187
View File
@@ -1,187 +0,0 @@
// Google tests cover oauth.http body-byte-cap for the Gemini CLI OAuth
// token-exchange/identity calls.
import http from "node:http";
import type { AddressInfo } from "node:net";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TOKEN_URL } from "./oauth.shared.js";
const fetchWithSsrFGuardMock = vi.fn();
const releaseMock = vi.fn(async () => undefined);
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: (params: unknown) => fetchWithSsrFGuardMock(params),
};
});
const { fetchWithTimeout } = await import("./oauth.http.js");
describe("oauth.http fetchWithTimeout body byte cap", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
releaseMock.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caps oversized response body at 16 MiB with labeled overflow error", async () => {
// Build a Response with a body that exceeds the 16 MiB cap.
// 1 MiB chunks × 18 chunks = 18 MiB queued; the bounded reader reads
// up to the 16 MiB cap (16 chunks = 16777216 bytes) and one extra
// chunk before throwing on overflow, so the labeled `size` is the
// cap plus the trailing chunk: 16777216 + 1048576 = 17825792 bytes.
const CHUNK = 1024 * 1024;
let sent = 0;
const body = new ReadableStream({
pull(controller) {
if (sent < 18) {
controller.enqueue(new Uint8Array(CHUNK));
sent++;
} else {
controller.close();
}
},
});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(body, {
status: 200,
headers: { "content-type": "application/json" },
}),
finalUrl: TOKEN_URL,
release: releaseMock,
});
await expect(fetchWithTimeout(TOKEN_URL, { method: "POST" })).rejects.toThrow(
/google HTTP fetch: body exceeds 16777216 bytes \(got 17825792\)/,
);
expect(releaseMock).toHaveBeenCalledOnce();
});
it("returns a Response for normal-size bodies", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"access_token":"abc","expires_in":3600}', {
status: 200,
headers: { "content-type": "application/json" },
}),
finalUrl: TOKEN_URL,
release: releaseMock,
});
const res = await fetchWithTimeout(TOKEN_URL, { method: "POST" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ access_token: "abc", expires_in: 3600 });
expect(releaseMock).toHaveBeenCalledOnce();
});
it("passes caller cancellation to the guarded fetch timeout composer", async () => {
const controller = new AbortController();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("{}"),
finalUrl: TOKEN_URL,
release: releaseMock,
});
await fetchWithTimeout(TOKEN_URL, { method: "POST", signal: controller.signal });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ signal: controller.signal }),
);
});
});
// Real-wire loopback proof. These tests bypass `fetchWithSsrFGuard` (which
// blocks 127.0.0.1 by design) and exercise `readResponseWithLimit` directly
// against a real `http.createServer` listener — the same helper that
// `fetchWithTimeout` calls inside its try/finally block. Captured vitest
// output for these two tests is the ClawSweeper "real behavior proof" required
// before merge.
describe("oauth.http bounded-read real wire proof (loopback http.createServer)", () => {
it("caps an oversized body streamed chunked over real wire", async () => {
const CHUNK = 1024 * 1024;
const MAX = 16 * 1024 * 1024;
const TOTAL = 18 * 1024 * 1024;
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "application/octet-stream" });
let sent = 0;
const tick = setInterval(() => {
if (sent < 18) {
res.write(Buffer.alloc(CHUNK));
sent++;
} else {
clearInterval(tick);
res.end();
}
}, 1);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const port = (server.address() as AddressInfo).port;
let captured: Error | undefined;
try {
const response = await fetch(`http://127.0.0.1:${port}/`);
// Wire framing merges TCP packets, so the exact reported size varies by
// runtime. The stable invariant is that the cap fires after MAX.
try {
await readResponseWithLimit(response, MAX, {
onOverflow: ({ size, maxBytes }) =>
new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`),
});
} catch (err) {
captured = err as Error;
}
expect(captured).toBeInstanceOf(Error);
const match = captured!.message.match(/real wire: body exceeds \d+ bytes \(got (\d+)\)/);
expect(match).not.toBeNull();
const got = Number(match![1]);
expect(got).toBeGreaterThan(MAX);
// Print to vitest stdout for PR-body real behavior proof capture.
console.log(
`[oauth.http loopback proof] oversized path: cap=${MAX} reported=${got} server_total=${TOTAL}`,
);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("returns a Buffer for normal-size responses on real wire", async () => {
const bodyText = '{"access_token":"loopback","expires_in":3600}';
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(bodyText);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const port = (server.address() as AddressInfo).port;
try {
const response = await fetch(`http://127.0.0.1:${port}/`);
const body = await readResponseWithLimit(response, 16 * 1024 * 1024, {
onOverflow: ({ size, maxBytes }) =>
new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`),
});
expect(body.byteLength).toBe(Buffer.byteLength(bodyText, "utf8"));
expect(new TextDecoder("utf-8").decode(body)).toBe(bodyText);
console.log(
`[oauth.http loopback proof] normal path: cap=16777216 returned=${body.byteLength} body=${JSON.stringify(new TextDecoder("utf-8").decode(body))}`,
);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});
-52
View File
@@ -1,52 +0,0 @@
// Google plugin module implements oauth.http behavior.
import {
shouldUseEnvHttpProxyForUrl,
withTrustedEnvProxyGuardedFetchMode,
} from "openclaw/plugin-sdk/fetch-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js";
const GOOGLE_OAUTH_BODY_MAX_BYTES = 16 * 1024 * 1024;
export async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
): Promise<Response> {
// The guard composes its timeout with this top-level signal. Passing only
// init.signal would be overwritten when timeoutMs creates the effective signal.
const guardedOptions = { url, init, timeoutMs, signal: init.signal ?? undefined };
const { response, release } = await fetchWithSsrFGuard(
shouldUseEnvHttpProxyForUrl(url)
? withTrustedEnvProxyGuardedFetchMode(guardedOptions)
: guardedOptions,
);
try {
// 16 MiB cap. A hostile or broken Google OAuth endpoint (or any
// accounts.google.com mirror / enterprise proxy) cannot force the
// runtime to buffer an unbounded body before the caller sees it.
// Complements #97587, which caps at the call site — this is the
// shared entry-point cap.
const body = await readResponseWithLimit(response, GOOGLE_OAUTH_BODY_MAX_BYTES, {
onOverflow: ({ size, maxBytes }) =>
new Error(`google HTTP fetch: body exceeds ${maxBytes} bytes (got ${size})`),
});
// `readResponseWithLimit` returns a `Buffer` (Node Uint8Array view). The
// global `Response` constructor accepts `BufferSource` (Uint8Array /
// ArrayBuffer) as a body; cast through `BodyInit` because `Buffer.buffer`
// is typed as `ArrayBufferLike` (could be `ArrayBuffer` or
// `SharedArrayBuffer`), but the helper always returns a regular `Buffer`
// backed by an `ArrayBuffer` with no shared-memory paths. The same
// wrap-shape is used by the googlechat google-auth helper at
// extensions/googlechat/src/google-auth.runtime.ts:454.
const bodyBytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Response(bodyBytes as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} finally {
await release();
}
}
@@ -1,72 +0,0 @@
// Google tests cover oauth.local login plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth?state=state-123";
const exchangeCodeForTokensMock = vi.hoisted(() =>
vi.fn(async () => ({
access: "access-token",
refresh: "refresh-token",
expires: 123,
})),
);
const waitForLocalCallbackMock = vi.hoisted(() =>
vi.fn(async () => ({ code: "oauth-code", state: "state-123" })),
);
vi.mock("./oauth.flow.js", () => ({
buildAuthUrl: () => AUTH_URL,
generateOAuthState: () => "state-123",
generatePkce: () => ({ challenge: "pkce-challenge", verifier: "pkce-verifier" }),
parseCallbackInput: vi.fn(),
shouldUseManualOAuthFlow: (isRemote: boolean) => isRemote,
waitForLocalCallback: waitForLocalCallbackMock,
}));
vi.mock("./oauth.token.js", () => ({
exchangeCodeForTokens: exchangeCodeForTokensMock,
}));
describe("loginGeminiCliOAuth local browser flow", () => {
beforeEach(() => {
exchangeCodeForTokensMock.mockClear();
waitForLocalCallbackMock.mockClear();
});
it("prints the auth URL before attempting best-effort browser launch", async () => {
const events: string[] = [];
const { loginGeminiCliOAuth } = await import("./oauth.js");
const signal = new AbortController().signal;
const openUrl = vi.fn(async () => {
events.push("open");
});
const log = vi.fn((message: string) => {
events.push(`log:${message}`);
});
const result = await loginGeminiCliOAuth({
isRemote: false,
openUrl,
log,
note: async () => {},
prompt: async () => "",
progress: { update: () => {}, stop: () => {} },
signal,
});
expect(result).toEqual({
access: "access-token",
refresh: "refresh-token",
expires: 123,
});
expect(log).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL));
expect(openUrl).toHaveBeenCalledWith(AUTH_URL);
expect(events.findIndex((event) => event.startsWith("log:"))).toBeLessThan(
events.indexOf("open"),
);
expect(waitForLocalCallbackMock).toHaveBeenCalledWith(
expect.objectContaining({ expectedState: "state-123" }),
);
expect(exchangeCodeForTokensMock).toHaveBeenCalledWith("oauth-code", "pkce-verifier", signal);
});
});
-254
View File
@@ -1,254 +0,0 @@
// Google plugin module implements oauth.project behavior.
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithTimeout } from "./oauth.http.js";
import {
CODE_ASSIST_ENDPOINT_PROD,
LOAD_CODE_ASSIST_ENDPOINTS,
TIER_FREE,
TIER_LEGACY,
TIER_STANDARD,
USERINFO_URL,
} from "./oauth.shared.js";
const LOAD_CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
} as const;
async function getUserEmail(
accessToken: string,
signal?: AbortSignal,
): Promise<string | undefined> {
try {
const response = await fetchWithTimeout(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
...(signal ? { signal } : {}),
});
if (response.ok) {
const data = await readProviderJsonResponse<{ email?: string }>(response, "google.userinfo");
return data.email;
}
} catch {
signal?.throwIfAborted();
// ignore
}
return undefined;
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== "object") {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === "object" &&
item &&
(item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
);
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
async function pollOperation(
endpoint: string,
operationName: string,
headers: Record<string, string>,
signal?: AbortSignal,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await sleepWithAbort(5000, signal);
const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, {
headers,
...(signal ? { signal } : {}),
});
if (!response.ok) {
continue;
}
const data = await readProviderJsonResponse<{
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
}>(response, "google.poll-operation");
if (data.done) {
return data;
}
}
throw new Error("Operation polling timeout");
}
export async function resolveGoogleOAuthIdentity(
accessToken: string,
signal?: AbortSignal,
): Promise<{
email?: string;
projectId?: string;
}> {
const email = await getUserEmail(accessToken, signal);
const projectId = await discoverProject(accessToken, signal);
return { email, projectId };
}
export async function resolveGooglePersonalOAuthIdentity(
accessToken: string,
signal?: AbortSignal,
): Promise<{
email?: string;
projectId?: string;
}> {
return { email: await getUserEmail(accessToken, signal) };
}
async function discoverProject(accessToken: string, signal?: AbortSignal): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": `gl-node/${process.versions.node}`,
"Client-Metadata": JSON.stringify(LOAD_CODE_ASSIST_METADATA),
};
const loadBody = {
...(envProject ? { cloudaicompanionProject: envProject } : {}),
metadata: {
...LOAD_CODE_ASSIST_METADATA,
...(envProject ? { duetProject: envProject } : {}),
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD;
let loadError: Error | undefined;
for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
try {
const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify(loadBody),
...(signal ? { signal } : {}),
});
if (!response.ok) {
const errorPayload = await readProviderJsonResponse(
response,
"google.load-code-assist",
).catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
activeEndpoint = endpoint;
loadError = undefined;
break;
}
loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
continue;
}
data = await readProviderJsonResponse<typeof data>(response, "google.load-code-assist");
activeEndpoint = endpoint;
loadError = undefined;
break;
} catch (err) {
signal?.throwIfAborted();
loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err });
}
}
const hasLoadCodeAssistData =
Boolean(data.currentTier) ||
Boolean(data.cloudaicompanionProject) ||
Boolean(data.allowedTiers?.length);
if (!hasLoadCodeAssistData && loadError) {
if (envProject) {
return envProject;
}
throw loadError;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) {
return project;
}
if (typeof project === "object" && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const tier = getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
...LOAD_CODE_ASSIST_METADATA,
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, {
method: "POST",
headers,
body: JSON.stringify(onboardBody),
...(signal ? { signal } : {}),
});
if (!onboardResponse.ok) {
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
}
let lro = await readProviderJsonResponse<{
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
}>(onboardResponse, "google.onboard-user");
if (!lro.done && lro.name) {
lro = await pollOperation(activeEndpoint, lro.name, headers, signal);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new Error(
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
);
}
-2
View File
@@ -1,2 +0,0 @@
// Google plugin module implements oauth behavior.
export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js";
-81
View File
@@ -1,81 +0,0 @@
// Google plugin module implements oauth.settings behavior.
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type OAuthSettingsFs = {
existsSync: (path: Parameters<typeof existsSync>[0]) => ReturnType<typeof existsSync>;
readFileSync: (path: Parameters<typeof readFileSync>[0], encoding: "utf8") => string;
homedir: typeof homedir;
};
const defaultFs: OAuthSettingsFs = {
existsSync,
readFileSync,
homedir,
};
const OAUTH_SETTINGS_TEST_API_KEY = Symbol.for("openclaw.google.oauthSettingsTestApi");
let oauthSettingsFs: OAuthSettingsFs = defaultFs;
type GeminiCliAuthSettings = {
security?: {
auth?: {
selectedType?: unknown;
enforcedType?: unknown;
};
};
selectedAuthType?: unknown;
enforcedAuthType?: unknown;
};
function readSettingsFile(): GeminiCliAuthSettings | null {
const settingsPath = join(oauthSettingsFs.homedir(), ".gemini", "settings.json");
if (!oauthSettingsFs.existsSync(settingsPath)) {
return null;
}
try {
const parsed = JSON.parse(oauthSettingsFs.readFileSync(settingsPath, "utf8")) as unknown;
return isRecord(parsed) ? (parsed as GeminiCliAuthSettings) : null;
} catch {
return null;
}
}
function setOAuthSettingsFsForTest(overrides?: Partial<OAuthSettingsFs>): void {
oauthSettingsFs = overrides ? { ...defaultFs, ...overrides } : defaultFs;
}
function resolveGeminiCliSelectedAuthType(): string | undefined {
const settings = readSettingsFile();
if (settings) {
const security = isRecord(settings.security) ? settings.security : undefined;
const auth = isRecord(security?.auth) ? security.auth : undefined;
const selectedAuthType =
normalizeOptionalString(auth?.selectedType) ??
normalizeOptionalString(auth?.enforcedType) ??
normalizeOptionalString(settings.selectedAuthType) ??
normalizeOptionalString(settings.enforcedAuthType);
if (selectedAuthType) {
return selectedAuthType;
}
}
if (process.env.GOOGLE_GENAI_USE_GCA === "true") {
return "oauth-personal";
}
return undefined;
}
export function isGeminiCliPersonalOAuth(): boolean {
return resolveGeminiCliSelectedAuthType() === "oauth-personal";
}
if (process.env.VITEST) {
(globalThis as Record<PropertyKey, unknown>)[OAUTH_SETTINGS_TEST_API_KEY] = {
setFs: setOAuthSettingsFsForTest,
};
}
-46
View File
@@ -1,46 +0,0 @@
// Google plugin module implements oauth.shared behavior.
export const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
export const CLIENT_SECRET_KEYS = [
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
export const REDIRECT_URI = "http://localhost:8085/oauth2callback";
export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
export const TOKEN_URL = "https://oauth2.googleapis.com/token";
export const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
export const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com";
const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com";
const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
export const LOAD_CODE_ASSIST_ENDPOINTS = [
CODE_ASSIST_ENDPOINT_PROD,
CODE_ASSIST_ENDPOINT_DAILY,
CODE_ASSIST_ENDPOINT_AUTOPUSH,
];
export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
export const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
export const TIER_FREE = "free-tier";
export const TIER_LEGACY = "legacy-tier";
export const TIER_STANDARD = "standard-tier";
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
signal?: AbortSignal;
};
File diff suppressed because it is too large Load Diff
-172
View File
@@ -1,172 +0,0 @@
// Google plugin module implements oauth.token behavior.
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
import { fetchWithTimeout } from "./oauth.http.js";
import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js";
import { isGeminiCliPersonalOAuth } from "./oauth.settings.js";
import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js";
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_OAUTH_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
async function requestTokenGrant(
body: URLSearchParams,
signal?: AbortSignal,
): Promise<{
access_token?: string;
refresh_token?: string;
expires_in?: unknown;
}> {
const response = await fetchWithTimeout(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "*/*",
"User-Agent": "google-api-nodejs-client/9.15.1",
},
body,
...(signal ? { signal } : {}),
});
if (!response.ok) {
const errorText = await readResponseTextLimited(
response,
GOOGLE_OAUTH_TOKEN_ERROR_BODY_LIMIT_BYTES,
);
throw new Error(`Token exchange failed: ${errorText}`);
}
return readProviderJsonResponse<{
access_token?: string;
refresh_token?: string;
expires_in?: unknown;
}>(response, "google.token");
}
function resolveExpiredTokenTimestampMs(nowMs: number): number {
return asDateTimestampMs(nowMs - TOKEN_EXPIRY_BUFFER_MS) ?? nowMs;
}
function resolveTokenExpiresAt(value: unknown): number {
const nowMs = asDateTimestampMs(Date.now());
if (nowMs === undefined) {
return 0;
}
return (
resolveExpiresAtMsFromDurationSeconds(value, { nowMs, bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
resolveExpiredTokenTimestampMs(nowMs)
);
}
async function buildGeminiCliCredentials(params: {
tokenResponse: {
access_token?: string;
refresh_token?: string;
expires_in?: unknown;
};
refreshTokenFallback?: string;
existing?: Pick<GeminiCliOAuthCredentials, "email" | "projectId">;
allowIdentityFallback?: boolean;
signal?: AbortSignal;
}): Promise<GeminiCliOAuthCredentials> {
const accessToken = params.tokenResponse.access_token;
if (!accessToken) {
throw new Error("No access token received. Please try again.");
}
let identity: { email?: string; projectId?: string } = params.existing ?? {};
try {
if (!identity.email || !identity.projectId) {
const discovered = await resolveGeminiCliIdentity(accessToken, params.signal);
identity = {
email: identity.email ?? discovered.email,
projectId: identity.projectId ?? discovered.projectId,
};
}
} catch (error) {
if (!params.allowIdentityFallback || (!params.existing?.email && !params.existing?.projectId)) {
throw error;
}
// If identity discovery is temporarily unavailable during refresh, keep the
// already-stored identity binding instead of failing token renewal.
}
const expiresAt = resolveTokenExpiresAt(params.tokenResponse.expires_in);
return {
refresh: params.tokenResponse.refresh_token ?? params.refreshTokenFallback ?? "",
access: accessToken,
expires: expiresAt,
projectId: identity.projectId,
email: identity.email,
};
}
async function resolveGeminiCliIdentity(
accessToken: string,
signal?: AbortSignal,
): Promise<{ email?: string; projectId?: string }> {
return isGeminiCliPersonalOAuth()
? await resolveGooglePersonalOAuthIdentity(accessToken, signal)
: await resolveGoogleOAuthIdentity(accessToken, signal);
}
export async function exchangeCodeForTokens(
code: string,
verifier: string,
signal?: AbortSignal,
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
const refreshed = await buildGeminiCliCredentials({
tokenResponse: await requestTokenGrant(body, signal),
signal,
});
if (!refreshed.refresh) {
throw new Error("No refresh token received. Please try again.");
}
return refreshed;
}
export async function refreshTokensForGeminiCli(credentials: {
refresh: string;
email?: string;
projectId?: string;
}): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
grant_type: "refresh_token",
refresh_token: credentials.refresh,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
return await buildGeminiCliCredentials({
tokenResponse: await requestTokenGrant(body),
refreshTokenFallback: credentials.refresh,
existing: {
email: credentials.email,
projectId: credentials.projectId,
},
allowIdentityFallback: true,
});
}
-105
View File
@@ -1,105 +0,0 @@
// Google plugin module implements oauth behavior.
import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import {
buildAuthUrl,
generateOAuthState,
generatePkce,
parseCallbackInput,
shouldUseManualOAuthFlow,
waitForLocalCallback,
} from "./oauth.flow.js";
import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js";
import { exchangeCodeForTokens, refreshTokensForGeminiCli } from "./oauth.token.js";
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
await ctx.note(
needsManual
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account for Gemini CLI access.",
"The callback will be captured automatically on localhost:8085.",
].join("\n"),
"Gemini CLI OAuth",
);
const { verifier, challenge } = generatePkce();
const state = generateOAuthState();
const authUrl = buildAuthUrl(challenge, state);
if (needsManual) {
return manualFlow(ctx, authUrl, state, verifier);
}
ctx.progress.update("Complete sign-in in browser...");
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
try {
await ctx.openUrl(authUrl);
} catch {
// The URL is already visible; browser launch is best-effort.
}
try {
const { code } = await waitForLocalCallback({
expectedState: state,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
...(ctx.signal ? { signal: ctx.signal } : {}),
});
ctx.progress.update("Exchanging authorization code for tokens...");
return await exchangeCodeForTokens(code, verifier, ctx.signal);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
ctx.progress.update("Local callback server failed. Switching to manual mode...");
return manualFlow(ctx, authUrl, state, verifier, err);
}
throw err;
}
}
async function manualFlow(
ctx: GeminiCliOAuthContext,
authUrl: string,
state: string,
verifier: string,
cause?: Error,
): Promise<GeminiCliOAuthCredentials> {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
await ctx.openUrl(authUrl);
await ctx.note(`Open this URL in your LOCAL browser:\n\n${authUrl}`, "Gemini CLI OAuth");
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput);
if ("error" in parsed) {
throw new Error(parsed.error, cause ? { cause } : undefined);
}
if (parsed.state !== state) {
throw new Error("OAuth state mismatch - please try again", cause ? { cause } : undefined);
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier, ctx.signal);
}
export async function refreshGeminiCliOAuthToken(
credentials: Pick<GeminiCliOAuthCredentials, "refresh" | "email" | "projectId">,
): Promise<OAuthCredential> {
const refreshed = await refreshTokensForGeminiCli(credentials);
return {
type: "oauth",
provider: "google-gemini-cli",
...refreshed,
};
}
@@ -1344,6 +1344,85 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
]);
});
it("allows each role's UTF-8 transcript limit and releases it on finished", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
await bridge.connect();
const onmessage = lastConnectParams().callbacks.onmessage;
const halfLimit = "é".repeat(64 * 1024);
onmessage({
serverContent: {
inputTranscription: { text: halfLimit },
outputTranscription: { text: halfLimit },
},
});
onmessage({
serverContent: {
inputTranscription: { text: halfLimit },
outputTranscription: { text: halfLimit },
},
});
onmessage({ serverContent: { outputTranscription: { finished: true } } });
onmessage({ serverContent: { inputTranscription: { finished: true } } });
expect(onTranscript.mock.calls.filter((call) => call[2] === true)).toEqual([
["assistant", `${halfLimit}${halfLimit}`, true],
["user", `${halfLimit}${halfLimit}`, true],
]);
expect(session.close).not.toHaveBeenCalled();
});
it("terminates and clears a runaway transcript stream at the UTF-8 byte limit", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onError = vi.fn();
const onClose = vi.fn();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onError,
onClose,
onTranscript,
});
await bridge.connect();
const callbacks = lastConnectParams().callbacks;
const transcriptChunk = "€".repeat(16);
const acceptedChunks = Math.floor((256 * 1024) / Buffer.byteLength(transcriptChunk, "utf8"));
for (let index = 0; index < 10_000; index += 1) {
callbacks.onmessage({
serverContent: {
inputTranscription: { text: transcriptChunk },
},
});
}
callbacks.onclose({ code: 1000, reason: "late clean close", wasClean: true });
expect(onTranscript).toHaveBeenCalledTimes(acceptedChunks);
expect(onTranscript.mock.calls.at(-1)).toEqual(["user", transcriptChunk, false]);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit",
}),
);
expect(onTranscript.mock.calls.filter((call) => call[2] === true)).toEqual([]);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith("error");
expect(session.close).toHaveBeenCalledTimes(1);
await expect(bridge.connect()).rejects.toThrow(
"Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit",
);
});
it("retains unordered transcript chunks until a protocol terminal or close", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
+37 -12
View File
@@ -68,6 +68,9 @@ const GOOGLE_REALTIME_BROWSER_NEW_SESSION_TTL_MS = 60 * 1000;
const GOOGLE_REALTIME_RECONNECT_MAX_ATTEMPTS = 3;
const GOOGLE_REALTIME_RECONNECT_BASE_DELAY_MS = 250;
const GOOGLE_REALTIME_RECONNECT_MAX_DELAY_MS = 2_000;
const GOOGLE_REALTIME_MAX_PENDING_TRANSCRIPT_BYTES = 256 * 1024;
const GOOGLE_REALTIME_TRANSCRIPT_OVERFLOW_MESSAGE =
"Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit";
// Google Live requires a leading letter/underscore and caps function names at 128 characters.
const GOOGLE_REALTIME_TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/;
const MULAW_LINEAR_SAMPLES = new Int16Array(256);
@@ -143,6 +146,10 @@ type GoogleRealtimeLiveConfig = {
type GoogleRealtimeVoiceBridgeConfig = RealtimeVoiceBridgeCreateRequest & GoogleRealtimeLiveConfig;
type GoogleLiveTranscription = NonNullable<LiveServerContent["inputTranscription"]>;
type GoogleLiveTranscriptAccumulator = {
text: string;
byteCount: number;
};
function trimToUndefined(value: unknown): string | undefined {
return normalizeOptionalString(value);
@@ -478,10 +485,13 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
private closeNotified = false;
private connectionOwner: GoogleLiveConnectionAttempt | undefined;
private connectAttempt: GoogleLiveConnectionAttempt | undefined;
private readonly pendingTranscripts: Record<RealtimeVoiceRole, string> = {
user: "",
assistant: "",
};
// Google can interleave independent input/output transcripts, so each role
// owns its own in-progress byte budget until `finished` or terminal cleanup.
private readonly pendingTranscripts: Record<RealtimeVoiceRole, GoogleLiveTranscriptAccumulator> =
{
user: { text: "", byteCount: 0 },
assistant: { text: "", byteCount: 0 },
};
constructor(private readonly config: GoogleRealtimeVoiceBridgeConfig) {
this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ;
@@ -858,13 +868,17 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
}
if (content.inputTranscription) {
this.appendTranscript("user", content.inputTranscription);
if (!this.appendTranscript("user", content.inputTranscription)) {
return;
}
}
if (content.outputTranscription) {
// outputAudioTranscription is requested in the session config. Keep that
// official stream canonical; modelTurn text has no transcript turn identity.
this.appendTranscript("assistant", content.outputTranscription);
if (!this.appendTranscript("assistant", content.outputTranscription)) {
return;
}
}
for (const part of content.modelTurn?.parts ?? []) {
@@ -886,10 +900,18 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
}
}
private appendTranscript(role: RealtimeVoiceRole, transcript: GoogleLiveTranscription): void {
private appendTranscript(role: RealtimeVoiceRole, transcript: GoogleLiveTranscription): boolean {
const text = transcript.text;
if (text) {
this.pendingTranscripts[role] += text;
const pending = this.pendingTranscripts[role];
const textBytes = Buffer.byteLength(text, "utf8");
if (pending.byteCount + textBytes > GOOGLE_REALTIME_MAX_PENDING_TRANSCRIPT_BYTES) {
this.resetPendingTranscripts();
this.failConnection(new Error(GOOGLE_REALTIME_TRANSCRIPT_OVERFLOW_MESSAGE));
return false;
}
pending.text += text;
pending.byteCount += textBytes;
this.emitTranscript(role, text, false);
}
// turnComplete belongs to model generation and is unordered with transcription.
@@ -897,11 +919,14 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
if (transcript.finished) {
this.flushPendingTranscript(role);
}
return true;
}
private flushPendingTranscript(role: RealtimeVoiceRole): void {
const completeText = this.pendingTranscripts[role].trim();
this.pendingTranscripts[role] = "";
const pending = this.pendingTranscripts[role];
const completeText = pending.text.trim();
pending.text = "";
pending.byteCount = 0;
if (completeText) {
this.emitTranscript(role, completeText, true);
}
@@ -927,8 +952,8 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
}
private resetPendingTranscripts(): void {
this.pendingTranscripts.user = "";
this.pendingTranscripts.assistant = "";
this.pendingTranscripts.user = { text: "", byteCount: 0 };
this.pendingTranscripts.assistant = { text: "", byteCount: 0 };
}
private failConnection(error: Error): void {
+8 -1
View File
@@ -10,7 +10,7 @@ const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard,
}));
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
export const matrixPluginBase = {
id: "matrix",
meta: {
id: "matrix",
@@ -44,6 +44,13 @@ export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
baseUrl: account.homeserver,
},
}),
},
} satisfies ChannelPlugin<ResolvedMatrixAccount>;
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
...matrixPluginBase,
config: {
...matrixPluginBase.config,
hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured,
},
};
+5 -55
View File
@@ -1,5 +1,4 @@
// Matrix plugin module implements channel behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
@@ -45,8 +44,8 @@ import {
import { matrixMessageActions } from "./actions.js";
import { matrixApprovalCapability } from "./approval-native.js";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
import { DEFAULT_ACCOUNT_ID, matrixConfigAdapter } from "./config-adapter.js";
import { MatrixChannelConfigSchema } from "./config-schema.js";
import { matrixPluginBase } from "./channel.setup.js";
import { DEFAULT_ACCOUNT_ID } from "./config-adapter.js";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
@@ -75,12 +74,6 @@ import {
import { matrixResolverAdapter } from "./resolver.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./setup-contract.js";
import { createMatrixSetupWizardProxy, matrixSetupContract } from "./setup-core.js";
import {
defaultTopLevelPlacement,
resolveMatrixInboundConversation,
@@ -89,10 +82,6 @@ import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const loadMatrixSetupWizard = createLazyRuntimeNamedExport(
() => import("./setup-surface.js"),
"matrixSetupWizard",
);
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
@@ -100,18 +89,6 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
const loadMatrixDoctorModule = createLazyRuntimeModule(() => import("./doctor.js"));
const meta = {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
markdownCapable: true,
quickstartAllowFrom: true,
};
function buildMatrixTrafficStatusSummary(
snapshot?: {
lastInboundAt?: number | null;
@@ -439,37 +416,16 @@ const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
id: "matrix",
meta,
setupWizard: createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: await loadMatrixSetupWizard(),
})),
...matrixPluginBase,
meta: { ...matrixPluginBase.meta, markdownCapable: true },
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
...matrixPluginBase.capabilities,
tts: {
voice: {
synthesisTarget: "voice-note",
},
},
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: MatrixChannelConfigSchema,
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
},
approvalCapability: matrixApprovalCapability,
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
@@ -540,12 +496,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setupContract: {
...matrixSetupContract,
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeMatrixAcpConversationId(conversationId),
+19 -72
View File
@@ -3,41 +3,21 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createDelegatedSetupWizardProxy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
createSetupTranslator,
} from "openclaw/plugin-sdk/setup-runtime";
import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js";
import { NostrConfigSchema } from "./config-schema.js";
import { DEFAULT_RELAYS } from "./default-relays.js";
import { createNostrSetupAdapter, createNostrSetupContract } from "./setup-adapter.js";
const t = createSetupTranslator();
import {
createNostrSetupAdapter,
createNostrSetupContract,
createNostrSetupStatus,
} from "./setup-adapter.js";
import type { ResolvedNostrAccount } from "./types.js";
const channel = "nostr" as const;
type NostrAccountConfig = {
enabled?: boolean;
name?: string;
defaultAccount?: string;
privateKey?: unknown;
relays?: string[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
profile?: unknown;
};
type ResolvedNostrSetupAccount = {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
privateKey: string;
publicKey: string;
relays: string[];
profile?: unknown;
config: NostrAccountConfig;
};
type NostrAccountConfig = ResolvedNostrAccount["config"];
function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined {
return (cfg.channels as Record<string, unknown> | undefined)?.nostr as
@@ -45,15 +25,6 @@ function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined {
| undefined;
}
function listSetupNostrAccountIds(cfg: OpenClawConfig): string[] {
const nostrCfg = getNostrConfig(cfg);
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
if (!privateKey) {
return [];
}
return [resolveDefaultSetupNostrAccountId(cfg)];
}
function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string {
const configured = getNostrConfig(cfg)?.defaultAccount;
return typeof configured === "string" && configured.trim()
@@ -64,7 +35,7 @@ function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string {
function resolveSetupNostrAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedNostrSetupAccount {
}): ResolvedNostrAccount {
const nostrCfg = getNostrConfig(params.cfg);
const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg);
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
@@ -90,47 +61,16 @@ function resolveSetupNostrAccount(params: {
};
}
function looksLikeNostrPrivateKey(privateKey: string): boolean {
return (
privateKey.startsWith("nsec1") ||
privateKey.startsWith("NSEC1") ||
/^[0-9a-fA-F]{64}$/.test(privateKey)
);
}
const nostrSetupAdapter = createNostrSetupAdapter({
resolveAccountId: (cfg, accountId) => accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg),
validatePrivateKey: looksLikeNostrPrivateKey,
});
const nostrSetupContract = createNostrSetupContract(nostrSetupAdapter);
const nostrSetupWizard = createDelegatedSetupWizardProxy({
channel,
loadWizard: async () => (await import("./setup-surface.js")).nostrSetupWizard,
status: {
...createStandardChannelSetupStatus({
channelLabel: "Nostr",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
resolveSetupNostrAccount({ cfg, accountId }).configured,
resolveExtraStatusLines: ({ cfg }) => {
const account = resolveSetupNostrAccount({ cfg });
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
},
}),
},
status: createNostrSetupStatus(resolveSetupNostrAccount),
resolveShouldPromptAccountIds: () => false,
delegatePrepare: true,
delegateFinalize: true,
});
export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrSetupAccount> = {
export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrAccount> = {
id: channel,
meta: {
id: channel,
@@ -147,10 +87,17 @@ export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrSetupAccount> = {
},
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setupContract: nostrSetupContract,
setupContract: createNostrSetupContract(
createNostrSetupAdapter({
resolveAccountId: (cfg, accountId) =>
accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg),
validatePrivateKey: (privateKey) => /^(?:nsec1|NSEC1)|^[0-9a-fA-F]{64}$/u.test(privateKey),
}),
),
setupWizard: nostrSetupWizard,
config: {
listAccountIds: listSetupNostrAccountIds,
listAccountIds: (cfg) =>
resolveSetupNostrAccount({ cfg }).configured ? [resolveDefaultSetupNostrAccountId(cfg)] : [],
resolveAccount: (cfg, accountId) => resolveSetupNostrAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSetupNostrAccountId,
isConfigured: (account) => account.configured,
+45 -16
View File
@@ -2,18 +2,25 @@
import {
defineChannelSetupContract,
type ChannelSetupAdapter,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { patchTopLevelChannelConfigSection, splitSetupEntries } from "openclaw/plugin-sdk/setup";
import {
createSetupTranslator,
createStandardChannelSetupStatus,
patchTopLevelChannelConfigSection,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { DEFAULT_RELAYS } from "./default-relays.js";
const channel = "nostr" as const;
type NostrSetupInput = ChannelSetupInput & {
type NostrSetupInput = {
name?: string;
privateKey?: string;
relayUrls?: string;
useEnv?: boolean;
};
export function buildNostrSetupPatch(accountId: string, patch: Record<string, unknown>) {
@@ -42,7 +49,7 @@ export function parseRelayUrls(raw: string): { relays: string[]; error?: string
export function createNostrSetupAdapter(params: {
resolveAccountId: (cfg: OpenClawConfig, accountId?: string | null) => string;
validatePrivateKey: (privateKey: string) => boolean;
}): ChannelSetupAdapter {
}): ChannelSetupAdapter<NostrSetupInput> {
return {
resolveAccountId: ({ cfg, accountId }) => params.resolveAccountId(cfg, accountId),
applyAccountName: ({ cfg, accountId, name }) =>
@@ -52,9 +59,8 @@ export function createNostrSetupAdapter(params: {
patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}),
}),
validateInput: ({ input }) => {
const typedInput = input as NostrSetupInput;
if (!typedInput.useEnv) {
const privateKey = typedInput.privateKey?.trim();
if (!input.useEnv) {
const privateKey = input.privateKey?.trim();
if (!privateKey) {
return "Nostr requires --private-key or --use-env.";
}
@@ -62,23 +68,22 @@ export function createNostrSetupAdapter(params: {
return "Nostr private key must be valid nsec or 64-character hex.";
}
}
if (typedInput.relayUrls?.trim()) {
return parseRelayUrls(typedInput.relayUrls).error ?? null;
if (input.relayUrls?.trim()) {
return parseRelayUrls(input.relayUrls).error ?? null;
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as NostrSetupInput;
const relayResult = typedInput.relayUrls?.trim()
? parseRelayUrls(typedInput.relayUrls)
const relayResult = input.relayUrls?.trim()
? parseRelayUrls(input.relayUrls)
: { relays: [] };
return patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
clearFields: input.useEnv ? ["privateKey"] : undefined,
patch: buildNostrSetupPatch(accountId, {
...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
...(input.useEnv ? {} : { privateKey: input.privateKey?.trim() }),
...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
}),
});
@@ -86,7 +91,7 @@ export function createNostrSetupAdapter(params: {
};
}
export function createNostrSetupContract(adapter: ChannelSetupAdapter) {
export function createNostrSetupContract(adapter: ChannelSetupAdapter<NostrSetupInput>) {
return defineChannelSetupContract({
fields: {
privateKey: {
@@ -103,6 +108,30 @@ export function createNostrSetupContract(adapter: ChannelSetupAdapter) {
cli: { flags: "--use-env", description: "Use NOSTR_PRIVATE_KEY" },
},
},
legacyAdapter: adapter,
adapter,
});
}
export function createNostrSetupStatus(
resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => {
configured: boolean;
relays: string[];
},
) {
const t = createSetupTranslator();
return createStandardChannelSetupStatus({
channelLabel: "Nostr",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) => resolveAccount({ cfg, accountId }).configured,
resolveExtraStatusLines: ({ cfg }) => {
const account = resolveAccount({ cfg });
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
},
});
}
+2 -16
View File
@@ -7,7 +7,6 @@ import {
import type { ChannelSetupDmPolicy, ChannelSetupWizard, DmPolicy } from "openclaw/plugin-sdk/setup";
import {
createSetupTranslator,
createStandardChannelSetupStatus,
createTopLevelChannelDmPolicy,
createTopLevelChannelParsedAllowFromPrompt,
defineTokenCredential,
@@ -23,6 +22,7 @@ import {
buildNostrSetupPatch,
createNostrSetupAdapter,
createNostrSetupContract,
createNostrSetupStatus,
parseRelayUrls,
} from "./setup-adapter.js";
import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
@@ -96,21 +96,7 @@ export const nostrSetupWizard: ChannelSetupWizard = {
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) =>
accountOverride?.trim() || defaultAccountId,
resolveShouldPromptAccountIds: () => false,
status: createStandardChannelSetupStatus({
channelLabel: "Nostr",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured,
resolveExtraStatusLines: ({ cfg }) => {
const account = resolveNostrAccount({ cfg });
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
},
}),
status: createNostrSetupStatus(resolveNostrAccount),
introNote: {
title: t("wizard.nostr.setupTitle"),
lines: NOSTR_SETUP_HELP_LINES,
+3 -3
View File
@@ -215,11 +215,11 @@ describe("Ollama provider", () => {
if (url.endsWith("/api/show")) {
const rawBody = init?.body;
const bodyText = typeof rawBody === "string" ? rawBody : "{}";
const parsed = JSON.parse(bodyText) as { name?: string };
if (parsed.name === "qwen3:32b") {
const parsed = JSON.parse(bodyText) as { model?: string };
if (parsed.model === "qwen3:32b") {
return jsonResponse({ model_info: { "qwen3.context_length": 131072 } });
}
if (parsed.name === "llama3.3:70b") {
if (parsed.model === "llama3.3:70b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
}
@@ -607,13 +607,7 @@ async function handleFakeOllamaRequest(
}
if (requestPath === "/api/show") {
const body = await readRequestJson(request);
// Ollama documents `model`; the current provider sends its supported `name` alias.
const modelName =
typeof body.model === "string"
? body.model
: typeof body.name === "string"
? body.name
: undefined;
const modelName = typeof body.model === "string" ? body.model : undefined;
if (!modelName) {
response.statusCode = 400;
response.end(JSON.stringify({ error: "model is required" }));
+5 -5
View File
@@ -79,16 +79,16 @@ async function withOllamaServer<T>(
return;
}
if (request.url === "/api/show") {
const body = (await readBody(request)) as { name?: string };
if (body.name) {
showRequests.push(body.name);
const body = (await readBody(request)) as { model?: string };
if (body.model) {
showRequests.push(body.model);
}
if (body.name === "unknown:latest") {
if (body.model === "unknown:latest") {
response.statusCode = 500;
response.end(JSON.stringify({ error: "show failed" }));
return;
}
const embedding = body.name?.startsWith("embedding") === true;
const embedding = body.model?.startsWith("embedding") === true;
response.end(
JSON.stringify({
capabilities: embedding ? ["embedding"] : ["completion", "tools"],
+20 -8
View File
@@ -50,6 +50,18 @@ describe("ollama provider models", () => {
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
});
it("inspects local models using Ollama's canonical model request field", async () => {
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
jsonResponse({ model_info: {} }),
);
vi.stubGlobal("fetch", fetchMock);
await readOllamaModelShowInfo("http://127.0.0.1:11434", "gemma4:e2b");
const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(requestBodyText(request?.body))).toEqual({ model: "gemma4:e2b" });
});
it("caps local discovered runtime context while preserving native metadata", () => {
const provider = capLocalOllamaProviderContext({
api: "ollama",
@@ -93,8 +105,8 @@ describe("ollama provider models", () => {
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "llama3:8b") {
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
if (body.model === "llama3:8b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
return jsonResponse({});
@@ -161,8 +173,8 @@ describe("ollama provider models", () => {
});
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const completion = body.name === "qwen-chat:latest";
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const completion = body.model === "qwen-chat:latest";
return jsonResponse({
capabilities: completion ? ["completion", "tools"] : ["embedding"],
model_info: completion ? { "qwen.context_length": 32_768 } : {},
@@ -275,14 +287,14 @@ describe("ollama provider models", () => {
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "kimi-k2.5:cloud") {
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
if (body.model === "kimi-k2.5:cloud") {
return jsonResponse({
model_info: { "kimi-k2.context_length": 262144 },
capabilities: ["vision", "thinking", "completion", "tools"],
});
}
if (body.name === "glm-5.1:cloud") {
if (body.model === "glm-5.1:cloud") {
return jsonResponse({
model_info: { "glm5.context_length": 202752 },
capabilities: ["thinking", "completion", "tools"],
@@ -409,7 +421,7 @@ describe("ollama provider models", () => {
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:normalized-base" };
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(requestUrl(input)).toBe("http://127.0.0.1:11434/api/show");
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ name: "qwen3:32b" });
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ model: "qwen3:32b" });
return jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
+1 -1
View File
@@ -172,7 +172,7 @@ export async function readOllamaModelShowInfo(
init: {
method: "POST",
headers,
body: JSON.stringify({ name: modelName }),
body: JSON.stringify({ model: modelName }),
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: Math.min(opts?.timeoutMs ?? OLLAMA_SHOW_TIMEOUT_MS, OLLAMA_SHOW_TIMEOUT_MS),
+76 -1
View File
@@ -1,6 +1,6 @@
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pullOllamaModel } from "./setup-pull.js";
import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -17,6 +17,81 @@ describe("Ollama onboarding model pulls", () => {
fetchWithSsrFGuardMock.mockReset();
});
it("uses the canonical Ollama model request field and requires its success terminal", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"pulling manifest"}\n{"status":"success"}\n'),
release,
});
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
true,
);
const request = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as { init?: { body?: string } };
expect(JSON.parse(request.init?.body ?? "null")).toEqual({ model: "gemma4:e2b" });
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b");
expect(release).toHaveBeenCalledOnce();
});
it.each([
{ label: "empty response", body: "" },
{ label: "interrupted manifest download", body: '{"status":"pulling manifest"}\n' },
{
label: "interrupted model layer",
body: '{"status":"pulling abcdef123456","total":100,"completed":40}\n',
},
{ label: "malformed stream", body: "not valid json\n" },
{ label: "incomplete trailing record", body: '{"status":"success"' },
])("does not report a completed model pull for an $label", async ({ body }) => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(body), release });
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
false,
);
expect(progress.stop).toHaveBeenCalledWith(
"Failed to download gemma4:e2b: pull stream ended before success",
);
expect(release).toHaveBeenCalledOnce();
});
it("accepts a final success record without a trailing newline", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"success"}'),
release: vi.fn(async () => {}),
});
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
true,
);
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b");
});
it("reports interrupted pulls as failures during non-interactive setup", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"pulling manifest"}\n'),
release: vi.fn(async () => {}),
});
const runtime = { log: vi.fn(), error: vi.fn() };
await expect(
pullOllamaModelNonInteractive("http://127.0.0.1:11434", "gemma4:e2b", runtime as never),
).resolves.toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
"Failed to download gemma4:e2b: pull stream ended before success",
);
expect(runtime.log).not.toHaveBeenCalledWith("Downloaded gemma4:e2b");
});
it("coerces non-Error stream failures through the shared error contract", async () => {
const release = vi.fn(async () => {});
const response = new Response(
+20 -20
View File
@@ -68,7 +68,7 @@ async function pullOllamaModelCore(params: {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
body: JSON.stringify({ model: modelName }),
},
signal: params.signal
? AbortSignal.any([responseController.signal, params.signal])
@@ -92,28 +92,25 @@ async function pullOllamaModelCore(params: {
let pendingRecordBytes = 0;
const layers = new Map<string, { total: number; completed: number }>();
const parseLine = (line: string): OllamaPullResult => {
const trimmed = line.trim();
if (!trimmed) {
return { ok: true };
const parseLine = (line: string): OllamaPullResult | undefined => {
if (!line.trim()) {
return undefined;
}
try {
const chunk = JSON.parse(trimmed) as OllamaPullChunk;
const chunk = JSON.parse(line) as OllamaPullChunk;
if (chunk.error) {
return { ok: false, message: `Download failed: ${chunk.error}` };
}
if (!chunk.status) {
return { ok: true };
if (!chunk.status || chunk.status === "success") {
return chunk.status ? { ok: true } : undefined;
}
if (chunk.total && chunk.completed !== undefined) {
layers.set(chunk.status, { total: chunk.total, completed: chunk.completed });
const totals = [...layers.values()].reduce(
(sum, layer) => ({
total: sum.total + layer.total,
completed: sum.completed + layer.completed,
}),
{ total: 0, completed: 0 },
);
const totals = { total: 0, completed: 0 };
for (const layer of layers.values()) {
totals.total += layer.total;
totals.completed += layer.completed;
}
params.onStatus?.(
chunk.status,
totals.total > 0 ? Math.round((totals.completed / totals.total) * 100) : null,
@@ -124,14 +121,18 @@ async function pullOllamaModelCore(params: {
} catch {
// Ignore malformed streaming lines from Ollama.
}
return { ok: true };
return undefined;
};
try {
for (;;) {
const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader);
if (done) {
return parseLine(buffer);
const terminal = parseLine(buffer);
if (terminal) {
return terminal;
}
throw new Error("pull stream ended before success");
}
pendingRecordBytes = checkNdjsonRecordCap(value, pendingRecordBytes);
buffer += decoder.decode(value, { stream: true });
@@ -139,7 +140,7 @@ async function pullOllamaModelCore(params: {
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) {
if (parsed) {
return parsed;
}
}
@@ -154,8 +155,7 @@ async function pullOllamaModelCore(params: {
await release();
}
} catch (err) {
const reason = formatErrorMessage(err);
return { ok: false, message: `Failed to download ${modelName}: ${reason}` };
return { ok: false, message: `Failed to download ${modelName}: ${formatErrorMessage(err)}` };
} finally {
clearTimeout(responseTimeout);
}
@@ -44,9 +44,11 @@ function createOllamaFetchMock(params: {
return jsonResponse({ models: params.tags.map((name) => ({ name })) });
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
const capabilities = body.name ? (params.capabilities?.[body.name] ?? ["tools"]) : ["tools"];
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const contextWindow = body.model ? params.show?.[body.model] : undefined;
const capabilities = body.model
? (params.capabilities?.[body.model] ?? ["tools"])
: ["tools"];
return jsonResponse({
...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}),
capabilities,
@@ -73,10 +75,21 @@ describe("Ollama non-interactive onboarding", () => {
upsertAuthProfileWithLock.mockClear();
});
it("does not persist local auth when non-interactive setup cannot select a model", async () => {
it.each([
{
label: "Ollama reports a pull failure",
body: '{"error":"disk full"}\n',
error: "Download failed: disk full",
},
{
label: "the model pull ends before success",
body: '{"status":"pulling manifest"}\n',
error: "Failed to download missing-model: pull stream ended before success",
},
])("does not persist unavailable local models when $label", async ({ body, error }) => {
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
pullResponse: new Response(body, { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
@@ -91,7 +104,7 @@ describe("Ollama non-interactive onboarding", () => {
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(runtime.error).toHaveBeenCalledWith(error);
expect(runtime.error).toHaveBeenCalledWith(
[
"No Ollama models are available at http://127.0.0.1:11434.",
@@ -186,7 +199,7 @@ describe("Ollama non-interactive onboarding", () => {
return false;
}
const init = call[1] as RequestInit | undefined;
return JSON.parse(requestBodyText(init?.body)).name === modelId;
return JSON.parse(requestBodyText(init?.body)).model === modelId;
}),
).toHaveLength(1);
});
+9 -9
View File
@@ -56,12 +56,12 @@ function createOllamaFetchMock(params: {
return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) });
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
const capabilities = body.name
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const contextWindow = body.model ? params.show?.[body.model] : undefined;
const capabilities = body.model
? params.capabilities === undefined
? ["tools"]
: params.capabilities[body.name]
: params.capabilities[body.model]
: undefined;
return jsonResponse({
...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}),
@@ -569,7 +569,7 @@ describe("ollama setup", () => {
});
const pullCall = fetchMock.mock.calls.find((call) => requestUrl(call[0]).endsWith("/api/pull"));
expect(pullCall).toBeDefined();
expect(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ name: "gemma4:e4b" });
expect(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ model: "gemma4:e4b" });
expect(progress.update).toHaveBeenCalledWith("Downloading gemma4:e4b - pulling part - 50%");
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e4b");
expect(result.config.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
@@ -657,7 +657,7 @@ describe("ollama setup", () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (requestUrl(input).endsWith("/api/show")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (body.name === "broken:20b") {
if (body.model === "broken:20b") {
return new Response("boom", { status: 500 });
}
}
@@ -714,8 +714,8 @@ describe("ollama setup", () => {
markScanStarted = resolve;
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { name?: string }) : {};
if (!requestUrl(input).endsWith("/api/show") || body.name !== "model-200") {
const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { model?: string }) : {};
if (!requestUrl(input).endsWith("/api/show") || body.model !== "model-200") {
return await baseFetch(input, init);
}
markScanStarted();
@@ -999,7 +999,7 @@ describe("ollama setup", () => {
});
const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ model: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" });
expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1);
});
@@ -3,8 +3,11 @@
// by the request deadline, not only by the per-chunk idle guard. This exercises the
// production containerRpcRequest -> containerRestRequest -> readSignalRestText path
// without mocking fetch, unlike the fake-timer unit tests.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { containerRpcRequest } from "./client-container.js";
@@ -95,4 +98,99 @@ describe("signal REST real-server deadline", () => {
);
expect(result).toEqual({ versions: ["v1"], build: 2 });
});
it.each([
{
stagedFilename: "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "report.jpg",
},
{
stagedFilename: "quarter;final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter_final.jpg",
},
{
stagedFilename: "first;middle;last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "first_middle_last.jpg",
},
{
stagedFilename: "quarter,final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter_final.jpg",
},
{
stagedFilename: "first,middle,last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "first_middle_last.jpg",
},
{
stagedFilename: "hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "hash_name.jpg",
},
{
stagedFilename: "mixed;comma,hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "mixed_comma_hash_name.jpg",
},
{ stagedFilename: "quarter;final.jpg", expectedFilename: "quarter_final.jpg" },
{ stagedFilename: "quarter,final.jpg", expectedFilename: "quarter_final.jpg" },
{ stagedFilename: "hash#name.jpg", expectedFilename: "hash_name.jpg" },
{
stagedFilename: "quarter final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter final.jpg",
},
])(
"posts the provider-safe original filename $expectedFilename",
async ({ stagedFilename, expectedFilename }) => {
let receivedPayload: unknown;
const server = await startServer((req, res) => {
if (req.method !== "POST" || req.url !== "/v2/send") {
res.writeHead(404);
res.end();
return;
}
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer | string) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
req.on("end", () => {
receivedPayload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ timestamp: "1735689600000" }));
});
});
const mediaDir = await mkdtemp(join(tmpdir(), "signal-real-filename-"));
const content = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
const stagedFile = join(mediaDir, stagedFilename);
try {
await writeFile(stagedFile, content);
await expect(
containerRpcRequest(
"send",
{
account: "+14259798283",
recipient: ["+15550001111"],
message: "Photo",
attachments: [stagedFile],
},
{ baseUrl: server.baseUrl, timeoutMs: 1_000 },
),
).resolves.toEqual({ timestamp: 1735689600000 });
expect(receivedPayload).toEqual({
message: "Photo",
number: "+14259798283",
recipients: ["+15550001111"],
base64_attachments: [
`data:image/jpeg;filename=${expectedFilename};base64,${content.toString("base64")}`,
],
});
const attachment = (receivedPayload as { base64_attachments: [string] })
.base64_attachments[0];
const decoded = await (await fetch(attachment)).arrayBuffer();
expect(Buffer.from(decoded)).toEqual(content);
} finally {
await rm(mediaDir, { recursive: true, force: true });
}
},
);
});
@@ -912,6 +912,78 @@ describe("containerSendMessage", () => {
await fs.rm(tmpDir, { recursive: true });
});
it.each([
{
stagedFilename: "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "report.jpg",
},
{
stagedFilename: "quarter;final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter_final.jpg",
},
{
stagedFilename: "first;middle;last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "first_middle_last.jpg",
},
{
stagedFilename: "quarter,final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter_final.jpg",
},
{
stagedFilename: "first,middle,last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "first_middle_last.jpg",
},
{
stagedFilename: "hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "hash_name.jpg",
},
{
stagedFilename: "mixed;comma,hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "mixed_comma_hash_name.jpg",
},
{ stagedFilename: "quarter;final.jpg", expectedFilename: "quarter_final.jpg" },
{ stagedFilename: "quarter,final.jpg", expectedFilename: "quarter_final.jpg" },
{ stagedFilename: "hash#name.jpg", expectedFilename: "hash_name.jpg" },
{
stagedFilename: "quarter final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg",
expectedFilename: "quarter final.jpg",
},
])(
"restores the provider-safe original attachment filename $expectedFilename",
async ({ stagedFilename, expectedFilename }) => {
const fs = await import("node:fs/promises");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "signal-test-"));
try {
const stagedFile = path.join(tmpDir, stagedFilename);
const content = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
await fs.writeFile(stagedFile, content);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
...bodyStream(JSON.stringify({})),
});
await containerSendMessage({
baseUrl: "http://localhost:8080",
account: "+14259798283",
recipients: ["+15550001111"],
message: "Photo",
attachments: [stagedFile],
});
expect(parseFetchBody().base64_attachments).toEqual([
`data:image/jpeg;filename=${expectedFilename};base64,${content.toString("base64")}`,
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
},
);
it("rejects outbound attachments that exceed the size cap", async () => {
const fs = await import("node:fs/promises");
const os = await import("node:os");
+7 -3
View File
@@ -6,10 +6,13 @@
* to keep the two modes cleanly isolated.
*/
import nodePath from "node:path";
import { toErrorObject } from "openclaw/plugin-sdk/error-runtime";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { detectMime, parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import {
detectMime,
extractOriginalFilename,
parseMediaContentLength,
} from "openclaw/plugin-sdk/media-runtime";
import {
parseStrictNonNegativeInteger,
resolveTimerTimeoutMs,
@@ -554,7 +557,8 @@ async function filesToBase64DataUris(
});
remainingBytes -= buffer.byteLength;
const mime = (await detectMime({ buffer, filePath })) ?? "application/octet-stream";
const filename = nodePath.basename(filePath);
// Signal splits on semicolons; commas and fragments break RFC 2397 attachment data.
const filename = extractOriginalFilename(filePath).replace(/[,;#]/g, "_");
const b64 = buffer.toString("base64");
results.push(`data:${mime};filename=${filename};base64,${b64}`);
}
+4 -26
View File
@@ -5,12 +5,11 @@ import { isSlackPluginAccountConfigured } from "./account-configured.js";
import { inspectSlackAccount } from "./account-inspect.js";
import type { ResolvedSlackAccount } from "./accounts.js";
import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js";
import { slackSetupPlugin } from "./channel.setup.js";
import { slackBaseConfigAdapter } from "./config-adapter.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackDoctor } from "./doctor.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { slackSecurityAdapter } from "./security.js";
import { SLACK_CHANNEL } from "./setup-shared.js";
export { SLACK_CHANNEL } from "./setup-shared.js";
@@ -42,26 +41,13 @@ export function createSlackPluginBase(params: {
| "secrets"
> {
return {
id: SLACK_CHANNEL,
...slackSetupPlugin,
meta: {
...getChatChannelMeta(SLACK_CHANNEL),
...getChatChannelMeta(slackSetupPlugin.id),
preferSessionLookupForAnnounceTarget: true,
},
setupWizard: params.setupWizard,
setupContract: params.setupContract,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
commands: {
nativeCommandsAutoEnabled: false,
nativeSkillsAutoEnabled: false,
resolveNativeCommandName: ({ commandKey, defaultName }) =>
commandKey === "status" ? "agentstatus" : defaultName,
},
doctor: slackDoctor,
agentPrompt: {
inboundFormattingHints: () => ({
@@ -83,18 +69,10 @@ export function createSlackPluginBase(params: {
"- Slack Block Kit or presentation text fields are sent as Slack mrkdwn directly; use `*bold*`, `_italic_`, `~strike~`, `<url|label>` links, and avoid Markdown headings or pipe tables there.",
],
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.slack"] },
security: slackSecurityAdapter,
configSchema: SlackChannelConfigSchema,
config: {
...slackSetupPlugin.config,
...slackConfigAdapter,
hasConfiguredState: ({ env }) =>
["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0,
),
isConfigured: (account) => isSlackPluginAccountConfigured(account),
describeAccount: (account) =>
describeAccountSnapshot({
@@ -25,13 +25,15 @@ function htmlResponse(status: number, body: string): Response {
function createRuntime(
responses: Response[],
options: { stopAfterPollSuccesses?: number } = {},
options: { stopAfterPollSuccesses?: number; timeoutSeconds?: number } = {},
): {
calls: number[];
pollBodies: Array<Record<string, unknown>>;
messages: TelegramIngressWorkerMessage[];
done: Promise<void>;
} {
const calls: number[] = [];
const pollBodies: Array<Record<string, unknown>> = [];
const messages: TelegramIngressWorkerMessage[] = [];
const listeners = new Set<(message: TelegramIngressWorkerCommand) => void>();
let pollSuccesses = 0;
@@ -62,8 +64,11 @@ function createRuntime(
},
close() {},
};
const fetchImpl: typeof fetch = async () => {
const fetchImpl: typeof fetch = async (_url, init) => {
calls.push(Date.now());
pollBodies.push(
JSON.parse((init?.body as string | undefined) ?? "{}") as Record<string, unknown>,
);
const responseIndex = Math.min(calls.length - 1, responses.length - 1);
return expectDefined(responses[responseIndex], `Telegram response ${responseIndex}`);
};
@@ -74,7 +79,7 @@ function createRuntime(
initialUpdateId: null,
spoolDir: "/tmp/openclaw-telegram-ingress-worker-test",
apiRoot: "https://api.telegram.test",
timeoutSeconds: 1,
timeoutSeconds: options.timeoutSeconds ?? 1,
},
port,
deps: {
@@ -82,7 +87,7 @@ function createRuntime(
closeTransport: async () => {},
},
});
return { calls, messages, done };
return { calls, pollBodies, messages, done };
}
async function flushRuntime(): Promise<void> {
@@ -94,6 +99,43 @@ afterEach(() => {
});
describe("telegram ingress worker poll cadence", () => {
it("confirms polling connectivity before entering the first long poll", async () => {
vi.useFakeTimers();
const runtime = createRuntime(
[jsonResponse(200, { ok: true, result: [] }), jsonResponse(200, { ok: true, result: [] })],
{ stopAfterPollSuccesses: 2, timeoutSeconds: 30 },
);
await flushRuntime();
await runtime.done;
expect(runtime.pollBodies.map((body) => body.timeout)).toEqual([0, 30]);
expect(runtime.messages.filter((message) => message.type === "poll-success")).toHaveLength(2);
});
it("keeps short polling until a getUpdates request succeeds", async () => {
vi.useFakeTimers();
const runtime = createRuntime(
[
jsonResponse(502, { ok: false, error_code: 502, description: "Bad Gateway" }),
jsonResponse(200, { ok: true, result: [] }),
jsonResponse(200, { ok: true, result: [] }),
],
{ stopAfterPollSuccesses: 2, timeoutSeconds: 30 },
);
await flushRuntime();
expect(runtime.messages).toContainEqual(
expect.objectContaining({ type: "poll-error", errorCode: 502 }),
);
await vi.advanceTimersByTimeAsync(1_000);
await flushRuntime();
await runtime.done;
expect(runtime.pollBodies.map((body) => body.timeout)).toEqual([0, 0, 30]);
expect(runtime.messages.filter((message) => message.type === "poll-success")).toHaveLength(2);
});
it("backs off consecutive empty polls without hot spinning", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
@@ -225,6 +267,7 @@ describe("telegram ingress worker durable-before-offset", () => {
expect(messages).toContainEqual(expect.objectContaining({ type: "spooled", updateId: 42 }));
// Second getUpdates must use offset = lastUpdateId + 1 only after spool-ack.
expect(pollBodies[1]?.offset).toBe(43);
expect(pollBodies.map((body) => body.timeout)).toEqual([0, 1]);
});
});
@@ -196,6 +196,7 @@ export async function runTelegramIngressWorkerRuntime(params: {
let lastUpdateId = options.initialUpdateId;
let failures = 0;
let consecutiveEmptyPolls = 0;
let pollingConfirmed = false;
port.onMessage((message) => {
if (message?.type === "stop") {
@@ -251,7 +252,9 @@ export async function runTelegramIngressWorkerRuntime(params: {
fetch: fetchImpl,
url: getUpdatesUrl,
body: {
timeout: pollTimeoutSeconds,
// Confirm getUpdates ownership with a completed short poll before
// entering the long poll; request start alone cannot prove connectivity.
timeout: pollingConfirmed ? pollTimeoutSeconds : 0,
limit: pollLimit,
allowed_updates: resolveTelegramAllowedUpdates(),
...(offset === null ? {} : { offset }),
@@ -273,6 +276,7 @@ export async function runTelegramIngressWorkerRuntime(params: {
}
port.postMessage({ type: "spooled", updateId, queued: result.length });
}
pollingConfirmed = true;
failures = 0;
port.postMessage({
type: "poll-success",
+71
View File
@@ -0,0 +1,71 @@
// Tlon tests cover settings store behavior.
import { describe, expect, it } from "vitest";
import { createSettingsManager } from "./settings.js";
import type { UrbitSSEClient } from "./urbit/sse-client.js";
type SubscriptionHandlers = {
event?: (data: unknown) => Promise<void> | void;
};
function createMockSettingsApi(scryResult: unknown): {
api: UrbitSSEClient;
emitSettingsEvent: (event: unknown) => Promise<void>;
} {
const handlers: SubscriptionHandlers = {};
const api = {
async scry() {
return scryResult;
},
async subscribe(params: {
app: string;
path: string;
event?: (data: unknown) => Promise<void> | void;
}) {
handlers.event = params.event;
return 1;
},
} as unknown as UrbitSSEClient;
return {
api,
emitSettingsEvent: async (event: unknown) => {
await handlers.event?.(event);
},
};
}
describe("tlon settings store", () => {
it("loads autoDiscoverChannels from the settings-store scry response", async () => {
const { api } = createMockSettingsApi({
all: { moltbot: { tlon: { autoDiscoverChannels: true } } },
});
const manager = createSettingsManager(api);
await manager.load();
// Regression: parseSettingsResponse previously read the dead `autoDiscover`
// key, so the live `autoDiscoverChannels` override never reached the monitor.
expect(manager.current.autoDiscoverChannels).toBe(true);
});
it("applies live autoDiscoverChannels updates delivered over the subscription", async () => {
const { api, emitSettingsEvent } = createMockSettingsApi({
all: { moltbot: { tlon: {} } },
});
const manager = createSettingsManager(api);
await manager.load();
expect(manager.current.autoDiscoverChannels).toBeUndefined();
await manager.startSubscription();
await emitSettingsEvent({
"put-entry": {
desk: "moltbot",
"bucket-key": "tlon",
"entry-key": "autoDiscoverChannels",
value: false,
},
});
expect(manager.current.autoDiscoverChannels).toBe(false);
});
});
+6 -4
View File
@@ -34,7 +34,6 @@ export type PendingApproval = {
export type TlonSettingsStore = {
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscover?: boolean;
showModelSig?: boolean;
autoAcceptDmInvites?: boolean;
autoDiscoverChannels?: boolean;
@@ -118,7 +117,10 @@ function parseSettingsResponse(raw: unknown): TlonSettingsStore {
dmAllowlist: Array.isArray(settings.dmAllowlist)
? settings.dmAllowlist.filter((x): x is string => typeof x === "string")
: undefined,
autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined,
autoDiscoverChannels:
typeof settings.autoDiscoverChannels === "boolean"
? settings.autoDiscoverChannels
: undefined,
showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined,
autoAcceptDmInvites:
typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : undefined,
@@ -249,8 +251,8 @@ function applySettingsUpdate(
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "autoDiscover":
next.autoDiscover = typeof value === "boolean" ? value : undefined;
case "autoDiscoverChannels":
next.autoDiscoverChannels = typeof value === "boolean" ? value : undefined;
break;
case "showModelSig":
next.showModelSig = typeof value === "boolean" ? value : undefined;
+1
View File
@@ -1894,6 +1894,7 @@
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node scripts/run-with-env.mjs CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",
+3 -19
View File
@@ -39,7 +39,6 @@ const INFLECTED_COUNT_MARKER = "](inflect: true)";
const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings";
const MACOS_CATALOG_PATH = "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings";
const MACOS_INFO_PLIST_PATH = "apps/macos/Sources/OpenClaw/Resources/Info.plist";
const IOS_CONTRADICTIONS_PATH = "apps/.i18n/apple-translation-contradictions.json";
const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json";
const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native";
const SHARED_CHAT_UI_SOURCE_PREFIX = "apps/shared/OpenClawKit/Sources/OpenClawChatUI/";
@@ -479,10 +478,6 @@ function serializeCatalog(catalog: Catalog): string {
return `${JSON.stringify(catalog, null, 2)}\n`;
}
function serializeContradictions(contradictions: AppleTranslationContradiction[]): string {
return `${JSON.stringify({ version: 1, contradictions }, null, 2)}\n`;
}
function decodeXml(value: string): string {
return value
.replaceAll("&quot;", '"')
@@ -933,17 +928,6 @@ export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild>
}
await writeFile(catalogPath, expected, "utf8");
}
const contradictionsPath = path.join(ROOT, IOS_CONTRADICTIONS_PATH);
const expectedContradictions = serializeContradictions(build.contradictions);
const actualContradictions = await readOptionalFile(contradictionsPath);
if (actualContradictions !== expectedContradictions) {
if (!write) {
throw new Error(
`Apple contradiction report ${IOS_CONTRADICTIONS_PATH} is stale; run apple-app-i18n.ts sync-ios --write`,
);
}
await writeFile(contradictionsPath, expectedContradictions, "utf8");
}
return build;
}
@@ -971,9 +955,9 @@ export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBui
}
/**
* Regenerates every Apple derived artifact (app catalogs, contradiction report,
* InfoPlist strings). Shared by this CLI and native-app-i18n's sync so the
* inventory can never be rewritten without its derived catalogs.
* Regenerates every Apple derived artifact (app catalogs and InfoPlist strings).
* Shared by this CLI and native-app-i18n's sync so the inventory can never be
* rewritten without its derived catalogs.
*/
export async function syncAppleAppI18n(): Promise<{
build: AppleCatalogBuild;
+1 -1
View File
@@ -74,7 +74,7 @@ const NATIVE_I18N_SCOPE_RE =
const NATIVE_COOWNED_GENERATED_I18N_RE =
/^apps\/android\/app\/src\/main\/res\/values\/(?:assistant|strings)\.xml$/;
const NATIVE_HARD_GENERATED_I18N_RE =
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
const FAST_INSTALL_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
const FULL_INSTALL_SMOKE_SCOPE_RE =
@@ -6,6 +6,8 @@
export type BundledCliBackendAuthPolicy = {
/** Disable profile fallback and fail closed when the selected profile cannot materialize. */
strictSelectedProfile: boolean;
/** Owner responsible for refreshing selected OAuth credentials before execution. */
oauthRefreshOwner: "core" | "cli";
/** Provider whose imported OAuth profiles use identity-verified native passthrough. */
nativePassthroughProviderId?: string;
};
@@ -13,9 +15,13 @@ export type BundledCliBackendAuthPolicy = {
const BUNDLED_CLI_BACKEND_AUTH_POLICIES = {
"claude-cli": {
strictSelectedProfile: true,
oauthRefreshOwner: "core",
nativePassthroughProviderId: "claude-cli",
},
"google-gemini-cli": { strictSelectedProfile: false },
"google-gemini-cli": {
strictSelectedProfile: false,
oauthRefreshOwner: "cli",
},
} satisfies Record<string, BundledCliBackendAuthPolicy>;
export function resolveBundledCliBackendAuthPolicy(
+26 -137
View File
@@ -413,20 +413,16 @@ describe("prepareCliRunContext", () => {
);
});
it("passes raw refreshed OAuth profile fields to profile-owned CLI preparation", async () => {
it("passes expired Gemini CLI OAuth fields to CLI-owned refresh", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "google-gemini-cli:user@example.test";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => ({
apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }),
profileId: authProfileId,
profileType: "oauth" as const,
provider: "google-gemini-cli",
email: "user@example.test",
}));
const resolveApiKeyForProfile = vi.fn(async () => {
throw new Error("Gemini CLI OAuth must not enter core refresh");
});
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
@@ -437,7 +433,7 @@ describe("prepareCliRunContext", () => {
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1_800_000_000_000,
expires: 1,
projectId: "project-1",
email: "user@example.test",
},
@@ -472,7 +468,7 @@ describe("prepareCliRunContext", () => {
config: {},
});
expect(resolveApiKeyForProfile).toHaveBeenCalledOnce();
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
@@ -481,7 +477,7 @@ describe("prepareCliRunContext", () => {
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1_800_000_000_000,
expires: 1,
}),
}),
);
@@ -489,42 +485,28 @@ describe("prepareCliRunContext", () => {
expect(context.authBindingSkipsLocalCredential).toBe(true);
});
it("stages the resolved OAuth fallback profile for Gemini CLI preparation", async () => {
it("still materializes selected API keys for Gemini CLI preparation", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const legacyProfileId = "google-gemini-cli:default";
const resolvedProfileId = "google-gemini-cli:user@example.test";
const authProfileId = "google:api-key";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => ({
apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }),
profileId: resolvedProfileId,
profileType: "oauth" as const,
provider: "google-gemini-cli",
email: "user@example.test",
apiKey: "resolved-api-key",
profileId: authProfileId,
profileType: "api_key" as const,
provider: "google",
}));
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[legacyProfileId]: {
type: "oauth",
provider: "google-gemini-cli",
access: "stale-access-token",
refresh: "stale-refresh-token",
expires: 1_700_000_000_000,
email: "legacy@example.test",
},
[resolvedProfileId]: {
type: "oauth",
provider: "google-gemini-cli",
access: "resolved-access-token",
refresh: "resolved-refresh-token",
expires: 1_800_000_000_000,
projectId: "project-1",
email: "user@example.test",
[authProfileId]: {
type: "api_key",
provider: "google",
key: "stored-api-key",
},
},
},
@@ -552,20 +534,18 @@ describe("prepareCliRunContext", () => {
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
authProfileId: legacyProfileId,
authProfileId,
config: {},
});
expect(resolveApiKeyForProfile).toHaveBeenCalledOnce();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: resolvedProfileId,
authProfileId,
authCredential: expect.objectContaining({
type: "oauth",
provider: "google-gemini-cli",
access: "resolved-access-token",
refresh: "resolved-refresh-token",
expires: 1_800_000_000_000,
type: "api_key",
provider: "google",
key: "resolved-api-key",
}),
}),
);
@@ -578,13 +558,9 @@ describe("prepareCliRunContext", () => {
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => ({
apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }),
profileId: authProfileId,
profileType: "oauth" as const,
provider: "google-gemini-cli",
email: "user@example.test",
}));
const resolveApiKeyForProfile = vi.fn(async () => {
throw new Error("Gemini CLI OAuth must not enter core refresh");
});
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
@@ -638,12 +614,7 @@ describe("prepareCliRunContext", () => {
} as OpenClawConfig,
});
expect(resolveApiKeyForProfile).toHaveBeenCalledWith(
expect.objectContaining({
profileId: authProfileId,
agentDir,
}),
);
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
@@ -658,88 +629,6 @@ describe("prepareCliRunContext", () => {
);
});
it("stages adopted OAuth credentials for Gemini CLI preparation", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "google-gemini-cli:user@example.test";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => ({
apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }),
profileId: authProfileId,
profileType: "oauth" as const,
provider: "google-gemini-cli",
email: "user@example.test",
credential: {
type: "oauth" as const,
provider: "google-gemini-cli",
access: "adopted-access-token",
refresh: "adopted-refresh-token",
expires: 1_900_000_000_000,
projectId: "project-1",
email: "user@example.test",
},
}));
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "google-gemini-cli",
access: "stale-access-token",
refresh: "stale-refresh-token",
expires: 1_700_000_000_000,
projectId: "project-1",
email: "user@example.test",
},
},
},
agentDir,
);
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: false,
authEpochMode: "profile-only",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
await fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
authProfileId,
config: {},
});
expect(resolveApiKeyForProfile).toHaveBeenCalledOnce();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
authCredential: expect.objectContaining({
type: "oauth",
provider: "google-gemini-cli",
access: "adopted-access-token",
refresh: "adopted-refresh-token",
expires: 1_900_000_000_000,
}),
}),
);
});
it("does not expose auth profile credentials to non-bundled prepare hooks", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
+7 -7
View File
@@ -344,13 +344,13 @@ function shouldRefreshAuthProfileForExecution(params: {
authProfileId?: string;
authCredential?: AuthProfileCredential;
}): boolean {
return Boolean(
params.policy &&
params.authProfileId &&
(params.authCredential?.type === "oauth" ||
params.authCredential?.type === "api_key" ||
params.authCredential?.type === "token"),
);
if (!params.policy || !params.authProfileId || !params.authCredential) {
return false;
}
if (params.authCredential.type === "oauth") {
return params.policy.oauthRefreshOwner === "core";
}
return params.authCredential.type === "api_key" || params.authCredential.type === "token";
}
type CliAuthProfileResolutionFailure =
+19
View File
@@ -3,6 +3,7 @@
* Exercises raw error coercion, remediation hints, timeout/auth/billing/rate-limit cases.
*/
import { describe, expect, it } from "vitest";
import { createAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js";
import { classifyFailoverSignal } from "./embedded-agent-helpers/errors.js";
import {
buildFailoverRemediationHint,
@@ -1416,6 +1417,17 @@ describe("failover-error", () => {
).toBe(true);
});
it("returns true for stale gateway lifecycle ownership loss", () => {
const staleLifecycle = createAgentRunStaleLifecycleError();
expect(isNonProviderRuntimeCoordinationError(staleLifecycle)).toBe(true);
expect(
isNonProviderRuntimeCoordinationError(new Error("wrapper", { cause: staleLifecycle })),
).toBe(true);
const abortWrapper = new Error("request was aborted", { cause: staleLifecycle });
abortWrapper.name = "AbortError";
expect(isNonProviderRuntimeCoordinationError(abortWrapper)).toBe(true);
});
it("returns true when the coordination error is nested via cause", () => {
const wrapped = new Error("wrapper", { cause: makeSessionLockError() });
expect(isNonProviderRuntimeCoordinationError(wrapped)).toBe(true);
@@ -1459,6 +1471,13 @@ describe("failover-error", () => {
cause: { result: { reason: "missing_tool_result" } },
}),
).toBe(false);
expect(
isNonProviderRuntimeCoordinationError({
status: 503,
message: "upstream overloaded",
cause: createAgentRunStaleLifecycleError(),
}),
).toBe(false);
expect(isNonProviderRuntimeCoordinationError(null)).toBe(false);
expect(isNonProviderRuntimeCoordinationError(undefined)).toBe(false);
});
+26 -1
View File
@@ -5,6 +5,7 @@
*/
import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion";
import { formatCliCommand } from "../cli/command-format.js";
import { isAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js";
import { readErrorName } from "../infra/errors.js";
import {
classifyFailoverSignal,
@@ -496,6 +497,22 @@ function hasMissingToolResultFailure(err: unknown): boolean {
return findErrorProperty(err, readMissingToolResultMarker) === true;
}
function hasStaleAgentRunLifecycleFailure(err: unknown): boolean {
return (
findErrorProperty(err, (candidate) =>
isAgentRunStaleLifecycleError(candidate) ? true : undefined,
) === true
);
}
function hasDirectProviderFailureIdentity(err: unknown): boolean {
if (isFailoverError(err)) {
return true;
}
const signal = normalizeDirectErrorSignal(err);
return Boolean(signal.status || signal.code || signal.errorType || signal.provider);
}
/**
* True when the error is a local runtime coordination/tool-execution error
* rather than a provider/model failure. The model fallback chain must abort on
@@ -899,6 +916,13 @@ export function resolveModelFallbackError(
if (err instanceof AgentHarnessSessionSupersededError) {
return { kind: "coordination", error: err };
}
const staleLifecycleFailure = hasStaleAgentRunLifecycleFailure(err);
if (
staleLifecycleFailure &&
(isAgentRunStaleLifecycleError(err) || !hasDirectProviderFailureIdentity(err))
) {
return { kind: "coordination", error: err };
}
// A direct takeover remains a coordination failure unless the dedicated
// cleanup wrapper owns a preserved prompt error. Its message alone must not
// reclassify session-state loss as a provider failure.
@@ -912,7 +936,8 @@ export function resolveModelFallbackError(
if (
hasSessionWriteLockContention(err) ||
hasEmbeddedAttemptSessionTakeover(err) ||
hasMissingToolResultFailure(err)
hasMissingToolResultFailure(err) ||
staleLifecycleFailure
) {
return { kind: "coordination", error: err };
}
@@ -333,6 +333,7 @@ export function scheduleRestartAbortedMainSessionRecovery(params: {
maxRetries?: number;
shouldContinue?: () => boolean;
stateDir?: string;
waitForStart?: () => Promise<void>;
gatewayRuntime: GatewayRecoveryRuntime;
}): { stop: () => Promise<void> } {
const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;
@@ -343,12 +344,16 @@ export function scheduleRestartAbortedMainSessionRecovery(params: {
let timer: ReturnType<typeof setTimeout> | undefined;
let queuedAttempt: Promise<void> | undefined;
let activeAttempt: Promise<void> | undefined;
let cancelStartWait: (() => void) | undefined;
const startWaitCancelled = new Promise<void>((resolve) => {
cancelStartWait = resolve;
});
const shouldContinue = () =>
!stopped &&
params.shouldContinue?.() !== false &&
isAgentEventLifecycleGenerationCurrent(lifecycleGeneration);
// Only reconcile rows that existed before this startup recovery was scheduled.
// Fresh runs started by this gateway are protected again by the active-run check.
// Capture the cutoff at registration, before any startup gate can release new
// work. Sessions created by this gateway must never become recovery candidates.
const startupRecoveryCutoffMs = Date.now();
const runRecoveryAttempt = (attempt: number, delay: number) => {
@@ -436,28 +441,36 @@ export function scheduleRestartAbortedMainSessionRecovery(params: {
activeAttempt = trackedAttempt;
};
const queueRecoveryAttempt = (attempt: number, delay: number) => {
const pendingStart = Promise.resolve().then(async () => {
if (attempt === 1 && params.waitForStart) {
// Shutdown must cancel an unresolved startup gate so failed startup and
// same-port replacement cannot hang while joining this lifetime owner.
await Promise.race([params.waitForStart(), startWaitCancelled]);
}
if (shouldContinue()) {
runRecoveryAttempt(attempt, delay);
}
});
const trackedStart = pendingStart.finally(() => {
if (queuedAttempt === trackedStart) {
queuedAttempt = undefined;
}
});
queuedAttempt = trackedStart;
};
const scheduleAttempt = (attempt: number, delay: number) => {
if (!shouldContinue()) {
return;
}
if (delay <= 0) {
// Publish the cancellable handle before immediate startup can claim a session.
const pendingStart = Promise.resolve().then(() => {
if (shouldContinue()) {
runRecoveryAttempt(attempt, delay);
}
});
const trackedStart = pendingStart.finally(() => {
if (queuedAttempt === trackedStart) {
queuedAttempt = undefined;
}
});
queuedAttempt = trackedStart;
queueRecoveryAttempt(attempt, delay);
return;
}
timer = setTimeout(() => {
timer = undefined;
runRecoveryAttempt(attempt, delay);
queueRecoveryAttempt(attempt, delay);
}, delay);
timer.unref?.();
};
@@ -468,6 +481,7 @@ export function scheduleRestartAbortedMainSessionRecovery(params: {
// Restart recovery belongs to its startup generation; stale timers must
// never claim a session after that gateway begins draining.
stopped = true;
cancelStartWait?.();
if (timer) {
clearTimeout(timer);
timer = undefined;
@@ -2387,6 +2387,87 @@ describe("main-session-restart-recovery", () => {
});
});
it("waits for startup release while preserving the registration cutoff", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
const releaseStartup = createDeferred();
await writeStore(sessionsDir, {
"agent:main:main": {
...runningSessionEntry("pre-start-session"),
updatedAt: 1,
},
});
await writeTranscript(sessionsDir, "pre-start-session", [
{ role: "user", content: "resume the interrupted work" },
{ role: "toolResult", content: "done" },
]);
const recovery = scheduleRestartAbortedMainSessionRecovery({
cfg: {},
delayMs: 0,
stateDir: tmpDir,
waitForStart: () => releaseStartup.promise,
});
await Promise.resolve();
expect(callGateway).not.toHaveBeenCalled();
const postRegistrationUpdatedAt = Date.now() + 60_000;
await writeStore(sessionsDir, {
...readStore(storePath),
"agent:main:fresh": {
...runningSessionEntry("post-start-session"),
updatedAt: postRegistrationUpdatedAt,
},
});
await writeTranscript(sessionsDir, "post-start-session", [
{ role: "user", content: "new work from this gateway" },
{ role: "toolResult", content: "done" },
]);
releaseStartup.resolve();
await waitForFast(() => expect(callGateway).toHaveBeenCalledOnce());
await recovery.stop();
const store = readStore(storePath);
expect(store["agent:main:main"]?.abortedLastRun).toBe(false);
expect(store["agent:main:fresh"]).toMatchObject({
sessionId: "post-start-session",
status: "running",
});
expect(store["agent:main:fresh"]?.abortedLastRun).toBeUndefined();
});
it("stops without waiting for an unresolved startup release", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
const releaseStartup = createDeferred();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: {
kind: "replayable",
text: "interrupted response",
createdAt: Date.now(),
},
});
const recovery = scheduleRestartAbortedMainSessionRecovery({
cfg: {},
delayMs: 0,
stateDir: tmpDir,
waitForStart: () => releaseStartup.promise,
});
await recovery.stop();
releaseStartup.resolve();
await Promise.resolve();
expect(callGateway).not.toHaveBeenCalled();
expect(getActiveGatewayRootWorkCount()).toBe(0);
expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
status: "running",
abortedLastRun: true,
});
});
it("fences an in-flight startup recovery before its durable session claim", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
+33
View File
@@ -5,6 +5,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { TranscriptNotContinuableError } from "../../packages/agent-core/src/errors.js";
import type { OpenClawConfig } from "../config/config.js";
import { createAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js";
import {
onTrustedInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
@@ -1927,6 +1928,38 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("aborts the fallback chain on stale gateway lifecycle errors (#116418)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "ollama/qwen3:0.6b",
fallbacks: ["minimax/MiniMax-M3"],
},
},
},
});
const lifecycleError = createAgentRunStaleLifecycleError();
const wrappedLifecycleError = new Error("request was aborted", { cause: lifecycleError });
wrappedLifecycleError.name = "AbortError";
const run = vi.fn().mockRejectedValue(wrappedLifecycleError);
const onFallbackStep = vi.fn();
await expect(
runWithModelFallback({
cfg,
provider: "ollama",
model: "qwen3:0.6b",
run,
onFallbackStep,
}),
).rejects.toBe(wrappedLifecycleError);
expect(run).toHaveBeenCalledTimes(1);
expect(onFallbackStep).not.toHaveBeenCalledWith(
expect.objectContaining({ decision: "candidate_failed" }),
);
});
it("aborts the fallback chain on transcript continuation failures without candidate_failed attribution", async () => {
const cfg = makeCfg({
agents: {
+74 -187
View File
@@ -1,4 +1,3 @@
import { expectDefined } from "@openclaw/normalization-core";
/**
* Channel setup config mutation helpers.
*
@@ -10,31 +9,26 @@ import { resolveSingleAccountKeysToMove } from "./setup-promotion-helpers.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
type ChannelSectionBase = {
type ChannelSectionBase = Record<string, unknown> & {
name?: string;
defaultAccount?: string;
accounts?: Record<string, Record<string, unknown>>;
};
function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
function getChannelSection(
cfg: OpenClawConfig,
channelKey: string,
): ChannelSectionBase | undefined {
const section = (cfg.channels as Record<string, unknown> | undefined)?.[channelKey];
return section && typeof section === "object" ? (section as ChannelSectionBase) : undefined;
}
function shouldStoreNameInAccounts(params: {
cfg: OpenClawConfig;
channelKey: string;
accountId: string;
alwaysUseAccounts?: boolean;
}): boolean {
if (params.alwaysUseAccounts) {
return true;
}
if (params.accountId !== DEFAULT_ACCOUNT_ID) {
return true;
}
return channelHasAccounts(params.cfg, params.channelKey);
function writeChannelSection(
cfg: OpenClawConfig,
channelKey: string,
section: ChannelSectionBase,
): OpenClawConfig {
return { ...cfg, channels: { ...cfg.channels, [channelKey]: section } } as OpenClawConfig;
}
export function applyAccountNameToChannelSection(params: {
@@ -49,51 +43,23 @@ export function applyAccountNameToChannelSection(params: {
return params.cfg;
}
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined;
const useAccounts = shouldStoreNameInAccounts({
cfg: params.cfg,
channelKey: params.channelKey,
accountId,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const safeBase = base ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...safeBase,
name: trimmed,
},
},
} as OpenClawConfig;
const base = getChannelSection(params.cfg, params.channelKey);
const accounts = base?.accounts ?? {};
const useAccounts =
params.alwaysUseAccounts ||
accountId !== DEFAULT_ACCOUNT_ID ||
Object.keys(accounts).length > 0;
if (!useAccounts) {
return writeChannelSection(params.cfg, params.channelKey, { ...base, name: trimmed });
}
const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
},
} as OpenClawConfig;
return writeChannelSection(params.cfg, params.channelKey, {
...baseWithoutName,
accounts: { ...accounts, [accountId]: { ...accounts[accountId], name: trimmed } },
});
}
/** Moves a root-level channel name into `accounts.default` before adding named accounts. */
@@ -105,8 +71,7 @@ export function migrateBaseNameToDefaultAccount(params: {
if (params.alwaysUseAccounts) {
return params.cfg;
}
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
const base = getChannelSection(params.cfg, params.channelKey);
const baseName = base?.name?.trim();
if (!baseName) {
return params.cfg;
@@ -119,16 +84,7 @@ export function migrateBaseNameToDefaultAccount(params: {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...rest,
accounts,
},
},
} as OpenClawConfig;
return writeChannelSection(params.cfg, params.channelKey, { ...rest, accounts });
}
/** Applies setup-time account naming and optional root-name migration in one step. */
@@ -164,12 +120,7 @@ export function applySetupAccountConfigPatch(params: {
accountId: string;
patch: Record<string, unknown>;
}): OpenClawConfig {
return patchScopedAccountConfig({
cfg: params.cfg,
channelKey: params.channelKey,
accountId: params.accountId,
patch: params.patch,
});
return patchScopedAccountConfig(params);
}
/** Creates a setup adapter that turns validated setup input into an account config patch. */
@@ -301,14 +252,7 @@ export function patchScopedAccountConfig(params: {
scopeDefaultToAccounts?: boolean;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[params.channelKey];
const base =
typeof channelConfig === "object" && channelConfig
? (channelConfig as Record<string, unknown> & {
accounts?: Record<string, Record<string, unknown>>;
})
: undefined;
const base = getChannelSection(params.cfg, params.channelKey);
const ensureChannelEnabled = params.ensureChannelEnabled ?? true;
const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled;
const patch = params.patch;
@@ -325,102 +269,67 @@ export function patchScopedAccountConfig(params: {
};
if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) {
// Default accounts historically live at channel root unless the channel opts into accounts.default.
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...clearFields(base ?? {}),
...(ensureChannelEnabled ? { enabled: true } : {}),
...patch,
},
},
} as OpenClawConfig;
return writeChannelSection(params.cfg, params.channelKey, {
...clearFields(base ?? {}),
...(ensureChannelEnabled ? { enabled: true } : {}),
...patch,
});
}
const accounts = base?.accounts ?? {};
const existingAccount = clearFields(accounts[accountId] ?? {});
// Preserve an explicit disabled account while enabling newly created accounts by default.
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...base,
...(ensureChannelEnabled ? { enabled: true } : {}),
accounts: {
...accounts,
[accountId]: {
...existingAccount,
...(ensureAccountEnabled
? {
enabled:
typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true,
}
: {}),
...accountPatch,
},
},
return writeChannelSection(params.cfg, params.channelKey, {
...base,
...(ensureChannelEnabled ? { enabled: true } : {}),
accounts: {
...accounts,
[accountId]: {
...existingAccount,
...(ensureAccountEnabled
? {
enabled:
typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true,
}
: {}),
...accountPatch,
},
},
} as OpenClawConfig;
}
type ChannelSectionRecord = Record<string, unknown> & {
accounts?: Record<string, Record<string, unknown>>;
};
function cloneIfObject<T>(value: T): T {
if (value && typeof value === "object") {
return structuredClone(value);
}
return value;
});
}
function moveSingleAccountKeysIntoAccount(params: {
cfg: OpenClawConfig;
channelKey: string;
channel: ChannelSectionRecord;
channel: ChannelSectionBase;
accounts: Record<string, Record<string, unknown>>;
keysToMove: string[];
targetAccountId: string;
baseAccount?: Record<string, unknown>;
}): OpenClawConfig {
const nextAccount: Record<string, unknown> = { ...params.baseAccount };
const nextChannel: ChannelSectionBase = { ...params.channel };
for (const key of params.keysToMove) {
if (!(key in nextAccount)) {
nextAccount[key] = cloneIfObject(params.channel[key]);
const value = params.channel[key];
nextAccount[key] = value && typeof value === "object" ? structuredClone(value) : value;
}
}
const nextChannel: ChannelSectionRecord = { ...params.channel };
for (const key of params.keysToMove) {
delete nextChannel[key];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...nextChannel,
accounts: {
...params.accounts,
[params.targetAccountId]: nextAccount,
},
},
},
} as OpenClawConfig;
return writeChannelSection(params.cfg, params.channelKey, {
...nextChannel,
accounts: { ...params.accounts, [params.targetAccountId]: nextAccount },
});
}
function resolveExistingAccountKey(
accounts: Record<string, Record<string, unknown>>,
targetAccountId: string,
): string {
for (const existingKey of Object.keys(accounts)) {
if (normalizeAccountId(existingKey) === targetAccountId) {
return existingKey;
}
}
return targetAccountId;
return (
Object.keys(accounts).find((key) => normalizeAccountId(key) === targetAccountId) ??
targetAccountId
);
}
function resolveSingleAccountPromotionTarget(params: {
@@ -446,9 +355,7 @@ function resolveSingleAccountPromotionTarget(params: {
);
}
const namedAccounts = Object.keys(accounts).filter(Boolean);
return namedAccounts.length === 1
? expectDefined(namedAccounts[0], "named accounts entry at 0")
: DEFAULT_ACCOUNT_ID;
return namedAccounts.length === 1 ? (namedAccounts[0] ?? DEFAULT_ACCOUNT_ID) : DEFAULT_ACCOUNT_ID;
}
/**
@@ -459,54 +366,34 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: {
channelKey: string;
setupSurface?: ChannelSetupAdapter;
}): OpenClawConfig {
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionRecord) : undefined;
const base = getChannelSection(params.cfg, params.channelKey);
if (!base) {
return params.cfg;
}
const accounts = base.accounts ?? {};
if (Object.keys(accounts).length > 0) {
const keysToMove = resolveSingleAccountKeysToMove({
channelKey: params.channelKey,
channel: base,
setupSurface: params.setupSurface,
includeSetupKeys: true,
});
if (keysToMove.length === 0) {
return params.cfg;
}
const targetAccountId = resolveSingleAccountPromotionTarget({
channel: base,
setupSurface: params.setupSurface,
});
// Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape.
const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId);
return moveSingleAccountKeysIntoAccount({
cfg: params.cfg,
channelKey: params.channelKey,
channel: base,
accounts,
keysToMove,
targetAccountId: resolvedTargetAccountKey,
baseAccount: accounts[resolvedTargetAccountKey],
});
}
const hasAccounts = Object.keys(accounts).length > 0;
const keysToMove = resolveSingleAccountKeysToMove({
channelKey: params.channelKey,
channel: base,
setupSurface: params.setupSurface,
includeSetupKeys: true,
});
if (hasAccounts && keysToMove.length === 0) {
return params.cfg;
}
const targetAccountId = hasAccounts
? resolveSingleAccountPromotionTarget({ channel: base, setupSurface: params.setupSurface })
: DEFAULT_ACCOUNT_ID;
// Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape.
const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId);
return moveSingleAccountKeysIntoAccount({
cfg: params.cfg,
channelKey: params.channelKey,
channel: base,
accounts,
keysToMove,
targetAccountId: DEFAULT_ACCOUNT_ID,
targetAccountId: resolvedTargetAccountKey,
baseAccount: accounts[resolvedTargetAccountKey],
});
}
+16 -28
View File
@@ -9,7 +9,6 @@ import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
type PromptAllowFromParams = Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0];
type ResolveConfiguredParams = Parameters<ChannelSetupWizard["status"]["resolveConfigured"]>[0];
type ResolveAllowFromEntriesParams = Parameters<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>[0];
@@ -20,30 +19,6 @@ type ResolveGroupAllowlistParams = Parameters<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>[0];
/**
* Delegates setup configured-state checks to a lazily loaded wizard.
*/
function createDelegatedResolveConfigured(loadWizard: () => Promise<ChannelSetupWizard>) {
return async ({ cfg, accountId }: ResolveConfiguredParams) =>
await (await loadWizard()).status.resolveConfigured({ cfg, accountId });
}
/**
* Delegates setup preparation to a lazily loaded wizard.
*/
function createDelegatedPrepare(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]) =>
await (await loadWizard()).prepare?.(params);
}
/**
* Delegates setup finalization to a lazily loaded wizard.
*/
function createDelegatedFinalize(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]) =>
await (await loadWizard()).finalize?.(params);
}
type DelegatedStatusBase = Omit<
ChannelSetupWizard["status"],
"resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore"
@@ -70,7 +45,8 @@ export function createDelegatedSetupWizardProxy(params: {
channel: params.channel,
status: {
...params.status,
resolveConfigured: createDelegatedResolveConfigured(params.loadWizard),
resolveConfigured: async (statusParams) =>
await (await params.loadWizard()).status.resolveConfigured(statusParams),
...createDelegatedSetupWizardStatusResolvers(params.loadWizard),
},
// Keep static setup metadata available immediately, while expensive
@@ -78,10 +54,22 @@ export function createDelegatedSetupWizardProxy(params: {
...(params.resolveShouldPromptAccountIds
? { resolveShouldPromptAccountIds: params.resolveShouldPromptAccountIds }
: {}),
...(params.delegatePrepare ? { prepare: createDelegatedPrepare(params.loadWizard) } : {}),
...(params.delegatePrepare
? {
prepare: async (
prepareParams: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0],
) => await (await params.loadWizard()).prepare?.(prepareParams),
}
: {}),
credentials: params.credentials ?? [],
...(params.textInputs ? { textInputs: params.textInputs } : {}),
...(params.delegateFinalize ? { finalize: createDelegatedFinalize(params.loadWizard) } : {}),
...(params.delegateFinalize
? {
finalize: async (
finalizeParams: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0],
) => await (await params.loadWizard()).finalize?.(finalizeParams),
}
: {}),
...(params.completionNote ? { completionNote: params.completionNote } : {}),
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
...(params.disable ? { disable: params.disable } : {}),
+1
View File
@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
resolveNativeServiceProfileConflict: () => null,
resolveGatewayPort: resolveGatewayPortMock,
resolveIsNixMode: resolveIsNixModeMock,
}));
+49
View File
@@ -254,6 +254,55 @@ describe("runServiceRestart token drift", () => {
);
});
it("runs the service mutation guard before restarting a loaded service", async () => {
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
});
expect(beforeServiceMutation).toHaveBeenCalledTimes(1);
expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan(
service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("aborts loaded-service mutation when the service guard rejects", async () => {
const repairLoadedService = vi.fn();
await expect(
runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation: () => {
throw new Error("service mutation denied");
},
repairLoadedService,
}),
).rejects.toThrow("service mutation denied");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(repairLoadedService).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("does not run the service mutation guard before not-loaded recovery", async () => {
service.isLoaded.mockResolvedValue(false);
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
onNotLoaded: async () => ({
result: "restarted",
message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.",
}),
});
expect(beforeServiceMutation).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("repairs managed port drift before restarting", async () => {
service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 });
service.readCommand.mockResolvedValue({
+7
View File
@@ -459,6 +459,7 @@ export async function runServiceRestart(params: {
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
expectedPort?: number;
beforeServiceMutation?: () => void;
repairLoadedService?: (
ctx: ServiceStartRepairContext,
) => Promise<ServiceRecoveryResult<"restarted"> | null>;
@@ -533,6 +534,12 @@ export async function runServiceRestart(params: {
}
}
// Loaded services cross the native mutation boundary here. Not-loaded recovery
// may still target a separately verified unmanaged listener.
if (loaded) {
params.beforeServiceMutation?.();
}
if (!loaded) {
try {
handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null;
@@ -0,0 +1,41 @@
type RestartPostCheckContext = {
json: boolean;
stdout: NodeJS.WritableStream;
warnings: string[];
fail: (message: string, hints?: string[]) => void;
};
export type RestartParams = {
opts?: { json?: boolean };
beforeServiceMutation?: () => void;
repairLoadedService?: (ctx: {
json: boolean;
stdout: NodeJS.WritableStream;
state: unknown;
issues: unknown[];
}) => Promise<unknown>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
};
export function requireMockCallArg(
mockFn: { mock: { calls: unknown[][] } },
label: string,
index = 0,
): Record<string, unknown> {
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
if (!arg) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return arg;
}
export async function expectRestartError(
promise: Promise<unknown>,
): Promise<Error & { hints?: string[] }> {
try {
await promise;
} catch (error) {
return error as Error & { hints?: string[] };
}
throw new Error("expected restart to fail");
}
+49 -68
View File
@@ -1,6 +1,11 @@
// Daemon lifecycle tests cover CLI service lifecycle orchestration and cleanup.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import {
expectRestartError,
requireMockCallArg,
type RestartParams,
} from "./lifecycle.test-helpers.js";
type RestartHealthSnapshot = {
healthy: boolean;
@@ -11,30 +16,13 @@ type RestartHealthSnapshot = {
elapsedMs?: number;
};
type RestartPostCheckContext = {
json: boolean;
stdout: NodeJS.WritableStream;
warnings: string[];
fail: (message: string, hints?: string[]) => void;
};
type RestartParams = {
opts?: { json?: boolean };
repairLoadedService?: (ctx: {
json: boolean;
stdout: NodeJS.WritableStream;
state: unknown;
issues: unknown[];
}) => Promise<unknown>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
};
const service = {
readCommand: vi.fn(),
readRuntime: vi.fn(),
restart: vi.fn(),
stop: vi.fn(),
};
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
const runServiceStart = vi.fn();
const runServiceRestart = vi.fn();
@@ -99,29 +87,6 @@ const createGatewayLifecycleMutationAudit = vi.fn(
}),
);
function requireMockCallArg(
mockFn: { mock: { calls: unknown[][] } },
label: string,
index = 0,
): Record<string, unknown> {
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
if (!arg) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return arg;
}
async function expectRestartError(
promise: Promise<unknown>,
): Promise<Error & { hints?: string[] }> {
try {
await promise;
} catch (error) {
return error as Error & { hints?: string[] };
}
throw new Error("expected restart to fail");
}
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => loadConfig(),
loadConfig: () => loadConfig(),
@@ -129,7 +94,10 @@ vi.mock("../../config/config.js", () => ({
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
}));
vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true }));
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: () => isDefaultInstallIdentity(),
resolveNativeServiceProfileConflict: () => null,
}));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync,
@@ -276,13 +244,12 @@ describe("runDaemonRestart health checks", () => {
]);
delete process.env.OPENCLAW_CONTAINER_HINT;
service.readCommand.mockReset();
service.readRuntime.mockReset();
service.readRuntime.mockResolvedValue({ status: "stopped" });
service.restart.mockReset();
service.readRuntime.mockReset().mockResolvedValue({ status: "stopped" });
service.restart.mockReset().mockResolvedValue({ outcome: "completed" });
service.stop.mockReset();
runServiceStart.mockReset();
runServiceStart.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockReset();
runServiceStop.mockReset();
runServiceStop.mockReset().mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockReset();
waitForGatewayHealthyRestart.mockReset();
terminateStaleGatewayPids.mockReset();
@@ -290,43 +257,36 @@ describe("runDaemonRestart health checks", () => {
renderRestartDiagnostics.mockReset();
resolveGatewayPort.mockReset();
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
signalVerifiedGatewayPidSync.mockReset();
writeGatewayRestartIntentSync.mockReset();
signalVerifiedGatewayPidSync.mockReset().mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReset().mockReturnValue(true);
clearGatewayRestartIntentSync.mockReset();
formatGatewayPidList.mockReset();
formatGatewayPidList.mockReset().mockImplementation((pids) => pids.join(", "));
probeGateway.mockReset();
callGatewayCli.mockReset();
isRestartEnabled.mockReset();
loadConfig.mockReset();
readActiveGatewayLockPort.mockReset();
readActiveGatewayLockPort.mockReset().mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockReset();
recoverInstalledLaunchAgent.mockReset();
recoverInstalledLaunchAgent.mockReset().mockResolvedValue(null);
repairLoadedGatewayServiceForStart.mockReset();
isTerminalInteractive.mockReset();
isTerminalInteractive.mockReturnValue(true);
isTerminalInteractive.mockReset().mockReturnValue(true);
appendGatewayLifecycleAudit.mockClear();
createGatewayLifecycleMutationAudit.mockClear();
isDefaultInstallIdentity.mockReset().mockReturnValue(true);
service.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceStart.mockResolvedValue(undefined);
recoverInstalledLaunchAgent.mockResolvedValue(null);
readActiveGatewayLockPort.mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockResolvedValue({
pid: 4200,
ownerId: "gateway-owner-old",
createdAt: "2026-07-16T12:00:00.000Z",
port: 18_789,
});
findInstalledSystemdGatewayScope.mockReset();
findInstalledSystemdGatewayScope.mockResolvedValue(null);
restartSystemdService.mockReset();
restartSystemdService.mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset();
stopSystemdService.mockResolvedValue(undefined);
findInstalledSystemdGatewayScope.mockReset().mockResolvedValue(null);
restartSystemdService.mockReset().mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@@ -342,7 +302,6 @@ describe("runDaemonRestart health checks", () => {
});
return true;
});
runServiceStop.mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockResolvedValue({
healthy: true,
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
@@ -383,9 +342,6 @@ describe("runDaemonRestart health checks", () => {
},
});
isRestartEnabled.mockReturnValue(true);
signalVerifiedGatewayPidSync.mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReturnValue(true);
formatGatewayPidList.mockImplementation((pids) => pids.join(", "));
});
afterEach(() => {
@@ -417,6 +373,16 @@ describe("runDaemonRestart health checks", () => {
expect(requireMockCallArg(runServiceRestart, "runServiceRestart").expectedPort).toBeUndefined();
});
it("guards loaded service restart at the native mutation boundary", async () => {
await runDaemonRestart({ json: true });
const restartParams = requireMockCallArg(runServiceRestart, "runServiceRestart");
isDefaultInstallIdentity.mockReturnValue(false);
expect(() => (restartParams.beforeServiceMutation as () => void)()).toThrow(
/non-default state dir/,
);
});
it("uses the installed service environment for managed restart health", async () => {
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-caller-state";
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway-maintenance.service";
@@ -840,12 +806,15 @@ describe("runDaemonRestart health checks", () => {
});
it("signals a single unmanaged gateway process on restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
mockUnmanagedRestart({ runPostRestartCheck: true });
await runDaemonRestart({ json: true });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
action: "restart",
@@ -860,6 +829,17 @@ describe("runDaemonRestart health checks", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("rejects denied Darwin recovery when no unmanaged listener exists", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
isDefaultInstallIdentity.mockReturnValue(false);
mockUnmanagedRestart();
await expect(runDaemonRestart({ json: true })).rejects.toThrow(/non-default state dir/);
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it("uses targeted RPC for an unmanaged Windows gateway restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
@@ -1025,6 +1005,7 @@ describe("runDaemonRestart health checks", () => {
});
it("fails unmanaged restart when multiple gateway listeners are present", async () => {
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]);
mockUnmanagedRestart();
+12 -9
View File
@@ -28,6 +28,7 @@ import {
assertGatewayServiceMutationAllowed,
formatExternalSupervisorActionRequired,
isGatewayExternallySupervised,
resolveGatewayServiceMutationError,
} from "../../infra/gateway-supervision.js";
import {
clearGatewayRestartIntentSync,
@@ -383,16 +384,13 @@ async function signalGatewayRestart(
};
}
async function restartGatewayWithoutServiceManager(
port: number,
restartIntent?: GatewayRestartIntent,
) {
const managed = await handleSystemScopeSystemdGateway("restart");
async function restartUnmanaged(port: number, intent?: GatewayRestartIntent, allowSystem = true) {
const managed = allowSystem ? await handleSystemScopeSystemdGateway("restart") : null;
if (managed) {
return managed;
}
return await signalGatewayRestart(port, {
restartIntent,
restartIntent: intent,
enforceRestartConfig: true,
processLabel: "unmanaged",
auditSource: "cli",
@@ -402,7 +400,7 @@ async function restartGatewayWithoutServiceManager(
type GatewaySignalRestartResult = NonNullable<Awaited<ReturnType<typeof signalGatewayRestart>>>;
function isGatewaySignalRestartResult(
result: Awaited<ReturnType<typeof restartGatewayWithoutServiceManager>>,
result: Awaited<ReturnType<typeof restartUnmanaged>>,
): result is GatewaySignalRestartResult {
return result !== null && "pid" in result && typeof result.pid === "number";
}
@@ -595,6 +593,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
},
checkTokenDrift: true,
expectedPort: configuredPort,
beforeServiceMutation: () => assertGatewayServiceMutationAllowed("restart the gateway"),
repairLoadedService: async ({ json, stdout, warn, state, issues }) => {
const result = await repairLoadedGatewayServiceForStart({
action: "restart",
@@ -612,7 +611,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return result;
},
onNotLoaded: async () => {
if (process.platform === "darwin") {
const mutationError = resolveGatewayServiceMutationError("restart the gateway");
if (process.platform === "darwin" && !mutationError) {
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
if (recovered) {
appendGatewayLifecycleAudit({
@@ -623,7 +623,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return recovered;
}
}
const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent);
const handled = await restartUnmanaged(unmanagedPort, restartIntent, !mutationError);
if (handled) {
restartedWithoutServiceManager = true;
if (isGatewaySignalRestartResult(handled) && handled.previousLockIdentity) {
@@ -635,6 +635,9 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
return handled;
}
if (mutationError) {
throw mutationError;
}
return null;
},
postRestartCheck: async ({ warnings, fail, stdout, warn }) => {
+35
View File
@@ -358,6 +358,41 @@ describe("applyCliProfileEnv", () => {
expect(env.OPENCLAW_CONFIG_PATH).toBe("/srv/openclaw/custom.json");
});
it.each(["openclaw-gateway-main", "openclaw-gateway-main.service"])(
"drops inherited canonical service identities when switching profiles (%s)",
(systemdUnit) => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "main",
OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main",
OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json",
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.main",
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (main)",
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined();
expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined();
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined();
},
);
it("preserves explicit custom service identities when switching profiles", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "main",
OPENCLAW_LAUNCHD_LABEL: "com.example.gateway",
OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service",
OPENCLAW_WINDOWS_TASK_NAME: "Custom Gateway",
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("com.example.gateway");
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("custom-gateway.service");
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("Custom Gateway");
});
it.each([
{ inheritedProfile: "Main", selectedProfile: "main" },
{ inheritedProfile: "main", selectedProfile: "Main" },
+23
View File
@@ -5,6 +5,11 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { isValidProfileName } from "./profile-utils.js";
@@ -129,6 +134,24 @@ export function applyCliProfileEnv(params: {
env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
}
if (switchesInheritedProfile) {
const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile);
const inheritedServiceIdentities = {
OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)],
OPENCLAW_SYSTEMD_UNIT: [
inheritedSystemdServiceName,
`${inheritedSystemdServiceName}.service`,
],
OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)],
};
for (const [key, inheritedValues] of Object.entries(inheritedServiceIdentities)) {
const activeValue = normalizeOptionalString(env[key]);
if (activeValue && inheritedValues.includes(activeValue)) {
delete env[key];
}
}
}
if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) {
env.OPENCLAW_GATEWAY_PORT = "19001";
}
+116 -1
View File
@@ -44,6 +44,11 @@ const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const serviceStop = vi.fn();
const serviceRestart = vi.fn();
const isDefaultInstallIdentity = vi.hoisted(() =>
vi.fn<(env?: NodeJS.ProcessEnv, homedir?: () => string, platform?: NodeJS.Platform) => boolean>(
() => true,
),
);
const suspendScheduledTaskAutoStartForUpdate = vi.fn();
const resumeScheduledTaskAutoStartAfterUpdate = vi.fn();
const prepareRestartScript = vi.fn();
@@ -322,7 +327,12 @@ vi.mock("../config/backup-rotation.js", () => ({
}));
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: async () => {
readGatewayServiceState: async (
_service: unknown,
args?: {
validateEnvBeforeStatusRead?: (env: NodeJS.ProcessEnv) => void;
},
) => {
const command = await serviceReadCommand();
const env = {
...process.env,
@@ -330,6 +340,7 @@ vi.mock("../daemon/service.js", () => ({
? (command.environment as NodeJS.ProcessEnv | undefined)
: undefined),
};
args?.validateEnvBeforeStatusRead?.(env);
const [loaded, runtime] = await Promise.all([
serviceLoaded({ env }).catch(() => false),
serviceReadRuntime(env).catch(() => undefined),
@@ -365,6 +376,15 @@ vi.mock("../daemon/schtasks.js", () => ({
resumeScheduledTaskAutoStartAfterUpdate(...args),
}));
vi.mock("../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/paths.js")>()),
isDefaultInstallIdentity: (
env?: NodeJS.ProcessEnv,
homedir?: () => string,
platform?: NodeJS.Platform,
) => isDefaultInstallIdentity(env, homedir, platform),
}));
vi.mock("../infra/ports.js", () => ({
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
@@ -1364,6 +1384,8 @@ describe("update-cli", () => {
resolveGlobalManager.mockResolvedValue("npm");
serviceStop.mockResolvedValue(undefined);
serviceRestart.mockResolvedValue({ outcome: "completed" });
isDefaultInstallIdentity.mockReset();
isDefaultInstallIdentity.mockReturnValue(true);
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(false);
resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(false);
serviceLoaded.mockResolvedValue(false);
@@ -4206,6 +4228,99 @@ describe("update-cli", () => {
processOffSpy.mockRestore();
});
it("does not inspect or mutate a Windows host service from an isolated install", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
mockRunningManagedGateway();
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
isDefaultInstallIdentity.mockReturnValue(false);
await withEnvAsync({ OPENCLAW_HOME: path.join(tempDir, "relocated-home") }, async () => {
await updateCommand({ yes: true });
});
platformSpy.mockRestore();
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadCommand).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeDefined();
});
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])(
"does not reuse a conflicting $envKey selector from the managed service on $platform",
async ({ platform, envKey, value }) => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform);
const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`);
const home = path.join(tempDir, "home");
const stateDir = path.join(home, ".openclaw-work");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_PROFILE: "work",
[envKey]: value,
},
});
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
try {
await withEnvAsync(
{
HOME: home,
USERPROFILE: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
[envKey]: undefined,
},
async () => {
await updateCommand({ yes: true });
},
);
} finally {
platformSpy.mockRestore();
}
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadRuntime).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeUndefined();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getErrorOutput()).toContain(envKey);
},
);
it("restores Windows Scheduled Task autostart when service stop fails", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mockPackageInstallStatus(createCaseDir("openclaw-update-stop-failure"));
@@ -38,9 +38,13 @@ import {
} from "./update-command-post-core.js";
import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js";
import {
assertGatewayServiceManagementAllowedForUpdate,
GatewayServiceUpdateOwnershipError,
gatewayServiceCommandUsesRoot,
isGatewayServiceManagementAllowedForUpdate,
maybeRestartService,
maybeRestartServiceAfterFailedMutableUpdate,
resolveGatewayServiceManagementBlockMessageForUpdate,
resolvePostUpdateServiceStateReadEnv,
resolveUpdatedGatewayRestartPort,
restoreWindowsTaskAutoStartOrExit,
@@ -340,18 +344,30 @@ export async function finishUpdate(params: {
let refreshGatewayServiceEnv = false;
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
let skipLegacyServiceRestart = false;
const serviceStateReadEnv = resolvePostUpdateServiceStateReadEnv({
updateMode: resultWithPostUpdate.mode,
processEnv: process.env,
preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv,
});
const serviceMutationAllowed =
params.preManagedServiceStop?.serviceMutationAllowed !== false &&
isGatewayServiceManagementAllowedForUpdate(process.env) &&
isGatewayServiceManagementAllowedForUpdate(serviceStateReadEnv);
const serviceMutationSkipMessage =
params.shouldRestart && !serviceMutationAllowed
? (params.preManagedServiceStop?.serviceMutationSkipMessage ??
resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ??
resolveGatewayServiceManagementBlockMessageForUpdate(serviceStateReadEnv))
: undefined;
let gatewayPort = resolveUpdatedGatewayRestartPort({
config: restartConfigSnapshot.valid ? restartConfigSnapshot.config : undefined,
processEnv: process.env,
});
if (params.shouldRestart) {
if (params.shouldRestart && serviceMutationAllowed) {
try {
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
env: resolvePostUpdateServiceStateReadEnv({
updateMode: resultWithPostUpdate.mode,
processEnv: process.env,
preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv,
}),
env: serviceStateReadEnv,
validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate,
});
const serviceMatchesUpdateRoot =
(await gatewayServiceCommandUsesRoot({
@@ -399,7 +415,12 @@ export async function finishUpdate(params: {
// ownership authorizes rewriting the service definition.
refreshGatewayServiceEnv = serviceOwnershipConfirmed;
}
} catch {
} catch (err) {
if (err instanceof GatewayServiceUpdateOwnershipError) {
defaultRuntime.error(err.message);
defaultRuntime.exit(1);
return;
}
// Ignore errors during pre-check; fallback to standard restart
}
}
@@ -420,7 +441,7 @@ export async function finishUpdate(params: {
return;
}
const restartOk = await maybeRestartService({
shouldRestart: params.shouldRestart,
shouldRestart: params.shouldRestart && serviceMutationAllowed,
result: resultWithPostUpdate,
opts: params.opts,
refreshServiceEnv: refreshGatewayServiceEnv,
@@ -432,6 +453,7 @@ export async function finishUpdate(params: {
skipLegacyServiceRestart,
requireRunningServiceAfterRestart:
resultWithPostUpdate.mode === "git" && params.preManagedServiceStop?.stopped === true,
serviceMutationSkipMessage,
timeoutMs: params.updateStepTimeoutMs,
});
if (!restartOk) {
+103 -5
View File
@@ -29,6 +29,7 @@ import {
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -111,6 +112,8 @@ export type PreManagedServiceStop = {
inspected: boolean;
runtimeInspected: boolean;
running: boolean;
serviceMutationAllowed?: boolean;
serviceMutationSkipMessage?: string;
serviceMatchesMutationRoot?: boolean;
blockMessage?: string;
serviceEnv?: NodeJS.ProcessEnv;
@@ -128,6 +131,41 @@ export type UpdateCommandRecoveryState = {
windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery;
};
export class GatewayServiceUpdateOwnershipError extends Error {
constructor(message: string, cause: unknown) {
super(message, { cause });
this.name = "GatewayServiceUpdateOwnershipError";
}
}
export function resolveGatewayServiceManagementBlockMessageForUpdate(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
try {
assertGatewayServiceManagementAllowedForUpdate(env);
return undefined;
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
export function assertGatewayServiceManagementAllowedForUpdate(
env: NodeJS.ProcessEnv = process.env,
): void {
try {
assertGatewayServiceMutationAllowed("manage the gateway service during update", env);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new GatewayServiceUpdateOwnershipError(message, err);
}
}
export function isGatewayServiceManagementAllowedForUpdate(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return resolveGatewayServiceManagementBlockMessageForUpdate(env) === undefined;
}
export class UpdateCommandAbort extends Error {
constructor() {
super("openclaw-update-abort");
@@ -334,12 +372,38 @@ export async function maybeStopManagedServiceBeforeMutableUpdate(params: {
shouldRestart: boolean;
jsonMode: boolean;
}): Promise<PreManagedServiceStop> {
const serviceMutationSkipMessage = resolveGatewayServiceManagementBlockMessageForUpdate(
process.env,
);
if (serviceMutationSkipMessage) {
return {
stopped: false,
inspected: false,
runtimeInspected: false,
running: false,
serviceMutationAllowed: false,
serviceMutationSkipMessage,
};
}
let service: ReturnType<typeof resolveGatewayService>;
let serviceState: Awaited<ReturnType<typeof readGatewayServiceState>>;
try {
service = resolveGatewayService();
serviceState = await readGatewayServiceState(service, { env: process.env });
} catch {
serviceState = await readGatewayServiceState(service, {
env: process.env,
validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate,
});
} catch (err) {
if (err instanceof GatewayServiceUpdateOwnershipError) {
return {
stopped: false,
inspected: false,
runtimeInspected: false,
running: false,
serviceMutationAllowed: false,
blockMessage: err.message,
};
}
return { stopped: false, inspected: false, runtimeInspected: false, running: false };
}
@@ -949,6 +1013,9 @@ function resolveManagedServiceNodeRunner(
* when the package root is the same.
*/
export async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
return undefined;
}
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -970,6 +1037,9 @@ export async function resolveManagedServiceNodeRunnerOverride(): Promise<string
export async function resolveManagedServicePackageUpdateRoot(params: {
root: string;
}): Promise<ManagedServiceRootRedirect | null> {
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
return null;
}
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -1004,9 +1074,11 @@ export async function gatewayServiceCommandUsesRoot(params: {
}
const command =
params.command === undefined
? await resolveGatewayService()
.readCommand(params.env ?? process.env)
.catch(() => null)
? isGatewayServiceManagementAllowedForUpdate(params.env ?? process.env)
? await resolveGatewayService()
.readCommand(params.env ?? process.env)
.catch(() => null)
: null
: params.command;
const layout = await summarizeGatewayServiceLayout(command);
const serviceRoot = layout?.packageRoot;
@@ -1037,8 +1109,22 @@ export async function maybeRestartService(params: {
nodeRunner?: string;
skipLegacyServiceRestart?: boolean;
requireRunningServiceAfterRestart?: boolean;
serviceMutationSkipMessage?: string;
timeoutMs: number;
}): Promise<boolean> {
if (
params.shouldRestart &&
(!isGatewayServiceManagementAllowedForUpdate(process.env) ||
!isGatewayServiceManagementAllowedForUpdate(params.serviceEnv ?? process.env))
) {
const message =
resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ??
resolveGatewayServiceManagementBlockMessageForUpdate(params.serviceEnv ?? process.env);
if (message) {
defaultRuntime.error(message);
}
return false;
}
const verifyRestartedGateway = async (
expectedGatewayVersion: string | undefined,
opts: { requireRunningService?: boolean } = {},
@@ -1325,6 +1411,18 @@ export async function maybeRestartService(params: {
return true;
}
if (params.serviceMutationSkipMessage) {
if (params.opts.json) {
defaultRuntime.error(params.serviceMutationSkipMessage);
} else {
defaultRuntime.log("");
defaultRuntime.log(
theme.warn(`Gateway: restart skipped: ${params.serviceMutationSkipMessage}`),
);
}
return true;
}
if (!params.opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart)."));
+27
View File
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
import {
updatePluginsAfterCoreUpdate,
type PostCorePluginUpdateResult,
@@ -17,6 +18,7 @@ import {
resolvePostInstallDoctorEnv,
resolvePostUpdateServiceStateReadEnv,
resolveUpdatedGatewayRestartPort,
maybeRestartService,
shouldPrepareUpdatedInstallRestart,
} from "./update-command-service.js";
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
@@ -176,6 +178,31 @@ describe("resolveUpdatedGatewayRestartPort", () => {
});
});
describe("maybeRestartService", () => {
it("reports service ownership skips to JSON callers", async () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
await expect(
maybeRestartService({
shouldRestart: false,
result: {
status: "ok",
mode: "npm",
steps: [],
durationMs: 0,
},
opts: { json: true },
refreshServiceEnv: false,
gatewayPort: 18789,
serviceMutationSkipMessage: "service management skipped: ownership conflict",
timeoutMs: 1_000,
}),
).resolves.toBe(true);
expect(errorSpy).toHaveBeenCalledWith("service management skipped: ownership conflict");
});
});
describe("resolvePostUpdateServiceStateReadEnv", () => {
it("keeps package restart preparation anchored to the pre-update service env", () => {
const processEnv = {
+30
View File
@@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { setupWizardCommand } from "./onboard.js";
type ConfigSnapshotStub = {
@@ -12,6 +13,7 @@ type ConfigSnapshotStub = {
valid: boolean;
config: OpenClawConfig;
sourceConfig?: OpenClawConfig;
readError?: { code: string | null };
};
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
@@ -364,10 +366,14 @@ describe("setupWizardCommand", () => {
it("requires an explicit workspace for a full reset when config is unreadable", async () => {
const runtime = makeRuntime();
// readConfigFileSnapshot always returns a sourceConfig object, so an
// unreadable config is only recognizable through readError.
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {},
readError: { code: "EACCES" },
});
await setupWizardCommand(
@@ -384,6 +390,30 @@ describe("setupWizardCommand", () => {
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("uses the default workspace for a full reset when a readable config configures none", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: { gateway: { port: 1 } },
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"full",
resolveUserPath("~/.openclaw/workspace"),
runtime,
);
});
it("accepts explicit --reset-scope full", async () => {
const runtime = makeRuntime();
+4 -1
View File
@@ -578,7 +578,10 @@ export async function setupWizardCommand(
normalizedOpts.workspace === undefined &&
snapshot.exists &&
!snapshot.valid &&
!snapshot.sourceConfig
// A snapshot always carries a sourceConfig object (empty on failure), so
// only readError distinguishes "config could not be read" from "config
// parsed but configures no workspace", where the default is correct.
snapshot.readError !== undefined
) {
rejectOption(
runtime,
+216 -3
View File
@@ -12,6 +12,7 @@ import {
isNixMode,
normalizeStateDirEnv,
pinRuntimePaths,
resolveNativeServiceProfileConflict,
resolveDefaultConfigCandidates,
resolveConfigPathCandidate,
resolveConfigPath,
@@ -57,6 +58,21 @@ describe("default install identity", () => {
).toBe(true);
});
it("preserves implicit legacy config discovery for the default profile", async () => {
await withTempDir({ prefix: "openclaw-default-install-legacy-config-" }, async (home) => {
const stateDir = path.join(home, ".openclaw");
const legacyStateDir = path.join(home, ".clawdbot");
const legacyConfigPath = path.join(legacyStateDir, "clawdbot.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(legacyStateDir, { recursive: true });
await fs.writeFile(legacyConfigPath, "{}");
const env = { HOME: home };
expect(resolveConfigPathCandidate(env, () => home)).toBe(legacyConfigPath);
expect(isDefaultInstallIdentity(env, () => home)).toBe(true);
});
});
it("rejects non-default state or config paths", () => {
const home = "/home/test";
@@ -73,11 +89,208 @@ describe("default install identity", () => {
it("rejects process home overrides that relocate the implicit install", () => {
const accountHome = "/home/test";
const stateDir = path.join(accountHome, ".openclaw");
expect(isDefaultInstallIdentity({ HOME: "/tmp/copied-home" }, () => accountHome)).toBe(false);
expect(isDefaultInstallIdentity({ OPENCLAW_HOME: "/tmp/copied-home" }, () => accountHome)).toBe(
false,
);
expect(
isDefaultInstallIdentity(
{
HOME: "/tmp/copied-home",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
USERPROFILE: "/tmp/copied-home",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
});
it("rejects installs relocated through OPENCLAW_HOME", () => {
const accountHome = "/home/test";
const installHome = "/srv/openclaw";
const stateDir = path.join(installHome, ".openclaw");
expect(isDefaultInstallIdentity({ OPENCLAW_HOME: installHome }, () => accountHome)).toBe(false);
expect(
isDefaultInstallIdentity(
{
OPENCLAW_HOME: installHome,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
OPENCLAW_HOME: installHome,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: path.join(installHome, ".openclaw-work"),
OPENCLAW_CONFIG_PATH: path.join(installHome, ".openclaw-work", "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
});
it("accepts the canonical paths a named profile projects", async () => {
await withTempDir({ prefix: "openclaw-profile-install-" }, async (home) => {
const defaultStateDir = path.join(home, ".openclaw");
const profileStateDir = path.join(home, ".openclaw-work");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.writeFile(path.join(defaultStateDir, "openclaw.json"), "{}");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"),
},
() => home,
),
).toBe(true);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
},
() => home,
),
).toBe(false);
await fs.mkdir(profileStateDir, { recursive: true });
await fs.writeFile(path.join(profileStateDir, "openclaw.json"), "{}");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
},
() => home,
),
).toBe(true);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: path.join(home, ".openclaw-other"),
},
() => home,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "default",
OPENCLAW_STATE_DIR: defaultStateDir,
},
() => home,
),
).toBe(true);
});
});
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])("rejects a named profile overriding $envKey on $platform", ({ platform, envKey, value }) => {
const home = "/home/test";
const stateDir = path.join(home, ".openclaw-work");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
[envKey]: value,
},
() => home,
platform,
),
).toBe(false);
});
it.each(["../escape", "work/../../escape", "work\\..\\escape", "."])(
"rejects invalid profile %j even when its derived paths match",
(profile) => {
const home = "/home/test";
const profileStateDir = path.join(home, `.openclaw-${profile}`);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: profile,
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"),
},
() => home,
),
).toBe(false);
},
);
it.each(["gateway", "node"])(
"rejects macOS profile %j because its LaunchAgent label is reserved",
(profile) => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe(
profile,
);
expect(
resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"),
).toBeNull();
},
);
it.each(["Main", "MAIN", "Work"])(
"rejects mixed-case native service profile %j on case-insensitive platforms",
(profile) => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe(
profile,
);
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "win32")).toBe(
profile,
);
expect(
resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"),
).toBeNull();
},
);
it("keeps lowercase native service profiles byte-compatible", () => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "darwin")).toBeNull();
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "win32")).toBeNull();
});
});
+70 -12
View File
@@ -2,6 +2,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isValidProfileName } from "../cli/profile-utils.js";
import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js";
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
import { parseTcpPort } from "../infra/tcp-port.js";
import { isFastTestRuntimeEnv } from "../infra/test-runtime-env.js";
@@ -124,32 +126,88 @@ export function isDefaultStateDir(
);
}
/** Canonical state directory name for the selected profile, mirroring root `--profile`. */
function profileStateDirName(env: NodeJS.ProcessEnv): string | null {
const profile = env.OPENCLAW_PROFILE?.trim();
if (!profile || profile.toLowerCase() === "default") {
return NEW_STATE_DIRNAME;
}
if (!isValidProfileName(profile)) {
return null;
}
return `${NEW_STATE_DIRNAME}-${profile}`;
}
export function resolveNativeServiceProfileConflict(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string | null {
if (platform !== "darwin" && platform !== "win32") {
return null;
}
const profile = env.OPENCLAW_PROFILE?.trim();
if (!profile || profile.toLowerCase() === "default") {
return null;
}
// Normal macOS and Windows filesystems fold case, so case-distinct profile
// names can share state and native-service paths even though the CLI keeps
// them distinct. Leave the runtime profile valid, but deny service mutation.
if (profile !== profile.toLowerCase()) {
return profile;
}
if (platform !== "darwin") {
return null;
}
// These names map to the shipped default Gateway and node-host LaunchAgent
// labels, so authorizing them would let one profile control another service.
return profile === "gateway" || profile === "node" ? profile : null;
}
/** Whether host service management belongs to the active default install identity. */
export function isDefaultInstallIdentity(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = resolveSystemAccountHomeDir,
platform: NodeJS.Platform = process.platform,
): boolean {
const accountHome = resolveRequiredHomeDir({}, homedir);
const accountHomedir = () => accountHome;
// Profiles have distinct host-service names; relocated homes do not. Keep
// OPENCLAW_HOME isolated so an alternate state tree cannot adopt that service.
if (env.OPENCLAW_HOME?.trim()) {
return false;
}
if (
normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !==
normalizePathForComparison(newStateDir(accountHomedir))
normalizePathForComparison(resolveRequiredHomeDir(env, homedir)) !==
normalizePathForComparison(accountHome)
) {
return false;
}
if (!env.OPENCLAW_CONFIG_PATH?.trim()) {
if (
resolveNativeServiceProfileConflict(env, platform) ||
resolveGatewayNativeServiceIdentityConflict(env, platform)
) {
return false;
}
const stateDirName = profileStateDirName(env);
// Environment profiles can bypass root CLI parsing. Reject them before path
// construction so separators or dot segments cannot authorize a host service.
if (!stateDirName) {
return false;
}
const canonicalStateDir = path.join(accountHome, stateDirName);
if (
normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !==
normalizePathForComparison(canonicalStateDir)
) {
return false;
}
// Default installs historically allow implicit legacy config discovery.
// Named profiles must resolve their own config so they cannot inherit the default profile.
if (stateDirName === NEW_STATE_DIRNAME && !env.OPENCLAW_CONFIG_PATH?.trim()) {
return true;
}
const defaultConfigEnv = {
...env,
HOME: accountHome,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: undefined,
OPENCLAW_CONFIG_PATH: undefined,
};
return (
normalizePathForComparison(resolveConfigPathCandidate(env, envHomedir(env))) ===
normalizePathForComparison(resolveConfigPathCandidate(defaultConfigEnv, accountHomedir))
normalizePathForComparison(path.join(canonicalStateDir, CONFIG_FILENAME))
);
}
+124 -58
View File
@@ -16,7 +16,11 @@ import {
import { listOpenIncognitoAgentDatabases } from "../../state/openclaw-agent-db.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { resolveStorePath } from "./paths.js";
import { listSessionEntries, listSessionEntriesReadOnly } from "./session-accessor.js";
import {
countSessionEntryRowsReadOnly,
listSessionEntries,
listSessionEntriesReadOnly,
} from "./session-accessor.js";
import type { SessionEntryListScope } from "./session-accessor.types.js";
import { canonicalSessionKeyMigrationRequiredError } from "./session-canonical-key.js";
import { resolveDeliveryProvenCanonicalSessionKey } from "./store-entry.js";
@@ -32,6 +36,23 @@ import type { SessionEntry } from "./types.js";
type GatewaySessionEntryProjection = NonNullable<SessionEntryListScope["projection"]>;
type GatewaySessionStoreOptions = {
agentId?: string;
configuredAgentsOnly?: boolean;
includeIncognito?: boolean;
projection?: SessionEntryListScope["projection"];
};
type ResolvedGatewaySessionStoreTargets = {
configuredAgentIds?: ReadonlySet<string>;
defaultAgentId: string;
diagnostics: string[];
durableTargets: Array<{ agentId: string; storePath: string }>;
incognitoTargets: Array<{ agentId: string; storePath: string }>;
requestedAgentId?: string;
storeConfig?: string;
};
// Template-backed stores need per-agent scans before they can be merged for Gateway views.
function isStorePathTemplate(store?: string): boolean {
return typeof store === "string" && store.includes("{agentId}");
@@ -97,20 +118,13 @@ function mergeSessionEntryIntoCombined(params: {
}
function mergeOpenIncognitoStores(params: {
allowedAgentIds?: ReadonlySet<string>;
cfg: OpenClawConfig;
combined: Record<string, SessionEntry>;
agentId?: string;
projection: GatewaySessionEntryProjection;
targets: Array<{ agentId: string; storePath: string }>;
}): string[] {
const storePaths: string[] = [];
for (const target of listOpenIncognitoAgentDatabases()) {
if (params.allowedAgentIds && !params.allowedAgentIds.has(target.agentId)) {
continue;
}
if (params.agentId && target.agentId !== params.agentId) {
continue;
}
for (const target of params.targets) {
const store = loadGatewayStoreEntries({
agentId: target.agentId,
includeOpenDatabases: true,
@@ -138,27 +152,12 @@ function mergeOpenIncognitoStores(params: {
return storePaths;
}
/** Loads and canonicalizes session entries for gateway views across one or more agent stores. */
export function loadCombinedSessionStoreForGateway(
function resolveGatewaySessionStoreTargets(
cfg: OpenClawConfig,
opts: {
agentId?: string;
configuredAgentsOnly?: boolean;
includeIncognito?: boolean;
projection?: SessionEntryListScope["projection"];
} = {},
): {
diagnostics?: string[];
durableStorePath?: string;
storePath: string;
store: Record<string, SessionEntry>;
} {
opts: GatewaySessionStoreOptions,
): ResolvedGatewaySessionStoreTargets {
const storeConfig = cfg.session?.store;
const projection = opts.projection ?? "full";
const diagnostics: string[] = [];
// Exclusion happens before path aggregation; filtering rows afterward would
// still leak a live incognito handle by changing the projected store path.
const includeIncognito = opts.includeIncognito !== false;
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const requestedAgentId =
typeof opts.agentId === "string" && opts.agentId.trim()
@@ -171,6 +170,13 @@ export function loadCombinedSessionStoreForGateway(
const allowedIncognitoAgentIds = requestedAgentId
? new Set([requestedAgentId])
: configuredAgentIds;
const incognitoTargets =
opts.includeIncognito === false
? []
: listOpenIncognitoAgentDatabases().filter(
(target) => !allowedIncognitoAgentIds || allowedIncognitoAgentIds.has(target.agentId),
);
if (storeConfig && !isStorePathTemplate(storeConfig)) {
const ownerIds = [
...new Set([
@@ -181,10 +187,7 @@ export function loadCombinedSessionStoreForGateway(
...(requestedAgentId ? [requestedAgentId] : []),
]),
];
const combined: Record<string, SessionEntry> = {};
// Runtime session access is SQLite-only: a fixed literal is a naming seed whose
// resolved database is partitioned per owner. Legacy flat JSON is migration-only.
const ownerTargets = dedupeSessionStoreTargetsBySqliteTarget(
const durableTargets = dedupeSessionStoreTargetsBySqliteTarget(
ownerIds.map((agentId) => ({
agentId,
storePath: resolveStorePath(storeConfig, { agentId }),
@@ -194,7 +197,81 @@ export function loadCombinedSessionStoreForGateway(
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message),
},
);
for (const { agentId, storePath } of ownerTargets) {
return {
configuredAgentIds,
defaultAgentId,
diagnostics,
durableTargets,
incognitoTargets,
requestedAgentId,
storeConfig,
};
}
const durableTargets = requestedAgentId
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
: opts.configuredAgentsOnly === true
? resolveSessionStoreTargets(cfg, { allAgents: true })
: resolveAllAgentSessionStoreTargetsSync(cfg);
return {
configuredAgentIds,
defaultAgentId,
diagnostics,
durableTargets,
incognitoTargets,
requestedAgentId,
storeConfig,
};
}
/** Checks whether Gateway prewarm can project the selected stores within a bounded row budget. */
export function canPrewarmCombinedSessionStoresForGateway(
cfg: OpenClawConfig,
params: { agentIds: readonly string[]; maxRows: number },
): boolean {
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
let totalRows = 0;
for (const agentId of params.agentIds) {
const resolved = resolveGatewaySessionStoreTargets(cfg, { agentId });
const projectionTargets = dedupeSessionStoreTargetsBySqliteTarget(
[...resolved.durableTargets, ...resolved.incognitoTargets],
{ defaultAgentId },
);
for (const target of projectionTargets) {
totalRows += countSessionEntryRowsReadOnly(target);
if (totalRows > params.maxRows) {
return false;
}
}
}
return true;
}
/** Loads and canonicalizes session entries for gateway views across one or more agent stores. */
export function loadCombinedSessionStoreForGateway(
cfg: OpenClawConfig,
opts: GatewaySessionStoreOptions = {},
): {
diagnostics?: string[];
durableStorePath?: string;
storePath: string;
store: Record<string, SessionEntry>;
} {
const projection = opts.projection ?? "full";
// Count admission and projection share this exact target set. Otherwise an optional
// prewarm can approve one database and synchronously materialize another.
const {
configuredAgentIds,
defaultAgentId,
diagnostics,
durableTargets,
incognitoTargets,
requestedAgentId,
storeConfig,
} = resolveGatewaySessionStoreTargets(cfg, opts);
if (storeConfig && !isStorePathTemplate(storeConfig)) {
const combined: Record<string, SessionEntry> = {};
for (const { agentId, storePath } of durableTargets) {
const store = loadGatewayStoreEntries({ agentId, projection, storePath });
for (const { sessionKey: key, entry } of store) {
const canonicalKey = resolveStoredSessionKeyForAgentStore({
@@ -226,15 +303,12 @@ export function loadCombinedSessionStoreForGateway(
}
}
const durableStorePath = resolveStorePath(storeConfig, { agentId: defaultAgentId });
const incognitoStorePaths = includeIncognito
? mergeOpenIncognitoStores({
...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}),
cfg,
combined,
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
projection,
})
: [];
const incognitoStorePaths = mergeOpenIncognitoStores({
cfg,
combined,
projection,
targets: incognitoTargets,
});
return {
diagnostics,
durableStorePath,
@@ -242,13 +316,8 @@ export function loadCombinedSessionStoreForGateway(
store: combined,
};
}
const targets = requestedAgentId
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
: opts.configuredAgentsOnly === true
? resolveSessionStoreTargets(cfg, { allAgents: true })
: resolveAllAgentSessionStoreTargetsSync(cfg);
const combined: Record<string, SessionEntry> = {};
for (const target of targets) {
for (const target of durableTargets) {
const agentId = target.agentId;
const storePath = target.storePath;
const store = loadGatewayStoreEntries({ agentId, projection, storePath });
@@ -282,17 +351,14 @@ export function loadCombinedSessionStoreForGateway(
}
}
const incognitoStorePaths = includeIncognito
? mergeOpenIncognitoStores({
...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}),
cfg,
combined,
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
projection,
})
: [];
const incognitoStorePaths = mergeOpenIncognitoStores({
cfg,
combined,
projection,
targets: incognitoTargets,
});
const durableStorePaths = targets.map((target) => target.storePath);
const durableStorePaths = durableTargets.map((target) => target.storePath);
const durableStorePath = resolveCombinedStorePath(durableStorePaths, storeConfig);
const storePath = resolveCombinedStorePath(
[...durableStorePaths, ...incognitoStorePaths],
@@ -10,6 +10,7 @@ import { resolveAgentMainSessionKey } from "./main-session.js";
import { resolveStorePath } from "./paths.js";
import { clearPluginOwnedSessionState } from "./plugin-host-cleanup.js";
import {
countSqliteSessionEntryRowsReadOnly as countSessionEntryRowsReadOnly,
copySqliteSessionOwnedStateForCanonicalRepair as copySessionOwnedStateForCanonicalRepair,
listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair,
listSqliteSessionChildEntriesReadOnly as listSessionChildEntriesReadOnly,
@@ -58,6 +59,7 @@ export { clearPluginOwnedSessionState };
// SQLite is the only runtime session store. Re-export its canonical entry
// operations directly instead of maintaining a second pass-through layer.
export {
countSessionEntryRowsReadOnly,
copySessionOwnedStateForCanonicalRepair,
listSessionGenerationIdsForCanonicalRepair,
listSessionChildEntriesReadOnly,
@@ -281,6 +281,22 @@ export function listSqliteSessionEntriesReadOnly(
return result.found ? result.value : [];
}
/** Counts durable session rows without materializing entry JSON or warming the entry cache. */
export function countSqliteSessionEntryRowsReadOnly(scope: SessionEntryListScope = {}): number {
const resolved = resolveSqliteScope({ ...scope, sessionKey: "" });
const result = withOpenClawAgentDatabaseReadOnly((database) => {
const db = getSessionKysely(database.db);
const row = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_nodes")
.select((expression) => expression.fn.countAll<number | bigint>().as("count")),
);
return row ? normalizeSqliteNumber(row.count) : 0;
}, toDatabaseOptions(resolved));
return result.found ? result.value : 0;
}
function listSqliteSessionEntriesFromDatabase(
database: Pick<OpenClawAgentDatabase, "agentId" | "db" | "path">,
resolved: ResolvedSqliteScope,
@@ -1,5 +1,6 @@
// Stable SQLite accessor surface. Domain owners live in the focused modules below.
export {
countSqliteSessionEntryRowsReadOnly,
listSqliteSessionEntries,
listSqliteSessionChildEntriesReadOnly,
listSqliteSessionEntriesReadOnly,
@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTestTimeout } from "../../../test/helpers/promise.js";
@@ -26,6 +27,7 @@ import {
appendTranscriptMessage,
applySessionEntryLifecycleMutation,
commitReplySessionInitialization,
countSessionEntryRowsReadOnly,
createSessionEntryWithTranscript,
deleteSessionEntryLifecycle,
findTranscriptEvent,
@@ -225,6 +227,24 @@ describe("session accessor seam", () => {
expect(readSqliteSessionEntryCount(database)).toBe(1);
expect(readSqliteSessionEntryKeys(database)).toEqual(["agent:main:logical-entry"]);
expect(countSessionEntryRowsReadOnly({ agentId: "main", storePath })).toBe(2);
});
it("counts rows on a cold handle without parsing invalid entry JSON", async () => {
await replaceSessionEntry(
{ sessionKey: "agent:main:cold-count", storePath },
{ sessionId: "cold-count-session", updatedAt: 10 },
);
const databasePath = expectDefined(
resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main" }).path,
"cold count database path",
);
closeOpenClawAgentDatabasesForTest();
const database = new DatabaseSync(databasePath);
database.prepare("UPDATE session_nodes SET entry_valid = 0").run();
database.close();
expect(countSessionEntryRowsReadOnly({ agentId: "main", storePath })).toBe(1);
});
it("retains legacy createdBy actor projections across rewrites", async () => {
+1
View File
@@ -119,6 +119,7 @@ export type {
UpdateSessionLastRouteParams,
} from "./session-accessor.entry-mutation.js";
export {
countSessionEntryRowsReadOnly,
copySessionOwnedStateForCanonicalRepair,
listSessionGenerationIdsForCanonicalRepair,
clearPluginOwnedSessionState,
+43
View File
@@ -4,6 +4,7 @@ import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
resolveGatewayLaunchAgentLabel,
resolveGatewayNativeServiceIdentityConflict,
resolveGatewayProfileSuffix,
resolveGatewayServiceDescription,
resolveGatewaySystemdServiceName,
@@ -47,6 +48,48 @@ describe("resolveGatewayWindowsTaskName", () => {
});
});
describe("resolveGatewayNativeServiceIdentityConflict", () => {
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])("rejects $envKey overrides for named profiles on $platform", ({ platform, envKey, value }) => {
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_PROFILE: "work", [envKey]: value },
platform,
),
).toMatchObject({ envKey });
});
it("accepts canonical named-profile identities and default-profile overrides", () => {
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_PROFILE: "work", OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-work" },
"linux",
),
).toBeNull();
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service" },
"linux",
),
).toBeNull();
});
});
describe("resolveGatewayProfileSuffix", () => {
it("returns empty string when no profile is set", () => {
expect(resolveGatewayProfileSuffix()).toBe("");
+36
View File
@@ -59,6 +59,42 @@ export function resolveGatewayWindowsTaskName(profile?: string): string {
return `OpenClaw Gateway (${normalized})`;
}
type GatewayNativeServiceIdentityConflict = {
envKey: "OPENCLAW_LAUNCHD_LABEL" | "OPENCLAW_SYSTEMD_UNIT" | "OPENCLAW_WINDOWS_TASK_NAME";
expected: string;
};
export function resolveGatewayNativeServiceIdentityConflict(
env: Record<string, string | undefined>,
platform: NodeJS.Platform = process.platform,
): GatewayNativeServiceIdentityConflict | null {
const profile = normalizeGatewayProfile(env.OPENCLAW_PROFILE);
if (!profile) {
return null;
}
if (platform === "darwin") {
const envKey = "OPENCLAW_LAUNCHD_LABEL";
const actual = env[envKey]?.trim();
const expected = resolveGatewayLaunchAgentLabel(profile);
return actual && actual !== expected ? { envKey, expected } : null;
}
if (platform === "linux") {
const envKey = "OPENCLAW_SYSTEMD_UNIT";
const actual = env[envKey]?.trim();
const normalizedActual = actual?.endsWith(".service") ? actual : actual && `${actual}.service`;
const expected = `${resolveGatewaySystemdServiceName(profile)}.service`;
return normalizedActual && normalizedActual !== expected ? { envKey, expected } : null;
}
if (platform === "win32") {
const envKey = "OPENCLAW_WINDOWS_TASK_NAME";
const actual = env[envKey]?.trim();
const expected = resolveGatewayWindowsTaskName(profile);
return actual && actual !== expected ? { envKey, expected } : null;
}
return null;
}
function formatGatewayServiceDescription(params?: { profile?: string; version?: string }): string {
const profile = normalizeGatewayProfile(params?.profile);
const version = params?.version?.trim();
+78 -3
View File
@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { withTimeout } from "../utils/with-timeout.js";
import {
installLaunchAgent,
@@ -13,6 +14,7 @@ import {
repairLaunchAgentBootstrap,
restartLaunchAgent,
resolveLaunchAgentPlistPath,
startLaunchAgent,
stopLaunchAgent,
uninstallLaunchAgent,
} from "./launchd.js";
@@ -192,6 +194,81 @@ describeLaunchdIntegration("launchd integration", () => {
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
}, 60_000);
it("manages a named profile through the guarded host-service lifecycle", async () => {
const testId = randomUUID().slice(0, 8);
const profile = `launchd-int-${testId}`;
const accountHome = os.userInfo().homedir;
const stateDir = path.join(accountHome, `.openclaw-${profile}`);
const profileEnv: GatewayServiceEnv = {
HOME: accountHome,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: profile,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_LAUNCHD_LABEL: undefined,
OPENCLAW_SUPERVISOR_MODE: undefined,
};
await withEnvAsync(profileEnv, async () => {
const service = resolveGatewayService();
try {
await service.install({
env: profileEnv,
stdout,
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
});
const installed = await waitForRunningRuntime({ env: profileEnv });
await service.stop({ env: profileEnv, stdout });
await waitForNotRunningRuntime({ env: profileEnv });
const startResult = await startGatewayService(service, { env: profileEnv, stdout });
expect(startResult.outcome).toBe("started");
const started = await waitForRunningRuntime({
env: profileEnv,
pidNot: installed.pid,
});
await service.restart({ env: profileEnv, stdout });
await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid });
} finally {
try {
await service.uninstall({ env: profileEnv, stdout });
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
});
}, 60_000);
it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => {
const testId = randomUUID().slice(0, 8);
const relocatedHome = await fs.mkdtemp(
path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`),
);
const relocatedEnv: GatewayServiceEnv = {
HOME: os.userInfo().homedir,
OPENCLAW_HOME: relocatedHome,
OPENCLAW_PROFILE: `launchd-int-${testId}`,
};
try {
await withEnvAsync(relocatedEnv, async () => {
const service = resolveGatewayService();
await expect(
service.install({
env: relocatedEnv,
stdout,
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
}),
).rejects.toThrow("service management skipped: non-default state dir or config path");
await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow();
});
} finally {
await fs.rm(relocatedHome, { recursive: true, force: true });
}
});
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
const launchEnv = launchEnvOrThrow(env);
await initializeLaunchdRuntime(launchEnv, stdout);
@@ -208,9 +285,7 @@ describeLaunchdIntegration("launchd integration", () => {
const before = await waitForRunningRuntime({ env: launchEnv });
await stopLaunchAgent({ env: launchEnv, stdout });
await waitForNotRunningRuntime({ env: launchEnv });
const service = resolveGatewayService();
const startResult = await startGatewayService(service, { env: launchEnv, stdout });
expect(startResult.outcome).toBe("started");
await startLaunchAgent({ env: launchEnv, stdout });
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
}, 60_000);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
import fs from "node:fs/promises";
const POLL_INTERVAL_MS = 200;
const RUN_EVENT_SETTLE_MS = 2_000;
const WAIT_TIMEOUT_MS = 30_000;
export type ProbeRunEvent = {
phase: "listening" | "started";
pid: number;
ppid: number;
};
async function readRunEvents(eventsPath: string): Promise<ProbeRunEvent[]> {
const content = await fs.readFile(eventsPath, "utf8").catch(() => "");
return content
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parsed = JSON.parse(line) as Partial<ProbeRunEvent>;
if (
(parsed.phase !== "started" && parsed.phase !== "listening") ||
!Number.isSafeInteger(parsed.pid) ||
(parsed.pid ?? 0) <= 1 ||
!Number.isSafeInteger(parsed.ppid) ||
(parsed.ppid ?? 0) <= 1
) {
throw new Error("Scheduled Task probe recorded an invalid run event");
}
return parsed as ProbeRunEvent;
});
}
export async function waitForExactProbeRun(
eventsPath: string,
expectedCount: number,
): Promise<ProbeRunEvent> {
const deadline = Date.now() + WAIT_TIMEOUT_MS;
let events: ProbeRunEvent[] = [];
while (Date.now() < deadline) {
events = await readRunEvents(eventsPath);
if (events.filter((event) => event.phase === "listening").length >= expectedCount) {
await new Promise((resolve) => {
setTimeout(resolve, RUN_EVENT_SETTLE_MS);
});
events = await readRunEvents(eventsPath);
break;
}
await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL_MS);
});
}
const started = events.filter((event) => event.phase === "started");
const listening = events.filter((event) => event.phase === "listening");
if (started.length !== expectedCount || listening.length !== expectedCount) {
throw new Error(
`Expected exactly ${expectedCount} Scheduled Task probe runs; observed ${started.length} starts and ${listening.length} listeners`,
);
}
const startedEvent = started[expectedCount - 1];
const listeningEvent = listening[expectedCount - 1];
if (!startedEvent || !listeningEvent) {
throw new Error(`Scheduled Task run ${expectedCount} did not record complete process events`);
}
if (startedEvent.pid !== listeningEvent.pid || startedEvent.ppid !== listeningEvent.ppid) {
throw new Error(
`Scheduled Task run ${expectedCount} changed process identity before listening`,
);
}
return startedEvent;
}
+98 -1
View File
@@ -23,8 +23,16 @@ const sleepMock = vi.hoisted(() =>
timeState.now += ms;
}),
);
type SpawnSyncResult = {
pid: number;
output: (string | null)[];
stdout: string;
stderr: string;
status: number;
signal: null;
};
const spawnSync = vi.hoisted(() =>
vi.fn(() => ({
vi.fn<(command: string, args?: readonly string[]) => SpawnSyncResult>(() => ({
pid: 0,
output: [null, "-2147024891", ""],
stdout: "-2147024891",
@@ -52,12 +60,14 @@ vi.mock("../utils.js", async () => {
});
const {
resolveTaskScriptPath,
restartScheduledTask,
resumeScheduledTaskAutoStartAfterUpdate,
startScheduledTask,
stopScheduledTask,
suspendScheduledTaskAutoStartForUpdate,
} = await import("./schtasks.js");
const { resolveScheduledTaskOwnedGatewayPids } = await import("./schtasks-process.js");
const GATEWAY_PORT = 18789;
const SUCCESS_RESPONSE = { code: 0, stdout: "", stderr: "" } as const;
const INSTALLED_GATEWAY_COMMAND_LINE =
@@ -465,6 +475,93 @@ describe("Scheduled Task stop/restart cleanup", () => {
});
});
it("does not adopt a portless arbitrary task action", async () => {
await withPreparedGatewayTask(async ({ env }) => {
delete env.OPENCLAW_GATEWAY_PORT;
const scriptPath = resolveTaskScriptPath(env);
await fs.writeFile(
scriptPath,
'@echo off\r\n"C:\\Program Files\\nodejs\\node.exe" "C:\\probe.cjs"\r\n',
"utf8",
);
await expect(resolveScheduledTaskOwnedGatewayPids(env)).resolves.toEqual([]);
expect(inspectPortUsage).not.toHaveBeenCalled();
});
});
it("adopts exact persisted Windows argv and escalates through taskkill tree cleanup", async () => {
await withPreparedGatewayTask(async ({ env, stdout }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
pushSuccessfulSchtasksResponses(3);
inspectPortUsage.mockResolvedValue(freePortUsage());
let forced = false;
spawnSync.mockImplementation((command, args) => {
const executable = command.toLowerCase();
if (executable.endsWith("taskkill.exe")) {
const argv = Array.isArray(args) ? args.map(String) : [];
if (argv.includes("/F")) {
forced = true;
return {
pid: 0,
output: [null, "", ""],
stdout: "",
stderr: "",
status: 0,
signal: null,
};
}
return {
pid: 0,
output: [null, "", ""],
stdout: "",
stderr: "",
status: 1,
signal: null,
};
}
const processes = [
{
ProcessId: 3131,
CommandLine:
'"C:\\Program Files\\nodejs\\node.exe" "C:\\other-openclaw.cjs" gateway --port 18789',
},
...(!forced
? [
{
ProcessId: 4242,
CommandLine: INSTALLED_GATEWAY_COMMAND_LINE,
},
]
: []),
{ ProcessId: 9999, CommandLine: "powershell.exe" },
];
const output = JSON.stringify(processes);
return {
pid: 0,
output: [null, output, ""],
stdout: output,
stderr: "",
status: 0,
signal: null,
};
});
await stopScheduledTask({ env, stdout });
const taskkillCalls = spawnSync.mock.calls
.filter(([command]) => command.toLowerCase().endsWith("taskkill.exe"))
.map(([, args]) => args);
expect(taskkillCalls).toEqual([
["/T", "/PID", "4242"],
["/F", "/T", "/PID", "4242"],
]);
expect(taskkillCalls.flat()).not.toContain("3131");
expect(killProcessTree).not.toHaveBeenCalled();
});
});
it("starts a registered task and ignores audit observer failures", async () => {
await withPreparedGatewayTask(async ({ env }) => {
schtasksResponses.push(
+25
View File
@@ -185,6 +185,31 @@ describe("readGatewayServiceState", () => {
{ timeoutMs: undefined },
);
});
it("validates merged service env before native status probes", async () => {
const isLoaded = vi.fn(async () => true);
const readRuntime = vi.fn(async () => ({ status: "running" as const }));
const service = createService({
isLoaded,
readCommand: vi.fn(async () => ({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service" },
})),
readRuntime,
});
await expect(
readGatewayServiceState(service, {
env: {},
validateEnvBeforeStatusRead: (env) => {
throw new Error(`refused ${env.OPENCLAW_SYSTEMD_UNIT}`);
},
}),
).rejects.toThrow("refused openclaw-gateway.service");
expect(isLoaded).not.toHaveBeenCalled();
expect(readRuntime).not.toHaveBeenCalled();
});
});
describe("startGatewayService", () => {
+8 -1
View File
@@ -90,6 +90,10 @@ export type GatewayService = {
) => Promise<GatewayServiceRuntime>;
};
type ReadGatewayServiceStateArgs = GatewayServiceEnvArgs & {
validateEnvBeforeStatusRead?: (env: GatewayServiceEnv) => void;
};
const TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"].map((entry) =>
path.resolve(entry),
);
@@ -179,11 +183,14 @@ export function formatGatewayServiceStartRepairIssues(
export async function readGatewayServiceState(
service: GatewayService,
args: GatewayServiceEnvArgs = {},
args: ReadGatewayServiceStateArgs = {},
): Promise<GatewayServiceState> {
const baseEnv = args.env ?? (process.env as GatewayServiceEnv);
const command = await service.readCommand(baseEnv).catch(() => null);
const env = mergeGatewayServiceEnv(baseEnv, command);
// Callers that may mutate the selected service can reject persisted selector
// drift before isLoaded/readRuntime invoke the native service manager.
args.validateEnvBeforeStatusRead?.(env);
// Propagate the status read deadline so a wedged service manager fails soft
// instead of hanging both probes. readCommand parses local files and needs no
// bound; isLoaded/readRuntime can spawn service-manager subprocesses.
+10
View File
@@ -78,6 +78,7 @@ const logPlugins = log.child("plugins");
const logWsControl = log.child("ws");
const logSecrets = log.child("secrets");
const gatewayRuntime = runtimeForLogger(log);
const POST_READY_WORK_START_DELAY_MS = 500;
function formatRuntimeGatewayAuthTokenWarning(): string {
const base =
@@ -101,6 +102,10 @@ export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
let releasePostReadyWork: () => void = () => {};
const postReadyWorkBarrier = new Promise<void>((resolve) => {
releasePostReadyWork = resolve;
});
const bootstrap = await prepareGatewayServerBootstrap({
port,
opts,
@@ -168,11 +173,16 @@ export async function startGatewayServer(
logReload,
logTailscale,
loadGatewayStartupPostAttachModule,
waitForPostReadyWork: () => postReadyWorkBarrier,
});
} catch (err) {
await closeOnStartupFailure();
throw err;
}
// The public server is fully initialized now. Leave a short I/O window before
// background prewarms and cleanup imports compete for the startup CPU.
const postReadyWorkTimer = setTimeout(releasePostReadyWork, POST_READY_WORK_START_DELAY_MS);
postReadyWorkTimer.unref?.();
const close = createCloseHandler();
+4 -4
View File
@@ -30,8 +30,7 @@ import {
type GatewayCoreRuntime = Awaited<ReturnType<typeof startGatewayCoreRuntime>>;
type GatewayLogger = ReturnType<typeof createSubsystemLogger>;
const POST_READY_MAINTENANCE_DELAY_MS = 250;
const RETAINED_PLUGIN_CLEANUP_DELAY_MS = 30_000;
const [POST_READY_MAINTENANCE_DELAY_MS, RETAINED_PLUGIN_CLEANUP_DELAY_MS] = [250, 30_000];
export async function finishGatewayStartup(params: {
coreRuntime: GatewayCoreRuntime;
@@ -48,6 +47,7 @@ export async function finishGatewayStartup(params: {
loadGatewayStartupPostAttachModule: () => Promise<
typeof import("./server-startup-post-attach.js")
>;
waitForPostReadyWork: () => Promise<void>;
}) {
const {
coreRuntime: runtime,
@@ -342,7 +342,6 @@ export async function finishGatewayStartup(params: {
import("./server/plugins-http/route-capability.js"),
]),
);
const pluginSurfaceScheme = gatewayTls.enabled ? "https" : "http";
await startupTrace.measure("gateway.ws-attach", () =>
attachGatewayWsHandlers({
wss,
@@ -350,7 +349,7 @@ export async function finishGatewayStartup(params: {
preauthConnectionBudget,
port,
gatewayHost: bindHost ?? undefined,
pluginSurfaceScheme,
pluginSurfaceScheme: gatewayTls.enabled ? "https" : "http",
getPluginNodeCapabilities: () =>
withCoreCanvasNodeCapability(
listPluginNodeCapabilities(pluginRuntime.registry),
@@ -530,6 +529,7 @@ export async function finishGatewayStartup(params: {
isClosing: () => lifecycle.closePreludeStarted,
startupTrace,
sidecarStartup,
waitForPostReadyWork: params.waitForPostReadyWork,
providerAuthPrewarm: {
getConfig: getRuntimeConfig,
},
@@ -3,19 +3,16 @@ import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js"
const mocks = vi.hoisted(() => ({
events: [] as string[],
sessionEntryCounts: new Map<string, number>(),
canPrewarmCombinedSessionStoresForGateway: vi.fn(() => {
mocks.events.push("sessions.count");
return true;
}),
loadCombinedSessionStoreForGateway: vi.fn((_cfg: unknown, options: { agentId: string }) => {
mocks.events.push(`sessions.load.${options.agentId}`);
const entryCount = mocks.sessionEntryCounts.get(options.agentId) ?? 0;
return {
durableStorePath: `/state/${options.agentId}.sqlite`,
storePath: `/state/${options.agentId}.sqlite`,
store: Object.fromEntries(
Array.from({ length: entryCount }, (_, index) => [
`agent:${options.agentId}:fixture-${index}`,
{ sessionId: `session-${index}`, updatedAt: index },
]),
),
store: {},
};
}),
listSessionsFromStoreAsync: vi.fn(async (params: { opts: { agentId: string } }) => {
@@ -32,6 +29,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("../config/sessions/combined-store-gateway.js", () => ({
canPrewarmCombinedSessionStoresForGateway: mocks.canPrewarmCombinedSessionStoresForGateway,
loadCombinedSessionStoreForGateway: mocks.loadCombinedSessionStoreForGateway,
}));
@@ -51,7 +49,11 @@ const { scheduleGatewayHandlerPrewarm } = await import("./server-startup-handler
beforeEach(() => {
mocks.events.length = 0;
mocks.sessionEntryCounts.clear();
mocks.canPrewarmCombinedSessionStoresForGateway.mockClear();
mocks.canPrewarmCombinedSessionStoresForGateway.mockImplementation(() => {
mocks.events.push("sessions.count");
return true;
});
mocks.loadCombinedSessionStoreForGateway.mockClear();
mocks.listSessionsFromStoreAsync.mockClear();
mocks.listManagedPlugins.mockClear();
@@ -79,6 +81,7 @@ describe("scheduleGatewayHandlerPrewarm", () => {
await vi.runAllTimersAsync();
expect(mocks.events).toEqual([
"sessions.count",
"sessions.load.main",
"sessions.rows.main",
"sessions.load.research",
@@ -120,9 +123,60 @@ describe("scheduleGatewayHandlerPrewarm", () => {
agentId: "research",
limitPerHost: 40,
});
expect(mocks.canPrewarmCombinedSessionStoresForGateway).toHaveBeenCalledWith(cfg, {
agentIds: ["main", "research"],
maxRows: 2_000,
});
sidecar.stop();
});
it("waits for gateway readiness before warming handler data", async () => {
vi.useFakeTimers();
let releaseGatewayReady!: () => void;
const gatewayReady = new Promise<void>((resolve) => {
releaseGatewayReady = resolve;
});
const load = vi.fn(async () => {});
const sidecar = scheduleGatewayHandlerPrewarm({
cfgAtStart: {} as never,
log: { warn: vi.fn() },
items: [{ name: "sessions", load }],
waitForPostReadyWork: () => gatewayReady,
});
await vi.advanceTimersToNextTimerAsync();
expect(load).not.toHaveBeenCalled();
releaseGatewayReady();
await vi.runAllTimersAsync();
expect(load).toHaveBeenCalledOnce();
sidecar.stop();
});
it("stays stopped when readiness arrives after shutdown", async () => {
vi.useFakeTimers();
let releaseGatewayReady!: () => void;
const gatewayReady = new Promise<void>((resolve) => {
releaseGatewayReady = resolve;
});
const load = vi.fn(async () => {});
const sidecar = scheduleGatewayHandlerPrewarm({
cfgAtStart: {} as never,
log: { warn: vi.fn() },
items: [{ name: "sessions", load }],
waitForPostReadyWork: () => gatewayReady,
});
await vi.advanceTimersToNextTimerAsync();
sidecar.stop();
releaseGatewayReady();
await vi.runAllTimersAsync();
expect(load).not.toHaveBeenCalled();
});
it("logs failures and continues without changing later request behavior", async () => {
vi.useFakeTimers();
const warn = vi.fn();
@@ -156,25 +210,26 @@ describe("scheduleGatewayHandlerPrewarm", () => {
it("skips optional catalog prewarm when the combined session stores are large", async () => {
vi.useFakeTimers();
mocks.sessionEntryCounts.set("main", 2_001);
const info = vi.fn();
mocks.canPrewarmCombinedSessionStoresForGateway.mockImplementation(() => {
mocks.events.push("sessions.count");
return false;
});
const cfg = {
agents: { list: [{ id: "main", default: true }, { id: "research" }] },
} as never;
scheduleGatewayHandlerPrewarm({
cfgAtStart: cfg,
log: { warn: vi.fn() },
log: { info, warn: vi.fn() },
});
await vi.runAllTimersAsync();
expect(mocks.events).toEqual([
"sessions.load.main",
"sessions.rows.main",
"sessions.load.research",
"sessions.rows.research",
"plugins",
]);
expect(mocks.events).toEqual(["sessions.count", "plugins"]);
expect(mocks.prewarmSessionCatalogList).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(
"skipping optional dashboard session prewarm: combined stores exceed 2000 rows",
);
});
it("stops before scheduling another event-loop turn", async () => {
+55 -33
View File
@@ -4,7 +4,7 @@ import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-w
const SIDEBAR_SESSION_LIST_LIMIT = 60;
const SIDEBAR_CATALOG_LIMIT_PER_HOST = 40;
const SIDEBAR_CATALOG_PREWARM_MAX_SESSION_ENTRIES = 2_000;
const SIDEBAR_PREWARM_MAX_SESSION_ENTRIES = 2_000;
type StartupTrace = {
measure: <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
@@ -19,10 +19,7 @@ type GatewayHandlerPrewarmHandle = {
stop: () => void;
};
async function prewarmGatewaySessionListData(
cfg: OpenClawConfig,
agentId: string,
): Promise<number> {
async function prewarmGatewaySessionListData(cfg: OpenClawConfig, agentId: string): Promise<void> {
const [{ loadCombinedSessionStoreForGateway }, { listSessionsFromStoreAsync }] =
await Promise.all([
import("../config/sessions/combined-store-gateway.js"),
@@ -46,19 +43,43 @@ async function prewarmGatewaySessionListData(
limit: SIDEBAR_SESSION_LIST_LIMIT,
},
});
return Object.keys(store).length;
}
function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmItem[] {
function dashboardDataPrewarmItems(
cfg: OpenClawConfig,
log: { info?: (msg: string) => void },
): GatewayHandlerPrewarmItem[] {
const agentIds = listAgentIds(cfg);
let loadedSessionStores = 0;
let totalSessionEntries = 0;
let sessionDataPrewarmChecked = false;
let sessionDataPrewarmAllowed = false;
const shouldPrewarmSessionData = async () => {
if (sessionDataPrewarmChecked) {
return sessionDataPrewarmAllowed;
}
sessionDataPrewarmChecked = true;
const { canPrewarmCombinedSessionStoresForGateway } =
await import("../config/sessions/combined-store-gateway.js");
sessionDataPrewarmAllowed = canPrewarmCombinedSessionStoresForGateway(cfg, {
agentIds,
maxRows: SIDEBAR_PREWARM_MAX_SESSION_ENTRIES,
});
if (!sessionDataPrewarmAllowed) {
log.info?.(
`skipping optional dashboard session prewarm: combined stores exceed ${SIDEBAR_PREWARM_MAX_SESSION_ENTRIES} rows`,
);
}
return sessionDataPrewarmAllowed;
};
return [
...agentIds.map((agentId) => ({
name: `sessions.${agentId}`,
load: async () => {
totalSessionEntries += await prewarmGatewaySessionListData(cfg, agentId);
loadedSessionStores += 1;
// A count-only query keeps unusually large stores off the synchronous JSON projection
// path. Request-time session and catalog handlers remain authoritative when skipped.
if (!(await shouldPrewarmSessionData())) {
return;
}
await prewarmGatewaySessionListData(cfg, agentId);
},
})),
{
@@ -71,12 +92,7 @@ function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmIt
...agentIds.map((agentId) => ({
name: `session-catalog.${agentId}`,
load: async () => {
// Catalog providers may project every OpenClaw session before returning their bounded
// page. Keep that optional cold-cache work off the event loop for unusually large stores.
if (
loadedSessionStores !== agentIds.length ||
totalSessionEntries > SIDEBAR_CATALOG_PREWARM_MAX_SESSION_ENTRIES
) {
if (!(await shouldPrewarmSessionData())) {
return;
}
const { prewarmSessionCatalogList } = await import("./server-methods/session-catalog.js");
@@ -93,14 +109,16 @@ function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmIt
export function scheduleGatewayHandlerPrewarm(params: {
cfgAtStart: OpenClawConfig;
startupTrace?: StartupTrace;
log: { warn: (msg: string) => void };
log: { info?: (msg: string) => void; warn: (msg: string) => void };
items?: readonly GatewayHandlerPrewarmItem[];
waitForPostReadyWork?: () => Promise<void>;
}): GatewayHandlerPrewarmHandle {
// Frequent updater restarts make cold dashboard data the remaining slow tier.
// Keep cheap session reads first, process-stable plugin data second, and provider catalogs last.
const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart);
const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart, params.log);
let stopped = false;
let nextIndex = 0;
let currentItemName = "unknown";
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleNext = () => {
@@ -109,23 +127,27 @@ export function scheduleGatewayHandlerPrewarm(params: {
}
timer = setTimeout(() => {
timer = undefined;
if (stopped) {
return;
}
const item = items[nextIndex++];
if (!item) {
return;
}
const load = () => item.load();
void runWithGatewayIndependentRootWorkAdmission(() =>
params.startupTrace
? params.startupTrace.measure(`post-ready.gateway-data.${item.name}`, load)
: load(),
)
void (async () => {
await params.waitForPostReadyWork?.();
if (stopped) {
return;
}
const item = items[nextIndex++];
if (!item) {
return;
}
currentItemName = item.name;
const load = () => item.load();
await runWithGatewayIndependentRootWorkAdmission(() =>
params.startupTrace
? params.startupTrace.measure(`post-ready.gateway-data.${item.name}`, load)
: load(),
);
})()
.catch((err: unknown) => {
// Prewarm only improves latency; readiness and request-time loaders remain authoritative.
params.log.warn(
`post-ready gateway data prewarm failed for ${item.name}: ${String(err)}`,
`post-ready gateway data prewarm failed for ${currentItemName}: ${String(err)}`,
);
})
.finally(scheduleNext);
@@ -426,6 +426,7 @@ describe("startGatewayPostAttachRuntime", () => {
cfg: { hooks: { internal: { enabled: false } } },
delayMs: 0,
shouldContinue: expect.any(Function),
waitForStart: undefined,
gatewayRuntime: expect.any(Object),
});
expect(hoisted.scheduleSubagentOrphanRecovery).toHaveBeenCalledWith();
@@ -461,6 +462,39 @@ describe("startGatewayPostAttachRuntime", () => {
);
});
it("gates main-session recovery behind post-ready work", async () => {
let releasePostReadyWork!: () => void;
const postReadyWork = new Promise<void>((resolve) => {
releasePostReadyWork = resolve;
});
let waitForStart: (() => Promise<void>) | undefined;
hoisted.scheduleRestartAbortedMainSessionRecovery.mockImplementationOnce(
(params: { waitForStart?: () => Promise<void> }) => {
waitForStart = params.waitForStart;
return { stop: vi.fn(async () => {}) };
},
);
await startGatewayPostAttachRuntime({
...createPostAttachParams(),
waitForPostReadyWork: () => postReadyWork,
});
await waitForGatewayTestState(() => {
expect(waitForStart).toEqual(expect.any(Function));
});
let released = false;
const waiting = waitForStart?.().then(() => {
released = true;
});
await Promise.resolve();
expect(released).toBe(false);
releasePostReadyWork();
await waiting;
expect(released).toBe(true);
});
it("stops restart recovery with gateway-lifetime sidecars", async () => {
const recoverySidecar = { stop: vi.fn() };
hoisted.scheduleRestartAbortedMainSessionRecovery.mockReturnValueOnce(recoverySidecar);
@@ -1192,11 +1226,16 @@ describe("startGatewayPostAttachRuntime", () => {
it("uses current config when agent runtime plugin prewarm runs", async () => {
const startupConfig = { marker: "startup" } as never;
const currentConfig = { marker: "current" } as never;
let releaseGatewayReady!: () => void;
const gatewayReady = new Promise<void>((resolve) => {
releaseGatewayReady = resolve;
});
await startGatewayPostAttachRuntime({
...createPostAttachParams({
gatewayPluginConfigAtStart: startupConfig,
}),
waitForPostReadyWork: () => gatewayReady,
providerAuthPrewarm: { enabled: false },
agentRuntimePluginPrewarm: {
enabled: true,
@@ -1205,6 +1244,12 @@ describe("startGatewayPostAttachRuntime", () => {
},
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(hoisted.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
releaseGatewayReady();
await waitForGatewayTestState(() => {
expect(hoisted.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: currentConfig,
+101 -58
View File
@@ -304,6 +304,7 @@ function scheduleAgentRuntimePluginPrewarm(params: {
warn: (msg: string) => void;
};
delayMs?: number;
waitForPostReadyWork?: () => Promise<void>;
}): GatewayPostReadySidecarHandle {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -311,29 +312,39 @@ function scheduleAgentRuntimePluginPrewarm(params: {
timer = setTimeout(
() => {
timer = undefined;
void runWithGatewayIndependentRootWorkAdmission(async () => {
await measureStartup(params.startupTrace, "post-ready.agent-runtime-plugins", async () => {
if (isStopped()) {
return;
}
const started = performance.now();
const { ensureRuntimePluginsLoaded } = await import("../agents/runtime-plugins.js");
const cfg = params.getConfig();
if (isStopped()) {
return;
}
ensureRuntimePluginsLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: true,
});
if (!isStopped()) {
params.log.info(
`agent runtime plugins pre-warmed in ${(performance.now() - started).toFixed(0)}ms`,
);
}
void (async () => {
await params.waitForPostReadyWork?.();
if (isStopped()) {
return;
}
await runWithGatewayIndependentRootWorkAdmission(async () => {
await measureStartup(
params.startupTrace,
"post-ready.agent-runtime-plugins",
async () => {
if (isStopped()) {
return;
}
const started = performance.now();
const { ensureRuntimePluginsLoaded } = await import("../agents/runtime-plugins.js");
const cfg = params.getConfig();
if (isStopped()) {
return;
}
ensureRuntimePluginsLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: true,
});
if (!isStopped()) {
params.log.info(
`agent runtime plugins pre-warmed in ${(performance.now() - started).toFixed(0)}ms`,
);
}
},
);
});
}).catch((err: unknown) => {
})().catch((err: unknown) => {
params.log.warn(`agent runtime plugin pre-warm failed: ${String(err)}`);
});
},
@@ -357,19 +368,23 @@ function schedulePostReadySidecarTask(params: {
log: { warn: (msg: string) => void };
run: (isStopped: () => boolean, signal: AbortSignal) => Awaitable<void>;
stop?: () => Awaitable<void>;
waitForPostReadyWork?: () => Promise<void>;
}): GatewayPostReadySidecarHandle {
let stopped = false;
const abortController = new AbortController();
const isStopped = () => stopped;
const handle = setImmediate(() => {
if (isStopped()) {
return;
}
void runWithGatewayIndependentRootWorkAdmission(async () => {
await measureStartup(params.startupTrace, params.name, () =>
params.run(isStopped, abortController.signal),
);
}).catch((err: unknown) => {
void (async () => {
await params.waitForPostReadyWork?.();
if (isStopped()) {
return;
}
await runWithGatewayIndependentRootWorkAdmission(async () => {
await measureStartup(params.startupTrace, params.name, () =>
params.run(isStopped, abortController.signal),
);
});
})().catch((err: unknown) => {
params.log.warn(`${params.name} failed after gateway ready: ${String(err)}`);
});
});
@@ -456,12 +471,14 @@ function scheduleTranscriptsAutoStartSidecar(params: {
cfg: OpenClawConfig;
startupTrace?: GatewayStartupTrace;
log: { warn: (msg: string) => void };
waitForPostReadyWork?: () => Promise<void>;
}): GatewayPostReadySidecarHandle {
let stopTranscriptsAutoStart: (() => Promise<void>) | undefined;
return schedulePostReadySidecarTask({
startupTrace: params.startupTrace,
name: "sidecars.transcripts-auto-start",
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
run: async (isStopped) => {
const { createTranscriptsAutoStartService } =
await import("../agents/tools/transcripts-tool.js");
@@ -619,6 +636,7 @@ export async function startGatewaySidecars(params: {
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
startupOutcomes?: GatewayStartupOutcomeRecorder;
waitForPostReadyWork?: () => Promise<void>;
}) {
const postReadySidecars: GatewayPostReadySidecarHandle[] = [];
@@ -793,6 +811,7 @@ export async function startGatewaySidecars(params: {
startupTrace: params.startupTrace,
name: "sidecars.session-locks",
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
run: async (isStopped) => {
try {
const [{ resolveAgentSessionDirs }, { cleanStaleLockFiles }] = await Promise.all([
@@ -818,6 +837,7 @@ export async function startGatewaySidecars(params: {
startupTrace: params.startupTrace,
name: "sidecars.restart-sentinel",
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
run: async () => {
if (!shouldCheckRestartSentinel()) {
return;
@@ -835,6 +855,7 @@ export async function startGatewaySidecars(params: {
startupTrace: params.startupTrace,
name: "sidecars.gmail-watch",
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
run: async (isStopped, signal) => {
const { startGmailWatcherWithLogs } = await import("../hooks/gmail-watcher-lifecycle.js");
if (isStopped()) {
@@ -857,6 +878,7 @@ export async function startGatewaySidecars(params: {
startupTrace: params.startupTrace,
name: "sidecars.gmail-model",
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
run: async (isStopped) => {
const [
{ DEFAULT_MODEL, DEFAULT_PROVIDER },
@@ -948,6 +970,7 @@ function createDeferredGatewayUpdateCheck(params: {
};
isNixMode: boolean;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
waitForPostReadyWork?: () => Promise<void>;
}): { start: () => void; stop: () => void } {
let started = false;
let stopped = false;
@@ -966,37 +989,47 @@ function createDeferredGatewayUpdateCheck(params: {
started = true;
// Update checks are intentionally post-attach so startup logging, sidecars,
// and Tailscale exposure are not serialized behind network I/O.
setImmediate(() => {
void (async () => {
await params.waitForPostReadyWork?.();
if (stopped) {
return;
}
void runWithGatewayIndependentRootWorkAdmission(
async () =>
await measureStartup(params.startupTrace, "post-attach.update-check", () =>
params.runtimeDeps.scheduleGatewayUpdateCheck({
cfg: params.cfg,
log: params.log,
isNixMode: params.isNixMode,
onUpdateAvailableChange: (updateAvailable) => {
const payload: GatewayUpdateAvailableEventPayload = { updateAvailable };
params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true });
},
}),
),
)
.then((nextStop) => {
if (stopped) {
nextStop();
return;
}
stopUpdateCheck = nextStop;
})
.catch((err: unknown) => {
if (stopped) {
return;
}
params.log.warn(`gateway update check failed to start: ${String(err)}`);
});
setImmediate(() => {
if (stopped) {
return;
}
void runWithGatewayIndependentRootWorkAdmission(
async () =>
await measureStartup(params.startupTrace, "post-attach.update-check", () =>
params.runtimeDeps.scheduleGatewayUpdateCheck({
cfg: params.cfg,
log: params.log,
isNixMode: params.isNixMode,
onUpdateAvailableChange: (updateAvailable) => {
const payload: GatewayUpdateAvailableEventPayload = { updateAvailable };
params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true });
},
}),
),
)
.then((nextStop) => {
if (stopped) {
nextStop();
return;
}
stopUpdateCheck = nextStop;
})
.catch((err: unknown) => {
if (stopped) {
return;
}
params.log.warn(`gateway update check failed to start: ${String(err)}`);
});
});
})().catch((err: unknown) => {
if (!stopped) {
params.log.warn(`gateway update check readiness wait failed: ${String(err)}`);
}
});
};
@@ -1076,6 +1109,7 @@ export async function startGatewayPostAttachRuntime(
delayMs?: number;
getConfig?: () => OpenClawConfig;
};
waitForPostReadyWork?: () => Promise<void>;
},
runtimeDeps: GatewayPostAttachRuntimeDeps = defaultGatewayPostAttachRuntimeDeps,
) {
@@ -1157,6 +1191,7 @@ export async function startGatewayPostAttachRuntime(
log: params.log,
isNixMode: params.isNixMode,
broadcast: params.broadcast,
waitForPostReadyWork: params.waitForPostReadyWork,
});
const tailscaleCleanupPromise = params.minimalTestGateway
@@ -1221,6 +1256,7 @@ export async function startGatewayPostAttachRuntime(
shouldStartPluginServices: () => params.isClosing?.() !== true,
broadcastPluginEvent: params.broadcastPluginEvent,
startupOutcomes,
waitForPostReadyWork: params.waitForPostReadyWork,
}),
);
} catch (error) {
@@ -1254,6 +1290,7 @@ export async function startGatewayPostAttachRuntime(
cfg: params.cfgAtStart,
delayMs: 0,
shouldContinue: () => params.isClosing?.() !== true,
waitForStart: params.waitForPostReadyWork,
gatewayRuntime: params.recoveryRuntime,
});
}
@@ -1295,6 +1332,7 @@ export async function startGatewayPostAttachRuntime(
startupTrace: params.startupTrace,
log: params.log,
delayMs: params.agentRuntimePluginPrewarm?.delayMs,
waitForPostReadyWork: params.waitForPostReadyWork,
}),
);
}
@@ -1314,6 +1352,7 @@ export async function startGatewayPostAttachRuntime(
cfg: params.gatewayPluginConfigAtStart,
startupTrace: params.startupTrace,
log: params.log,
waitForPostReadyWork: params.waitForPostReadyWork,
}),
);
}
@@ -1340,6 +1379,10 @@ export async function startGatewayPostAttachRuntime(
if (params.minimalTestGateway) {
return;
}
await params.waitForPostReadyWork?.();
if (params.isClosing?.()) {
return;
}
schedulePostAttachUpdateSentinelRefresh({
startupTrace: params.startupTrace,
log: params.log,
@@ -175,6 +175,62 @@ test("startup prewarm fills session snapshot and title caches before the first l
}
});
test("startup skips a large session prewarm while request-time listing remains available", async () => {
const { storePath } = await createSessionStoreDir();
await writeSessionStore({
entries: Object.fromEntries(
Array.from({ length: 2_001 }, (_, index) => [
`agent:main:large-${index}`,
sessionStoreEntry(`large-${index}`, { updatedAt: 1_781_000_000_000 - index }),
]),
),
});
const info = vi.fn();
const listSpy = vi.spyOn(sessionAccessor, "listSessionEntriesReadOnly");
let sidecar: ReturnType<typeof scheduleGatewayHandlerPrewarm> | undefined;
vi.useFakeTimers();
try {
let resolveSessionPrewarm!: () => void;
const sessionPrewarm = new Promise<void>((resolve) => {
resolveSessionPrewarm = resolve;
});
sidecar = scheduleGatewayHandlerPrewarm({
cfgAtStart: {
agents: { list: [{ id: "main", default: true }] },
session: { store: storePath },
} as never,
log: { info, warn: vi.fn() },
startupTrace: {
measure: async (name, run) => {
try {
return await run();
} finally {
if (name === "post-ready.gateway-data.sessions.main") {
resolveSessionPrewarm();
}
}
},
},
});
await vi.advanceTimersToNextTimerAsync();
await sessionPrewarm;
sidecar.stop();
expect(info).toHaveBeenCalledWith(
"skipping optional dashboard session prewarm: combined stores exceed 2000 rows",
);
expect(listSpy).not.toHaveBeenCalled();
vi.useRealTimers();
const result = await directSessionReq("sessions.list", LIST_PARAMS);
expect(result.ok).toBe(true);
} finally {
sidecar?.stop();
vi.useRealTimers();
listSpy.mockRestore();
}
});
test("sessions.list projects out prompt snapshots without changing full entry reads", async () => {
await createSessionStoreDir();
await writeSessionStore({

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