mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Google plugin module implements oauth behavior.
|
||||
export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(""", '"')
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
|
||||
resolveNativeServiceProfileConflict: () => null,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
resolveIsNixMode: resolveIsNixModeMock,
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)."));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ export type {
|
||||
UpdateSessionLastRouteParams,
|
||||
} from "./session-accessor.entry-mutation.js";
|
||||
export {
|
||||
countSessionEntryRowsReadOnly,
|
||||
copySessionOwnedStateForCanonicalRepair,
|
||||
listSessionGenerationIdsForCanonicalRepair,
|
||||
clearPluginOwnedSessionState,
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user